Skip to content

dwe render ide

Generate IDE-specific config files for each enabled service from a template pack. Output goes into the service’s hub directory (e.g. services/main/.vscode/settings.json).

Manifest required. Every IDE pack must contain a manifest.yml at its root listing each file to render. A missing manifest is a hard error with a migration hint. The schema is shared with render ai and render git — see Shared manifest schema. Per-file local overrides via the sibling <pack>.local/ shadow tree apply identically to all three renderers.

flowchart TD
  CFG["Load merged config"] --> SEL{"Argument given?"}
  SEL -- no --> SP["Select services<br/>policy filter + collision"]
  SEL -- yes --> EXV["Validate the argument"]
  EXV --> RHA["Resolve hub anchor<br/>deepest wins"]
  SP --> LIST["Sorted service list"]
  RHA --> LIST
  LIST --> EACH{"For each service"}
  EACH --> RP["Resolve template pack"]
  RP --> LM["Load manifest.yml<br/>strict decode"]
  LM --> VM["Validate manifest"]
  VM --> RNDR["Render each manifest.render<br/>parse, execute, write"]
  RNDR --> EACH
  EACH -- "end" --> DONE["done"]

A service participates in IDE rendering only when both flags are true:

GateSourceDefault
Project-levelservices.<name>.enabled (3-layer merged + required service override)depends on service
IDE policyservices.<name>.render.ide.enabledtrue for type: app; false otherwise

If either gate is false, the service is skipped. Skips fall into two groups:

GroupWhenReported as warning?
Policy skipsThe service is disabled at the project level, or render.ide.enabled is false (explicitly or by type default).no — these are the documented opt-in/opt-out behavior
Actionable skipsThe service has no hub directory, or another service won a directory collision.yes — these usually indicate a misconfiguration

After the activation gate, services without a hub directory are dropped — either because dir is empty or because it resolves to the project root. A service with no hub has nowhere to write, and a hub equal to the project root would let the renderer scribble over workspace.yml itself.

When more than one surviving service points at the same dir, exactly one wins:

  1. Walk each service’s extends chain to compute a depth.
  2. The service with the deepest chain wins. The chain depth is capped (currently at 32 hops) as a cycle guard.
  3. Ties at the same depth are broken lexicographically by service name, so the result is deterministic.

The losing services emit a warning that names the winning service and the contested directory.

flowchart LR
  M["main<br/>extends: nothing<br/>depth 0"] --> D
  MD["main-debug<br/>extends: main<br/>depth 1"] --> D
  D["dir: services/main"] --> W{"deepest wins"}
  W --> WIN["main-debug renders"]
  W -. "skip warning" .-> M

Rationale: IDE configs are about per-variant overrides (different debugger settings for main-debug, different launch profiles for a stage variant), so the most-specialized service in the chain owns the rendered files.

dwe render ide <name> treats <name> as a hub anchor: it must be a real, eligible service, but the deepest-wins policy is then applied to figure out which sibling actually renders.

Validation order (first failure wins):

  1. Service not in config.
  2. Service is disabled at the project level.
  3. Service has no hub directory, or its hub is the project root.
  4. render.ide.enabled evaluates to false — either explicitly or by the type’s default (non-app types are off by default; the error message tells you which case applies and how to opt in).

Once validated, the same deepest-wins resolution is applied scoped to siblings sharing the same dir. If the winner differs from the argument, an info line announces the substitution.

So dwe render ide main from a per-service deploy pipeline still does the right thing when main-debug is the active variant.

For each selected service the renderer picks one pack directory under workspace/templates/ide/.

flowchart TD
  S{"render.ide.template set?"}
  S -- yes --> EX["workspace/templates/ide/{template}/"]
  EX -- exists --> USE["use this pack"]
  EX -- missing --> ERR["error<br/>explicit is strict"]
  S -- no --> SN["workspace/templates/ide/{service-name}/"]
  SN -- exists --> USE
  SN -- missing --> DEF["workspace/templates/ide/default/"]
  DEF -- exists --> USE
  DEF -- missing --> WARN["warning + skip<br/>implicit not found"]

Rules:

  • Explicit is strict. If render.ide.template is set, only that pack is tried. A missing pack is a hard error — no silent fallback. This protects against typos like templete: accidentally resolving to default/ and rendering surprising content.
  • Implicit chain (when render.ide.template is unset): <service-name> → walk the extends: chain ancestor-by-ancestor → default. The first existing pack wins. Fall-through happens only when the candidate directory is missing; any other filesystem error is a hard error. If the implicit chain exhausts without finding a pack, rendering is skipped with a warning. Invalid pack names in the chain (leading dot, path separator) are skipped silently and the walk continues.
  • Why ancestors: an extends: child like main-debug typically does not ship its own pack; it inherits the parent’s IDE configuration. Falling straight to default/ would render the wrong content because the default pack is calibrated for unrelated services.
  • A pack must be a real directory. Symlinked packs are rejected.
  • The chosen pack must be inside the project root with no symlinked parent components.

