Skip to content

Commit 447ba3e

Browse files
gustavobertoiclaude
andcommitted
feat(ide): generate devcontainer + workspace + launch configs (spec 17)
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 fd90f84 commit 447ba3e

17 files changed

Lines changed: 949 additions & 1 deletion

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
@@ -89,6 +89,7 @@ func NewRootCmd(opts Options) *cobra.Command {
8989
newConfigCmd(g),
9090
newInitCmd(g),
9191
newGenerateCmd(g),
92+
newIdeCmd(g),
9293
newTemplateCmd(g),
9394
newSharedCmd(g),
9495
newResourceCmd(g),

internal/cli/stubs.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
3535
root.AddCommand(
3636
stub("logs", "Stream service logs", "v2 (spec 16)"),
3737
stub("dashboard", "Live TUI cockpit", "v2 (spec 16)"),
38-
stub("ide", "Generate devcontainer/.code-workspace/launch configs", "v2 (spec 17)"),
3938
stub("telemetry", "Opt-in usage telemetry (default OFF)", "a later release (spec 20)"),
4039
)
4140
}

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)