Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ coverage.txt
.DS_Store
.idea/
.vscode/

# Exception: the spec-17 IDE generation goldens ARE editor configs — the
# .vscode/.devcontainer fixtures under test must stay tracked (a parent dir must
# be re-included before its files, so negate the directories explicitly).
!internal/ide/testdata/golden/**/.vscode/
!internal/ide/testdata/golden/**/.vscode/**
112 changes: 112 additions & 0 deletions internal/cli/ide.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package cli

import (
"fmt"

"github.com/spf13/cobra"

"github.com/open-source-cloud/devstack/internal/ide"
)

// newIdeCmd wires `devstack ide` — the spec-17 editor/IDE generation sink. It loads
// the workspace and authors, deterministically, the artifacts that point editors at
// devstack's already-generated compose stacks: per-repo .devcontainer/devcontainer.json,
// a multi-root <name>.code-workspace, and per-repo .vscode/{launch,settings}.json.
//
// It is pure file authorship (no Docker, no ledger, no flock), so it mirrors the
// generate command's --check/--json contract. Targets are selected with
// --devcontainer / --vscode; --all (the no-flag default) emits both.
func newIdeCmd(g *GlobalOpts) *cobra.Command {
var (
devcontainer bool
vscode bool
all bool
check bool
)
cmd := &cobra.Command{
Use: "ide",
Short: "Generate devcontainer/.code-workspace/launch editor configs",
Long: "ide authors, from the same resolved config as `generate`, the editor artifacts\n" +
"that point at devstack's already-generated compose stacks:\n\n" +
" * <repo>/.devcontainer/devcontainer.json (attach the IDE to the SAME\n" +
" devstack-<name> compose project + shared network devstack up runs)\n" +
" * <workspace-root>/<name>.code-workspace (VS Code multi-root)\n" +
" * <repo>/.vscode/{launch,settings}.json (debugger + schema-map stubs)\n\n" +
"Select targets with --devcontainer / --vscode; --all (the default when no target\n" +
"flag is given) emits both. Output is byte-deterministic (writeIfChanged); --check\n" +
"reports drift without writing (CI-friendly).",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
m, err := loadWorkspace()
if err != nil {
return err
}
targets := ide.Targets{Devcontainer: devcontainer, VSCode: vscode}
if all || (!devcontainer && !vscode) {
targets = ide.All()
}
gen := ide.New(m)
arts, err := gen.Build(targets)
if err != nil {
return err
}
if check {
return reportIdeCheck(cmd, g, arts)
}
results, err := ide.Write(arts)
if err != nil {
return err
}
return reportIdeWrite(cmd, g, results)
},
}
cmd.Flags().BoolVar(&devcontainer, "devcontainer", false, "emit per-repo .devcontainer/devcontainer.json only")
cmd.Flags().BoolVar(&vscode, "vscode", false, "emit the .code-workspace + per-repo .vscode/ configs only")
cmd.Flags().BoolVar(&all, "all", false, "emit every target (default when no target flag is given)")
cmd.Flags().BoolVar(&check, "check", false, "report drift without writing (CI)")
return cmd
}

func reportIdeWrite(cmd *cobra.Command, g *GlobalOpts, results []ide.WriteResult) error {
if g.JSON {
return writeJSON(cmd, map[string]any{"ok": true, "artifacts": results})
}
if g.Quiet {
return nil
}
w := cmd.OutOrStdout()
for _, r := range results {
state := "unchanged"
if r.Changed {
state = "updated"
}
fmt.Fprintf(w, "%s → %s (%s)\n", r.Kind, r.Path, state)
}
return nil
}

func reportIdeCheck(cmd *cobra.Command, g *GlobalOpts, arts []ide.Artifact) error {
upToDate := ide.UpToDate(arts)
type entry struct {
Path string `json:"path"`
Kind string `json:"kind"`
}
paths := make([]entry, 0, len(arts))
for _, a := range arts {
paths = append(paths, entry{Path: a.Rel, Kind: a.Kind})
}
if g.JSON {
if err := writeJSON(cmd, map[string]any{"ok": upToDate, "artifacts": paths}); err != nil {
return err
}
} else if !g.Quiet {
w := cmd.OutOrStdout()
for _, p := range paths {
fmt.Fprintf(w, "%s: %s\n", p.Kind, p.Path)
}
}
if !upToDate {
return fmt.Errorf("IDE artifacts are stale; run `%s ide`", rootName(cmd))
}
return nil
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func NewRootCmd(opts Options) *cobra.Command {
newConfigCmd(g),
newInitCmd(g),
newGenerateCmd(g),
newIdeCmd(g),
newTemplateCmd(g),
newSharedCmd(g),
newResourceCmd(g),
Expand Down
12 changes: 6 additions & 6 deletions internal/cli/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ func rootName(c *cobra.Command) string { return c.Root().Name() }

// addStubCommands reserves the post-1.0 command surface from spec 07 as
// milestone-tagged placeholders so `--help`/completions stay consistent (exit 0,
// clear notice). `shell` has GRADUATED to a real command (spec 26); `logs` and
// `dashboard` have GRADUATED to real commands (spec 16, see logs.go/dashboard.go).
// `db` has GRADUATED to a real command group (spec 29, see db.go).
// clear notice). Every verb that once lived here has GRADUATED to a real command:
// `shell` (spec 26), `logs`/`dashboard` (spec 16), `telemetry` (spec 20), `ide`
// (spec 17), and the `db` group (spec 15/29). None remain in this root list; the
// `stub` helper still backs the `db reset`/`db pull` and template-registry
// (spec 19) placeholders. Kept as a no-op seam for the next reserved verb.
func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
root.AddCommand(
stub("ide", "Generate devcontainer/.code-workspace/launch configs", "v2 (spec 17)"),
)
root.AddCommand()
}
5 changes: 5 additions & 0 deletions internal/generate/names.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const StateFile = "state.json"
// projectStackName is the compose project name for a project stack.
func projectStackName(project string) string { return "devstack-" + project }

// ProjectStackName is the exported compose project name for a project stack. IDE
// artifacts (internal/ide, spec 17) reference it so the editor's `compose up`
// lands in the SAME tool-owned project as `devstack up` — never a forked one.
func ProjectStackName(project string) string { return projectStackName(project) }

// sharedAlias is the stable DNS alias a shared service is reached by over the
// shared network (never the bare service name — the collision guardrail).
func sharedAlias(name string) string { return "shared-" + name }
Expand Down
69 changes: 69 additions & 0 deletions internal/ide/devcontainer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package ide

import (
"fmt"
"path/filepath"

"github.com/open-source-cloud/devstack/internal/config"
"github.com/open-source-cloud/devstack/internal/generate"
)

// devcontainer is the typed devcontainer.json model (spec 17 "Devcontainer
// model"). Fields are emitted in declaration order for byte-stable output. It uses
// the dockerComposeFile+service+workspaceFolder ATTACH form — not image/build — so
// the IDE joins the exact container devstack runs (same shared infra, provisioned
// DB, secret env). It never carries a resolved secret value (spec 17 gotcha /
// ARCHITECTURE §7.5): secrets reach the container only via the compose it points at.
type devcontainer struct {
Schema string `json:"$schema"`
Name string `json:"name"`
DockerComposeFile []string `json:"dockerComposeFile"`
Service string `json:"service"`
RunServices []string `json:"runServices"`
WorkspaceFolder string `json:"workspaceFolder"`
ForwardPorts []int `json:"forwardPorts,omitempty"`
// OverrideCommand:false and ShutdownAction:"none" keep the IDE from hijacking
// the entrypoint or tearing down the shared stack (spec 17).
OverrideCommand bool `json:"overrideCommand"`
ShutdownAction string `json:"shutdownAction"`
PostCreateCommand string `json:"postCreateCommand"`
}

// devcontainerSchema is the well-known Dev Containers metadata schema URL (the
// authoring aid for devcontainer.json itself, distinct from the devstack config
// schema modeline).
const devcontainerSchema = "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json"

// buildDevcontainer authors <repo>/.devcontainer/devcontainer.json for one project.
func (g *Generator) buildDevcontainer(name string, p config.Project, dir string) (Artifact, error) {
// dockerComposeFile is relative to the .devcontainer/ directory and points at
// the generated project compose (which carries `name: devstack-<name>` and the
// external devstack_shared network — the devcontainer inherits both).
composeRel := filepath.ToSlash(filepath.Join("..", generate.GenDir, generate.ComposeFile))

dc := devcontainer{
Schema: devcontainerSchema,
Name: generate.ProjectStackName(name),
DockerComposeFile: []string{composeRel},
Service: primaryService(name, p),
RunServices: sortedServiceNames(p),
WorkspaceFolder: workspaceFolder,
ForwardPorts: forwardPorts(p),
OverrideCommand: false,
ShutdownAction: "none",
// Opt-in reconcile if the folder was opened cold (spec 17): devstack still
// owns network-ensure + shared services; this only re-registers refs.
PostCreateCommand: fmt.Sprintf("devstack up %s --skip-clone --no-hooks", name),
}
data, err := marshalJSON(dc)
if err != nil {
return Artifact{}, err
}
abs := filepath.Join(dir, ".devcontainer", "devcontainer.json")
return Artifact{Path: abs, Rel: g.rel(abs), Kind: "devcontainer", Data: data}, nil
}

// workspaceFolder is the in-container mount the IDE opens. devstack's project
// templates bind the repo to /workspace; a template that mounts elsewhere is a
// future refinement (spec 17: derive from the typed mount, not a default).
const workspaceFolder = "/workspace"
Loading
Loading