From 4caac7737fa18cf5caf9aefde8b996c009df9455 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 18:15:17 -0300 Subject: [PATCH] =?UTF-8?q?feat(init):=20`devstack=20init`=20=E2=80=94=20w?= =?UTF-8?q?orkspace=20authoring=20wizard=20(spec=2022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the file-authoring front door: pick shared engines (postgres/redis/minio), fill typed params, and write a structurally-validated workspace.yaml. No lock, no Docker, no ledger — pure config authorship. Structured as the spec's "two faces, one builder": the flag path and the TUI both feed internal/scaffold. Foundation (internal/scaffold, fully unit-tested): - EmitWorkspaceYAML: deterministic ordered goccy emitter (sorted keys; params kept as strings so 16 never renders 16.0). - BuildWorkspace/resolveParams: Provides-filter, drop-at-default, required fail-fast, store-seed. - SanitizeName: CWD-basename -> valid dsname. - config.ValidateWorkspaceBytes: in-memory structural validation reusing the unexported structValidate/formatStructErr — a bad file is caught pre-write. Flag path (internal/cli/init.go): --name/--service engine@ver/--param svc.key=val/--alias/--project/--from-store/--out/--dry-run/--force/--json/ --no-input; atomic no-clobber write (+ backup); parent-workspace guard; RefuseWindowsMount. Interactive face (internal/prompt + internal/cli/init_tui.go): a Bubble Tea v2 wizard via charm.land/huh/v2 (engine multi-select, per-engine param forms with defaults, a confirm screen previewing the YAML in a lipgloss box), behind a shared prompt.IsInteractive gate so --json/--quiet/--no-input/non-TTY/CI always reach the builder without bubbletea. The charm v2 stack is pure-Go (CGO_ENABLED=0 4-target cross-build clean; no v1 charm). Tests: scaffold golden/determinism/build matrix, config validate, cli init (happy/json/dry-run/no-clobber+force/parent-guard/from-store/non-TTY gate), prompt IsInteractive matrix. make ci + determinism green. Deferred: the internal/migrate consolidation onto EmitWorkspaceYAML (pinned by the emit golden); a live two-pane preview is a polish increment. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 12 +- go.sum | 26 +++ internal/cli/init.go | 311 +++++++++++++++++++++++++ internal/cli/init_test.go | 188 +++++++++++++++ internal/cli/init_tui.go | 171 ++++++++++++++ internal/cli/root.go | 1 + internal/config/load.go | 19 ++ internal/config/validate_bytes_test.go | 32 +++ internal/prompt/prompt.go | 22 ++ internal/prompt/prompt_test.go | 36 +++ internal/prompt/theme.go | 19 ++ internal/scaffold/build.go | 108 +++++++++ internal/scaffold/build_test.go | 113 +++++++++ internal/scaffold/emit.go | 99 ++++++++ internal/scaffold/emit_test.go | 103 ++++++++ internal/scaffold/name.go | 34 +++ internal/scaffold/name_test.go | 32 +++ 17 files changed, 1324 insertions(+), 2 deletions(-) create mode 100644 internal/cli/init.go create mode 100644 internal/cli/init_test.go create mode 100644 internal/cli/init_tui.go create mode 100644 internal/config/validate_bytes_test.go create mode 100644 internal/prompt/prompt.go create mode 100644 internal/prompt/prompt_test.go create mode 100644 internal/prompt/theme.go create mode 100644 internal/scaffold/build.go create mode 100644 internal/scaffold/build_test.go create mode 100644 internal/scaffold/emit.go create mode 100644 internal/scaffold/emit_test.go create mode 100644 internal/scaffold/name.go create mode 100644 internal/scaffold/name_test.go diff --git a/go.mod b/go.mod index 5a15e54..0baba12 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,12 @@ module github.com/open-source-cloud/devstack // Go 1.25 toolchain floor — the max required by fang, validator/v10 (1.25), // compose-go (1.24), infisical-sdk (1.24). Enforced project-wide and in CI. -go 1.25.0 +go 1.25.8 require ( charm.land/fang/v2 v2.0.1 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.1 filippo.io/age v1.3.1 github.com/adrg/xdg v0.5.3 github.com/compose-spec/compose-go/v2 v2.12.1 @@ -24,13 +26,18 @@ require ( ) require ( - charm.land/lipgloss/v2 v2.0.1 // indirect + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect filippo.io/hpke v0.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect @@ -60,6 +67,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect diff --git a/go.sum b/go.sum index 12c401f..6cdba28 100644 --- a/go.sum +++ b/go.sum @@ -1,35 +1,57 @@ c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -41,6 +63,8 @@ github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -109,6 +133,8 @@ github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjc github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..faaadba --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,311 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/prompt" + "github.com/open-source-cloud/devstack/internal/scaffold" + "github.com/open-source-cloud/devstack/internal/store" + "github.com/open-source-cloud/devstack/internal/xdg" +) + +// initHeader is the provenance comment prepended to a generated workspace.yaml. +const initHeader = "# Generated by `devstack init` — edit freely; re-run is safe with --force.\n" + +// newInitCmd wires `devstack init` — the workspace authoring front door (spec 22). +// It picks shared engines (filtered to those declaring `provides:`), fills their +// typed params, and writes a structurally-validated workspace.yaml. Pure config +// authorship: no ledger, no Docker, no lock. The flag path is the scriptable face; +// the Bubble Tea wizard (the interactive face) feeds the same scaffold builder. +func newInitCmd(g *GlobalOpts) *cobra.Command { + var ( + name string + profile string + services []string + params []string + aliases []string + projects []string + fromStore bool + outDir string + dryRun bool + force bool + noInput bool + ) + cmd := &cobra.Command{ + Use: "init", + Short: "Author a workspace.yaml (pick shared services + params)", + Long: "init scaffolds a workspace.yaml: pick the shared engines (postgres/redis/minio),\n" + + "fill their typed params, and write a structurally-validated file. It takes no lock,\n" + + "starts no Docker, and never recreates a stateful service — it just authors config.\n" + + "Run with flags for a scriptable result; run it bare in a terminal for the wizard.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if outDir == "" { + outDir = "." + } + absOut, err := filepath.Abs(outDir) + if err != nil { + return err + } + if err := xdg.RefuseWindowsMount(absOut); err != nil { + return err + } + // Refuse to nest inside an existing workspace (a parent dir owns one). + // A workspace.yaml in the target dir itself is handled by the no-clobber + // check below, so only a strictly-higher root is a hard error here. + if root, derr := config.Discover(absOut); derr == nil && root != absOut { + return fmt.Errorf("already inside a workspace at %s (nested workspaces are unsupported)", root) + } + + // Two faces, one builder: a bare invocation on a TTY runs the wizard; + // any input flag, --no-input, --json/--quiet, non-TTY, or CI takes the + // scriptable flag path. Both produce a scaffold.Inputs. + hasFlags := len(services) > 0 || len(projects) > 0 || len(aliases) > 0 || + len(params) > 0 || name != "" || fromStore + var in scaffold.Inputs + if prompt.IsInteractive(g.JSON, g.Quiet, noInput) && !hasFlags { + seed := map[string]config.SharedSvc{} + if cfg, ok, _ := store.Load(); ok { + seed = cfg.Shared + } + in, err = runInitWizard(builtinSource(), seed, scaffold.SanitizeName(filepath.Base(absOut))) + if errors.Is(err, errInitCancelled) { + if !g.Quiet { + fmt.Fprintln(cmd.ErrOrStderr(), "init cancelled — nothing written") + } + return nil + } + if err != nil { + return err + } + } else { + in, err = inputsFromFlags(flagInputs{ + name: name, profile: profile, services: services, params: params, + aliases: aliases, projects: projects, fromStore: fromStore, absOut: absOut, + }) + if err != nil { + return err + } + } + + ws, err := scaffold.BuildWorkspace(builtinSource(), in) + if err != nil { + return err + } + body, err := scaffold.EmitWorkspaceYAML(ws) + if err != nil { + return err + } + out := append([]byte(initHeader), body...) + if err := config.ValidateWorkspaceBytes(out); err != nil { + return err + } + + target := filepath.Join(outDir, config.WorkspaceFile) + if dryRun { + if g.JSON { + return writeJSON(cmd, initSummaryOf(ws, target, false)) + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "%s\n--- %s ---\n(dry-run: validates ok, nothing written)\n", out, target) + return nil + } + + if err := writeWorkspaceFile(target, out, force); err != nil { + return err + } + + if g.JSON { + return writeJSON(cmd, initSummaryOf(ws, target, true)) + } + if !g.Quiet { + w := cmd.OutOrStdout() + fmt.Fprintf(w, "wrote %s\n", target) + fmt.Fprintf(w, "next: run `%s up` to start the shared stack\n", rootName(cmd)) + } + return nil + }, + } + f := cmd.Flags() + f.StringVar(&name, "name", "", "workspace name (default: sanitized basename of --out)") + f.StringVar(&profile, "profile", "dev", "default env profile") + f.StringArrayVar(&services, "service", nil, "shared engine to add, repeatable (e.g. postgres@16, redis, minio)") + f.StringArrayVar(¶ms, "param", nil, "override a shared-service param, repeatable (svc.key=value)") + f.StringArrayVar(&aliases, "alias", nil, "workspace alias, repeatable") + f.StringArrayVar(&projects, "project", nil, "project ref, repeatable (name=path[,git=url])") + f.BoolVar(&fromStore, "from-store", false, "seed shared services from the global store (~/.devstack/config.yaml)") + f.StringVar(&outDir, "out", "", "output directory (default: current directory)") + f.BoolVar(&dryRun, "dry-run", false, "print the would-be file + validation verdict, write nothing") + f.BoolVar(&force, "force", false, "overwrite an existing workspace.yaml (backs up the original first)") + f.BoolVar(&noInput, "no-input", false, "never launch the wizard; use flags only (implied by --json/--quiet/non-TTY/CI)") + return cmd +} + +// flagInputs carries the raw flag values into the parser. +type flagInputs struct { + name, profile string + services, params, aliases []string + projects []string + fromStore bool + absOut string +} + +// inputsFromFlags parses the flag surface into a scaffold.Inputs (the same struct +// the wizard produces). Engine/param/project syntax is validated here; semantic +// validation (unknown engine, missing required param) happens in BuildWorkspace. +func inputsFromFlags(f flagInputs) (scaffold.Inputs, error) { + name := f.name + if name == "" { + name = scaffold.SanitizeName(filepath.Base(f.absOut)) + } + + svcByName := map[string]*scaffold.ServiceInput{} + var order []string + for _, raw := range f.services { + engine, ver, hasVer := strings.Cut(raw, "@") + if engine == "" { + return scaffold.Inputs{}, fmt.Errorf("--service %q: empty engine name", raw) + } + si, ok := svcByName[engine] + if !ok { + si = &scaffold.ServiceInput{Engine: engine, Params: map[string]string{}} + svcByName[engine] = si + order = append(order, engine) + } + if hasVer { + if ver == "" { + return scaffold.Inputs{}, fmt.Errorf("--service %q: empty version after '@'", raw) + } + si.Params["version"] = ver + } + } + for _, raw := range f.params { + lhs, val, ok := strings.Cut(raw, "=") + if !ok { + return scaffold.Inputs{}, fmt.Errorf("--param %q: expected svc.key=value", raw) + } + svc, key, ok := strings.Cut(lhs, ".") + if !ok || svc == "" || key == "" { + return scaffold.Inputs{}, fmt.Errorf("--param %q: expected svc.key=value", raw) + } + si, ok := svcByName[svc] + if !ok { + return scaffold.Inputs{}, fmt.Errorf("--param %q: unknown service %q (add --service %s first)", raw, svc, svc) + } + si.Params[key] = val + } + svcInputs := make([]scaffold.ServiceInput, 0, len(order)) + for _, engine := range order { + svcInputs = append(svcInputs, *svcByName[engine]) + } + + projRefs, err := parseProjects(f.projects) + if err != nil { + return scaffold.Inputs{}, err + } + + var fromStore map[string]config.SharedSvc + if f.fromStore { + cfg, ok, err := store.Load() + if err != nil { + return scaffold.Inputs{}, err + } + if ok { + fromStore = cfg.Shared + } + } + + return scaffold.Inputs{ + Name: name, Profile: f.profile, Aliases: f.aliases, + Services: svcInputs, Projects: projRefs, FromStore: fromStore, + }, nil +} + +// parseProjects parses `name=path[,git=url]` entries into ProjectRefs. +func parseProjects(raw []string) ([]config.ProjectRef, error) { + var refs []config.ProjectRef + for _, r := range raw { + pname, rest, ok := strings.Cut(r, "=") + if !ok || pname == "" { + return nil, fmt.Errorf("--project %q: expected name=path[,git=url]", r) + } + parts := strings.Split(rest, ",") + ref := config.ProjectRef{Name: pname, Path: parts[0]} + for _, extra := range parts[1:] { + git, found := strings.CutPrefix(extra, "git=") + if !found { + return nil, fmt.Errorf("--project %q: unknown segment %q (only git= is supported)", r, extra) + } + ref.Git = git + } + if ref.Path == "" { + ref.Path = pname + } + refs = append(refs, ref) + } + return refs, nil +} + +// writeWorkspaceFile writes target atomically (temp + rename, 0644), refusing to +// clobber an existing file unless force (which backs up the original first). +func writeWorkspaceFile(target string, body []byte, force bool) error { + if _, err := os.Stat(target); err == nil { + if !force { + return fmt.Errorf("%s already exists; pass --force to overwrite (the original is backed up)", target) + } + if err := os.Rename(target, fmt.Sprintf("%s.bak.%d", target, time.Now().Unix())); err != nil { + return fmt.Errorf("back up %s: %w", target, err) + } + } + dir := filepath.Dir(target) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".workspace-*.yaml.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(body); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, 0o644); err != nil { + return err + } + return os.Rename(tmpName, target) +} + +type initSummary struct { + Workspace string `json:"workspace"` + Shared []string `json:"shared"` + Projects []string `json:"projects"` + Wrote bool `json:"wrote"` +} + +func initSummaryOf(ws config.Workspace, target string, wrote bool) initSummary { + shared := make([]string, 0, len(ws.Shared)) + for n := range ws.Shared { + shared = append(shared, n) + } + sort.Strings(shared) + projects := make([]string, 0, len(ws.Projects)) + for _, p := range ws.Projects { + projects = append(projects, p.Name) + } + sort.Strings(projects) + return initSummary{Workspace: target, Shared: shared, Projects: projects, Wrote: wrote} +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..8648e20 --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,188 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/store" +) + +// runInit executes `init` with args via the root command, capturing output. +// DEVSTACK_WORKSPACE is cleared so config.Discover does not short-circuit to an +// unrelated workspace during the test. +func runInit(t *testing.T, args ...string) (string, error) { + t.Helper() + t.Setenv("DEVSTACK_WORKSPACE", "") + var buf strings.Builder + root := NewRootCmd(Options{}) + root.SetArgs(append([]string{"init"}, args...)) + root.SetOut(&buf) + root.SetErr(&buf) + err := root.Execute() // run before reading buf (return-arg eval order would read it empty) + return buf.String(), err +} + +func initArgs(out string, extra ...string) []string { + return append([]string{"--name", "demo", "--service", "postgres@16", "--service", "redis", "--out", out}, extra...) +} + +func TestInitRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"init"}) + if err != nil || c.Name() != "init" || c.RunE == nil { + t.Fatalf("init not registered as a real command: %v", err) + } +} + +func TestInit_NonInteractiveHappyPath(t *testing.T) { + d := t.TempDir() + out, err := runInit(t, "--name", "app", "--service", "postgres@16", "--service", "redis", "--service", "minio", "--out", d) + if err != nil { + t.Fatalf("init: %v\n%s", err, out) + } + _, ws, err := config.LoadWorkspaceOnly(d) + if err != nil { + t.Fatalf("emitted workspace does not load: %v", err) + } + if ws.Name != "app" { + t.Errorf("name = %q, want app", ws.Name) + } + for _, e := range []string{"postgres", "redis", "minio"} { + if _, ok := ws.Shared[e]; !ok { + t.Errorf("missing shared engine %q", e) + } + } + if v := ws.Shared["postgres"].Params["version"]; v != "16" { + t.Errorf("postgres version = %v, want \"16\"", v) + } +} + +func TestInit_Deterministic(t *testing.T) { + a, b := t.TempDir(), t.TempDir() + if _, err := runInit(t, initArgs(a, "--alias", "rq")...); err != nil { + t.Fatal(err) + } + if _, err := runInit(t, initArgs(b, "--alias", "rq")...); err != nil { + t.Fatal(err) + } + ba, _ := os.ReadFile(filepath.Join(a, "workspace.yaml")) + bb, _ := os.ReadFile(filepath.Join(b, "workspace.yaml")) + if !bytes.Equal(ba, bb) { + t.Errorf("init output not deterministic:\n--- a ---\n%s\n--- b ---\n%s", ba, bb) + } +} + +func TestInit_JSONDryRun(t *testing.T) { + d := t.TempDir() + out, err := runInit(t, initArgs(d, "--json", "--dry-run")...) + if err != nil { + t.Fatalf("init --json --dry-run: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(d, "workspace.yaml")); !os.IsNotExist(err) { + t.Error("--dry-run must not write workspace.yaml") + } + var s struct { + Shared []string `json:"shared"` + Projects []string `json:"projects"` + Wrote bool `json:"wrote"` + } + if err := json.NewDecoder(strings.NewReader(out)).Decode(&s); err != nil { + t.Fatalf("decode summary: %v\n%s", err, out) + } + if s.Wrote { + t.Error("summary.wrote should be false on --dry-run") + } + if strings.Join(s.Shared, ",") != "postgres,redis" { + t.Errorf("shared = %v, want [postgres redis]", s.Shared) + } +} + +func TestInit_NoClobberAndForceBackup(t *testing.T) { + d := t.TempDir() + if _, err := runInit(t, initArgs(d)...); err != nil { + t.Fatal(err) + } + // Second run without --force must refuse. + if _, err := runInit(t, initArgs(d)...); err == nil { + t.Error("expected no-clobber refusal without --force") + } + // With --force it backs up the original. + if _, err := runInit(t, initArgs(d, "--force")...); err != nil { + t.Fatalf("init --force: %v", err) + } + entries, _ := os.ReadDir(d) + var backups int + for _, e := range entries { + if strings.Contains(e.Name(), "workspace.yaml.bak.") { + backups++ + } + } + if backups == 0 { + t.Error("--force should leave a workspace.yaml.bak.* backup") + } +} + +func TestInit_ParentWorkspaceRefused(t *testing.T) { + parent := t.TempDir() + if err := os.WriteFile(filepath.Join(parent, "workspace.yaml"), + []byte("apiVersion: devstack/v1\nkind: Workspace\nname: parent\n"), 0o644); err != nil { + t.Fatal(err) + } + child := filepath.Join(parent, "child") + out, err := runInit(t, initArgs(child)...) + if err == nil { + t.Errorf("init inside an existing workspace should be refused:\n%s", out) + } +} + +// TestInit_NoFlagsNoInputDoesNotLaunchWizard asserts the non-interactive gate: +// with --no-input and no flags, init takes the flag path (never the bubbletea +// runtime) and writes a minimal valid workspace — it must not hang on a prompt. +func TestInit_NoFlagsNoInputDoesNotLaunchWizard(t *testing.T) { + d := t.TempDir() + if _, err := runInit(t, "--out", d, "--no-input"); err != nil { + t.Fatalf("init --no-input: %v", err) + } + _, ws, err := config.LoadWorkspaceOnly(d) + if err != nil { + t.Fatalf("minimal workspace does not load: %v", err) + } + if ws.Name == "" { + t.Error("expected a sanitized default name") + } + if len(ws.Shared) != 0 { + t.Errorf("expected no shared services, got %v", ws.Shared) + } +} + +func TestInit_FromStoreSeedsAndLeavesStore(t *testing.T) { + home := t.TempDir() + t.Setenv("DEVSTACK_HOME", home) + if err := store.DefaultConfig().Save(); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(store.ConfigPath()) + + d := t.TempDir() + if _, err := runInit(t, "--name", "demo", "--from-store", "--out", d); err != nil { + t.Fatalf("init --from-store: %v", err) + } + _, ws, err := config.LoadWorkspaceOnly(d) + if err != nil { + t.Fatal(err) + } + for _, e := range []string{"postgres", "redis", "minio"} { + if _, ok := ws.Shared[e]; !ok { + t.Errorf("--from-store should seed %q", e) + } + } + after, _ := os.ReadFile(store.ConfigPath()) + if !bytes.Equal(before, after) { + t.Error("init must never modify the store config") + } +} diff --git a/internal/cli/init_tui.go b/internal/cli/init_tui.go new file mode 100644 index 0000000..9c70bdb --- /dev/null +++ b/internal/cli/init_tui.go @@ -0,0 +1,171 @@ +package cli + +import ( + "errors" + "fmt" + "regexp" + "sort" + + huh "charm.land/huh/v2" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/prompt" + "github.com/open-source-cloud/devstack/internal/scaffold" + "github.com/open-source-cloud/devstack/internal/template" +) + +// errInitCancelled signals the user dismissed the wizard; init prints a notice and +// exits 0 (a correct outcome, not a failure). +var errInitCancelled = errors.New("init cancelled") + +var wizardNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,62}$`) + +// runInitWizard drives the interactive init flow (huh on Bubble Tea v2) and returns +// the same scaffold.Inputs the flag path produces — the spec-22 "two faces, one +// builder" seam. Engines default-select the store seed; per-engine params are +// pre-filled with their template defaults; a final confirm previews the YAML. +func runInitWizard(src template.TemplateSource, seed map[string]config.SharedSvc, defaultName string) (scaffold.Inputs, error) { + engines, descByName, err := sharedEngines(src) + if err != nil { + return scaffold.Inputs{}, err + } + if len(engines) == 0 { + return scaffold.Inputs{}, fmt.Errorf("no shared engines available in the template source") + } + + name := defaultName + profile := "dev" + var chosen []string + + opts := make([]huh.Option[string], 0, len(engines)) + for _, e := range engines { + d := descByName[e] + label := e + if d.DefaultPort > 0 { + label = fmt.Sprintf("%s — %s :%d", e, d.Provides, d.DefaultPort) + } + o := huh.NewOption(label, e) + if _, seeded := seed[e]; seeded { + o = o.Selected(true) + } + opts = append(opts, o) + } + + main := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Workspace name").Value(&name). + Validate(func(s string) error { + if !wizardNameRE.MatchString(s) { + return fmt.Errorf("lowercase letters/digits/-/_, starting with a letter") + } + return nil + }), + huh.NewInput().Title("Default profile").Value(&profile).Validate(huh.ValidateNotEmpty()), + huh.NewMultiSelect[string](). + Title("Shared services"). + Description("space to toggle · enter to continue"). + Options(opts...).Value(&chosen), + ), + ).WithTheme(prompt.Theme()) + if err := main.Run(); err != nil { + return scaffold.Inputs{}, wizardErr(err) + } + + var services []scaffold.ServiceInput + for _, eng := range chosen { + d := descByName[eng] + si := scaffold.ServiceInput{Engine: eng, Params: map[string]string{}} + if len(d.Params) > 0 { + vals := map[string]*string{} + fields := make([]huh.Field, 0, len(d.Params)) + for _, pk := range sortedParamNames(d.Params) { + ps := d.Params[pk] + def := "" + if ps.Default != nil { + def = fmt.Sprint(ps.Default) + } + v := def + vals[pk] = &v + in := huh.NewInput().Title(eng + "." + pk).Value(vals[pk]) + if ps.Description != "" { + in = in.Description(ps.Description) + } + if ps.Required { + in = in.Validate(huh.ValidateNotEmpty()) + } + fields = append(fields, in) + } + f := huh.NewForm(huh.NewGroup(fields...).Title(eng + " parameters")).WithTheme(prompt.Theme()) + if err := f.Run(); err != nil { + return scaffold.Inputs{}, wizardErr(err) + } + for pk, vp := range vals { + si.Params[pk] = *vp + } + } + services = append(services, si) + } + + in := scaffold.Inputs{Name: name, Profile: profile, Services: services} + + // Build + preview, then confirm. A build error (e.g. a bad required param) is + // surfaced before the confirm rather than after writing. + ws, err := scaffold.BuildWorkspace(src, in) + if err != nil { + return scaffold.Inputs{}, err + } + body, err := scaffold.EmitWorkspaceYAML(ws) + if err != nil { + return scaffold.Inputs{}, err + } + confirm := true + cf := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Write this workspace.yaml?"). + Description(prompt.PreviewBox(string(body))). + Value(&confirm), + ), + ).WithTheme(prompt.Theme()) + if err := cf.Run(); err != nil { + return scaffold.Inputs{}, wizardErr(err) + } + if !confirm { + return scaffold.Inputs{}, errInitCancelled + } + return in, nil +} + +// sharedEngines lists the source's shared engines (non-empty Provides), sorted, +// with their descriptions — the picker catalogue. +func sharedEngines(src template.TemplateSource) ([]string, map[string]*template.Description, error) { + descByName := map[string]*template.Description{} + var engines []string + for _, n := range src.List() { + d, err := template.Describe(src, n) + if err != nil || d.Provides == "" { + continue + } + engines = append(engines, n) + descByName[n] = d + } + sort.Strings(engines) + return engines, descByName, nil +} + +func sortedParamNames(m map[string]template.ParamSpec) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// wizardErr maps a huh user-abort (ctrl+c / esc) to errInitCancelled. +func wizardErr(err error) error { + if errors.Is(err, huh.ErrUserAborted) { + return errInitCancelled + } + return err +} diff --git a/internal/cli/root.go b/internal/cli/root.go index fcdc603..5db69e4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -86,6 +86,7 @@ func NewRootCmd(opts Options) *cobra.Command { newSecretsCmd(g), newDoctorCmd(g), newConfigCmd(g), + newInitCmd(g), newGenerateCmd(g), newTemplateCmd(g), newSharedCmd(g), diff --git a/internal/config/load.go b/internal/config/load.go index f697f51..1db5f5f 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -3,6 +3,8 @@ package config import ( "fmt" "path/filepath" + + "github.com/goccy/go-yaml" ) // Load discovers the workspace by walking up from start, parses workspace.yaml @@ -40,6 +42,23 @@ func LoadWorkspaceOnly(start string) (string, *Workspace, error) { return root, &ws, nil } +// ValidateWorkspaceBytes structurally validates raw workspace.yaml bytes in +// memory — the apiVersion/kind header and every dsname — WITHOUT reading any +// project devstack.yaml (which need not exist yet). It is what `devstack init` +// runs over its emitted bytes before writing, so a malformed file is caught +// pre-write instead of as a parse failure on the next `generate`. Cross-ref / +// shared-graph resolution still defers to Load once project repos are on disk. +func ValidateWorkspaceBytes(b []byte) error { + var ws Workspace + if err := yaml.UnmarshalWithOptions(b, &ws); err != nil { + return fmt.Errorf("%s: %s", WorkspaceFile, yaml.FormatError(err, false, true)) + } + if err := structValidate(&ws); err != nil { + return formatStructErr(WorkspaceFile, err) + } + return nil +} + // LoadAt loads the workspace rooted at an already-discovered directory. func LoadAt(root string) (*Model, error) { wsSrc, err := newSource(filepath.Join(root, WorkspaceFile)) diff --git a/internal/config/validate_bytes_test.go b/internal/config/validate_bytes_test.go new file mode 100644 index 0000000..018ea89 --- /dev/null +++ b/internal/config/validate_bytes_test.go @@ -0,0 +1,32 @@ +package config_test + +import ( + "testing" + + "github.com/open-source-cloud/devstack/internal/config" +) + +func TestValidateWorkspaceBytes(t *testing.T) { + valid := "apiVersion: devstack/v1\nkind: Workspace\nname: demo\n" + if err := config.ValidateWorkspaceBytes([]byte(valid)); err != nil { + t.Fatalf("valid workspace rejected: %v", err) + } + + bad := []struct{ name, yaml string }{ + {"uppercase name", "apiVersion: devstack/v1\nkind: Workspace\nname: Bad\n"}, + {"underscore-leading name", "apiVersion: devstack/v1\nkind: Workspace\nname: _x\n"}, + {"missing name", "apiVersion: devstack/v1\nkind: Workspace\n"}, + {"wrong apiVersion", "apiVersion: devstack/v2\nkind: Workspace\nname: demo\n"}, + {"wrong kind", "apiVersion: devstack/v1\nkind: Project\nname: demo\n"}, + {"bad alias", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\naliases:\n- BAD\n"}, + {"bad project name", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n- name: Bad\n path: x\n"}, + {"project missing path", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n- name: ok\n"}, + } + for _, tc := range bad { + t.Run(tc.name, func(t *testing.T) { + if err := config.ValidateWorkspaceBytes([]byte(tc.yaml)); err == nil { + t.Errorf("expected validation error, got nil for:\n%s", tc.yaml) + } + }) + } +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..e3483bd --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,22 @@ +// Package prompt is the shared interactive-TUI substrate for devstack commands +// (spec 22): the TTY gate that keeps every wizard non-interactive-degradable, plus +// the shared huh/lipgloss theme and small render helpers. It is UI-only and imports +// no other internal package, so any command can depend on it without a cycle. +package prompt + +import ( + "os" + + "golang.org/x/term" +) + +// IsInteractive reports whether an interactive wizard should launch: a real TTY on +// both stdin and stdout, not suppressed by --json/--quiet/--no-input, and not under +// CI. Callers fall back to their flag/scriptable path when this is false, so the +// headline-output contract (ARCHITECTURE §7.9) holds and CI never drives Bubble Tea. +func IsInteractive(jsonOut, quiet, noInput bool) bool { + if jsonOut || quiet || noInput || os.Getenv("CI") != "" { + return false + } + return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) +} diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go new file mode 100644 index 0000000..4a94cb0 --- /dev/null +++ b/internal/prompt/prompt_test.go @@ -0,0 +1,36 @@ +package prompt + +import "testing" + +func TestIsInteractive_Disablers(t *testing.T) { + // Each suppressor forces non-interactive regardless of TTY state. + cases := []struct { + name string + jsonOut, quiet, noInput bool + }{ + {"--json", true, false, false}, + {"--quiet", false, true, false}, + {"--no-input", false, false, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if IsInteractive(tc.jsonOut, tc.quiet, tc.noInput) { + t.Errorf("%s must disable interactive", tc.name) + } + }) + } +} + +func TestIsInteractive_CI(t *testing.T) { + t.Setenv("CI", "1") + if IsInteractive(false, false, false) { + t.Error("CI must disable interactive") + } +} + +func TestPreviewBox(t *testing.T) { + // Smoke: the box renders and contains the content (no panic on lipgloss render). + if got := PreviewBox("hello"); got == "" { + t.Error("PreviewBox returned empty") + } +} diff --git a/internal/prompt/theme.go b/internal/prompt/theme.go new file mode 100644 index 0000000..34a2389 --- /dev/null +++ b/internal/prompt/theme.go @@ -0,0 +1,19 @@ +package prompt + +import ( + huh "charm.land/huh/v2" + "charm.land/lipgloss/v2" +) + +// Theme is the shared huh theme for every devstack wizard, so the interactive +// surfaces look like one product. It tracks the terminal's light/dark background. +func Theme() huh.Theme { return huh.ThemeFunc(huh.ThemeCharm) } + +var previewBox = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("63")). + Padding(0, 1) + +// PreviewBox frames content (e.g. a would-be workspace.yaml) in a rounded, padded +// box for a confirm-screen preview. +func PreviewBox(s string) string { return previewBox.Render(s) } diff --git a/internal/scaffold/build.go b/internal/scaffold/build.go new file mode 100644 index 0000000..fae2534 --- /dev/null +++ b/internal/scaffold/build.go @@ -0,0 +1,108 @@ +package scaffold + +import ( + "fmt" + "maps" + "sort" + "strings" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/template" +) + +// ServiceInput is one shared engine the user chose, with raw string param +// overrides. The `engine@ver` shorthand is folded into Params["version"] by the +// caller before reaching BuildWorkspace. +type ServiceInput struct { + Engine string + Params map[string]string +} + +// Inputs is the UI-agnostic description of a workspace to author. Both the flag +// path and the Bubble Tea wizard populate it, then call BuildWorkspace — the +// spec-22 "two faces, one builder" seam. +type Inputs struct { + Name string + Profile string + Aliases []string + Services []ServiceInput + Projects []config.ProjectRef + // FromStore seeds shared services from the global store (already config types); + // nil when --from-store was not given. An explicit Service overrides a same-named seed. + FromStore map[string]config.SharedSvc +} + +// BuildWorkspace assembles a typed config.Workspace from Inputs, resolving each +// chosen engine's params against its template metadata. It rejects a non-shared +// template (empty Provides), an unknown engine, an unknown param key, or a missing +// required param — the same failures generation would hit later, surfaced now. +func BuildWorkspace(src template.TemplateSource, in Inputs) (config.Workspace, error) { + shared := map[string]config.SharedSvc{} + // Seed from the store first (verbatim) so explicit --service entries override. + for name, svc := range in.FromStore { + shared[name] = config.SharedSvc{Template: svc.Template, Params: maps.Clone(svc.Params)} + } + + for _, s := range in.Services { + desc, err := template.Describe(src, s.Engine) + if err != nil { + return config.Workspace{}, fmt.Errorf("unknown template %q: %w", s.Engine, err) + } + if desc.Provides == "" { + return config.Workspace{}, fmt.Errorf( + "%q is not a shared engine (only engines that declare `provides:`, e.g. postgres/redis/minio, can be shared)", s.Engine) + } + params, err := resolveParams(s.Engine, desc.Params, s.Params) + if err != nil { + return config.Workspace{}, err + } + shared[s.Engine] = config.SharedSvc{Template: s.Engine, Params: params} + } + + return config.Workspace{ + APIVersion: config.APIVersion, + Kind: config.KindWorkspace, + Name: in.Name, + Aliases: in.Aliases, + Profiles: config.Profiles{Default: in.Profile}, + Shared: shared, + Projects: in.Projects, + }, nil +} + +// resolveParams overlays user string params on a template's declared params: it +// rejects unknown keys, drops a value equal to the template default (minimal, +// diff-stable output), and fails fast when a required param has neither a default +// nor a user value (mirrors template.effectiveParams). Kept values stay strings, +// so the emitter never produces a float (16, never 16.0) and `@ver` == --from-store. +func resolveParams(engine string, specs map[string]template.ParamSpec, user map[string]string) (map[string]any, error) { + out := map[string]any{} + for k, v := range user { + spec, ok := specs[k] + if !ok { + return nil, fmt.Errorf("service %q: unknown param %q (valid: %s)", engine, k, strings.Join(sortedKeys(specs), ", ")) + } + if spec.Default != nil && fmt.Sprint(spec.Default) == v { + continue // already at the template default → omit for minimal output + } + out[k] = v + } + + var missing []string + for name, spec := range specs { + if !spec.Required { + continue + } + if _, set := out[name]; !set && spec.Default == nil { + missing = append(missing, name) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return nil, fmt.Errorf("service %q: missing required param(s): %s", engine, strings.Join(missing, ", ")) + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} diff --git a/internal/scaffold/build_test.go b/internal/scaffold/build_test.go new file mode 100644 index 0000000..e5d9ac3 --- /dev/null +++ b/internal/scaffold/build_test.go @@ -0,0 +1,113 @@ +package scaffold_test + +import ( + "testing" + "testing/fstest" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/scaffold" + "github.com/open-source-cloud/devstack/internal/template" +) + +// testSrc is a synthetic template source: two shared engines (one with a +// defaulted param), a non-shared app template, and an engine with a required +// default-less param. +func testSrc() template.TemplateSource { + f := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} } + return template.NewFSSource(fstest.MapFS{ + "pg/template.yaml": f("provides: postgres\nparams:\n version:\n type: string\n default: \"16\"\nservice:\n image: postgres\n"), + "redis/template.yaml": f("provides: redis\nservice:\n image: redis\n"), + "app/template.yaml": f("description: a buildable app\nservice:\n image: app\n"), + "need/template.yaml": f("provides: thing\nparams:\n token:\n type: string\n required: true\nservice:\n image: thing\n"), + }) +} + +func TestBuildWorkspace_ProvidesFilter(t *testing.T) { + src := testSrc() + if _, err := scaffold.BuildWorkspace(src, scaffold.Inputs{Name: "w", Services: []scaffold.ServiceInput{{Engine: "pg"}}}); err != nil { + t.Errorf("shared engine pg should be accepted: %v", err) + } + if _, err := scaffold.BuildWorkspace(src, scaffold.Inputs{Name: "w", Services: []scaffold.ServiceInput{{Engine: "app"}}}); err == nil { + t.Error("non-shared template app should be rejected") + } + if _, err := scaffold.BuildWorkspace(src, scaffold.Inputs{Name: "w", Services: []scaffold.ServiceInput{{Engine: "nope"}}}); err == nil { + t.Error("unknown engine should error") + } +} + +func TestBuildWorkspace_DropAtDefault(t *testing.T) { + src := testSrc() + cases := []struct { + name string + params map[string]string + wantParams map[string]any // nil = no params section + }{ + {"no params", nil, nil}, + {"version at default dropped", map[string]string{"version": "16"}, nil}, + {"version overridden kept", map[string]string{"version": "18"}, map[string]any{"version": "18"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ws, err := scaffold.BuildWorkspace(src, scaffold.Inputs{Name: "w", Services: []scaffold.ServiceInput{{Engine: "pg", Params: tc.params}}}) + if err != nil { + t.Fatal(err) + } + got := ws.Shared["pg"].Params + if len(got) != len(tc.wantParams) { + t.Fatalf("params = %v, want %v", got, tc.wantParams) + } + for k, v := range tc.wantParams { + if got[k] != v { + t.Errorf("param %q = %v, want %v", k, got[k], v) + } + } + }) + } +} + +func TestBuildWorkspace_UnknownParam(t *testing.T) { + _, err := scaffold.BuildWorkspace(testSrc(), scaffold.Inputs{Name: "w", + Services: []scaffold.ServiceInput{{Engine: "pg", Params: map[string]string{"bogus": "x"}}}}) + if err == nil { + t.Fatal("unknown param should error") + } +} + +func TestBuildWorkspace_RequiredFailFast(t *testing.T) { + src := testSrc() + if _, err := scaffold.BuildWorkspace(src, scaffold.Inputs{Name: "w", Services: []scaffold.ServiceInput{{Engine: "need"}}}); err == nil { + t.Error("missing required param should fail fast") + } + if _, err := scaffold.BuildWorkspace(src, scaffold.Inputs{Name: "w", + Services: []scaffold.ServiceInput{{Engine: "need", Params: map[string]string{"token": "abc"}}}}); err != nil { + t.Errorf("required param supplied should succeed: %v", err) + } +} + +func TestBuildWorkspace_FromStoreSeedAndOverride(t *testing.T) { + src := testSrc() + seed := map[string]config.SharedSvc{ + "pg": {Template: "pg", Params: map[string]any{"version": "16"}}, + "redis": {Template: "redis"}, + } + // No explicit services: the seed is carried through verbatim. + ws, err := scaffold.BuildWorkspace(src, scaffold.Inputs{Name: "w", FromStore: seed}) + if err != nil { + t.Fatal(err) + } + if _, ok := ws.Shared["pg"]; !ok { + t.Error("store seed pg missing") + } + if _, ok := ws.Shared["redis"]; !ok { + t.Error("store seed redis missing") + } + // An explicit --service overrides the same-named seed (re-resolved params). + ws2, err := scaffold.BuildWorkspace(src, scaffold.Inputs{Name: "w", FromStore: seed, + Services: []scaffold.ServiceInput{{Engine: "pg", Params: map[string]string{"version": "18"}}}}) + if err != nil { + t.Fatal(err) + } + if v := ws2.Shared["pg"].Params["version"]; v != "18" { + t.Errorf("explicit service should override seed: version = %v, want 18", v) + } +} diff --git a/internal/scaffold/emit.go b/internal/scaffold/emit.go new file mode 100644 index 0000000..6d41f43 --- /dev/null +++ b/internal/scaffold/emit.go @@ -0,0 +1,99 @@ +// Package scaffold builds and emits a workspace.yaml from typed inputs. It is the +// shared, UI-agnostic core behind `devstack init`: both the flag path and the +// Bubble Tea wizard populate an Inputs, call BuildWorkspace, then EmitWorkspaceYAML. +// It takes no lock, touches no ledger, and starts no Docker — pure config +// authorship (spec 22). Emission is deterministic (ordered goccy MapSlice, sorted +// keys) so re-running is byte-stable. +package scaffold + +import ( + "sort" + + "github.com/goccy/go-yaml" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// EmitWorkspaceYAML renders a workspace.yaml from a typed model with a fixed key +// order (apiVersion, kind, name, aliases, profiles, shared, projects), omitting +// empty sections. Output is deterministic: shared keys, the project list, and each +// service's params are sorted, and values go through an ordered MapSlice — never +// yaml.Marshal of a Go map (goccy randomizes map order and renders 16 as 16.0). +func EmitWorkspaceYAML(ws config.Workspace) ([]byte, error) { + apiVersion := ws.APIVersion + if apiVersion == "" { + apiVersion = config.APIVersion + } + kind := ws.Kind + if kind == "" { + kind = config.KindWorkspace + } + + doc := yaml.MapSlice{ + {Key: "apiVersion", Value: apiVersion}, + {Key: "kind", Value: kind}, + {Key: "name", Value: ws.Name}, + } + if len(ws.Aliases) > 0 { + doc = append(doc, yaml.MapItem{Key: "aliases", Value: append([]string(nil), ws.Aliases...)}) + } + if ws.Profiles.Default != "" { + doc = append(doc, yaml.MapItem{Key: "profiles", + Value: yaml.MapSlice{{Key: "default", Value: ws.Profiles.Default}}}) + } + if len(ws.Shared) > 0 { + shared := yaml.MapSlice{} + for _, name := range sortedKeys(ws.Shared) { + shared = append(shared, yaml.MapItem{Key: name, Value: sharedEntry(ws.Shared[name])}) + } + doc = append(doc, yaml.MapItem{Key: "shared", Value: shared}) + } + if len(ws.Projects) > 0 { + refs := make([]yaml.MapSlice, 0, len(ws.Projects)) + for _, p := range sortedProjects(ws.Projects) { + refs = append(refs, projectEntry(p)) + } + doc = append(doc, yaml.MapItem{Key: "projects", Value: refs}) + } + return yaml.Marshal(doc) +} + +// sharedEntry renders one `shared:` value: template, then optional sorted params. +func sharedEntry(s config.SharedSvc) yaml.MapSlice { + entry := yaml.MapSlice{{Key: "template", Value: s.Template}} + if len(s.Params) > 0 { + params := yaml.MapSlice{} + for _, k := range sortedKeys(s.Params) { + params = append(params, yaml.MapItem{Key: k, Value: s.Params[k]}) + } + entry = append(entry, yaml.MapItem{Key: "params", Value: params}) + } + return entry +} + +// projectEntry renders one `projects:` entry: name, path, then optional git. git +// is emitted verbatim — shorthand expansion is `devstack import`'s job, not init's. +func projectEntry(p config.ProjectRef) yaml.MapSlice { + entry := yaml.MapSlice{{Key: "name", Value: p.Name}, {Key: "path", Value: p.Path}} + if p.Git != "" { + entry = append(entry, yaml.MapItem{Key: "git", Value: p.Git}) + } + return entry +} + +// sortedKeys returns a map's keys sorted, for deterministic iteration/output. +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// sortedProjects returns a copy of refs sorted by Name (deterministic output). +func sortedProjects(refs []config.ProjectRef) []config.ProjectRef { + out := append([]config.ProjectRef(nil), refs...) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/scaffold/emit_test.go b/internal/scaffold/emit_test.go new file mode 100644 index 0000000..7731992 --- /dev/null +++ b/internal/scaffold/emit_test.go @@ -0,0 +1,103 @@ +package scaffold_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/scaffold" +) + +func TestEmitWorkspaceYAML_Golden(t *testing.T) { + cases := []struct { + name string + ws config.Workspace + want string + }{ + { + name: "name only (apiVersion/kind defaulted)", + ws: config.Workspace{Name: "demo"}, + want: "apiVersion: devstack/v1\nkind: Workspace\nname: demo\n", + }, + { + name: "aliases and profile", + ws: config.Workspace{ + Name: "demo", Aliases: []string{"rq", "uranus"}, + Profiles: config.Profiles{Default: "dev"}, + }, + want: "apiVersion: devstack/v1\nkind: Workspace\nname: demo\n" + + "aliases:\n- rq\n- uranus\n" + + "profiles:\n default: dev\n", + }, + { + // keys sorted (minio < postgres < redis); params kept as strings. + name: "shared sorted with params", + ws: config.Workspace{ + Name: "demo", + Shared: map[string]config.SharedSvc{ + "redis": {Template: "redis"}, + "postgres": {Template: "postgres", Params: map[string]any{"version": "16"}}, + "minio": {Template: "minio"}, + }, + }, + want: "apiVersion: devstack/v1\nkind: Workspace\nname: demo\n" + + "shared:\n minio:\n template: minio\n" + + " postgres:\n template: postgres\n params:\n version: \"16\"\n" + + " redis:\n template: redis\n", + }, + { + // projects sorted by Name (api < web); git omitted when empty. + name: "projects sorted, git optional", + ws: config.Workspace{ + Name: "demo", + Projects: []config.ProjectRef{ + {Name: "web", Path: "services/web"}, + {Name: "api", Path: "services/api", Git: "git@github.com:acme/api.git"}, + }, + }, + want: "apiVersion: devstack/v1\nkind: Workspace\nname: demo\n" + + "projects:\n- name: api\n path: services/api\n git: git@github.com:acme/api.git\n" + + "- name: web\n path: services/web\n", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := scaffold.EmitWorkspaceYAML(tc.ws) + if err != nil { + t.Fatalf("emit: %v", err) + } + if string(got) != tc.want { + t.Errorf("emit mismatch:\n--- got ---\n%q\n--- want ---\n%q", got, tc.want) + } + // Every golden must be structurally valid (the spec-22 pre-write check). + if err := config.ValidateWorkspaceBytes(got); err != nil { + t.Errorf("emitted golden does not validate: %v", err) + } + // Determinism: a second emit is byte-identical. + if got2, _ := scaffold.EmitWorkspaceYAML(tc.ws); !bytes.Equal(got, got2) { + t.Error("emit is not deterministic") + } + }) + } +} + +// TestEmitWorkspaceYAML_IntNotFloat pins the goccy MapSlice hazard: a Go int +// param must render as `16`, never `16.0` (which would break determinism and +// re-parsing). Also doubles as the byte-shape guard for a future internal/migrate +// consolidation onto this emitter. +func TestEmitWorkspaceYAML_IntNotFloat(t *testing.T) { + ws := config.Workspace{Name: "demo", Shared: map[string]config.SharedSvc{ + "pg": {Template: "postgres", Params: map[string]any{"n": 16}}, + }} + got, err := scaffold.EmitWorkspaceYAML(ws) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(got), "16.0") { + t.Errorf("int param rendered as float:\n%s", got) + } + if !strings.Contains(string(got), "16") { + t.Errorf("int param missing:\n%s", got) + } +} diff --git a/internal/scaffold/name.go b/internal/scaffold/name.go new file mode 100644 index 0000000..5590700 --- /dev/null +++ b/internal/scaffold/name.go @@ -0,0 +1,34 @@ +package scaffold + +import "strings" + +// SanitizeName coerces an arbitrary string into a valid devstack name +// (^[a-z][a-z0-9_-]{0,62}$, the config dsname rule): lowercase, any other rune +// becomes '-', repeated '-' collapse, leading non-letters are trimmed, and the +// result is capped at 63 chars. Falls back to "workspace" when nothing valid +// remains. Used for the CWD-basename default and the wizard's live name field +// (so a repo dir like "My.App" pre-fills as "my-app", never an invalid value). +func SanitizeName(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + out := b.String() + for strings.Contains(out, "--") { + out = strings.ReplaceAll(out, "--", "-") + } + out = strings.TrimLeft(out, "0123456789-_") // first char must be a letter + if len(out) > 63 { + out = out[:63] + } + out = strings.TrimRight(out, "-_") + if out == "" { + return "workspace" + } + return out +} diff --git a/internal/scaffold/name_test.go b/internal/scaffold/name_test.go new file mode 100644 index 0000000..2109011 --- /dev/null +++ b/internal/scaffold/name_test.go @@ -0,0 +1,32 @@ +package scaffold_test + +import ( + "regexp" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/scaffold" +) + +func TestSanitizeName(t *testing.T) { + dsNameRE := regexp.MustCompile(`^[a-z][a-z0-9_-]{0,62}$`) + cases := []struct{ in, want string }{ + {"My.App", "my-app"}, + {"demo", "demo"}, + {"123abc", "abc"}, + {"Foo Bar!", "foo-bar"}, + {"___", "workspace"}, + {"", "workspace"}, + {"a..b", "a-b"}, + {strings.Repeat("a", 80), strings.Repeat("a", 63)}, + } + for _, tc := range cases { + got := scaffold.SanitizeName(tc.in) + if got != tc.want { + t.Errorf("SanitizeName(%q) = %q, want %q", tc.in, got, tc.want) + } + if !dsNameRE.MatchString(got) { + t.Errorf("SanitizeName(%q) = %q does not satisfy dsNameRE", tc.in, got) + } + } +}