Skip to content

Commit 6f869a3

Browse files
gustavobertoiclaude
andcommitted
feat(cli): complete the command surface — shell, workspace list, up flags, tunnel up/down (spec 26)
Land the spec-26 code deliverables (README already reconciled): - shell <service>: real interactive exec via a NEW docker seam (docker.InteractiveRunner + Compose.Exec) that wires Stdin + a -it TTY, captures no stderr, and propagates the child exit code. Resolves project/service (single-default, else required), ValidArgsFunction completion, bash→sh default, clear non-TTY error. - workspace list + registry: additive schemaV3 `workspace` pointer table (keyed by ctx, CASCADE on docker_context) + RecordWorkspace/ListWorkspaces/ RemoveWorkspace CRUD; `up` upserts the row under the flock. `list` re-derives projects/shared refs from each workspace.yaml at list time, flags stale/unparseable roots, --prune drops vanished roots, --json. - up flags: --rebuild (new UpDeps.Rebuild → build --no-cache), --skip-clone (new UpDeps.SkipClone), --health-timeout (exposes existing HealthTimeout). - self update --force: new selfupdate.Options.Force re-installs over an up-to-date binary; the package-manager CanSelfReplace refusal still holds. - tunnel up/down: standalone commands managing the cloudflared container; default-DOWN; refuses services carrying non-local secret:// values (--allow-secrets override); {tunnel,state} JSON. - reserved post-1.0 stubs (dashboard/ide/telemetry, db + template v2 children); shell graduated out of stubs.go; logs re-tagged v2 (spec 16). Tests: registry CRUD + context isolation + CASCADE, interactive Exec arg construction, up rebuild threading, self --force semantics, tunnel secret refusal + container up/down, workspace list projection/prune/degrade. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ea9e51f commit 6f869a3

24 files changed

Lines changed: 1547 additions & 22 deletions

‎internal/cli/destroy.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func newWorkspaceCmd(g *GlobalOpts) *cobra.Command {
2424
Use: "workspace",
2525
Short: "Workspace-level lifecycle (teardown)",
2626
}
27-
cmd.AddCommand(newWorkspaceDestroyCmd(g))
27+
cmd.AddCommand(newWorkspaceDestroyCmd(g), newWorkspaceListCmd(g))
2828
return cmd
2929
}
3030

