Skip to content

Commit ea1e675

Browse files
feat(ide): generate devcontainer + workspace + launch configs (spec 17) (#98)
Add `devstack ide`, the spec-17 editor/IDE generation sink. It rides the existing deterministic pipeline (typed struct -> stable encoding/json -> atomic writeIfChanged) and the two-file config + name index to author, byte-deterministically, the artifacts that point editors at devstack's already-generated compose stacks: - <repo>/.devcontainer/devcontainer.json in the dockerComposeFile+service+ workspaceFolder ATTACH form, referencing the generated project compose (../.devstack/docker-compose.yaml), forwardPorts from declared host ports, runServices scoped to the repo, overrideCommand:false + shutdownAction:none so the IDE never hijacks the entrypoint or tears down the shared stack, and an opt-in postCreateCommand reconcile. The referenced compose carries `name: devstack-<name>` + the external devstack_shared network, so the IDE lands in the SAME tool-owned project (no forked network/containers). - <workspace-root>/<name>.code-workspace multi-root file listing each repo in declared order + the generated .devstack/ tree, with yaml.schemas mappings and the Dev Containers extension recommendation. - <repo>/.vscode/launch.json + settings.json stubs (schema-map authoring aid; debug-attach configs are a follow-up gated on flock port allocation). Selection via --devcontainer / --vscode / --all (default all); --check reports drift without writing; --json emits a written-paths manifest. Pure file authorship: no Docker, no ledger, no flock. Exports generate.ProjectStackName so the name index is the single source of truth for the compose project name. Removes the ide stub from stubs.go. Tests: golden devcontainer.json + .code-workspace + launch/settings, byte-determinism across builds, idempotent re-write (nothing changes second run), target-flag paths, devcontainer wiring, service-rename reflow, and a no-secret-leak assertion. `make determinism` stays green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9bd2c40 commit ea1e675

17 files changed

Lines changed: 955 additions & 6 deletions

File tree

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ coverage.txt
1717
.DS_Store
1818
.idea/
1919
.vscode/
20+
21+
# Exception: the spec-17 IDE generation goldens ARE editor configs — the
22+
# .vscode/.devcontainer fixtures under test must stay tracked (a parent dir must
23+
# be re-included before its files, so negate the directories explicitly).
24+
!internal/ide/testdata/golden/**/.vscode/
25+
!internal/ide/testdata/golden/**/.vscode/**

‎internal/cli/ide.go‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
"github.com/open-source-cloud/devstack/internal/ide"
9+
)
10+
11+
// newIdeCmd wires `devstack ide` — the spec-17 editor/IDE generation sink. It loads
12+
// the workspace and authors, deterministically, the artifacts that point editors at
13+
// devstack's already-generated compose stacks: per-repo .devcontainer/devcontainer.json,
14+
// a multi-root <name>.code-workspace, and per-repo .vscode/{launch,settings}.json.
15+
//
16+
// It is pure file authorship (no Docker, no ledger, no flock), so it mirrors the
17+
// generate command's --check/--json contract. Targets are selected with
18+
// --devcontainer / --vscode; --all (the no-flag default) emits both.
19+
func newIdeCmd(g *GlobalOpts) *cobra.Command {
20+
var (
21+
devcontainer bool
22+
vscode bool
23+
all bool
24+
check bool
25+
)
26+
cmd := &cobra.Command{
27+
Use: "ide",
28+
Short: "Generate devcontainer/.code-workspace/launch editor configs",
29+
Long: "ide authors, from the same resolved config as `generate`, the editor artifacts\n" +
30+
"that point at devstack's already-generated compose stacks:\n\n" +
31+
" * <repo>/.devcontainer/devcontainer.json (attach the IDE to the SAME\n" +
32+
" devstack-<name> compose project + shared network devstack up runs)\n" +
33+
" * <workspace-root>/<name>.code-workspace (VS Code multi-root)\n" +
34+
" * <repo>/.vscode/{launch,settings}.json (debugger + schema-map stubs)\n\n" +
35+
"Select targets with --devcontainer / --vscode; --all (the default when no target\n" +
36+
"flag is given) emits both. Output is byte-deterministic (writeIfChanged); --check\n" +
37+
"reports drift without writing (CI-friendly).",
38+
Args: cobra.NoArgs,
39+
RunE: func(cmd *cobra.Command, _ []string) error {
40+
m, err := loadWorkspace()
41+
if err != nil {
42+
return err
43+
}
44+
targets := ide.Targets{Devcontainer: devcontainer, VSCode: vscode}
45+
if all || (!devcontainer && !vscode) {
46+
targets = ide.All()
47+
}
48+
gen := ide.New(m)
49+
arts, err := gen.Build(targets)
50+
if err != nil {
51+
return err
52+
}
53+
if check {
54+
return reportIdeCheck(cmd, g, arts)
55+
}
56+
results, err := ide.Write(arts)
57+
if err != nil {
58+
return err
59+
}
60+
return reportIdeWrite(cmd, g, results)
61+
},
62+
}
63+
cmd.Flags().BoolVar(&devcontainer, "devcontainer", false, "emit per-repo .devcontainer/devcontainer.json only")
64+
cmd.Flags().BoolVar(&vscode, "vscode", false, "emit the .code-workspace + per-repo .vscode/ configs only")
65+
cmd.Flags().BoolVar(&all, "all", false, "emit every target (default when no target flag is given)")
66+
cmd.Flags().BoolVar(&check, "check", false, "report drift without writing (CI)")
67+
return cmd
68+
}
69+
70+
func reportIdeWrite(cmd *cobra.Command, g *GlobalOpts, results []ide.WriteResult) error {
71+
if g.JSON {
72+
return writeJSON(cmd, map[string]any{"ok": true, "artifacts": results})
73+
}
74+
if g.Quiet {
75+
return nil
76+
}
77+
w := cmd.OutOrStdout()
78+
for _, r := range results {
79+
state := "unchanged"
80+
if r.Changed {
81+
state = "updated"
82+
}
83+
fmt.Fprintf(w, "%s → %s (%s)\n", r.Kind, r.Path, state)
84+
}
85+
return nil
86+
}
87+
88+
func reportIdeCheck(cmd *cobra.Command, g *GlobalOpts, arts []ide.Artifact) error {
89+
upToDate := ide.UpToDate(arts)
90+
type entry struct {
91+
Path string `json:"path"`
92+
Kind string `json:"kind"`
93+
}
94+
paths := make([]entry, 0, len(arts))
95+
for _, a := range arts {
96+
paths = append(paths, entry{Path: a.Rel, Kind: a.Kind})
97+
}
98+
if g.JSON {
99+
if err := writeJSON(cmd, map[string]any{"ok": upToDate, "artifacts": paths}); err != nil {
100+
return err
101+
}
102+
} else if !g.Quiet {
103+
w := cmd.OutOrStdout()
104+
for _, p := range paths {
105+
fmt.Fprintf(w, "%s: %s\n", p.Kind, p.Path)
106+
}
107+
}
108+
if !upToDate {
109+
return fmt.Errorf("IDE artifacts are stale; run `%s ide`", rootName(cmd))
110+
}
111+
return nil
112+
}

‎internal/cli/root.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ func NewRootCmd(opts Options) *cobra.Command {
9191
newConfigCmd(g),
9292
newInitCmd(g),
9393
newGenerateCmd(g),
94+
newIdeCmd(g),
9495
newTemplateCmd(g),
9596
newSharedCmd(g),
9697
newResourceCmd(g),

‎internal/cli/stubs.go‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ func rootName(c *cobra.Command) string { return c.Root().Name() }
2828

2929
// addStubCommands reserves the post-1.0 command surface from spec 07 as
3030
// milestone-tagged placeholders so `--help`/completions stay consistent (exit 0,
31-
// clear notice). `shell` has GRADUATED to a real command (spec 26); `logs` and
32-
// `dashboard` have GRADUATED to real commands (spec 16, see logs.go/dashboard.go).
33-
// `db` has GRADUATED to a real command group (spec 29, see db.go).
31+
// clear notice). Every verb that once lived here has GRADUATED to a real command:
32+
// `shell` (spec 26), `logs`/`dashboard` (spec 16), `telemetry` (spec 20), `ide`
33+
// (spec 17), and the `db` group (spec 15/29). None remain in this root list; the
34+
// `stub` helper still backs the `db reset`/`db pull` and template-registry
35+
// (spec 19) placeholders. Kept as a no-op seam for the next reserved verb.
3436
func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
35-
root.AddCommand(
36-
stub("ide", "Generate devcontainer/.code-workspace/launch configs", "v2 (spec 17)"),
37-
)
37+
root.AddCommand()
3838
}

‎internal/generate/names.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ const StateFile = "state.json"
4343
// projectStackName is the compose project name for a project stack.
4444
func projectStackName(project string) string { return "devstack-" + project }
4545

46+
// ProjectStackName is the exported compose project name for a project stack. IDE
47+
// artifacts (internal/ide, spec 17) reference it so the editor's `compose up`
48+
// lands in the SAME tool-owned project as `devstack up` — never a forked one.
49+
func ProjectStackName(project string) string { return projectStackName(project) }
50+
4651
// sharedAlias is the stable DNS alias a shared service is reached by over the
4752
// shared network (never the bare service name — the collision guardrail).
4853
func sharedAlias(name string) string { return "shared-" + name }

‎internal/ide/devcontainer.go‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package ide
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/open-source-cloud/devstack/internal/config"
8+
"github.com/open-source-cloud/devstack/internal/generate"
9+
)
10+
11+
// devcontainer is the typed devcontainer.json model (spec 17 "Devcontainer
12+
// model"). Fields are emitted in declaration order for byte-stable output. It uses
13+
// the dockerComposeFile+service+workspaceFolder ATTACH form — not image/build — so
14+
// the IDE joins the exact container devstack runs (same shared infra, provisioned
15+
// DB, secret env). It never carries a resolved secret value (spec 17 gotcha /
16+
// ARCHITECTURE §7.5): secrets reach the container only via the compose it points at.
17+
type devcontainer struct {
18+
Schema string `json:"$schema"`
19+
Name string `json:"name"`
20+
DockerComposeFile []string `json:"dockerComposeFile"`
21+
Service string `json:"service"`
22+
RunServices []string `json:"runServices"`
23+
WorkspaceFolder string `json:"workspaceFolder"`
24+
ForwardPorts []int `json:"forwardPorts,omitempty"`
25+
// OverrideCommand:false and ShutdownAction:"none" keep the IDE from hijacking
26+
// the entrypoint or tearing down the shared stack (spec 17).
27+
OverrideCommand bool `json:"overrideCommand"`
28+
ShutdownAction string `json:"shutdownAction"`
29+
PostCreateCommand string `json:"postCreateCommand"`
30+
}
31+
32+
// devcontainerSchema is the well-known Dev Containers metadata schema URL (the
33+
// authoring aid for devcontainer.json itself, distinct from the devstack config
34+
// schema modeline).
35+
const devcontainerSchema = "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json"
36+
37+
// buildDevcontainer authors <repo>/.devcontainer/devcontainer.json for one project.
38+
func (g *Generator) buildDevcontainer(name string, p config.Project, dir string) (Artifact, error) {
39+
// dockerComposeFile is relative to the .devcontainer/ directory and points at
40+
// the generated project compose (which carries `name: devstack-<name>` and the
41+
// external devstack_shared network — the devcontainer inherits both).
42+
composeRel := filepath.ToSlash(filepath.Join("..", generate.GenDir, generate.ComposeFile))
43+
44+
dc := devcontainer{
45+
Schema: devcontainerSchema,
46+
Name: generate.ProjectStackName(name),
47+
DockerComposeFile: []string{composeRel},
48+
Service: primaryService(name, p),
49+
RunServices: sortedServiceNames(p),
50+
WorkspaceFolder: workspaceFolder,
51+
ForwardPorts: forwardPorts(p),
52+
OverrideCommand: false,
53+
ShutdownAction: "none",
54+
// Opt-in reconcile if the folder was opened cold (spec 17): devstack still
55+
// owns network-ensure + shared services; this only re-registers refs.
56+
PostCreateCommand: fmt.Sprintf("devstack up %s --skip-clone --no-hooks", name),
57+
}
58+
data, err := marshalJSON(dc)
59+
if err != nil {
60+
return Artifact{}, err
61+
}
62+
abs := filepath.Join(dir, ".devcontainer", "devcontainer.json")
63+
return Artifact{Path: abs, Rel: g.rel(abs), Kind: "devcontainer", Data: data}, nil
64+
}
65+
66+
// workspaceFolder is the in-container mount the IDE opens. devstack's project
67+
// templates bind the repo to /workspace; a template that mounts elsewhere is a
68+
// future refinement (spec 17: derive from the typed mount, not a default).
69+
const workspaceFolder = "/workspace"

0 commit comments

Comments
 (0)