Skip to content

Commit 643e293

Browse files
gustavobertoiclaude
andcommitted
feat(cli): project + env authoring & use picker (spec 30 phase 3)
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 <name>` — 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) <noreply@anthropic.com>
1 parent ef795da commit 643e293

9 files changed

Lines changed: 565 additions & 11 deletions

File tree

‎docs/guide/command-reference.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ See [projects.md](projects.md), [templates.md](templates.md).
3636
| Command | Does | Key flags |
3737
|---|---|---|
3838
| `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` |
39+
| `use [name]` | Set the active project (or switch workspace); bare + TTY opens a fuzzy picker. | `--project`, `--print` |
40+
| `context` | Show the active workspace/project/role/docker-context/version. | `--json`, `--prompt` |
41+
| `project list` / `project new <name>` | List projects / scaffold a `devstack.yaml` + register it. | `--path`, `--template`, `--uses`, `--git` |
42+
| `env list` / `env set KEY=VALUE` / `env unset KEY` | View/edit a service's local env vars (comment-preserving). | `--project`, `--service` |
3943
| `config validate` | Validate workspace + project config (errors as `file:line:col`). | — |
4044
| `config show` | Print a summary of the resolved workspace config. | — |
4145
| `generate` | Render compose + build artifacts from config and templates. | `--project`, `--profile`, `--check` |

‎docs/guide/whats-next.md‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,15 @@ All are Bubble Tea v2 behind `internal/prompt`, each with a non-TTY / `--json` /
4848

4949
## Not yet / gaps you asked about
5050

51-
### A project-management TUI (create / edit / manage projects)
52-
53-
**Does not exist today.** The TUIs above cover workspace *init*, *template*
54-
authoring, secrets *ingest*, and *monitoring* — there is no create/edit-projects
55-
TUI. It is a natural extension of the existing `internal/prompt` stack (same
56-
Bubble Tea theme, same non-TTY fallback), but it hasn't been built. For now,
57-
projects are authored by editing `devstack.yaml` directly
58-
([projects.md](projects.md)) or scaffolded via `init`.
51+
### Project & env management — ✅ shipped
52+
53+
`devstack project list` / `project new` scaffold and register projects (writing
54+
`devstack.yaml` + a comment-preserving merge into `workspace.yaml`).
55+
`devstack env list` / `env set KEY=VALUE` / `env unset` edit a service's local
56+
env vars **without reflowing your file** — the AST rewrite preserves comments and
57+
key order. `devstack use` (bare, on a TTY) opens a fuzzy **project picker**.
58+
A richer full-screen create/edit TUI remains a possible extension of the
59+
`internal/prompt` stack.
5960

6061
### "Command-runner" / task projects & monorepo orchestration (Turborepo-style)
6162

‎internal/cli/context.go‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,40 @@
11
package cli
22

33
import (
4+
"errors"
45
"fmt"
56
"strings"
67
"text/tabwriter"
78

9+
huh "charm.land/huh/v2"
810
"github.com/spf13/cobra"
911

1012
"github.com/open-source-cloud/devstack/internal/lock"
13+
"github.com/open-source-cloud/devstack/internal/prompt"
1114
"github.com/open-source-cloud/devstack/internal/version"
1215
"github.com/open-source-cloud/devstack/internal/workspace"
1316
)
1417

18+
// pickProject runs a huh select over the workspace's projects, pre-selecting the
19+
// current active one. Returns "" (no error) if the user aborts.
20+
func pickProject(projects []string, active string) (string, error) {
21+
sel := active
22+
opts := make([]huh.Option[string], 0, len(projects))
23+
for _, p := range projects {
24+
opts = append(opts, huh.NewOption(p, p))
25+
}
26+
form := huh.NewForm(huh.NewGroup(
27+
huh.NewSelect[string]().Title("Select the active project").Options(opts...).Value(&sel),
28+
)).WithTheme(prompt.Theme())
29+
if err := form.Run(); err != nil {
30+
if errors.Is(err, huh.ErrUserAborted) {
31+
return "", nil
32+
}
33+
return "", err
34+
}
35+
return sel, nil
36+
}
37+
1538
// contextInfo is the resolved active-context projection printed by `context` and
1639
// used for the console header + shell prompt segment (spec 30).
1740
type contextInfo struct {
@@ -162,9 +185,16 @@ func newUseCmd(g *GlobalOpts) *cobra.Command {
162185
targetRoot = root
163186
}
164187
default:
165-
// Bare `use`: report current context + candidates (the fuzzy picker
166-
// TUI lands in the shell-integration phase).
167-
return printUseHint(cmd, mgr, projects)
188+
// Bare `use`: a fuzzy picker on a TTY, else a printed hint.
189+
if prompt.IsInteractive(g.JSON, g.Quiet, false) && len(projects) > 0 {
190+
sel, err := pickProject(projects, resolveActiveProject(mgr.Model, mgr.DB))
191+
if err != nil || sel == "" {
192+
return err // aborted → nil
193+
}
194+
targetProject = sel
195+
} else {
196+
return printUseHint(cmd, mgr, projects)
197+
}
168198
}
169199

170200
if err := lock.WithLock(cmd.Context(), mgr.LockPath, func() error {

‎internal/cli/env.go‎

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"sort"
8+
"strings"
9+
"text/tabwriter"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/open-source-cloud/devstack/internal/config"
15+
"github.com/open-source-cloud/devstack/internal/envingest"
16+
"github.com/open-source-cloud/devstack/internal/workspace"
17+
)
18+
19+
// newEnvCmd wires the `env` group (spec 30): view and edit a project service's
20+
// local environment variables (env.raw) — the zero-setup, no-external-store
21+
// key/value mechanism. Edits are comment/order-preserving AST rewrites of the
22+
// project's devstack.yaml (the same engine `secrets ingest` uses). A value that is
23+
// a secret:// ref is stored verbatim and resolved at generation (spec 04).
24+
func newEnvCmd(g *GlobalOpts) *cobra.Command {
25+
cmd := &cobra.Command{
26+
Use: "env",
27+
Short: "View and set a project service's local env vars (env.raw)",
28+
}
29+
cmd.AddCommand(newEnvListCmd(g), newEnvSetCmd(g), newEnvUnsetCmd(g))
30+
return cmd
31+
}
32+
33+
func newEnvListCmd(g *GlobalOpts) *cobra.Command {
34+
var project, service string
35+
cmd := &cobra.Command{
36+
Use: "list",
37+
Short: "List a service's env vars (raw, prefixed, imports)",
38+
Args: cobra.NoArgs,
39+
RunE: func(cmd *cobra.Command, _ []string) error {
40+
mgr, closeFn, err := buildManager(cmd)
41+
if err != nil {
42+
return err
43+
}
44+
defer closeFn()
45+
proj, svc, _, sv, err := resolveEnvTarget(mgr, project, service)
46+
if err != nil {
47+
return err
48+
}
49+
if g.JSON {
50+
return writeJSON(cmd, map[string]any{
51+
"project": proj, "service": svc,
52+
"raw": sv.Env.Raw, "prefixed": sv.Env.Prefixed, "import": sv.Env.Import,
53+
})
54+
}
55+
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
56+
fmt.Fprintf(tw, "# %s / %s\n", proj, svc)
57+
printEnvMap(tw, "raw", sv.Env.Raw)
58+
printEnvMap(tw, "prefixed", sv.Env.Prefixed)
59+
for _, im := range sv.Env.Import {
60+
fmt.Fprintf(tw, "import\t%s\t%s\n", im.From, strings.Join(im.Vars, ","))
61+
}
62+
return tw.Flush()
63+
},
64+
}
65+
cmd.Flags().StringVar(&project, "project", "", "target project (default: the active/first project)")
66+
cmd.Flags().StringVar(&service, "service", "", "target service (default: the only service)")
67+
return cmd
68+
}
69+
70+
func newEnvSetCmd(g *GlobalOpts) *cobra.Command {
71+
var project, service string
72+
cmd := &cobra.Command{
73+
Use: "set KEY=VALUE [KEY=VALUE ...]",
74+
Short: "Set local env var(s) on a service (comment-preserving)",
75+
Long: "Set one or more KEY=VALUE env vars in a service's env.raw block. Existing keys\n" +
76+
"and comments are preserved. A VALUE of the form secret://... is stored as-is and\n" +
77+
"resolved from your secret provider at generation (never written to compose).",
78+
Args: cobra.MinimumNArgs(1),
79+
RunE: func(cmd *cobra.Command, args []string) error {
80+
kv := map[string]string{}
81+
for _, a := range args {
82+
k, v, ok := strings.Cut(a, "=")
83+
if !ok || k == "" {
84+
return fmt.Errorf("invalid KEY=VALUE: %q", a)
85+
}
86+
kv[k] = v
87+
}
88+
mgr, closeFn, err := buildManager(cmd)
89+
if err != nil {
90+
return err
91+
}
92+
defer closeFn()
93+
proj, svc, path, _, err := resolveEnvTarget(mgr, project, service)
94+
if err != nil {
95+
return err
96+
}
97+
src, err := os.ReadFile(path)
98+
if err != nil {
99+
return err
100+
}
101+
out, err := envingest.SetEnv(src, svc, "raw", kv)
102+
if err != nil {
103+
return err
104+
}
105+
if err := backupAndWrite(path, out); err != nil {
106+
return err
107+
}
108+
if !g.Quiet {
109+
fmt.Fprintf(cmd.OutOrStdout(), "set %d var(s) on %s/%s\n", len(kv), proj, svc)
110+
}
111+
return nil
112+
},
113+
}
114+
cmd.Flags().StringVar(&project, "project", "", "target project (default: the active/first project)")
115+
cmd.Flags().StringVar(&service, "service", "", "target service (default: the only service)")
116+
return cmd
117+
}
118+
119+
func newEnvUnsetCmd(g *GlobalOpts) *cobra.Command {
120+
var project, service string
121+
cmd := &cobra.Command{
122+
Use: "unset KEY [KEY ...]",
123+
Short: "Remove local env var(s) from a service",
124+
Args: cobra.MinimumNArgs(1),
125+
RunE: func(cmd *cobra.Command, args []string) error {
126+
mgr, closeFn, err := buildManager(cmd)
127+
if err != nil {
128+
return err
129+
}
130+
defer closeFn()
131+
proj, svc, path, sv, err := resolveEnvTarget(mgr, project, service)
132+
if err != nil {
133+
return err
134+
}
135+
remaining := map[string]string{}
136+
for k, v := range sv.Env.Raw {
137+
remaining[k] = v
138+
}
139+
for _, k := range args {
140+
delete(remaining, k)
141+
}
142+
src, err := os.ReadFile(path)
143+
if err != nil {
144+
return err
145+
}
146+
out, err := envingest.ReplaceEnvBlock(src, svc, "raw", remaining)
147+
if err != nil {
148+
return err
149+
}
150+
if err := backupAndWrite(path, out); err != nil {
151+
return err
152+
}
153+
if !g.Quiet {
154+
fmt.Fprintf(cmd.OutOrStdout(), "unset %d var(s) on %s/%s\n", len(args), proj, svc)
155+
}
156+
return nil
157+
},
158+
}
159+
cmd.Flags().StringVar(&project, "project", "", "target project (default: the active/first project)")
160+
cmd.Flags().StringVar(&service, "service", "", "target service (default: the only service)")
161+
return cmd
162+
}
163+
164+
// resolveEnvTarget resolves the (project, service, devstack.yaml path, service
165+
// config). The service defaults to the sole service, else --service is required.
166+
func resolveEnvTarget(mgr *workspace.Manager, project, service string) (string, string, string, config.Service, error) {
167+
proj := project
168+
if proj == "" {
169+
proj = resolveActiveProject(mgr.Model, mgr.DB)
170+
}
171+
p, ok := mgr.Model.Projects[proj]
172+
if !ok {
173+
return "", "", "", config.Service{}, fmt.Errorf("unknown project %q", proj)
174+
}
175+
svc := service
176+
if svc == "" {
177+
names := make([]string, 0, len(p.Services))
178+
for n := range p.Services {
179+
names = append(names, n)
180+
}
181+
if len(names) != 1 {
182+
sort.Strings(names)
183+
return "", "", "", config.Service{}, fmt.Errorf("project %q has %d services (%s) — pass --service", proj, len(names), strings.Join(names, ", "))
184+
}
185+
svc = names[0]
186+
}
187+
sv, ok := p.Services[svc]
188+
if !ok {
189+
return "", "", "", config.Service{}, fmt.Errorf("service %q not found in project %q", svc, proj)
190+
}
191+
path := filepath.Join(mgr.Model.ProjectDir(proj), "devstack.yaml")
192+
return proj, svc, path, sv, nil
193+
}
194+
195+
func printEnvMap(w *tabwriter.Writer, block string, m map[string]string) {
196+
keys := make([]string, 0, len(m))
197+
for k := range m {
198+
keys = append(keys, k)
199+
}
200+
sort.Strings(keys)
201+
for _, k := range keys {
202+
v := m[k]
203+
if strings.HasPrefix(v, "secret://") {
204+
v = "secret://… (resolved at generate)"
205+
}
206+
fmt.Fprintf(w, "%s\t%s\t%s\n", block, k, v)
207+
}
208+
}
209+
210+
// backupAndWrite backs up path to path.bak.<ts> then writes data (0644), matching
211+
// the init/import overwrite pattern.
212+
func backupAndWrite(path string, data []byte) error {
213+
if _, err := os.Stat(path); err == nil {
214+
if err := os.Rename(path, fmt.Sprintf("%s.bak.%d", path, time.Now().Unix())); err != nil {
215+
return err
216+
}
217+
}
218+
return os.WriteFile(path, data, 0o644)
219+
}

0 commit comments

Comments
 (0)