Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,23 @@ Go-based local development orchestrator for PHP and web projects (Magento, Larav
cmd/govard/main.go # CLI entrypoint
cmd/govard-desktop/ # Desktop app (Wails)
desktop/frontend/ # Desktop frontend (vanilla JS)
internal/cmd/ # Cobra commands (70 files)
internal/cmd/ # Cobra commands
bootstrap*.go # Bootstrap workflows
config_*.go # Config management
db*.go # Database commands
doctor*.go # Diagnostics & fixes
profile*.go # Profile detection/apply
up*.go # Environment startup
internal/engine/ # Core engine (20 files)
internal/engine/ # Core engine (framework-agnostic dispatch + registries)
config*.go # Config structs, normalize, persist
compose*.go # Docker compose generation
blueprint*.go # Blueprint rendering
profile*.go # Runtime profiles
lockfile.go # Lock file management
migrate.go # DDEV/Warden migration
doctor*.go # Diagnostics
internal/blueprints/ # Blueprint templates
internal/frameworks/<name>/ # Per-framework definition, bootstrap, and blueprint assets
internal/blueprints/ # Shared blueprint templates (assets specific to one framework live in that framework's own package instead)
internal/conventions/ # Constants, conventions
internal/desktop/ # Desktop backend
internal/proxy/ # Caddy/proxy TLS
Expand Down Expand Up @@ -101,7 +102,7 @@ When adding/modifying commands:

`internal/engine/render.go`'s `BlueprintVersion` const forces existing projects to re-render (`govard env up`) by invalidating a stored content hash.

- Editing files under `internal/blueprints/files/**` (base.yml, framework yml, nginx templates) already busts that hash automatically via content fingerprinting — **no bump needed**.
- Editing files under `internal/blueprints/files/**` (shared base.yml, generic nginx templates) or any `internal/frameworks/<name>/blueprint/**` (a framework's own compose fragment, nginx template, etc. — embedded via that package's `embed.go` and grafted into the merged `blueprints.FS` at init time) already busts that hash automatically via content fingerprinting — **no bump needed**.
- Bump `BlueprintVersion` only when Go rendering logic changes (`render.go`, `config_normalize.go`, `framework_config.go`, `profile.go`, etc.) in a way that changes rendered output *without* changing blueprint file bytes — those changes aren't hash-detected.
- When bumped, note it in `CHANGELOG.md` under a "Blueprint Lifecycle" bullet (see prior entries for wording).

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ At a glance, these are the areas where Govard delivers stronger day-to-day value
- **Global Services**: Built-in Proxy (Caddy), Mailpit, PHPMyAdmin, and Portainer (Default login for Portainer is `admin` / `AdminGovard123$`).
- **Search Engine Host Access**: Elasticsearch/OpenSearch is automatically reachable from the host at `http://<project>.test:9200` — no extra config, reuses the same Caddy proxy that serves your project's HTTPS domain.
- **Desktop Dashboard**: Wails-based UI with live logs, quick actions, and settings.
- **Native Framework Upgrades**: Multi-framework upgrade pipeline (`govard upgrade`) for Magento 2, Mage-OS, Laravel, Symfony, and WordPress that automates environment restarts, dependency updates, and database migrations.
- **Native Framework Upgrades**: Multi-framework upgrade pipeline (`govard upgrade`) for Magento 2, Mage-OS, Magento 1, Laravel, Symfony, and WordPress that automates environment restarts, dependency updates, and database migrations.

---

Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2022,6 +2022,7 @@ const bootstrap = async () => {
loadFooterVersion(),
settingsController.load(),
refreshDashboard(),
onboardingController.loadFrameworkOptions(),
]).catch((e) => console.error("Parallel bootstrap error:", e));
if (getState().sidebarMode === "global-services") {
await globalServicesController.refreshLogs();
Expand Down
184 changes: 131 additions & 53 deletions desktop/frontend/modules/onboarding.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { normalizeRemotesPayload } from "./remotes.js";

// Legacy shorthand aliases kept as a static fallback so normalization still
// works correctly for callers that run before loadFrameworkOptions() has
// populated frameworkOptionsCache (e.g. very early app lifecycle, or any
// caller that doesn't go through main.js's bootstrap). Once the registry
// loads, buildFrameworkLookups(frameworkOptionsCache) takes precedence.
const legacyFrameworkAliases = {
m2: "magento2",
"mage-os": "mageos",
m1: "magento1",
wp: "wordpress",
};

export const normalizeOnboardingFramework = (framework = "") => {
const normalized = String(framework || "")
.trim()
Expand All @@ -8,19 +20,12 @@ export const normalizeOnboardingFramework = (framework = "") => {
if (["", "auto", "detect"].includes(normalized)) {
return "";
}
if (normalized === "m2") {
return "magento2";
}
if (normalized === "mage-os") {
return "mageos";
}
if (normalized === "m1") {
return "magento1";
}
if (normalized === "wp") {
return "wordpress";
}
return normalized;
const { aliasToName } = buildFrameworkLookups(frameworkOptionsCache);
return (
aliasToName.get(normalized) ||
legacyFrameworkAliases[normalized] ||
normalized
);
};

export const normalizeOnboardingGitProtocol = (protocol = "") => {
Expand Down Expand Up @@ -87,33 +92,48 @@ export const normalizeOnboardingDomain = (domain = "", projectPath = "") => {
return `${base}.test`;
};

// extraPathInferenceAliases covers path-matching hints that are NOT
// registered as Go-side Aliases (that field also drives CLI/config alias
// normalization, a broader surface out of scope for path inference) but
// were historically recognized when inferring a framework from a project
// path (e.g. a folder named "my-next-app" or "mage-os-store").
const extraPathInferenceAliases = {
nextjs: ["next"],
mageos: ["mage-os"],
};

const inferFrameworkFromPath = (projectPath = "") => {
const value = String(projectPath || "").toLowerCase();
if (!value) {
return "";
}
if (value.includes("mageos") || value.includes("mage-os")) {
return "mageos";
}
if (value.includes("magento2") || value.includes("m2")) {
return "magento2";
}
if (value.includes("magento1") || value.includes("m1")) {
return "magento1";
}
if (value.includes("laravel")) {
return "laravel";
}
if (value.includes("symfony")) {
return "symfony";
}
if (value.includes("wordpress") || value.includes("wp")) {
return "wordpress";
}
if (value.includes("next")) {
return "nextjs";
// frameworkOptionsCache is sourced from a Go map with no defined
// iteration order, so "first match wins" would be nondeterministic
// whenever two frameworks' candidates overlap as substrings (e.g.
// magento2's "magento" alias is itself a substring of "magento1").
// Scanning every candidate across every framework and keeping the
// single longest match instead makes the more specific name/alias win
// deterministically, independent of cache order.
let bestName = "";
let bestLength = 0;
for (const option of frameworkOptionsCache) {
const name = String(option?.name || "").toLowerCase();
if (!name) {
continue;
}
const candidates = [
name,
...(option?.aliases || []).map((a) => String(a).toLowerCase()),
...(extraPathInferenceAliases[name] || []),
];
for (const candidate of candidates) {
if (candidate && value.includes(candidate) && candidate.length > bestLength) {
bestName = name;
bestLength = candidate.length;
}
}
}
return "";
return bestName;
};

const levelToHintClass = {
Expand Down Expand Up @@ -150,18 +170,16 @@ const setHint = (element, message, level = "muted") => {

const formatFrameworkLabel = (framework = "") => {
const normalized = normalizeOnboardingFramework(framework);
const labels = {
"": "Auto-detect",
magento2: "Magento 2",
mageos: "Mage-OS",
magento1: "Magento 1",
laravel: "Laravel",
symfony: "Symfony",
wordpress: "WordPress",
nextjs: "Next.js",
custom: "Custom",
};
return labels[normalized] || framework || "Auto-detect";
if (normalized === "") {
return "Auto-detect";
}
if (normalized === "custom") {
return "Custom";
}
const match = frameworkOptionsCache.find(
(option) => String(option?.name || "").toLowerCase() === normalized,
);
return match?.displayName || framework || "Auto-detect";
};

const normalizeOnboardingFrameworkVersion = (frameworkVersion = "") =>
Expand All @@ -186,6 +204,33 @@ const frameworkVersionPlaceholderByFramework = {
nextjs: "15",
};

let frameworkOptionsCache = [];

const buildFrameworkLookups = (options = []) => {
const byName = new Map();
const aliasToName = new Map();
options.forEach((option) => {
const name = String(option?.name || "").trim().toLowerCase();
if (!name) {
return;
}
byName.set(name, {
name,
displayName: String(option?.displayName || "").trim() || name,
aliases: Array.isArray(option?.aliases)
? option.aliases.map((alias) => String(alias || "").trim().toLowerCase()).filter(Boolean)
: [],
});
(option?.aliases || []).forEach((alias) => {
const normalizedAlias = String(alias || "").trim().toLowerCase();
if (normalizedAlias) {
aliasToName.set(normalizedAlias, name);
}
});
});
return { byName, aliasToName };
};

const defaultServiceOptions = {
varnish: false,
redis: false,
Expand Down Expand Up @@ -505,6 +550,45 @@ export const createOnboardingController = ({
};
};

const populateFrameworkSelect = () => {
if (!refs.projectFramework) {
return;
}
const select = refs.projectFramework;
const previousValue = select.value;
Array.from(select.querySelectorAll("option[data-dynamic-framework]")).forEach(
(node) => node.remove(),
);
const customOption = select.querySelector('option[value="custom"]');
frameworkOptionsCache
.slice()
.sort((a, b) => String(a.displayName || a.name).localeCompare(String(b.displayName || b.name)))
.forEach((option) => {
const node = document.createElement("option");
node.value = option.name;
node.textContent = option.displayName || option.name;
node.dataset.dynamicFramework = "true";
if (customOption) {
select.insertBefore(node, customOption);
} else {
select.appendChild(node);
}
});
if (previousValue) {
select.value = previousValue;
}
};

const loadFrameworkOptions = async () => {
try {
const list = await bridge.listFrameworks();
frameworkOptionsCache = Array.isArray(list) ? list : [];
populateFrameworkSelect();
} catch (err) {
console.error("Failed to load framework list:", err);
}
};

const resetForm = () => {
hasAttemptedSubmit = false;
if (refs.projectPath) refs.projectPath.value = "";
Expand Down Expand Up @@ -932,6 +1016,7 @@ export const createOnboardingController = ({
handleProgress,
handleInputChange: () => syncPreview(),
resetForm,
loadFrameworkOptions,
};
};

Expand Down Expand Up @@ -1117,13 +1202,6 @@ export const renderOnboardingModal = (container) => {
class="w-full bg-surface-secondary dark:bg-black/40 border border-border-primary dark:border-white/10 rounded-2xl px-5 py-4 text-text-primary dark:text-white font-bold focus:ring-4 focus:ring-primary/15 transition-all text-sm outline-none appearance-none cursor-pointer"
>
<option value="auto">🔍 Auto-detect</option>
<option value="magento2">Magento 2</option>
<option value="mageos">Mage-OS</option>
<option value="magento1">Magento 1</option>
<option value="laravel">Laravel</option>
<option value="symfony">Symfony</option>
<option value="wordpress">WordPress</option>
<option value="nextjs">Next.js</option>
<option value="custom">Custom System</option>
</select>
<span class="material-symbols-outlined absolute right-5 top-1/2 -translate-y-1/2 text-text-tertiary dark:text-slate-500 pointer-events-none text-xl">expand_more</span>
Expand Down
4 changes: 4 additions & 0 deletions desktop/frontend/services/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export const desktopBridge = {
const bridge = getBridge();
return call(bridge?.PickProjectDirectory?.bind(bridge));
},
async listFrameworks() {
const bridge = getBridge();
return call(bridge?.ListFrameworks?.bind(bridge));
},
async onboardProject(
inputOrPath,
framework,
Expand Down
Loading
Loading