@meitogi/devcontainer-cli
v0.10.1
Published
Host-side control plane for the meitogi devcontainer sandbox — init wizard, initialize, update, doctor.
Readme
@meitogi/devcontainer-cli
Host-side control plane for the meitogi devcontainer sandbox. Runs on the host, before and around the container — which is what distinguishes it from the tooling baked into the image.
npx @meitogi/devcontainer-cli initDocumentation
The image's documentation is the documentation: what a devcontainer is, what this one adds, the vocabulary, one how-to per common gesture, and troubleshooting by symptom — https://github.com/meitogi/devcontainer-sandbox/blob/master/docs/index.md. Every container built on the image prints that link at startup. This README covers only the host-side commands.
If you have never done this before, start at
getting started;
it walks devc init question by question.
Status
0.7.0 — four real commands, published on npm.
| Command | State |
|---|---|
| devc init | implemented — scaffold a project's .devcontainer/ (wizard) |
| devc initialize | implemented — host-side pre-container setup |
| devc migrate | implemented — read-only report on an install.sh tree and what moving it costs |
| devc firewall-mode | implemented — report the firewall mode, or set it |
| devc update | stub — bump base + Claude Code versions |
| devc doctor | stub — diagnostics |
firewall-mode is the one firewall verb that shipped; the general
devc firewall of the design's command table is still absent, as are patch,
skill, notify and host — absent rather than stubbed. devc knowledge and
devc lessons were dropped for v1 — those files are edited directly.
What this scaffolds
This package alone does nothing for a container — it scaffolds a project that pulls a published image. Two artefacts, two repos, versioned separately:
| Artefact | Source | Distributed via |
|---|---|---|
| the image | meitogi/devcontainer-sandbox | ghcr.io/meitogi/devcontainer-sandbox:<base>-cc<claude-code> |
| the CLI | this repo, meitogi/devcontainer-cli | npm, @meitogi/devcontainer-cli |
Hooks, skills, knowledge and the firewall machinery all live in the image,
not in what devc init writes — see
EXTENDING.md
and the per-stack Dockerfile blocks:
stacks/php.md,
stacks/android.md,
stacks/android-capacitor.md.
devc init
One command, nothing to install first — npx fetches the CLI into its cache
and runs it, the way npm create <x> does:
npx @meitogi/devcontainer-cli init [dir]The wizard looks at what is in the directory (manifests, file extensions) to guess the stack, then asks — saying what each answer is used for:
- Stack — Node.js needs nothing; PHP, Android and Capacitor point at the
documented Dockerfile blocks in the base repo's
stacks/(see What this scaffolds above). - Project id —
DC_PROJECT: the compose project name and the prefix of the project's volumes. - Display name — the devcontainer's name in VS Code.
- Claude credentials volume — every
claude-creds-*volume on the host is listed with the projects that mount it, most shared first; or a new shared one, or a private per-project one. - Claude Code line — which published
<base>-cc<claude-code>image. - Extension patchers — the access token first (a GitHub fine-grained PAT,
Contents read-only), then the repo is read from what the token sees: one
repo is taken as is, a few are listed (an
ext-patchname is the default), more than 20 or no answer from GitHub fall back to typingowner/name. An empty token still takes a typed public repo; empty twice skips the step. The answers can be remembered in~/.config/devc/ext-patches.env(mode 600), and later projects get a single reuse question.
Then it writes the thin project layer (~45 files: devcontainer.json,
Dockerfile, docker-compose.yml, .env, initialize.sh, the firewall
allowlist and its worked policy.local.d.example/, the hook and skill overlay
directories, claude/CLAUDE-*.md, the host-side helpers, the generic test
harness, a blank LESSONS.md, the wtf firewall / wtf ext-patch commands in
.devcontainer/.wtfcmd.yaml and at the root), appends a fragment to the root .gitignore,
offers to add the missing commands to an existing root .wtfcmd.yaml (matched by group +
name, never replacing yours), links LESSONS.md
and .claude/rules/* at the root, adds itself as a devDependency to the root
package.json and runs npm install — so the npx --yes
--package=@meitogi/[email protected] devc initialize that initialize.sh
execs resolves the local, lockfile-pinned copy with no registry round-trip.
Everything
else — hooks, skills, knowledge, the firewall machinery, the toolchain — is
inherited from ghcr.io/meitogi/devcontainer-sandbox at runtime.
devc init [dir] [--yes] [--project-id <slug>] [--display-name <name>]
[--creds-volume <name|none>] [--stack <id>] [--cc <x.y.z>] [--base <x.y.z>]
[--ext-patches-repo <owner/name>] [--ext-patches-ref <ref>]
[--no-install] [--dry-run]--yes takes every default and is required when stdin is not a terminal.
Non-interactively, the extension patchers are opted into with
EXT_PATCHES_TOKEN in the environment — never a flag, it would land in shell
history. Alone, it uses the one repo the token reads; when it reads several,
or GitHub does not answer, the step is skipped and the reason printed.
--ext-patches-repo names the repo instead:
EXT_PATCHES_TOKEN=github_pat_… npx @meitogi/devcontainer-cli init --yesThe base image pinned is the newest published on the chosen Claude Code line,
read from ghcr.io at scaffold time and written as an explicit BASE_IMAGE in
.env; offline, the template's own pin is used and the summary says so.
--base pins a version by hand.
An existing .devcontainer/ is never overwritten. A tree this CLI scaffolded
gets a per-file report and only its missing files added; a tree made by
install.sh (v1/v2) or by another tool is refused with a message saying what
was found. For an install.sh tree the message points at devc migrate.
devc migrate
npx --yes @meitogi/[email protected] migrate [dir]The report a tree made by install.sh gets — and nothing else. It reads
.devcontainer/, names what it found (the .configured-setup values, the
Dockerfile lineage, the initializeCommand and lifecycle lines, the v2
skills loader), sorts every top-level entry by what becomes of it after the
switch (config read in place, image now shipped by the base image,
pending with no v3 home yet, retired, runtime, yours), and prints the
checklist with this project's own values filled in. Exit 0 once the report is
printed; 1 when there is nothing to migrate, saying what was found instead.
It writes nothing, and no flag makes it write. Measured on three real v2 trees
before it was built: there is no pristine baseline to diff against (install.sh
kept no manifest, and the template moved on since each install), the three
files the switch must change are the three most hand-edited, and each tree
carries subsystems the v3 scaffold has no slot for. A command that rewrote such
a tree would have to guess which edits are yours. So the switch is a short
checklist of edits git can show and revert, and once it is done — the tree
builds on the published image and carries the customizations.stitchu-devc
block — devc init recognises it and adds the missing files.
The bash entry point is not a compatibility concession — it is the shipped form.
initializeCommand is bash .devcontainer/initialize.sh, and the scaffold
writes that script, because the hook runs under /bin/sh -c on Unix but
cmd.exe /c on Windows (hardcoded on process.platform in devcontainers/cli),
and Git Bash hosts stay win32. A bare command name is the only form both
shells resolve. The script finds a Node >= 18 wherever nvm, fnm, asdf, volta or
Homebrew keeps it — VS Code resolves the login shell environment on macOS and
Linux, but never on Windows and not for the WSL server — then execs the npx
line above. A v2 initialize.sh is replaced by this one wholesale, so
devcontainer.json's initializeCommand need not change. Add the CLI as a
root devDependency so that npx resolves the local copy offline.
devc firewall-mode
npx @meitogi/devcontainer-cli firewall-mode [off|basic|strict] # host
npx devc firewall-mode [off|basic|strict] # containerWith no argument it reports the mode and writes nothing. With one it writes
firewall/default-mode and then aligns the proxy/CA variables in .env with
it — which a bare echo strict > firewall/default-mode does not, leaving .env
saying the opposite of the flag. Running it for the mode already set is the
repair for exactly that.
It needs no Docker, so the same call works from the host and from inside the
container. What it cannot do is apply the change: firewall/ is COPYed into the
image at build time, so a rebuild is required and the command says so rather
than pretending otherwise. The v2 okeish and paranoid are accepted with a
warning and never written back.
Supersedes .devcontainer/firewall-mode.sh, which a v3 tree no longer ships.
devc initialize
The step VS Code runs through initializeCommand, before the container is
built. It is a port of the 663-line .devcontainer/initialize.sh, and does, in
order:
- Classifies the host (
mac/linux/wsl/gitbash) and records it inlogs/host-os. This is the only code that runs on the host, so it is the only place that can answer — from inside the container the kernel reveals the hypervisor, not the OS. - Opens a timestamped log under
logs/, plus a decision trace whenDEBUG=1. - Seeds the files the image build depends on:
firewall/domains.local.txt,firewall/default-mode,firewall/ports.txt,firewall/policy.local.d/,claude-bridge/config.json, and the.vscode/settings.jsonstub Docker Desktop on macOS needs before it will bind a file. - Projects team defaults from
customizations.stitchu-devcindevcontainer.jsoninto.env, never overwriting a value already there. - Creates the Claude credentials volume.
- Decides whether this is a Rebuild, a Reopen or a first run (the
container-presence probe) and logs which. Nothing is built locally — the
base image is
ghcr.io/meitogi/devcontainer-sandbox:<base>-cc<cc>, pulled by compose; bumping that tag is what an upgrade means. - Prompts for the Claude mode on first run, writes the flag files, aligns the
proxy variables in
.envwith the firewall mode, prints a summary, and spawns the host notify daemon.
devc initialize [--devcontainer-dir <path>] [--dry-run]--dry-run reports every decision and writes nothing — no files, no docker, no
daemon.
| Environment | Effect |
|---|---|
| DEBUG=1 | Structured decision trace beside the log |
| DEBUG_REBUILD_CONTEXT=1 | Dump the rebuild-signal diagnostic |
Notable divergences from the bash script
Documented rather than hidden, because the two run side by side for now.
.envis parsed, notsourced. Bash handed the file to a shell, so$VARinterpolated and$(…)executed. docker-compose'senv_file:reader never did either, so the two consumers of the same file already disagreed. This parser matches compose..envwrites are line edits, never a parse/serialize round-trip — comments, blank lines and ordering are preserved byte for byte.- The rolling window repaints on a timer (~20 fps) instead of once per output line, and truncates on code points rather than bytes.
DEBUG=1cannot reproduceset -x. Node has no per-statement hook. The trace records spawns, exits, decisions and file writes instead, in the same+ …shape.- Team defaults are projected into
.env.initialize.shnever readdevcontainer.json. This command mapscustomizations.stitchu-devc.allowLocalAtRebuildtoFIREWALL_ALLOW_LOCAL_AT_REBUILD, writing it only when.envdoes not already set it. No project sets that key yet, so the two implementations currently produce identical.envfiles — but they will not once one does. - The node-not-found diagnostic is gone. Its 180 lines existed because
command -v nodecould fail under VS Code's non-login shell. A Node CLI is already running Node, so that state is unreachable from here.
Requirements
Node 18+, Docker, and VS Code with the Dev Containers extension. On Windows, run
from WSL2 or Git Bash — a bare cmd.exe host is refused by name rather than
failing later on a missing wslpath.
Development
npm install
npm run build
npm testTests are node:test; there are no runtime dependencies and one development
dependency.
License
MIT
