Module: internal/ide (+ internal/generate) · Milestone: v2 · Effort: ~2w (feature #9)
Meet developers in their editor: emit, from the same resolved config, the editor artifacts that point at devstack's already-generated compose stacks — per-repo .devcontainer/devcontainer.json, a multi-repo VS Code .code-workspace, per-language debugger attach configs, and the # yaml-language-server: schema modeline that turns workspace.yaml/devstack.yaml into autocompleted, validated files. This is almost entirely generation: it rides the existing deterministic pipeline (spec 02) and the workspace service graph (spec 03), adding one new sink, not new orchestration.
Post-1.0 because it builds on three v1 substrates that must be solid first: the deterministic generation engine +
writeIfChanged(spec 02), the tool-owned compose project name / labels / external-network stanza (spec 03), and the published JSON Schema +$schemamodeline policy (spec 01, DECISIONS D16). Without those, an IDE artifact pointing at the wrong project name or a drifting network is worse than none. It is also pure quality-of-life — it never gatesup— so it correctly waits behind the four pillars.
A single devstack ide gen (and a --json manifest of written paths) produces, per the workspace it discovers:
| Artifact | Path | Drives |
|---|---|---|
| Dev Container | <repo>/.devcontainer/devcontainer.json |
VS Code Dev Containers, JetBrains Gateway, GitHub Codespaces |
| Multi-root workspace | <workspace-root>/<name>.code-workspace |
VS Code multi-root (all repos + shared logs) |
| Debug configs | <repo>/.vscode/launch.json (+ .idea/ run configs, opt-in) |
per-language debugger attach |
| Schema modeline | injected into workspace.yaml/devstack.yaml on init/ide gen |
editor validation/autocomplete |
| Neovim/LSP hint | <workspace-root>/.devstack/ide/lsp.json (+ README snippet) |
yaml-language-server, dap attach map |
All paths and references are derived from the service/project name index the config layer already builds (the same index that powers ${ref} resolution and shell/exec completions, spec 07) — never from string guesses — so a service rename re-flows into every artifact.
The generated devcontainer.json uses the dockerComposeFile + service + workspaceFolder form, not a standalone image/build, so the IDE attaches to the exact same container devstack runs — same shared infra, same provisioned DB role, same secret env. To make that correct, the file MUST:
- list the generated compose file(s) for that repo under
dockerComposeFile(relative to.devcontainer/), and name the project service inservice; - set
"runServices"to only that repo's services (the devcontainer must not try to own the shared stack — devstack ownsdevstack-sharedlifecycle, spec 03); - inherit, never redefine, the external network: the referenced compose already carries
networks: { devstack_shared: { external: true } }and the tool-owned project name (-p devstack-<name>) via a siblingname:/COMPOSE_PROJECT_NAME— the devcontainer must reuse it so the IDE'scompose uplands in the same project and doesn't fork a parallel network or container set; - use
"overrideCommand": falseand"shutdownAction": "none"so the IDE neither hijacks the entrypoint nor tears down a stack devstack and other repos share.
Because the IDE may run its own docker compose up against these files, the generated compose must be self-consistent without devstack present for that one repo (network is external: true and assumed pre-created by devstack up); the devcontainer is an attach surface, not an orchestration surface. devstack still owns network-ensure and shared services; a postCreateCommand of devstack up <repo> --skip-clone --no-hooks (opt-in) reconciles the ledger/refs if the user opened the folder cold.
- The
.code-workspacelists each repo folder plus a virtual entry for.devstack/(generated artifacts, logs), sets workspace-levelyaml.schemasmappings, and recommends the Dev Containers extension. Folder order follows the workspace's declared repo order for stable diffs. - Debugger attach needs published host ports. Per spec 03, bridge IPs are not host-routable on Docker Desktop (macOS/WSL2), so a
launch.json"attach" config can only reach a debug port (delve:2345,debugpy,--inspect,xdebug 9003) if it is published to the host.ide gentherefore requests host-port allocation for any service withdebug: true, going throughinternal/workspaceport allocation inside the flock (spec 08) — it never invents a port itself — and writes the resolvedlocalhost:<port>into the attach config. Without an allocated port, it emits a commented-out stub plus a remediation line, not a wrong config. - Per-language presets keyed off the resolved stack (the same tech-gating that registers
composer/artisan/npm, spec 07): Go→delve, Node→pwa-nodeattach +localRoot/remoteRootpath map, Python→debugpy, PHP→XdebugpathMappings.remoteRoot/workspaceFoldercome from the container's known mount, not a guess.
On init and ide gen, devstack injects the first-line modeline
# yaml-language-server: $schema=https://raw.githubusercontent.com/open-source-cloud/devstack/v<ver>/schemas/devstack.schema.json
(pinned to the running binary's schema version) into workspace.yaml/devstack.yaml, and adds equivalent yaml.schemas globs to the .code-workspace for editors that prefer settings over modelines. The published schema is the same hand-authored JSON Schema the structural validator loads (spec 01, DECISIONS D16). Crucially, the editor's yaml-language-server (AJV-based) is not spec-conformant with the Go validator/v10 semantic layer on edge keywords — so the modeline is an authoring aid only; the Go validator remains the source of truth and up/doctor still reject configs the editor accepted (and may accept ones the editor red-squiggled). ide gen --offline rewrites $schema to a file:// path under .devstack/ for air-gapped/pinned setups.
- Artifacts are produced through the existing pipeline: typed struct → marshal (sorted keys, JSONC for
devcontainer.json/launch.json) →writeIfChanged(atomic tmp + rename) → golden tests. Byte-identical inputs yield byte-identical files (ARCHITECTURE §3); CI asserts it. - Regeneration must never clobber user edits. devcontainer/launch files are commonly hand-tweaked, so devstack writes only inside a fenced managed block and preserves everything outside it:
- JSONC files: a
"// devstack:managed:begin … end"comment-fenced object region; on rewrite, parse, replace only the managed keys, re-emit, and bail with a clear error if a user edited inside the fence (offer--force). - First-time creation writes the whole file; thereafter it is merge-by-managed-block, never overwrite.
- JSONC files: a
- Commit-vs-gitignore follows Q-GEN: default gitignore (regenerated freely,
.devcontainer//.vscode/added to the managed.gitignorestanza), with--commit-artifactsfor teams that want reviewable editor configs checked in. The schema modeline is always committed (it lives in the user's source file, not a generated one).
- VS Code — primary: Dev Containers (
dockerComposeFileattach) + multi-root.code-workspace+launch.json. Fully generated. - JetBrains Gateway — consumes the same
devcontainer.json(Gateway's Dev Containers support reads the spec); JetBrains run/debug configs (.idea/runConfigurations/*.xml) are opt-in (--jetbrains) because the XML format is finicky and less load-bearing than the portable spec. - neovim / generic LSP — no proprietary project file; devstack emits
.devstack/ide/lsp.json(schema map + per-service DAP attach host:port) and a README snippet wiringyaml-language-server+nvim-dap. This keeps the non-VS-Code path first-class without owning an editor's config format.
Selection of which targets to emit is config-driven (ide.targets: [vscode, jetbrains, lsp] in workspace.yaml, default [vscode]) so a team standardizes on one set and CI golden tests stay scoped; ide gen --target <name> overrides per-invocation. Unknown/disabled targets are skipped silently, never error, so adding an editor later is additive.
- A
dockerComposeFiledevcontainer must reference the SAME external network and tool-owned project name/labels asdevstack up, or the IDE forks a seconddevstack_shared-less project — broken DNS toshared-postgres, duplicate containers (spec 03). Reusename:/COMPOSE_PROJECT_NAME, don't let the IDE recompute it. - Debugger attach requires published host ports — bridge IPs are not host-routable on Docker Desktop (macOS/WSL2); only an allocated, published port reaches a debug socket. Allocate inside the flock; never bind-guess (spec 03, spec 08).
- The editor
yaml-language-serveris AJV-based and not fully spec-conformant with the Go validator — the Go validator is the source of truth (DECISIONS D16); ship schema that sticks to broadly-supported keywords so editor and authority diverge minimally. devcontainer.json/launch.jsonare JSONC (comments + trailing commas); use a JSONC-aware marshal/parse, notencoding/json, or the managed-block fence and user comments are destroyed on round-trip.- The Dev Containers CLI is a separate Node tool; devstack generates the spec file, it does not embed or shell the
devcontainerCLI (would violate the single-static-binary constraint). Opening the folder is the editor's job. - WSL2 path duality: a
.code-workspaceauthored from the Linux side must use Linux paths and thevscode-remote://wsl+<distro>/…convention; never emit a/mnt/cpath (devstack refuses/mnt/*working dirs anyway, ARCHITECTURE §8). mounts/workspaceFoldermust match the project compose's actual bind mount; deriving them from the typed model (not defaults) avoids the "edits don't appear in the container" class of bug.- Secrets reach the devcontainer only because they are already per-service
environment:keys in the generated compose (spec 04);ide genmust never copy a resolved secret value intocontainerEnv/remoteEnv(a committed-file leak) — a CI test asserts no secret value lands in any IDE artifact (ARCHITECTURE §7.5).
-
ide genin a two-repo workspace writes one.devcontainer/devcontainer.jsonper repo (correctservice,dockerComposeFile,runServices) and one<name>.code-workspacelisting both repos +.devstack/. - Opening a generated devcontainer attaches to the same container/network as
devstack up(samedevstack-<name>project, DNS toshared-postgresresolves) — no duplicate network or container set. - A service marked
debug: truegets a host port allocated through the flock-guarded allocator and a workinglaunch.jsonattach config atlocalhost:<port>; a service without it gets a commented stub + remediation, never a wrong port. - Byte-identical config → byte-identical IDE artifacts on re-run (
writeIfChanged, no spurious diff); CI determinism check passes. - A user edit outside the managed block survives regeneration; an edit inside the fence is detected and
ide genrefuses without--force. -
init/ide geninjects the$schemamodeline pinned to the binary's schema version; removing it and re-running restores it idempotently. - No resolved secret value appears in any
devcontainer.json/launch.json/.code-workspace(CI grep + golden assertion). - A service rename in
devstack.yamlre-flows into every artifact's references (name index, not string match).
Consumes internal/generate (typed marshal + writeIfChanged + golden harness, spec 02), internal/config (the service/project name index + published schema, spec 01), internal/workspace + internal/lock (host-port allocation for debug attach inside the flock, spec 03/spec 08), and the --json/error contract of internal/cli (spec 07). It is purely a generation sink — it never runs or execs containers (Compose/IDE own that; the Engine SDK stays read-only, ARCHITECTURE §4). Thinner v2 (~1w): VS Code only — devcontainer + .code-workspace + Go/Node launch.json + the schema modeline. Full (~2w) adds JetBrains run configs, the neovim/LSP path, the managed-block merge/force machinery, and debug-port allocation wired through the workspace lock.
Q-GEN (commit vs gitignore generated artifacts — IDE files inherit the same policy). New: Q-IDE-DEBUGPORTS — should debug: true allocate a persistent published host port (stable across up, but consumes the registry and a host port even when no debugger attaches) or an ephemeral port requested only by ide gen/an explicit --debug up (frees ports but the launch.json value can drift between runs)? Recommendation: persistent per-(project,service) debug port in the ledger, gated behind debug: true, so committed launch.json stays valid — confirm against the port-budget concern on low-port hosts.