‎internal/cli/root.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func NewRootCmd(opts Options) *cobra.Command {
7979
root.AddCommand(
8080
newUpCmd(g),
8181
newDownCmd(g),
82+
newShellCmd(g),
8283
newStatusCmd(g),
8384
newDnsCmd(g),
8485
newTrustCmd(g),

‎internal/cli/self.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command {
5353
var (
5454
check bool
5555
pin string
56+
force bool
5657
)
5758
cmd := &cobra.Command{
5859
Use: "update",
@@ -65,7 +66,7 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command {
6566
if check {
6667
return newSelfCheckCmd(g).RunE(cmd, nil)
6768
}
68-
res, err := selfupdate.Update(cmd.Context(), version.Version, selfupdate.Options{Version: pin})
69+
res, err := selfupdate.Update(cmd.Context(), version.Version, selfupdate.Options{Version: pin, Force: force})
6970
if err != nil {
7071
return err
7172
}
@@ -96,5 +97,6 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command {
9697
}
9798
cmd.Flags().BoolVar(&check, "check", false, "only check for a newer version; do not install")
9899
cmd.Flags().StringVar(&pin, "version", "", "install a specific release tag (e.g. v0.2.0)")
100+
cmd.Flags().BoolVar(&force, "force", false, "re-install even when already up to date (still refuses package-managed installs)")
99101
return cmd
100102
}

‎internal/cli/shell.go‎

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package cli
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"sort"
10+
11+
"github.com/spf13/cobra"
12+
13+
"github.com/open-source-cloud/devstack/internal/config"
14+
"github.com/open-source-cloud/devstack/internal/docker"
15+
"github.com/open-source-cloud/devstack/internal/generate"
16+
)
17+
18+
// newShellCmd wires `devstack shell <service> [--project P] [-- cmd...]` (spec 26):
19+
// an interactive exec into a running service container of the current (or
20+
// --project) project stack. It shells `docker compose exec` with all three std
21+
// streams inherited (a real -it TTY) via the InteractiveRunner and propagates the
22+
// container's exit code verbatim. With no `-- cmd` it opens a login shell
23+
// (bash→sh). A non-TTY invocation errors clearly rather than hanging.
24+
func newShellCmd(g *GlobalOpts) *cobra.Command {
25+
var project string
26+
cmd := &cobra.Command{
27+
Use: "shell [service] [-- cmd...]",
28+
Short: "Open an interactive shell (or run a command) in a service container",
29+
Long: "shell execs into a running service container of this workspace. With one\n" +
30+
"service it defaults to it; with many, name the service (completion helps).\n" +
31+
"With no `-- cmd` it opens a login shell (bash, falling back to sh); pass\n" +
32+
"`shell <service> -- <cmd...>` to run a one-off command instead. Requires an\n" +
33+
"interactive terminal.",
34+
// Args are the service name and, after `--`, the command. Cobra puts args
35+
// after `--` in ArgsLenAtDash; we split them ourselves below.
36+
ValidArgsFunction: shellServiceCompletion(&project),
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
// Split the positional service arg from the post-`--` command.
39+
service, command := splitShellArgs(cmd, args)
40+
41+
cwd, err := os.Getwd()
42+
if err != nil {
43+
return err
44+
}
45+
model, err := config.Load(cwd)
46+
if err != nil {
47+
return err
48+
}
49+
proj, svc, err := resolveShellTarget(model, project, service)
50+
if err != nil {
51+
return err
52+
}
53+
54+
// An interactive `compose exec -it` needs a real terminal on stdin; a
55+
// pipe/CI would otherwise hang or fail opaquely. Fail clearly first.
56+
if !isTerminal(os.Stdin) {
57+
return fmt.Errorf("shell needs an interactive terminal (stdin is not a TTY); " +
58+
"run it from a real terminal, or use a lifecycle hook / `docker compose exec -T` for scripted commands")
59+
}
60+
61+
outDir := filepath.Join(model.ProjectDir(proj), generate.GenDir)
62+
composeFile := filepath.Join(outDir, generate.ComposeFile)
63+
if _, err := os.Stat(composeFile); err != nil {
64+
composeFile = "" // fall back to label-driven `compose -p <proj> exec`
65+
}
66+
run := command
67+
if len(run) == 0 {
68+
run = defaultShellCmd()
69+
}
70+
cp := docker.Compose{
71+
Project: "devstack-" + proj,
72+
File: composeFile,
73+
Dir: outDir,
74+
Runner: docker.InteractiveRunner{},
75+
}
76+
err = cp.Exec(cmd.Context(), svc, true, run...)
77+
if err != nil {
78+
// Propagate the container's exit code verbatim (spec 26): a non-zero
79+
// shell exit is the shell's outcome, not a devstack failure.
80+
var ee *exec.ExitError
81+
if errors.As(err, &ee) {
82+
os.Exit(ee.ExitCode())
83+
}
84+
return err
85+
}
86+
return nil
87+
},
88+
}
89+
cmd.Flags().StringVar(&project, "project", "", "project whose stack to exec into (default: the only project, else required)")
90+
return cmd
91+
}
92+
93+
// splitShellArgs separates the positional service name (before `--`) from the
94+
// command to run (after `--`). Cobra records the `--` position in ArgsLenAtDash.
95+
func splitShellArgs(cmd *cobra.Command, args []string) (service string, command []string) {
96+
dash := cmd.ArgsLenAtDash()
97+
if dash < 0 {
98+
// No `--`: the first arg (if any) is the service; anything else is ignored.
99+
if len(args) > 0 {
100+
return args[0], nil
101+
}
102+
return "", nil
103+
}
104+
if dash > 0 {
105+
service = args[0]
106+
}
107+
command = args[dash:]
108+
return service, command
109+
}
110+
111+
// resolveShellTarget resolves the project + service to exec into. project defaults
112+
// to the workspace's only project (else it is required); service defaults to the
113+
// project's only service (else it is required). Errors list the choices.
114+
func resolveShellTarget(m *config.Model, project, service string) (proj, svc string, err error) {
115+
proj = project
116+
if proj == "" {
117+
names := sortedProjectNames(m)
118+
switch len(names) {
119+
case 0:
120+
return "", "", fmt.Errorf("this workspace has no projects")
121+
case 1:
122+
proj = names[0]
123+
default:
124+
return "", "", fmt.Errorf("workspace has multiple projects (%v); pass --project", names)
125+
}
126+
}
127+
p, ok := m.Projects[proj]
128+
if !ok {
129+
return "", "", fmt.Errorf("project %q is not in this workspace", proj)
130+
}
131+
svc = service
132+
if svc == "" {
133+
names := sortedServiceNames(p.Services)
134+
switch len(names) {
135+
case 0:
136+
return "", "", fmt.Errorf("project %q has no services", proj)
137+
case 1:
138+
svc = names[0]
139+
default:
140+
return "", "", fmt.Errorf("project %q has multiple services (%v); name one", proj, names)
141+
}
142+
return proj, svc, nil
143+
}
144+
if _, ok := p.Services[svc]; !ok {
145+
return "", "", fmt.Errorf("service %q is not in project %q (have %v)", svc, proj, sortedServiceNames(p.Services))
146+
}
147+
return proj, svc, nil
148+
}
149+
150+
// defaultShellCmd is the login-shell command when no `-- cmd` is given: prefer
151+
// bash, fall back to sh, probed inside the container in a single exec
152+
// (Q-SHELL-DEFAULT-CMD).
153+
func defaultShellCmd() []string {
154+
return []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
155+
}
156+
157+
// shellServiceCompletion completes the service argument with the workspace's
158+
// service names (of --project, or all projects), per spec 07's ValidArgsFunction.
159+
func shellServiceCompletion(project *string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
160+
return func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
161+
if len(args) > 0 {
162+
return nil, cobra.ShellCompDirectiveNoFileComp
163+
}
164+
cwd, err := os.Getwd()
165+
if err != nil {
166+
return nil, cobra.ShellCompDirectiveNoFileComp
167+
}
168+
m, err := config.Load(cwd)
169+
if err != nil {
170+
return nil, cobra.ShellCompDirectiveNoFileComp
171+
}
172+
set := map[string]bool{}
173+
for pname, p := range m.Projects {
174+
if *project != "" && pname != *project {
175+
continue
176+
}
177+
for sname := range p.Services {
178+
set[sname] = true
179+
}
180+
}
181+
out := make([]string, 0, len(set))
182+
for s := range set {
183+
out = append(out, s)
184+
}
185+
sort.Strings(out)
186+
return out, cobra.ShellCompDirectiveNoFileComp
187+
}
188+
}
189+
190+
// isTerminal reports whether f is a character device (a TTY), using only stdlib.
191+
func isTerminal(f *os.File) bool {
192+
if f == nil {
193+
return false
194+
}
195+
fi, err := f.Stat()
196+
if err != nil {
197+
return false
198+
}
199+
return fi.Mode()&os.ModeCharDevice != 0
200+
}

‎internal/cli/shell_test.go‎

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/open-source-cloud/devstack/internal/config"
10+
)
11+
12+
// writeWorkspace writes a workspace.yaml + one or more project devstack.yaml files
13+
// under a fresh temp dir and returns the root. projects maps project name → body.
14+
func writeWS(t *testing.T, workspaceYAML string, projects map[string]string) string {
15+
t.Helper()
16+
root := t.TempDir()
17+
write := func(rel, body string) {
18+
p := filepath.Join(root, rel)
19+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
20+
t.Fatal(err)
21+
}
22+
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
23+
t.Fatal(err)
24+
}
25+
}
26+
write("workspace.yaml", workspaceYAML)
27+
for name, body := range projects {
28+
write(filepath.Join(name, "devstack.yaml"), body)
29+
}
30+
return root
31+
}
32+
33+
func TestShellRegistered(t *testing.T) {
34+
if !findCmd(t, "shell") {
35+
t.Fatal("shell must be a real RunE command (graduated from stubs)")
36+
}
37+
// And it must NOT appear as a stub anymore.
38+
root := NewRootCmd(Options{})
39+
for _, c := range root.Commands() {
40+
if c.Name() == "shell" && c.RunE == nil {
41+
t.Fatal("shell is still a stub group")
42+
}
43+
}
44+
}
45+
46+
func TestResolveShellTarget(t *testing.T) {
47+
oneSvc := "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\n"
48+
twoSvc := "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\n api: { template: node.vite }\n"
49+
50+
t.Run("single project single service defaults", func(t *testing.T) {
51+
root := writeWS(t,
52+
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n",
53+
map[string]string{"app": oneSvc})
54+
m := mustLoad(t, root)
55+
proj, svc, err := resolveShellTarget(m, "", "")
56+
if err != nil || proj != "app" || svc != "web" {
57+
t.Fatalf("resolve = (%q,%q,%v), want app/web", proj, svc, err)
58+
}
59+
})
60+
61+
t.Run("multi service requires explicit name", func(t *testing.T) {
62+
root := writeWS(t,
63+
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n",
64+
map[string]string{"app": twoSvc})
65+
m := mustLoad(t, root)
66+
if _, _, err := resolveShellTarget(m, "", ""); err == nil {
67+
t.Fatal("multi-service project must require an explicit service")
68+
}
69+
proj, svc, err := resolveShellTarget(m, "", "api")
70+
if err != nil || proj != "app" || svc != "api" {
71+
t.Fatalf("resolve with name = (%q,%q,%v)", proj, svc, err)
72+
}
73+
})
74+
75+
t.Run("unknown service errors", func(t *testing.T) {
76+
root := writeWS(t,
77+
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n",
78+
map[string]string{"app": oneSvc})
79+
m := mustLoad(t, root)
80+
if _, _, err := resolveShellTarget(m, "", "nope"); err == nil {
81+
t.Fatal("unknown service must error")
82+
}
83+
})
84+
85+
t.Run("multi project requires --project", func(t *testing.T) {
86+
root := writeWS(t,
87+
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n - { name: svc, path: svc }\n",
88+
map[string]string{
89+
"app": oneSvc,
90+
"svc": "apiVersion: devstack/v1\nkind: Project\nname: svc\nservices:\n worker: { template: node.vite }\n",
91+
})
92+
m := mustLoad(t, root)
93+
if _, _, err := resolveShellTarget(m, "", ""); err == nil {
94+
t.Fatal("multi-project workspace must require --project")
95+
}
96+
proj, svc, err := resolveShellTarget(m, "svc", "")
97+
if err != nil || proj != "svc" || svc != "worker" {
98+
t.Fatalf("resolve with --project = (%q,%q,%v)", proj, svc, err)
99+
}
100+
})
101+
}
102+
103+
func TestDefaultShellCmd(t *testing.T) {
104+
cmd := defaultShellCmd()
105+
if len(cmd) < 3 || cmd[0] != "sh" || cmd[1] != "-c" {
106+
t.Fatalf("defaultShellCmd = %v, want an sh -c probe", cmd)
107+
}
108+
if !strings.Contains(cmd[2], "bash") || !strings.Contains(cmd[2], "sh") {
109+
t.Errorf("default shell probe should prefer bash, fall back to sh: %q", cmd[2])
110+
}
111+
}
112+
113+
func TestShellNonTTYErrorsClearly(t *testing.T) {
114+
root := writeWS(t,
115+
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n",
116+
map[string]string{"app": "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite }\n"})
117+
t.Chdir(root)
118+
119+
// Force a non-TTY stdin (a pipe is not a character device) so the check is
120+
// deterministic regardless of how the tests are launched.
121+
r, w, err := os.Pipe()
122+
if err != nil {
123+
t.Fatal(err)
124+
}
125+
defer func() { _ = w.Close(); _ = r.Close() }()
126+
old := os.Stdin
127+
os.Stdin = r
128+
defer func() { os.Stdin = old }()
129+
130+
rootCmd := NewRootCmd(Options{})
131+
var out strings.Builder
132+
rootCmd.SetArgs([]string{"shell", "web"})
133+
rootCmd.SetOut(&out)
134+
rootCmd.SetErr(&out)
135+
err = rootCmd.Execute()
136+
if err == nil {
137+
t.Fatal("shell in a non-TTY must error, not hang")
138+
}
139+
if !strings.Contains(err.Error(), "TTY") && !strings.Contains(err.Error(), "terminal") {
140+
t.Errorf("non-TTY error should mention the terminal requirement: %v", err)
141+
}
142+
}
143+
144+
func mustLoad(t *testing.T, root string) *config.Model {
145+
t.Helper()
146+
m, err := config.LoadAt(root)
147+
if err != nil {
148+
t.Fatalf("load %s: %v", root, err)
149+
}
150+
return m
151+
}

0 commit comments

Comments
 (0)