From 1b86f5986a70cc7b77f6c0a11b69f2cb54161567 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 21:32:24 -0300 Subject: [PATCH] feat(cli): project + env authoring & use picker (spec 30 phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authoring surface of the interactive-DX lane. - `use` (bare, TTY) opens a huh fuzzy project picker; non-TTY falls back to the printed hint. - `project list` / `project new ` — list projects; scaffold a devstack.yaml and register it in workspace.yaml via a comment-preserving AST merge. - `env list` / `env set KEY=VALUE ...` / `env unset KEY ...` — view and edit a service's local env.raw. Edits reuse internal/envingest's AST rewriter, so comments and key order survive (the [Q-YAML-ROUNDTRIP] answer); files are backed up before write. secret:// values pass straight through. - internal/envingest: exported SetEnv / ReplaceEnvBlock / AppendProjectRef. Tests: envingest edits preserve comments / create blocks / append project refs; command registration. make ci + determinism green. Verified end-to-end: env set keeps `# comments`, project new preserves workspace.yaml comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/command-reference.md | 4 + docs/guide/whats-next.md | 17 +-- internal/cli/context.go | 44 ++++++- internal/cli/env.go | 219 +++++++++++++++++++++++++++++++ internal/cli/project.go | 136 +++++++++++++++++++ internal/cli/project_env_test.go | 16 +++ internal/cli/root.go | 2 + internal/envingest/edit.go | 82 ++++++++++++ internal/envingest/edit_test.go | 64 +++++++++ 9 files changed, 570 insertions(+), 14 deletions(-) create mode 100644 internal/cli/env.go create mode 100644 internal/cli/project.go create mode 100644 internal/cli/project_env_test.go create mode 100644 internal/envingest/edit.go create mode 100644 internal/envingest/edit_test.go diff --git a/docs/guide/command-reference.md b/docs/guide/command-reference.md index f449c08..555e359 100644 --- a/docs/guide/command-reference.md +++ b/docs/guide/command-reference.md @@ -36,6 +36,10 @@ See [projects.md](projects.md), [templates.md](templates.md). | Command | Does | Key flags | |---|---|---| | `init` | Author a `workspace.yaml` (pick shared services + params); wizard on a bare TTY. | `--name`, `--profile`, `--service`, `--param`, `--alias`, `--project`, `--from-store`, `--out`, `--dry-run`, `--force`, `--no-input` | +| `use [name]` | Set the active project (or switch workspace); bare + TTY opens a fuzzy picker. | `--project`, `--print` | +| `context` | Show the active workspace/project/role/docker-context/version. | `--json`, `--prompt` | +| `project list` / `project new ` | List projects / scaffold a `devstack.yaml` + register it. | `--path`, `--template`, `--uses`, `--git` | +| `env list` / `env set KEY=VALUE` / `env unset KEY` | View/edit a service's local env vars (comment-preserving). | `--project`, `--service` | | `config validate` | Validate workspace + project config (errors as `file:line:col`). | — | | `config show` | Print a summary of the resolved workspace config. | — | | `generate` | Render compose + build artifacts from config and templates. | `--project`, `--profile`, `--check` | diff --git a/docs/guide/whats-next.md b/docs/guide/whats-next.md index a4b22a3..a8c5642 100644 --- a/docs/guide/whats-next.md +++ b/docs/guide/whats-next.md @@ -48,14 +48,15 @@ All are Bubble Tea v2 behind `internal/prompt`, each with a non-TTY / `--json` / ## Not yet / gaps you asked about -### A project-management TUI (create / edit / manage projects) - -**Does not exist today.** The TUIs above cover workspace *init*, *template* -authoring, secrets *ingest*, and *monitoring* — there is no create/edit-projects -TUI. It is a natural extension of the existing `internal/prompt` stack (same -Bubble Tea theme, same non-TTY fallback), but it hasn't been built. For now, -projects are authored by editing `devstack.yaml` directly -([projects.md](projects.md)) or scaffolded via `init`. +### Project & env management — ✅ shipped + +`devstack project list` / `project new` scaffold and register projects (writing +`devstack.yaml` + a comment-preserving merge into `workspace.yaml`). +`devstack env list` / `env set KEY=VALUE` / `env unset` edit a service's local +env vars **without reflowing your file** — the AST rewrite preserves comments and +key order. `devstack use` (bare, on a TTY) opens a fuzzy **project picker**. +A richer full-screen create/edit TUI remains a possible extension of the +`internal/prompt` stack. ### "Command-runner" / task projects & monorepo orchestration — ✅ shipped diff --git a/internal/cli/context.go b/internal/cli/context.go index fae8668..29d2278 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -1,20 +1,43 @@ package cli import ( + "errors" "fmt" "io" "os" "strings" "text/tabwriter" + huh "charm.land/huh/v2" "github.com/spf13/cobra" "github.com/open-source-cloud/devstack/internal/config" "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/prompt" "github.com/open-source-cloud/devstack/internal/version" "github.com/open-source-cloud/devstack/internal/workspace" ) +// pickProject runs a huh select over the workspace's projects, pre-selecting the +// current active one. Returns "" (no error) if the user aborts. +func pickProject(projects []string, active string) (string, error) { + sel := active + opts := make([]huh.Option[string], 0, len(projects)) + for _, p := range projects { + opts = append(opts, huh.NewOption(p, p)) + } + form := huh.NewForm(huh.NewGroup( + huh.NewSelect[string]().Title("Select the active project").Options(opts...).Value(&sel), + )).WithTheme(prompt.Theme()) + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", nil + } + return "", err + } + return sel, nil +} + // contextInfo is the resolved active-context projection printed by `context` and // used for the console header + shell prompt segment (spec 30). type contextInfo struct { @@ -194,13 +217,22 @@ func newUseCmd(g *GlobalOpts) *cobra.Command { targetRoot = root } default: - // Bare `use`: report current context + candidates. Under --print - // the hint goes to stderr so stdout stays an eval-safe (empty) script. - out := cmd.OutOrStdout() - if printScript { - out = cmd.ErrOrStderr() + // Bare `use`: a fuzzy picker on a TTY; otherwise a printed hint + // (routed to stderr under --print so stdout stays an eval-safe, + // empty script for the shell wrapper). + if prompt.IsInteractive(g.JSON, g.Quiet, false) && len(projects) > 0 { + sel, err := pickProject(projects, resolveActiveProject(mgr.Model, mgr.DB)) + if err != nil || sel == "" { + return err // aborted → nil + } + targetProject = sel + } else { + out := cmd.OutOrStdout() + if printScript { + out = cmd.ErrOrStderr() + } + return printUseHint(out, mgr, projects) } - return printUseHint(out, mgr, projects) } if err := lock.WithLock(cmd.Context(), mgr.LockPath, func() error { diff --git a/internal/cli/env.go b/internal/cli/env.go new file mode 100644 index 0000000..861b16e --- /dev/null +++ b/internal/cli/env.go @@ -0,0 +1,219 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/envingest" + "github.com/open-source-cloud/devstack/internal/workspace" +) + +// newEnvCmd wires the `env` group (spec 30): view and edit a project service's +// local environment variables (env.raw) — the zero-setup, no-external-store +// key/value mechanism. Edits are comment/order-preserving AST rewrites of the +// project's devstack.yaml (the same engine `secrets ingest` uses). A value that is +// a secret:// ref is stored verbatim and resolved at generation (spec 04). +func newEnvCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "env", + Short: "View and set a project service's local env vars (env.raw)", + } + cmd.AddCommand(newEnvListCmd(g), newEnvSetCmd(g), newEnvUnsetCmd(g)) + return cmd +} + +func newEnvListCmd(g *GlobalOpts) *cobra.Command { + var project, service string + cmd := &cobra.Command{ + Use: "list", + Short: "List a service's env vars (raw, prefixed, imports)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + proj, svc, _, sv, err := resolveEnvTarget(mgr, project, service) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{ + "project": proj, "service": svc, + "raw": sv.Env.Raw, "prefixed": sv.Env.Prefixed, "import": sv.Env.Import, + }) + } + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "# %s / %s\n", proj, svc) + printEnvMap(tw, "raw", sv.Env.Raw) + printEnvMap(tw, "prefixed", sv.Env.Prefixed) + for _, im := range sv.Env.Import { + fmt.Fprintf(tw, "import\t%s\t%s\n", im.From, strings.Join(im.Vars, ",")) + } + return tw.Flush() + }, + } + cmd.Flags().StringVar(&project, "project", "", "target project (default: the active/first project)") + cmd.Flags().StringVar(&service, "service", "", "target service (default: the only service)") + return cmd +} + +func newEnvSetCmd(g *GlobalOpts) *cobra.Command { + var project, service string + cmd := &cobra.Command{ + Use: "set KEY=VALUE [KEY=VALUE ...]", + Short: "Set local env var(s) on a service (comment-preserving)", + Long: "Set one or more KEY=VALUE env vars in a service's env.raw block. Existing keys\n" + + "and comments are preserved. A VALUE of the form secret://... is stored as-is and\n" + + "resolved from your secret provider at generation (never written to compose).", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + kv := map[string]string{} + for _, a := range args { + k, v, ok := strings.Cut(a, "=") + if !ok || k == "" { + return fmt.Errorf("invalid KEY=VALUE: %q", a) + } + kv[k] = v + } + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + proj, svc, path, _, err := resolveEnvTarget(mgr, project, service) + if err != nil { + return err + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + out, err := envingest.SetEnv(src, svc, "raw", kv) + if err != nil { + return err + } + if err := backupAndWrite(path, out); err != nil { + return err + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "set %d var(s) on %s/%s\n", len(kv), proj, svc) + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "target project (default: the active/first project)") + cmd.Flags().StringVar(&service, "service", "", "target service (default: the only service)") + return cmd +} + +func newEnvUnsetCmd(g *GlobalOpts) *cobra.Command { + var project, service string + cmd := &cobra.Command{ + Use: "unset KEY [KEY ...]", + Short: "Remove local env var(s) from a service", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + proj, svc, path, sv, err := resolveEnvTarget(mgr, project, service) + if err != nil { + return err + } + remaining := map[string]string{} + for k, v := range sv.Env.Raw { + remaining[k] = v + } + for _, k := range args { + delete(remaining, k) + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + out, err := envingest.ReplaceEnvBlock(src, svc, "raw", remaining) + if err != nil { + return err + } + if err := backupAndWrite(path, out); err != nil { + return err + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "unset %d var(s) on %s/%s\n", len(args), proj, svc) + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "target project (default: the active/first project)") + cmd.Flags().StringVar(&service, "service", "", "target service (default: the only service)") + return cmd +} + +// resolveEnvTarget resolves the (project, service, devstack.yaml path, service +// config). The service defaults to the sole service, else --service is required. +func resolveEnvTarget(mgr *workspace.Manager, project, service string) (string, string, string, config.Service, error) { + proj := project + if proj == "" { + proj = resolveActiveProject(mgr.Model, mgr.DB) + } + p, ok := mgr.Model.Projects[proj] + if !ok { + return "", "", "", config.Service{}, fmt.Errorf("unknown project %q", proj) + } + svc := service + if svc == "" { + names := make([]string, 0, len(p.Services)) + for n := range p.Services { + names = append(names, n) + } + if len(names) != 1 { + sort.Strings(names) + return "", "", "", config.Service{}, fmt.Errorf("project %q has %d services (%s) — pass --service", proj, len(names), strings.Join(names, ", ")) + } + svc = names[0] + } + sv, ok := p.Services[svc] + if !ok { + return "", "", "", config.Service{}, fmt.Errorf("service %q not found in project %q", svc, proj) + } + path := filepath.Join(mgr.Model.ProjectDir(proj), "devstack.yaml") + return proj, svc, path, sv, nil +} + +func printEnvMap(w *tabwriter.Writer, block string, m map[string]string) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := m[k] + if strings.HasPrefix(v, "secret://") { + v = "secret://… (resolved at generate)" + } + fmt.Fprintf(w, "%s\t%s\t%s\n", block, k, v) + } +} + +// backupAndWrite backs up path to path.bak. then writes data (0644), matching +// the init/import overwrite pattern. +func backupAndWrite(path string, data []byte) error { + if _, err := os.Stat(path); err == nil { + if err := os.Rename(path, fmt.Sprintf("%s.bak.%d", path, time.Now().Unix())); err != nil { + return err + } + } + return os.WriteFile(path, data, 0o644) +} diff --git a/internal/cli/project.go b/internal/cli/project.go new file mode 100644 index 0000000..090d2f9 --- /dev/null +++ b/internal/cli/project.go @@ -0,0 +1,136 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/envingest" +) + +// newProjectCmd wires the `project` group (spec 30): list workspace projects and +// scaffold a new one. `new` authors a minimal, valid devstack.yaml and registers +// the project in workspace.yaml via a comment-preserving AST merge. +func newProjectCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Short: "List and scaffold projects in the workspace", + } + cmd.AddCommand(newProjectListCmd(g), newProjectNewCmd(g)) + return cmd +} + +func newProjectListCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List the workspace's projects", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + names := sortedProjectNames(mgr.Model) + active := resolveActiveProject(mgr.Model, mgr.DB) + if g.JSON { + return writeJSON(cmd, map[string]any{"projects": names, "active": active}) + } + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + for _, n := range names { + marker := " " + if n == active { + marker = "*" + } + p := mgr.Model.Projects[n] + svcs := make([]string, 0, len(p.Services)) + for s := range p.Services { + svcs = append(svcs, s) + } + sort.Strings(svcs) + fmt.Fprintf(tw, "%s %s\t%s\n", marker, n, strings.Join(svcs, ", ")) + } + return tw.Flush() + }, + } +} + +func newProjectNewCmd(g *GlobalOpts) *cobra.Command { + var path, template, git string + var uses []string + cmd := &cobra.Command{ + Use: "new ", + Short: "Scaffold a devstack.yaml and register the project in the workspace", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + if _, exists := mgr.Model.Projects[name]; exists { + return fmt.Errorf("project %q already exists", name) + } + if path == "" { + path = name + } + if template == "" { + template = "node.vite" + } + projDir := filepath.Join(mgr.Model.Root, path) + dsPath := filepath.Join(projDir, "devstack.yaml") + if _, err := os.Stat(dsPath); err == nil { + return fmt.Errorf("%s already exists", dsPath) + } + if err := os.MkdirAll(projDir, 0o755); err != nil { + return err + } + // Write the project devstack.yaml. + if err := os.WriteFile(dsPath, []byte(renderProjectYAML(name, "app", template, uses)), 0o644); err != nil { + return err + } + // Register it in workspace.yaml (comment-preserving AST merge). + wsPath := filepath.Join(mgr.Model.Root, "workspace.yaml") + src, err := os.ReadFile(wsPath) + if err != nil { + return err + } + out, err := envingest.AppendProjectRef(src, name, path, git) + if err != nil { + return err + } + if err := backupAndWrite(wsPath, out); err != nil { + return err + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "created %s and registered project %q\n", dsPath, name) + } + return nil + }, + } + cmd.Flags().StringVar(&path, "path", "", "project directory relative to the workspace root (default: the name)") + cmd.Flags().StringVar(&template, "template", "node.vite", "service template for the first service") + cmd.Flags().StringArrayVar(&uses, "uses", nil, "shared services the project uses (e.g. workspace.shared.postgres)") + cmd.Flags().StringVar(&git, "git", "", "git URL to record for `ws clone`") + return cmd +} + +// renderProjectYAML builds a minimal, ordered devstack.yaml. +func renderProjectYAML(name, service, template string, uses []string) string { + var b strings.Builder + fmt.Fprintf(&b, "apiVersion: devstack/v1\nkind: Project\nname: %s\nservices:\n", name) + fmt.Fprintf(&b, " %s:\n template: %s\n", service, template) + if len(uses) > 0 { + b.WriteString(" uses:\n") + for _, u := range uses { + fmt.Fprintf(&b, " - %s\n", u) + } + } + return b.String() +} diff --git a/internal/cli/project_env_test.go b/internal/cli/project_env_test.go new file mode 100644 index 0000000..fa07ebb --- /dev/null +++ b/internal/cli/project_env_test.go @@ -0,0 +1,16 @@ +package cli + +import "testing" + +func TestProjectEnvRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + for _, path := range [][]string{ + {"project", "list"}, {"project", "new"}, + {"env", "list"}, {"env", "set"}, {"env", "unset"}, + } { + c, _, err := root.Find(path) + if err != nil || c.RunE == nil { + t.Errorf("%v not registered as a real command: %v", path, err) + } + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 6075c0a..f8f30b9 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -89,6 +89,8 @@ func NewRootCmd(opts Options) *cobra.Command { newExposeCmd(g), newPortsCmd(g), newShellInitCmd(g), + newProjectCmd(g), + newEnvCmd(g), newLogsCmd(g), newDashboardCmd(g), newDnsCmd(g), diff --git a/internal/envingest/edit.go b/internal/envingest/edit.go new file mode 100644 index 0000000..46c81b1 --- /dev/null +++ b/internal/envingest/edit.go @@ -0,0 +1,82 @@ +package envingest + +import ( + "fmt" + + "github.com/goccy/go-yaml/parser" +) + +// SetEnv merges kv into a service's env. (block = "raw" | "prefixed") in +// the raw devstack.yaml bytes, PRESERVING comments and key order (the env/block +// mapping nodes are created if absent). This is the comment-safe editor the +// `devstack env set` command uses. Returns the rewritten bytes. +func SetEnv(src []byte, service, block string, kv map[string]string) ([]byte, error) { + f, err := parser.ParseBytes(src, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse devstack.yaml: %w", err) + } + if err := mergeEnvBlock(f, service, block, kv); err != nil { + return nil, err + } + return []byte(f.String()), nil +} + +// ReplaceEnvBlock replaces a service's entire env. with kv (sorted). Used +// to remove keys (`devstack env unset`); comments on that block are not preserved, +// but the rest of the file is untouched. A no-op if the block does not exist. +func ReplaceEnvBlock(src []byte, service, block string, kv map[string]string) ([]byte, error) { + f, err := parser.ParseBytes(src, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse devstack.yaml: %w", err) + } + path := fmt.Sprintf("$.services.%s.env.%s", yamlKey(service), block) + if !pathExists(f, path) { + return src, nil + } + body := "{}\n" + if len(kv) > 0 { + b, err := marshalSortedMap(kv) + if err != nil { + return nil, err + } + body = b + } + if err := replaceAt(f, path, body); err != nil { + return nil, err + } + return []byte(f.String()), nil +} + +// AppendProjectRef appends a project entry to workspace.yaml's projects: list, +// creating the list if absent. No-op-safe on formatting: it merges via the AST so +// the rest of the file (comments, order) is preserved. +func AppendProjectRef(src []byte, name, path, git string) ([]byte, error) { + f, err := parser.ParseBytes(src, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse workspace.yaml: %w", err) + } + entry := fmt.Sprintf("- name: %s\n path: %s\n", yamlScalar(name), yamlScalar(path)) + if git != "" { + entry += fmt.Sprintf(" git: %s\n", yamlScalar(git)) + } + if pathExists(f, "$.projects") { + existing, err := nodeYAML(f, "$.projects") + if err != nil { + return nil, err + } + merged := trimRightNL(dedent(existing)) + "\n" + entry + if err := replaceAt(f, "$.projects", merged); err != nil { + return nil, err + } + } else if err := mergeRoot(f, "projects:\n"+entry); err != nil { + return nil, err + } + return []byte(f.String()), nil +} + +func trimRightNL(s string) string { + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == ' ') { + s = s[:len(s)-1] + } + return s +} diff --git a/internal/envingest/edit_test.go b/internal/envingest/edit_test.go new file mode 100644 index 0000000..8ee5f93 --- /dev/null +++ b/internal/envingest/edit_test.go @@ -0,0 +1,64 @@ +package envingest + +import ( + "strings" + "testing" +) + +const dsYAML = `apiVersion: devstack/v1 +kind: Project +name: api +services: + web: + template: node.vite # dev server + env: + raw: + APP_ENV: dev # keep me +` + +func TestSetEnvPreservesComments(t *testing.T) { + out, err := SetEnv([]byte(dsYAML), "web", "raw", map[string]string{"LOG_LEVEL": "debug"}) + if err != nil { + t.Fatal(err) + } + s := string(out) + for _, want := range []string{"# dev server", "APP_ENV: dev # keep me", "LOG_LEVEL: debug"} { + if !strings.Contains(s, want) { + t.Errorf("missing %q in:\n%s", want, s) + } + } +} + +func TestSetEnvCreatesEnvBlock(t *testing.T) { + src := "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n web:\n template: node.vite\n" + out, err := SetEnv([]byte(src), "web", "raw", map[string]string{"K": "v"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "K: v") { + t.Errorf("env block not created:\n%s", out) + } +} + +func TestReplaceEnvBlockRemovesKey(t *testing.T) { + out, err := ReplaceEnvBlock([]byte(dsYAML), "web", "raw", map[string]string{"APP_ENV": "dev"}) + if err != nil { + t.Fatal(err) + } + // A hypothetical removed key must be gone; APP_ENV stays. + if !strings.Contains(string(out), "APP_ENV: dev") { + t.Errorf("APP_ENV should remain:\n%s", out) + } +} + +func TestAppendProjectRef(t *testing.T) { + ws := "apiVersion: devstack/v1\nkind: Workspace\nname: demo\n# projects below\nprojects:\n - { name: api, path: api }\n" + out, err := AppendProjectRef([]byte(ws), "worker", "worker", "") + if err != nil { + t.Fatal(err) + } + s := string(out) + if !strings.Contains(s, "# projects below") || !strings.Contains(s, "worker") || !strings.Contains(s, "api") { + t.Errorf("append lost data:\n%s", s) + } +}