Module: internal/cli (init), internal/scaffold (the ordered emitter + wizard model), internal/prompt (the TUI interface + non-TTY fallback) · Milestone: post-GA / v0.2 polish lane (after M7, ships in v0.2.0 — the project is in BETA; this never forces a v1.0.0) · Effort: ~2w
Turn the cold-start of a workspace from "hand-author two YAML files against spec 01 from memory" into a guided, validated devstack init that emits a correct-by-construction workspace.yaml (and, optionally, per-repo devstack.yaml stubs). Today both files are hand-written; there is no init command (not even a stub), and the only existing config emitter is devstack import (spec 14, internal/migrate). This spec adds the missing onboarding front door: pick the shared engines (Postgres/Redis/MinIO/…), fill their typed params from template metadata, name the workspace, and write a file that is structurally validated before it is written — so the very next devstack generate/up starts from a parseable, well-formed config. The wizard is a thin TUI over the same data the rest of the tool already exposes (template.Describe, builtinSource(), config.Workspace); its hard requirement is a fully scriptable, non-interactive equivalent so CI, --json, and non-TTY shells never depend on the TUI.
- One command, two faces.
devstack initruns a Bubble Tea v2 TUI on an interactive TTY and a flag-/stdin-driven non-interactive path everywhere else. The two paths build the same in-memoryconfig.Workspaceand call the same emitter — the TUI only collects inputs. - Authors
workspace.yamlonly (the shared layer). Name,aliases,profiles.default,shared, andprojects[]refs. Per-repodevstack.yamlstays a separate portable file (spec 01); the wizard may scaffold a minimaldevstack.yamlstub per declared project behind an opt-in, but never folds project service config intoworkspace.yaml. - The shared-engine catalogue is
builtinSource()filtered byProvides. Only templates with a non-emptyProvides(todaypostgres/redis/minio; plus any store-custom engine that declares one) are offered as shared services.php.*/node.*(noProvides) are project templates and are hidden from the shared picker. Same sourcegenerateuses, so custom store templates appear automatically (spec 02). - Per-service param forms are generated from
ParamSpec. For each chosen engine, render one field pertemplate.Describe(...).Params[name](Typestring/int/bool,Default,Required,Description). Required-without-default params are mandatory in the form; defaults are pre-filled and omitted from output when left at default (diff-stable, minimal YAML). - Pre-seed from the global store. If
$DEVSTACK_HOME/config.yamlexists, offer itssharedset (store.Load, defaultpostgres@16/redis/minio) as the default multi-select selection. The wizard never writes the store — it only reads it as a starting point (store authorship staysstore init's job). - Emit via a shared ordered emitter, not struct marshal. New
scaffold.EmitWorkspaceYAML(config.Workspace) ([]byte, error)buildsyaml.MapSlice/MapItemin fixed key order (apiVersion,kind,name,aliases,profiles,shared,projects; empty sections omitted) andyaml.Marshals it — the proveninternal/migratepattern (migrate.go already hand-builds the sameMapSlice).internal/migrateis refactored to call this same emitter so there is one goccy builder, not two divergent ones, with byte-stability golden-tested. - Validate before write (structural). The assembled model is rendered, then the bytes are run through
config.LoadWorkspaceOnly(or a new exportedconfig.ValidateWorkspaceBytes) before the real file lands — apiVersion/kind header, everydsname, and overall shape. A malformed selection is caught in the wizard / before the file is written, never as a parse failure on the nextgenerate. Note the limit: this is structural validation only; the full shared-graph +${ref}cross-resolution thatvalidateModel/generateperform needs each project'sdevstack.yamlon disk (which init does not require), so that layer is necessarily deferred to the nextgenerateonce the repos are present. - No-clobber + dry-run, mirroring
import. Refuse to overwrite the target dir'sworkspace.yaml(or any target stub) without--force; with--force, back up originals first. Separately, ifconfig.Discoverfinds a workspace.yaml in a parent dir, refuse with an "already inside a workspace at " error (nested workspaces are unsupported, spec 01).--dry-runprints the would-be file(s) and a validation verdict, writes nothing.--out <dir>redirects output. - The TUI is a custom Bubble Tea v2 program — modern aesthetics are a first-class requirement, not a nicety (owner directive: "a really nice modern TUI for CLIs"). The wizard is a hand-written
charm.land/bubbletea/v2model composed fromcharm.land/bubbles/v2components —list(the shared-engine multi-select, each row showingProvides/DefaultPort/description),textinput(name/alias/param fields), aviewportlive-preview pane that re-renders the would-beworkspace.yamlon every keystroke, plusspinner/progress/help/key.charm.land/huh/v2is embedded for the linear sub-forms (per-service param entry) where a form is the right primitive — it is not the top-level driver. The layout is a cohesive two-panecharm.land/lipgloss/v2theme (picker left, live preview right) with adaptive color, rounded borders, and a persistent keymap footer — one shared theme + model scaffolding across every devstack TUI, owned byinternal/prompt/internal/tuiand reused by spec 23/spec 24. - New TUI dependencies: the v2 charm stack (all direct). Add
charm.land/bubbletea/v2+charm.land/bubbles/v2+charm.land/huh/v2on top of the already-vendoredcharm.land/lipgloss/v2/charm.land/fang/v2— all pure-Go, CGO-free, safe for theCGO_ENABLED=0static binary, pinned to the v2 line. Wrapped behindinternal/promptso the Bubble Tea runtime is never entered on a non-TTY. - Honors the global output contract. Under
--json,--quiet,CI, or a non-TTY stdin/stdout, the TUI does not launch:initeither runs the flag-driven non-interactive path (if enough flags were given) or exits non-zero with a one-line "not a TTY — pass--service/--nameor run in a terminal" guidance.--jsonemits a machine summary of what was written.
devstack init [flags]
# interactive (TTY): launches the huh wizard, ignores most flags as pre-fills
devstack init
# non-interactive / scriptable (no TUI):
--name <dsname> workspace name (default: basename of CWD, dsname-sanitized)
--service <engine[@ver]> add a shared service; repeatable
e.g. --service postgres@16 --service redis --service minio
--param <svc>.<key>=<val> set/override one shared-service param; repeatable
--alias <dsname> add a workspace alias; repeatable
--profile <name> profiles.default (default: dev)
--project <name>=<path>[,git=<url>] add a projects[] ref; repeatable
--scaffold-projects also write a minimal devstack.yaml stub per --project
--from-store seed the shared set from $DEVSTACK_HOME/config.yaml
--out <dir> output directory (default: CWD)
--dry-run print result + validation verdict, write nothing
--force overwrite existing files (backs up originals first)
--no-input never launch the TUI; require flags (implied by --json/--quiet/non-TTY/CI)
--accessible huh accessible mode (screen-reader friendly, no full-screen redraw)
The in-memory target is exactly config.Workspace:
ws := config.Workspace{
APIVersion: config.APIVersion, // "devstack/v1" (fixed)
Kind: config.KindWorkspace, // "Workspace" (fixed)
Name: name, // dsname-validated, live
Aliases: aliases, // each dsname
Profiles: config.Profiles{Default: profile},
Shared: map[string]config.SharedSvc{ // key=service name (dsname)
"postgres": {Template: "postgres", Params: map[string]any{"version": "16"}},
"redis": {Template: "redis"},
"minio": {Template: "minio"},
},
Projects: projects, // []config.ProjectRef{Name,Path,Git}
}
// scaffold.EmitWorkspaceYAML(ws) -> ordered goccy bytes (NOT yaml.Marshal(ws))
// config.ValidateWorkspaceBytes(b) / config.LoadWorkspaceOnly -> structural check BEFORE write
// (NOT config.validateModel — unexported; NOT config.LoadAt — loads project dirs that may not exist)The pipeline is identical for both faces; only step 2 differs (TUI vs flags).
- Detect + guard. Resolve the output dir (
--outor CWD). Check the target dir for an existingworkspace.yaml; if present and neither--forcenor--dry-runis set → refuse with "workspace.yaml already exists at ; use --force to overwrite or run from a fresh directory", exit non-zero. Separately,config.Discover()walks up for a parent workspace; if one is found above the target → refuse with "already inside a workspace at " (nested workspaces unsupported). Decide the mode: TUI iffterm.IsTerminal(stdin)&&term.IsTerminal(stdout)and none of--json/--quiet/--no-input/CIare set; else non-interactive. - Collect inputs.
- TUI path (
internal/prompt): a custom Bubble Tea v2 model with a left picker + right liveworkspace.yamlpreview pane (embeddedhuhsub-forms for the linear groups) — (a) Name input (validated live againstdsNameRE ^[a-z][a-z0-9_-]{0,62}$, pre-filled with the sanitized CWD basename); (b) Shared services multi-select listingbuiltinSource().List()filtered to non-emptyProvides, each row showing name +Provides/DefaultPort/description (thetemplate listrender), default-checked from the store set if--from-store/store present; (c) per-service param group for each picked engine, one field perParamSpec(text forstring/int, confirm forbool), required fields enforced, defaults pre-filled; (d) optional aliases (repeatable text, each dsname-validated) and profile (defaultdev); (e) optional projects (name=dsname, path, git) — opt-in screen, skippable. - Non-interactive path: assemble the same struct from
--name/--service/--param/--alias/--profile/--project, applying template defaults for any unset param and failing fast (mirroringeffectiveParams) on a missing required param with no default.
- TUI path (
- Assemble + default-overlay. Build
config.Workspace. For each shared service, overlay user params on top of the template defaults; drop any param left at its default so output stays minimal and diff-stable. Sortsharedkeys andprojectsdeterministically. - Validate in memory (structural). Render via
scaffold.EmitWorkspaceYAML, then run the bytes throughconfig.ValidateWorkspaceBytes(or write to an isolated temp dir and callconfig.LoadWorkspaceOnlythere, so Discover cannot escape upward to an unrelated parent). This checks theapiVersion: devstack/v1+kind: Workspaceheader and everydsname, without requiring project dirs to exist. On failure: TUI surfaces thefile:line:col/field error inline and returns to the offending group; non-interactive prints the error and exits non-zero without writing. (Full${ref}/shared-graph validation runs at the nextgenerate, once project repos are on disk.) - Preview + confirm. TUI shows the rendered YAML (+ "validates: ok") in a final confirm screen;
--dry-run(either face) prints the same to stdout and stops.--jsonprints a summary object ({workspace: <path>, shared: [...], projects: [...], wrote: bool}). - Write (no-clobber, atomic).
MkdirAll(out, 0o755); if a target exists and--force, copy it to<name>.bak.<ts>first. Writeworkspace.yamlatomically (temp file in the same dir +rename,0o644) — thestore.Save/importpattern. With--scaffold-projects, write eachprojects[].path/devstack.yamlstub (apiVersion/kind: Project/name/emptyservices: {}) under the same no-clobber rule. The file carries a leading# Generated by \devstack init` — edit freely; re-run is safe with --force.` provenance comment. - Report. Print the written path(s) and a one-line next step (
run \devstack up` to start the shared stack`). Exit 0.
No ledger or shared-stack mutation happens here — init is pure YAML authorship, so no flock is taken (same as import).
- Add
charm.land/huh/v2, NOTgithub.com/charmbracelet/huh(v1). huh v2 is built on Bubble Tea v2 + Lip Gloss v2 (charm.land/bubbletea/v2,charm.land/bubbles/v2,charm.land/lipgloss/v2), the exact vanity stack already vendored (charm.land/lipgloss/v2 v2.0.1,charm.land/fang/v2 v2.0.1). The v1 module pulls bubbletea v1/lipgloss v1 and would double-vendor a conflicting charm stack. Pin to the v2 line (currentlycharm.land/huh/v2 v2.0.3) to stay byte-aligned. README import is literallyimport "charm.land/huh/v2". - The whole charm terminal stack is CGO-free — terminal I/O goes through
golang.org/x/sys+charmbracelet/x/term/x/ansi+ultraviolet, all already vendored underCGO_ENABLED=0. huh adds no native deps. Safe for the single static binary across darwin/linux {amd64,arm64}; no build tags. Keepmake vuln/govulncheck in CI after adding. - A TUI must never be the only path.
form.Run()errors when stdin is not a TTY; do not let that bubble as a crash. Gate ongolang.org/x/term.IsTerminal(already a direct dep) before entering bubbletea and route to the flag path —--json/--quiet/CI/non-TTY must produce a deterministic result or a clear guidance error, never a half-drawn TUI. Offerhuh.WithAccessible()via--accessiblefor screen readers. - Emit YAML via goccy ordered nodes, never
yaml.Marshal(struct)/ amap. goccy randomizes map key order and renders16as16.0(DECISIONS); struct-marshal would also drop the fixed header ordering. Reuse theinternal/migrateMapSlice/MapItembuilder — extract it toscaffold.EmitWorkspaceYAMLand havemigratecall it, so there is exactly one emitter and its output is golden-tested for byte-stability. - Validate before writing — with the RIGHT entrypoint. Use the EXPORTED
config.LoadWorkspaceOnly(which structurally validates workspace.yaml and explicitly tolerates absent project dirs) or a new thinconfig.ValidateWorkspaceBytes([]byte) error. Do NOT useconfig.validateModel(it is unexported — lowercase, not callable frominternal/cli/internal/scaffold) and do NOT useconfig.LoadAt(it reads everyprojects[].path/devstack.yamland errors on a missing dir — wrong for an init where repos aren't cloned yet). This turns "produces a file that fails the nextgenerate" into "fails in the wizard with an inlinefile:line:col". - Validate names live, per the real regex. Workspace name, each shared key, alias, and project name must satisfy
dsNameRE ^[a-z][a-z0-9_-]{0,62}$. Sanitize the CWD-basename default (lowercase, strip illegal chars) — a repo dir likeMy.Appmust become a valid pre-fill (my-app), not an invalid one the user must fix. - Only offer engines with a non-empty
Provides. ASharedSvcwhose template lacksProvidesisn't a shared engine (it's a project template) and would fail the shared-graph resolution in generation. Filter the picker ontemplate.Describe(...).Provides != "". - Enforce required params like
effectiveParamsdoes. A chosen engine with a required, default-less param must block (TUI) or fail-fast (non-interactive) — emitting it unset produces a config thatgeneraterejects. Conversely, drop params left at their template default so the file is minimal and re-running is diff-stable. - No-clobber is non-negotiable and atomic. Overwriting a hand-tuned
workspace.yamlsilently is data loss; default-refuse,--forcebacks up first, write temp+rename so an interrupted run never leaves a truncated file (thestore.Save/importcontract). initauthors config; it does not touch shared state. No ledger row, no network-ensure, no Docker call — therefore no flock, no Docker context dependency. It works with the daemon down (unlikeup). This is what lets it ship in the v0.2 polish lane without the dind/integration lane.
-
devstack initon an interactive TTY launches the huh wizard, lets the user pick shared engines + fill per-ParamSpecparams, and writes aworkspace.yamlthatdevstack generateaccepts unchanged (against a workspace whose project repos, if any, are present). - The shared-service picker lists exactly the
builtinSource()templates with a non-emptyProvides(postgres/redis/minio + any store-custom engine) and hidesphp.*/node.*. -
devstack init --name app --service postgres@16 --service redis --service minio(no TTY) writes a validworkspace.yamlwith no prompts, deterministic byte-for-byte across runs. - Under
--json,--quiet,CI, or a non-TTY stdin/stdout, the TUI never launches;initeither completes from flags or exits non-zero with TTY/flag guidance.--jsonemits a machine summary. - A required, default-less param left unset fails fast (non-interactive) / blocks the form (TUI); a param left at its template default is omitted from the emitted file.
- Output is emitted via
scaffold.EmitWorkspaceYAML(goccyMapSlice) with fixed key order and integer params rendered as16, not16.0;internal/migrateuses the same emitter (one builder, golden-tested). - The rendered bytes pass
config.LoadWorkspaceOnly/config.ValidateWorkspaceBytes(structural) before any file is written; a malformed selection is reported in-wizard / pre-write, never deferred togenerate. (No use of the unexportedvalidateModelor of project-loadingLoadAt.) -
initrefuses to overwrite the target dir'sworkspace.yamlwithout--force; with--forceit backs up the original first; it also refuses whenDiscoverfinds a parent workspace; writes are atomic (temp+rename,0o644). -
--dry-runprints the rendered file(s) + a "validates: ok" verdict and writes nothing. -
--from-storeseeds the default selection from$DEVSTACK_HOME/config.yaml(store.Load,ok=false⇒ no seed) when present;initnever writes the store. - Adding
charm.land/huh/v2keepsmake cigreen underCGO_ENABLED=0andmake vulnclean; the static cross-build for darwin/linux {amd64,arm64} still succeeds.
Consumes internal/config (the config.Workspace/SharedSvc{Template,Params}/ProjectRef{Name,Path,Git} target schema, APIVersion/KindWorkspace, dsNameRE, Discover, and the workspace-only validators LoadWorkspaceOnly / new ValidateWorkspaceBytes — explicitly NOT the unexported validateModel nor project-loading LoadAt), internal/template (Describe/ParamSpec for the param forms, the Provides filter, effectiveParams semantics), internal/cli builtinSource() (the live template catalogue), internal/store (store.Load() (*store.Config, ok bool, err error) + DefaultConfig for the --from-store seed — treat ok=false as "no store, no seed"), and internal/xdg (output-dir + store-path resolution). New: internal/scaffold (EmitWorkspaceYAML, shared with — and refactored into — internal/migrate, plus possibly the ValidateWorkspaceBytes helper landing in internal/config) and internal/prompt (the huh wrapper + non-TTY fallback, behind an interface so unit tests drive the flag path without a PTY). Wired into NewRootCmd.AddCommand as a vanilla cobra command taking *GlobalOpts (so fang stays removable). New module deps charm.land/bubbletea/v2 + charm.land/bubbles/v2 + charm.land/huh/v2 (all direct, v2 line). Pairs with spec 09 (the up onboarding promise + its planned bubbletea checklist — init is the file-authoring front door that precedes the first up, and shares the v2 charm stack) and spec 14 import (the other workspace.yaml emitter, now sharing one ordered builder). Git/OCI template sources for the picker arrive with spec 19.
- Scope of v1: shared-only, or also
projects[]+ per-repo stubs? Authoringprojects[](name/path/git) and scaffolding minimaldevstack.yamlstubs adds real form surface but is what makes the output a complete workspace (spec 01). Decision: shared-services-first; projects opt-in (TUI screen +--project/--scaffold-projects), no project service authoring (that stays the per-repo file's job). - Does
initalso offer to write/update the global store? Tempting (one wizard for both), but it conflates two files with different lifecycles. Decision: read the store as a seed, never write it. - Where does the ordered emitter live? A new
internal/scaffoldvs folding intointernal/config. Decision:internal/scaffold.EmitWorkspaceYAML, and refactorinternal/migrateonto it. The byte-level validator (ValidateWorkspaceBytes), by contrast, belongs ininternal/confignext toLoadWorkspaceOnlysince it needs the unexportedstructValidate. - huh v2 vs a zero-dep
x/termprompt loop. A hand-rolled loop adds no dependency but reinvents validation/multi-select/redraw and diverges from the spec 09 bubbletea intent. Decision:charm.land/huh/v2, behindinternal/promptwith a non-TTY fallback.