Template-key validation rejects:

  • Path separators (/, \).
  • Leading dot (subsumes .. and hidden-name keys).
  • Empty (treated as “unset”, which is allowed and triggers the implicit chain).

Service names used as the implicit pack key go through the same strict pack-name validator (manifest.ValidatePackName): a service name with a leading dot, leading hyphen, or path separator is silently skipped as a candidate and the walk continues.

Each IDE pack must contain a manifest.yml at its root using the shared manifest schema:

render:
- from: .vscode/settings.json.tmpl
to: .vscode/settings.json
- from: .devcontainer/devcontainer.json.tmpl
to: .devcontainer/devcontainer.json
# symlinks: optional — same semantics as render ai

A missing manifest.yml is a hard error: every pack must include a manifest listing each file. Files inside the pack that are not referenced by render are ignored (the renderer never walks the pack on its own).

Validation runs in two passes:

PassWhat it checks
Shape (pure)At least one render or symlinks entry; to paths are contained under the hub; no duplicate to; every symlink to references a known render destination.
Sources (resolver-aware)Every from exists either at the canonical workspace/templates/ide/<pack>/<from> or at the override workspace/templates/ide/<pack>.local/<from>.

Strict YAML decode (yaml.Decoder.KnownFields(true)) rejects misspelled keys like renders: at load time.

For each entry the destination is built by joining the service hub directory with the entry’s explicit to path (the from/source path is independent and is not used to derive the destination). The renderer:

  1. Reads the template file from the pack.
  2. Parses it as a Go text template with strict mode enabled — any reference to a missing field aborts rendering instead of writing a <no value> placeholder.
  3. Executes the template against the template variables.
  4. Resolves the destination and runs the path-safety guards.
  5. Creates any missing parent directories.
  6. Refuses to overwrite the destination if it already exists as a symlink.
  7. Writes the rendered bytes.

Existing regular files at the destination are overwritten without prompting — that is the whole point of the command.

Templates receive a single object with these top-level fields:

VariableSourceNotes
.Projectproject: block from workspace.ymle.g. .Project.Name, .Project.Prefix
.Servicecanonical config identity — the root of the rendering service’s extends: chain.Use this for raw-config lookups keyed by service name (e.g. (index .Cfg.Raw.cs .Service).standard). Equals .Resolved when no extends chain.
.Resolvedrendering identity — the service whose hub is actually being rendered (the deepest-extends collision winner).Equals .Service in the no-collision case.
.ServiceCfgthe effective service config of .Resolved, after extends resolution.e.g. .ServiceCfg.Container, .ServiceCfg.Dir, .ServiceCfg.DirInternal, .ServiceCfg.WorkDirInternal, .ServiceCfg.CLI.* reflect the rendering service’s overlay.
.Runtimemerged runtime block.Runtime.UseHTTPS, .Runtime.SPX.Path. Per-service ports / hosts live on each service entry — use ((index .Services "<name>").Port "<port-name>") / ((index .Services "<name>").Host "<host-name>").
.Servicesmap[string]ServiceConfig keyed by service nameIndexed access only (Go template requirement): (index .Services "main"). Filter by type via .AppServices / .ToolServices / .InfraServices (zero-arg methods returning typed subsets).
.Cfgmerged DweConfig (advanced).Cfg.Raw is the post-merge config map after DWE normalization (services.* injected from per-service service.yml files) — see Templates. Prefer the dedicated fields above for common cases.

Advisory. IDE outputs land at <svc.Dir>/<entry.To> — typically tracked project files (.vscode/settings.json, .devcontainer/devcontainer.json, …). Avoid consuming developer-local or secret keys via .Cfg.Raw in IDE templates: any value layered in from workspace/local.yml will surface in the rendered file and produce per-developer diffs in tracked artefacts. Use .Cfg.Raw for repo-wide conventions only.

Strict-mode means a typo in {{.Servic.Name}} aborts rendering instead of writing a <no value> placeholder. Use {{if ...}} guards for fields that may legitimately be empty.

Go’s text/template resolves dot-segments only when each segment matches [A-Za-z_][A-Za-z0-9_]*. Keys with hyphens, dots, leading digits, or any other non-identifier character cannot be reached with dot syntax — use index instead.

{{ .Cfg.Raw.ide.workspace_name }} {{- /* dot — identifier-safe keys */ -}}
{{ index .Cfg.Raw "my-tool" "api-key" }} {{- /* index — hyphenated keys */ -}}

