-
Notifications
You must be signed in to change notification settings - Fork 5
Adding a framework
Govard ships support for a growing list of frameworks — Magento 2, Mage-OS, Magento 1, OpenMage, Laravel, Symfony, Drupal, WordPress, Next.js, Emdash, Shopware, CakePHP, PrestaShop, Django, Custom, and more over time. This page documents how that support is structured internally, and what to touch to add a new one.
Each framework has one small package under internal/frameworks/<name>/ that produces a types.FrameworkDefinition — a single struct that's the framework's identity card inside Govard:
// internal/frameworks/types/definition.go
type FrameworkDefinition struct {
Name string // canonical key, e.g. "magento2"
Aliases []string // e.g. "magento" -> "magento2"
DisplayName string // human-facing label, e.g. "Magento 2"
Config engine.FrameworkConfig // PHP/Node version, nginx template, DB defaults...
Manifest engine.FrameworkManifestConfig // sync excludes, sensitive tables, feature flags
Detect engine.DetectionSpec // composer/package.json/auth.json/file-path signatures
DefaultDBCredentials DefaultDBCredentials // local-dev DB port/username/password/database-name defaults
PHPStanPaths []string // default `govard test phpstan` analysis paths, nil for the generic {"app","src"} default
ComposerCodingStandard ComposerCodingStandard // Composer package + phpcs --standard label for this framework's coding standard
Bootstrap BootstrapFactory // func(bootstrap.Options) bootstrap.FrameworkBootstrap
BaseURLManager func() tunnel.BaseURLManager // nil if the framework needs no tunnel base-URL rewriting
SupportsBootstrap bool // allow `govard bootstrap` (remote/clone workflow)
SupportsFreshInstall bool // allow `govard bootstrap --fresh`
FreshInstall func(bootstrap.Options, string, bootstrap.CmdHelpers) error // fresh-install orchestration; nil until migrated off the legacy switch
FreshInstallNeedsDB bool // populate DB credentials before invoking FreshInstall
FreshInstallNeedsDomain bool // populate the domain before invoking FreshInstall
FreshInstallManagesOwnEnvUp bool // true if FreshInstall already calls `env up` itself
PreConfigureHook func(bootstrap.Options, string, bootstrap.CmdHelpers) error // clone-workflow setup that must run before `govard config auto`
PostCloneHook func(bootstrap.Options, string, bootstrap.CmdHelpers) error // clone-workflow setup that runs after the generic PostClone dispatch
PHPImageVariant string // PHP image variant suffix for this framework's container, "" for the plain image
DBDriverCategory string // phpMyAdmin per-project DB-user/label category, "" falls back to "app"
Upgrade engine.UpgradeFunc // `govard upgrade` pipeline (deps, migrations, cache flush), nil if unimplemented
RunMappingAssetPreparer engine.RunMappingAssetPreparer // per-store nginx/apache "run mapping" assets prepared before render, nil if none
TablePrefixDetector engine.TablePrefixDetector // reads this framework's own config file for its DB table prefix, nil if no table-prefix concept
VersionProfileResolver engine.VersionProfileResolver // resolves version-specific runtime-profile overrides, nil for all but magento2 today
TemplateFuncs template.FuncMap // extra blueprint-template functions this framework contributes, nil for most
ProbeRemoteDB func(remoteName string, remoteCfg engine.RemoteConfig) (remote.MagentoDBInfo, error) // probes a remote for live DB credentials, nil if unimplemented
AutoConfigure func(cmd *cobra.Command, config engine.Config) error // `govard config auto`'s framework-specific post-render setup, nil if unsupported
}internal/frameworks/all_generated.go — generated by go generate ./internal/frameworks/... from each package's Spec() (see step 6, below) — calls RegisterSpecs([]types.FrameworkSpec{<pkg>.Spec(), ...}) once with every registered framework package's spec, in a specific order (more on why, below). RegisterSpecs resolves each spec into a full types.FrameworkDefinition (a root spec's Definition is used as-is; a child spec's Parent definition is resolved first, then the child's types.FrameworkPatch is applied on top — see "Forking an existing framework" below) and populates a package-level registry. Adding a framework to the registry means its resolved Definition() fields automatically flow through every place that dispatches on framework identity — but that's now three different mechanisms, not one:
| File | Purpose |
|---|---|
internal/frameworks/registry.go |
Get(name), All(), Normalize(name) — the registry itself, alias resolution |
internal/frameworks/run.go |
RunBootstrap(name, opts) — dispatches to def.Bootstrap instead of a switch |
internal/frameworks/base_url.go |
NewBaseURLManager(name) — dispatches to def.BaseURLManager, falls back to tunnel.NoopManager
|
-
The three files above are the registry's own read side, used by
govard bootstrap's allowlists,govard tunnel's base-URL rewriting, and the bootstrap dispatcher. -
Top-down field reads: code in
internal/cmd/internal/desktopthat already importsinternal/frameworkscallsframeworks.Get(name)and reads aDefinition()field directly —DefaultDBCredentials,PHPStanPaths,ComposerCodingStandard,ProbeRemoteDB,AutoConfigure,FreshInstall(and itsFreshInstallNeedsDB/FreshInstallNeedsDomain/FreshInstallManagesOwnEnvUpcompanions),PreConfigureHook/PostCloneHook. This is the default choice for anything onlycmd/desktopneeds. -
Engine-owned registries:
internal/enginecan never importinternal/frameworksback (frameworksimportsengine, not the reverse), so the handful of things engine itself needs to dispatch on —PHPImageVariant,DBDriverCategory,Upgrade,RunMappingAssetPreparer,TablePrefixDetector,VersionProfileResolver, and each entry ofTemplateFuncs— are instead pushed into a matchingengine.RegisterX(...)call fromframeworks.Register(internal/frameworks/registry.go) at registration time, e.g.engine.RegisterPHPImageVariant,engine.RegisterUpgrader. Engine's own read-side functions (PHPImageVariantForFramework,UpgradeFramework, etc.) then dispatch off that registry instead of a per-framework switch.
Whichever path a given field takes, no hardcoded switch framework { case "magento2": ... } remains for it — adding a framework to the registry means it automatically participates everywhere that field is read, no switch to edit.
internal/cmd/bootstrap_remote.go (clone-workflow orchestration) and internal/cmd/bootstrap_fresh_install.go (fresh-install orchestration) dispatch entirely through the registry, with no framework-name switch in either.
The generic FrameworkBootstrap.PostClone interface method (bootstrapPostCloneDefinition) handles clone-workflow post-clone steps for most frameworks. Definition()'s optional PreConfigureHook/PostCloneHook fields cover frameworks whose clone-workflow needs finer step timing, or *cobra.Command access, than that interface method can express — currently only the Magento family (env.php generation before govard config auto, admin-user-creation/reindex after the generic post-clone dispatch).
Every registered framework owns a FreshInstall field on its Definition() (internal/frameworks/<name>/freshinstall.go):
- Symfony, Laravel, Drupal, WordPress, Shopware, and CakePHP delegate to the shared
bootstrap.GenericFreshInstallhelper for the genericCreateProject → Install → govard config autosequence. - OpenMage, Next.js, Emdash, and Django write bespoke sequencing directly in their own
freshinstall.go. - Magento 2 and Mage-OS delegate to the shared
magento2.FreshInstallorchestrator (internal/frameworks/magento2/bootstrap.go), parameterized bymagento2.Variant/mageos.Variant— Mage-OS's package imports Magento 2's directly rather than both depending on a neutral third package, since Magento 2 is the primary implementation and Mage-OS is the fork (the same ownership pattern applies to their sharedConfig/Manifest— see step 1, below). - Magento 1's
FreshInstallreturns a fixed "use OpenMage instead" error: fresh install is unsupported for it by design, butSupportsFreshInstallstaystrueso this specific error fires instead of the generic CLI-allowlist rejection. - PrestaShop is the only registered framework with no
FreshInstallfield: it never setsSupportsFreshInstall, sorunBootstrapFrameworkFreshInstallnever runs for it.
custom is the only entry in internal/engine/framework_config.go's FrameworkConfigs map and internal/engine/framework_manifest.json's "frameworks" object. Every other framework's Config/Manifest lives as a config.go/manifest.go literal inside its own internal/frameworks/<name>/ package, pushed into engine.FrameworkConfigs/the manifest store at registration time via engine.RegisterFrameworkConfig/RegisterFrameworkManifest (called from frameworks.Register, itself called from generated internal/frameworks/all_generated.go init code). engine.GetFrameworkConfig/GetFrameworkManifestConfig are the read side of that same map/store.
Say you're adding a fictional framework called whimsy. Every step below has a real, working example already in the codebase — the file references point at the closest existing analog to copy from.
Create a config.go with a package-level var config = engine.FrameworkConfig{...} literal (PHP/Node version, nginx template, DB engine/version, includes list). Copy the closest existing framework's shape — e.g. internal/frameworks/cakephp/config.go for a vanilla PHP+MariaDB stack, internal/frameworks/nextjs/config.go for a Node-only one with no DB.
If whimsy is a near-fork of an existing framework (same runtime stack, nearly identical defaults), don't duplicate the whole literal — put a small shared constructor, parameterized by just the handful of fields that actually differ, in whichever framework's package is the primary/older implementation, and have the fork's package import it directly. Two examples: magento2.BuildConfig (internal/frameworks/magento2/config.go, called from internal/frameworks/mageos/config.go) and magento1.BuildConfig (internal/frameworks/magento1/config.go, called from internal/frameworks/openmage/config.go).
Create a manifest.go with a package-level var manifest = engine.FrameworkManifestConfig{...} literal: sync excludes (Paths.LocalMedia/RemoteMedia, WebRootCandidates), sensitive/ignored DB tables, and the Features block:
package whimsy
import "govard/internal/engine"
var manifest = engine.FrameworkManifestConfig{
Ignored: []string{},
Sensitive: []string{},
Paths: engine.FrameworkPathConfig{
LocalMedia: "public/uploads",
RemoteMedia: "public/uploads",
WebRootCandidates: []engine.FrameworkWebRootCandidate{},
},
Features: engine.FrameworkFeatureConfig{
RequiresRunningEnvForFreshInstall: false,
SupportsPostClone: true,
},
}Copy the closest existing framework's shape — e.g. internal/frameworks/cakephp/manifest.go or internal/frameworks/django/manifest.go. RequiresRunningEnvForFreshInstall controls whether govard bootstrap --fresh starts containers before or after running CreateProject — see the gotcha about this below before setting it true.
Same rule as config.go above: magento2.Manifest (internal/frameworks/magento2/manifest.go, referenced by internal/frameworks/mageos/manifest.go) and magento1.Manifest (internal/frameworks/magento1/manifest.go, referenced by internal/frameworks/openmage/manifest.go).
Framework blueprint assets — the Compose fragment, the nginx vhost template, anything else the framework's blueprint needs — live inside the framework's own package now, not under a shared internal/blueprints/files/<name>/ directory (that tree still exists, but only for assets genuinely shared across frameworks: proxy.yml, includes/, and the generic support/nginx/templates default). Two pieces:
-
internal/frameworks/whimsy/blueprint/— the actual asset files:-
services.yml(Docker Compose fragment, rendered as a Go template) if the framework needs one — copy the closest analog (internal/frameworks/nextjs/blueprint/services.ymlfor a Node runtime,internal/frameworks/cakephp/blueprint/for a PHP framework that needs only an nginx template, no compose fragment of its own). Not every framework needs one: a framework that reuses another's compose entirely (Mage-OS reuses Magento 2's — seevarnishTemplateFrameworkininternal/engine/render.go) skips this file, as does one contributing only an nginx template (cakephp, drupal, wordpress today). - an nginx vhost template (e.g.
whimsy.conf) if the framework needs one distinct from the generic default. - any other nested assets the blueprint needs — see
internal/frameworks/magento2/blueprint/varnish/default.vclfor an example beyondservices.yml/the nginx template.
-
-
internal/frameworks/whimsy/embed.go— embeds that directory and grafts it into the mergedblueprints.FStree at package-init time. Copyinternal/frameworks/magento2/embed.go(has both a nested asset and an nginx template) or the simplerinternal/frameworks/cakephp/embed.go(nginx template only) as your starting shape:package whimsy import ( "embed" "io/fs" "govard/internal/blueprints" ) //go:embed all:blueprint var blueprintFiles embed.FS var BlueprintFS fs.FS func init() { var err error BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") if err != nil { panic(err) } blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ Framework: "whimsy", FS: BlueprintFS, HasDir: true, // false if whimsy contributes only an nginx template, no services.yml/other assets NginxTemplate: "whimsy.conf", // "" if whimsy has no nginx template of its own }) }
HasDir: truegrafts the whole ofBlueprintFSaswhimsy/in the merged tree (e.g.whimsy/services.yml);NginxTemplate, if set, is always grafted atsupport/nginx/templates/whimsy.confregardless ofHasDir. See theFrameworkMountdoc comment ininternal/blueprints/blueprints.gofor the full contract. No registration call is needed beyond thisinit()— as long as something importsinternal/frameworks/whimsy(step 5'sall_generated.godoes), Go runs thisinit()beforeblueprints.FSis ever read.
If your service needs to run as user: root (e.g. a stock Node/Python image where npm/pip need root to install), isolate any directory it writes into that's a build cache or dependency tree — not source you want to inspect on the host — behind a named Docker volume instead of the bind mount, so root-owned files never land on the host filesystem at all. See node-modules: in internal/frameworks/emdash/blueprint/services.yml and next-cache: in internal/frameworks/nextjs/blueprint/services.yml. For writes that can't be isolated this way (e.g. Django's __pycache__, scattered throughout the project tree), chown the directory back to the bind mount's own owner (stat -c %u:%g . — no UID plumbing needed) after the command that wrote as root; see internal/frameworks/django/blueprint/services.yml's command: and internal/frameworks/django/bootstrap.go's installAndMigrate.
Implement the FrameworkBootstrap interface (internal/engine/bootstrap/base.go):
type FrameworkBootstrap interface {
Name() string
SupportsFreshInstall() bool
SupportsClone() bool
FreshCommands() []string // human-readable summary, not necessarily what actually runs
CreateProject(projectDir string) error
Install(projectDir string) error
Configure(projectDir string) error
PostClone(projectDir string) error
}Copy internal/frameworks/cakephp/bootstrap.go for a PHP framework using the shared bootstrap.RunStagedCreateProject helper (internal/engine/bootstrap/staged_project.go), or internal/frameworks/emdash/bootstrap.go for a framework whose CreateProject doesn't need any container at all (plain HTTP download).
If your framework needs to run a CLI tool (npx, composer, etc.) to scaffold the project, it must do so inside a container — never assume host tooling is present. See the "Container execution" gotcha below.
package whimsy
import (
"govard/internal/engine"
"govard/internal/engine/bootstrap"
"govard/internal/frameworks/types"
)
func Definition() types.FrameworkDefinition {
return types.FrameworkDefinition{
Name: "whimsy",
DisplayName: "Whimsy",
Config: config,
Manifest: manifest,
Detect: engine.DetectionSpec{
ComposerPackages: []string{"whimsy/framework"}, // or PackageJSONDeps, AuthJSONHosts, FilePaths
},
Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap {
return NewWhimsyBootstrap(opts)
},
SupportsFreshInstall: true,
SupportsBootstrap: true, // only if it supports the remote/clone workflow too
}
}config and manifest are the package-level vars from steps 1 and 2 — no lookup by name needed, since Definition() lives in the same package that declares them. NewWhimsyBootstrap is the local constructor from step 4's bootstrap.go (no bootstrap. prefix — the bootstrapper lives in whimsy's own package, not internal/engine/bootstrap). Only set BaseURLManager if the framework needs specialized base-URL rewriting for govard tunnel (most don't — the default tunnel.NoopManager is a no-op, which is correct for anything that doesn't store its own base URL in the database or a config file).
Every framework package also needs a Spec() function — this, not Definition(), is what all_generated.go actually calls. For a brand-new, standalone framework like whimsy, it's a one-liner that just wraps Definition():
// Spec declares Whimsy as a root framework.
func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} }Copy this verbatim from any non-fork framework, e.g. internal/frameworks/wordpress/spec.go or internal/frameworks/custom/spec.go. If whimsy is instead a close fork of an existing framework, see "Forking an existing framework" below before writing Spec() — a fork's Spec() looks quite different from this one-liner.
If whimsy is a near-identical fork of an already-registered framework (the way Mage-OS forks Magento 2, or OpenMage forks Magento 1), don't duplicate that framework's whole Definition(). Instead, declare whimsy as a child spec: give it a Parent and a types.FrameworkPatch listing only the fields that actually differ from the parent. Every other field is inherited automatically when RegisterSpecs resolves the registry at startup.
types.FrameworkPatch has one types.Override[T] field per inheritable FrameworkDefinition field (see internal/frameworks/types/spec.go for the full list). An Override[T]'s zero value means "inherit the parent's value unchanged" — to actually change something you must call one of:
-
types.Set(value)— replace the inherited value withvalue. -
types.Clear[T]()— explicitly reset toT's zero value (distinct from "inherit," since a fork's correct value genuinely is the zero value for some fields, e.g. anUpgradepipeline the parent has but the fork deliberately doesn't).
Real example — internal/frameworks/mageos/mageos.go's Spec() (Mage-OS inherits most of Magento 2's behavior, but patches its own display name, DB defaults, detection signature, and a handful of Magento-2-specific fields it doesn't share):
// Spec declares Mage-OS as a Magento 2 child. Every inherited behavior is
// intentionally omitted; only distribution-specific deltas remain here.
func Spec() types.FrameworkSpec {
def := Definition()
return types.FrameworkSpec{
Parent: "magento2",
Definition: types.FrameworkDefinition{
Name: def.Name,
Aliases: def.Aliases,
},
Patch: types.FrameworkPatch{
DisplayName: types.Set(def.DisplayName),
MigrationTypes: types.Clear[types.MigrationTypes](),
Config: types.Set(def.Config),
DefaultDBCredentials: types.Set(def.DefaultDBCredentials),
Detect: types.Set(def.Detect),
Bootstrap: types.Set(def.Bootstrap),
FreshInstall: types.Set(def.FreshInstall),
DBDriverCategory: types.Clear[string](),
Upgrade: types.Set(def.Upgrade),
VersionProfileResolver: types.Clear[engine.VersionProfileResolver](),
// ...and so on for every other field that differs from magento2.
},
}
}Note that def := Definition() still exists and is still fully populated (other code, and this Spec() itself, read fields off it directly) — the fork pattern only changes what Spec() reports to the registry, not Definition() itself. See internal/frameworks/openmage/openmage.go for a second real example (OpenMage forking Magento 1) with a different, smaller patch set — the two forks don't patch the same fields, because each fork's actual behavioral deltas from its parent are different.
Parent must name an already-registered framework (checked at startup — RegisterSpecs panics on an unknown parent or an inheritance cycle, so a typo here fails loudly at process start, not silently at runtime). Only fork a framework this way if it's genuinely a close variant with a real, alphabetically-earlier parent to inherit from; most new frameworks are roots, not forks.
Registration is generated, not hand-maintained: run make generate (or go generate ./internal/frameworks/...) and internal/frameworks/all_generated.go picks up the new whimsy package's Spec() automatically — no import or RegisterSpecs() call to add by hand. make build and make test already run this for you, so in practice you only need to run it explicitly if you want to inspect the generated file before building.
Position still matters for detection: DetectFramework walks registered frameworks in registration order and returns the first match, so a framework whose detection signature could also match another registered framework's must register in the right relative position. Ordering defaults to alphabetical; the one known exception (Emdash must register before Next.js) is declared in internal/frameworks/gen/generator/order.go's PriorityOverrides map — add an entry there, not in all_generated.go, if whimsy's detection signature is similarly ambiguous with an existing framework's.
All of this is registry-driven — no framework-name switch to edit:
- If
whimsyfits the genericCreateProject → Install → govard config autoshape, addinternal/frameworks/whimsy/freshinstall.gowith afreshInstallfunction that just delegates tobootstrap.GenericFreshInstall(NewWhimsyBootstrap(opts), projectDir, helpers)— copyinternal/frameworks/cakephp/freshinstall.go— and wire it up via theFreshInstall(plusFreshInstallNeedsDB/FreshInstallNeedsDomain) fields onDefinition().runBootstrapFrameworkFreshInstallpicks it up automatically, no switch to edit. Ifwhimsy's compose service can't come up against an empty/unmigrated project (soFreshInstallmust bring the environment up itself before runningInstall()/migrate — Django needs this), also setFreshInstallManagesOwnEnvUp: trueonDefinition()sobootstrapCmd.RunEskips its own redundantenv upafterward. - If it needs bespoke steps, write that orchestration in
internal/frameworks/whimsy/freshinstall.goinstead — it still gets wired up the same way viaDefinition()'sFreshInstallfield, just without delegating tobootstrap.GenericFreshInstall; copyinternal/frameworks/openmage/freshinstall.goorinternal/frameworks/django/freshinstall.goas a starting shape (Django's shows how to overrideOptions.Runnerto a non-PHPCmdHelpersrunner and how to useOptions.SkipUp/bootstrap.ErrFreshInstallSkipUp). If fresh install genuinely isn't supported (Magento 1'sFreshInstalljust returns an error telling the user to use OpenMage instead), aFreshInstallclosure that returns that error is still simpler than a switch case — every registered framework expresses its fresh-install behavior this way, with no exceptions. - If it supports the remote/clone workflow (
SupportsBootstrap: true, not just fresh-install), it's picked up automatically bybootstrap_remote.go's post-clone dispatch (bootstrapPostCloneDefinition) — no switch to edit there unless it's part of the Magento family (whichbootstrapPostCloneDefinitionexcludes viaengine.IsMagento2Family, since its real pre-configure/post-clone setup goes through thePreConfigureHook/PostCloneHookfields instead - see the paragraph below).
If whimsy's clone-workflow post-clone setup needs *cobra.Command access (running govard tool <x>) that the plain FrameworkBootstrap.PostClone(projectDir) interface method can't express, or needs to run before govard config auto rather than after the generic post-clone dispatch, set PreConfigureHook/PostCloneHook on Definition() instead - the same func(opts bootstrap.Options, projectDir string, helpers bootstrap.CmdHelpers) error shape as FreshInstall. Copy magento2.PreConfigure/magento2.PostClone (internal/frameworks/magento2/bootstrap.go) as a starting shape, and add whatever new CmdHelpers closures your hook needs to internal/engine/bootstrap/base.go and internal/cmd/bootstrap_remote.go's dispatcher - both fields are framework-agnostic infrastructure, not Magento-specific, even though Magento 2/Mage-OS are their first consumers.
Add a row to the support/runtime-defaults tables and a short section in docsFrameworks.md (and the Vietnamese mirror, docs/viFrameworks.md).
-
tests/framework_detection_test.go— aTestWhimsyDiscoverymatching whateverDetectsignature you used. -
tests/framework_definitions_test.goor a newwhimsy-specific test file — assertDefinition()'sConfig/Manifest/Bootstrapare populated as expected. -
tests/framework_snapshot_test.go— this is a golden-snapshot regression net covering every registered framework automatically (blueprint rendering,FreshCommands(), resolved config/profile, manifest/DB-credential defaults) viaallFrameworkNames; registeringwhimsy(automatic once you runmake generateafter adding its folder — see step 6) makes it start running, but its golden fixtures undertests/testdata/framework_snapshots/whimsy/won't exist yet. Generate them once you're confident the rendered output is correct:UPDATE_GOLDEN=1 go test ./tests/... -run TestFrameworkSnapshotAlways review the generated fixture diff before committing it —
UPDATE_GOLDEN=1writes whatever the code currently produces, correct or not.
Rendering and dispatch tests verify expected output, not whether a container can actually reach the network, whether an image has the binary it needs, or whether a container is really ready to serve traffic by the time the reverse proxy registers it. Those failure modes are invisible to go test ./... and only show up by actually running the command and checking the result:
mkdir -p /tmp/whimsy-test && cd /tmp/whimsy-test
govard bootstrap --framework whimsy --fresh --yes
curl -sk -o /dev/null -w '%{http_code}\n' https://whimsy-test.test/ # expect 200, not a docker/proxy error
govard env downEvery FrameworkBootstrap.CreateProject/Install that shells out to a CLI tool (composer, npx, npm) must run that tool inside a container, never via a bare exec.Command on the host — the host's tooling (or lack of it, or a stray global config like ~/.npmrc) is outside Govard's control and produces silent, machine-specific failures. PHP frameworks do this via bootstrap.Options.Runner (a func(command string) error closure that internal/cmd wires to runPHPContainerShellCommand, exec'ing into the already-running PHP container). Node-based frameworks with no running compose service to exec into yet (Next.js's CreateProject) use internal/cmd/bootstrap.go's nodeCreateProjectRunner instead, which runs the scaffolding command in a throwaway docker run --rm -v <projectDir>:/app node:<version> ... container — independent of both the host environment and of whether any compose-managed service is running yet.
A tempting alternative to the throwaway-container approach above is to exec into the framework's own long-lived "web" service container instead (matching the PHP pattern). This works only if that container is already running by the time CreateProject executes, which for most frameworks it isn't — govard bootstrap --fresh runs fresh-install before env up for anything whose manifest doesn't set requires_running_env_for_fresh_install: true. Flipping that flag to force env-up first introduces a subtler problem: the container starts running its normal long-lived command (e.g. npm run dev) against what is still an empty project directory, so it exits immediately — and the bootstrap pipeline's domain/proxy-registration step runs during that same window, registering a route to a container that isn't actually serving anything yet. The registration silently succeeds, but the reverse proxy never gets a working backend, and the very first https://<project>.test/ request 502s until a manual env down && env up.
If a framework genuinely needs its long-lived service container up before CreateProject can run inside it, that container's startup command needs to tolerate an empty/partial project directory (loop waiting for a marker file, or similar) and the domain-registration timing needs to happen after the app is actually serving — not just after the container process started. Emdash sidesteps this entirely: its CreateProject needs no container (a plain HTTP tarball download), and its compose command already has an "install if node_modules missing" guard for defense, but never a "wait for files to exist" one, because by the time its container starts, the files are already there.
Mage-OS is a drop-in fork of Magento 2 and reuses most of its runtime behavior, but "reuses most of" is not "reuses all of" — DB credential defaults, the search-engine version gate, and the exec user are each their own decision point, and each one needs an explicit check rather than an assumption. engine.IsMagento2Family(framework) / engine.Magento2FamilyDisplayName(framework) (internal/engine/framework_family.go) is the single place that decision lives, instead of a framework == "magento2" string comparison at each call site. When adding a framework that's a close variant of an existing one, grep for every == "<existing-framework>" string comparison in internal/cmd and internal/engine and decide, case by case, whether the new framework belongs on each check — don't assume "looks similar" implies "behaves identically everywhere" (a DB auto-configuration call site is exactly the kind of place this silently goes wrong: it keeps working, just against the wrong database).
A service that runs as user: root (stock Node/Python images need this for npm/pip to install without permission errors) writes any file it creates in the bind-mounted project directory as root — the host user can't delete or edit it without sudo. There's no single fix; pick per directory:
- If the directory is a rebuildable cache or dependency tree, not something a developer needs to browse on the host, isolate it behind a named Docker volume instead of the bind mount (
node-modules:ininternal/frameworks/emdash/blueprint/services.yml,next-cache:ininternal/frameworks/nextjs/blueprint/services.yml) — root-owned files never touch the host filesystem at all. - If root-owned files can appear anywhere in the tree (Django's
__pycache__), chown the project directory back to the bind mount's own owner after the command that ran as root:chown -R "$(stat -c %u:%g .)" .— this reads the mount point's existing ownership rather than requiring the host UID/GID to be threaded through as a new parameter. Seeinternal/frameworks/django/bootstrap.go'sinstallAndMigrateandinternal/frameworks/django/blueprint/services.yml'scommand:.
Govard — Go-based Versatile Runtime & Development GitHub · Releases · Issues · MIT License