Note: per-service ports / hosts live on the ServiceConfig value for that service — ((index .Services "main").Port "http"), ((index .Services "adminer").Host "web"). There is no separate .Runtime.Ports / .Runtime.Hosts / .Tools namespace.

The full set of helper functions available inside *.tmpl files (appURL, sprout registries, text/template built-ins) is documented in Templates.

The renderer applies multiple boundary checks because a malicious or careless template path could otherwise be used to write files outside the service hub or the project root. The full chain, in order:

  1. Path cleanup. Each pack entry’s relative path is normalized; absolute paths and .. escapes are rejected during the pack walk.
  2. Service-dir containment. The resolved destination must be inside the service hub directory.
  3. No symlinks in the destination path. Existing path components are checked before any directory is created. This catches a pre-existing .devcontainer symlink that would otherwise let directory creation follow it outside the hub.
  4. Real-path boundaries after creation. After parent directories are created, the destination directory is resolved through any symlinks and must still be inside both the real project root and the real service hub. This catches a race where a symlink is dropped between checks.
  5. No symlink at the destination file. If a symlink already exists at the target path, the write is refused (rather than following the link and overwriting whatever it points at).

The same guards are applied to the pack itself: the pack directory is checked for symlinked parent components, and the walker rejects any symlink inside the tree.

Layout:

workspace/services/
main/
service.yml
main-debug/
service.yml
workspace/templates/ide/
default/
.devcontainer/devcontainer.json.tmpl
.vscode/settings.json.tmpl
main-debug/
.devcontainer/devcontainer.json.tmpl
.vscode/settings.json.tmpl
.vscode/launch.json.tmpl

workspace/services/main/service.yml:

type: app
container: app-main
dir: ./services/main
# render.ide.enabled defaults to true (type: app)

workspace/services/main-debug/service.yml:

type: app
extends: main
container: app-main-debug
dir: ./services/main # same dir as parent — collision
render:
ide:
template: main-debug # use the main-debug pack

Template workspace/templates/ide/main-debug/.vscode/settings.json.tmpl:

{
"container.name": "{{.ServiceCfg.Container}}",
"workspace.root": "{{.ServiceCfg.DirInternal}}",
"service": "{{.Resolved}}"
}

dwe render ide (no argument):

  1. Selection: both main and main-debug pass the activation gate. They share dir: ./services/main. main-debug has a deeper extends chain (depth 1 vs 0), so main-debug wins. main is reported as a collision skip and a warning is printed.
  2. Pack resolution for main-debug: render.ide.template: main-debug is explicit; workspace/templates/ide/main-debug/ exists, so it is used.
  3. Manifest render entries (in declaration order) yield the three outputs: .devcontainer/devcontainer.json, .vscode/launch.json, .vscode/settings.json.
  4. Each is rendered with .Service = "main" (the chain root — what user-config maps are keyed on), .Resolved = "main-debug" (the rendering service), .ServiceCfg.Container = "app-main-debug", etc.

Result:

services/main/
.devcontainer/
devcontainer.json
.vscode/
launch.json
settings.json

dwe render ide main produces the same result — main is validated, but the hub-anchor resolution picks main-debug and prints ide [main] — resolved to main-debug (hub services/main).

StreamTrigger
infoExplicit argument resolved to a different sibling — names the chosen winner and the shared hub directory.
warningA selected service was skipped because it has no hub directory (or its hub is the project root).
warningA selected service was skipped because another service won the directory collision — the winner is named.
infoA <pack>.local/<rel> override was used in place of the canonical pack file.
successOne line per rendered file, naming the relative path inside the project.
infoNothing was selected after applying policy and collision rules.

Errors are returned as command failures and name the offending service so the source of the problem is clear.

  • Non-app services do not render by default. Set render.ide.enabled: true in workspace/services/<name>/service.yml to opt in.
  • Typos in render.ide.template are hard errors. Explicit packs are strict; a missing workspace/templates/ide/<name>/ does not silently fall through to default/. Either fix the name or remove render.ide.template.
  • Templates referencing missing fields fail. Strict-mode rendering means {{.ServiceCfg.NoSuchField}} aborts rendering. Guard optional fields with {{if ...}}.
  • Symlinks at destinations are refused. If .devcontainer/ or settings.json is a symlink, the renderer will not overwrite it. Remove the symlink and re-run.
  • Files not listed in manifest.yml are silently ignored. The renderer does not walk the pack — add an entry under render: to include a template.
  • dir: "." is rejected. A service whose hub is the project root would let templates scribble over workspace.yml and other root files. Give every IDE-rendered service a real subdirectory.