dreamteamer 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,6 +67,15 @@ schema:
67
67
  have lived in `modules/<module>/<kind>/` since the 2026-08-05 flatten, and a `system/`
68
68
  prefix is only how `runtime.js` recognises a RUNTIME collection in a descriptor compiled
69
69
  by a pre-flatten engine.
70
+ driver:
71
+ type: string
72
+ description: >-
73
+ The name of an engine DRIVER that answers this collection's verbs instead of a folder of
74
+ files — `docker` is the one shipped (src/containers.js: `containers`, `images`). A driver
75
+ collection has no records on disk: its derived `path` names a folder that never exists,
76
+ so check, commit and the store read zero records and never write one; the CLI and the REST
77
+ route dispatch to the driver first. A string checked against the drivers the engine has,
78
+ not an enum — one implementation, and a second is a code change, not a vocabulary change.
70
79
  codec:
71
80
  type: string
72
81
  enum: [md, yaml, json, file]
@@ -0,0 +1,81 @@
1
+ name: containers
2
+ description: >-
3
+ A workspace running as a container — one person's editor, harness and compiled workspace over a
4
+ named volume set, answered over the Docker Engine API rather than from a folder of files.
5
+ use_when: >-
6
+ a workspace has to RUN somewhere for someone — `dt start container <name> --template <t>` makes
7
+ one and prints its editor URL; `dt list containers` says what is running and where; `stop`, `open`,
8
+ `rm` are the lifecycle. Not a record: nothing lands under data/, nothing is committed, and Docker
9
+ keeps the history
10
+ # MACHINERY WITH A DRIVER, NOT A RECORD COLLECTION. `storage.driver: docker` says every verb on this
11
+ # collection is answered by src/containers.js over the Engine API: `list` is GET /containers/json,
12
+ # `get` is an inspect, `add`/`start` create-and-start, `rm` removes. The derived storage.path names a
13
+ # folder that never exists, so every walker (check, commit, the store's index) reads zero records here
14
+ # and never writes one; the REST route and the CLI dispatch to the driver before the store is asked.
15
+ # `group: system` folds it out of the orientation block's domain listing, beside `repos` and the
16
+ # compiled kinds, which is where a stranger's mental model puts "the thing my workspace runs in".
17
+ storage:
18
+ driver: docker
19
+ schema:
20
+ type: object
21
+ required: [name, template]
22
+ properties:
23
+ # ---- identity ----
24
+ name:
25
+ type: string
26
+ description: The workspace name — also the container name and the stem of its three volumes (`dreamteamer-<name>-workspace` · `-home` · `-files`).
27
+ template:
28
+ type: string
29
+ description: The template it was made from — an image carrying `dreamteamer.template`, `dreamteamer.ports` and `dreamteamer.modules` labels; `--template hq` resolves to `<DT_REGISTRY>/hq:<DT_TEMPLATE_TAG>` or to `DT_IMAGE_hq`.
30
+ image:
31
+ type: string
32
+ description: The image reference the container runs.
33
+ id:
34
+ type: string
35
+ description: Docker's short id.
36
+ # ---- runtime ----
37
+ state:
38
+ type: string
39
+ enum: [created, running, paused, restarting, exited, dead]
40
+ description: Docker's container state.
41
+ status:
42
+ type: string
43
+ description: Docker's own phrase — `Up 2 hours`, `Exited (0) 3 minutes ago`.
44
+ started:
45
+ type: string
46
+ format: date-time
47
+ description: When it last started.
48
+ # ---- network ----
49
+ editor_url:
50
+ type: string
51
+ description: Where the person opens the machine — `http://localhost:<port>/`, its home (every workspace, create, clone); the host port comes from DT_PORT_BASE upward, bound to DT_BIND (127.0.0.1). Never carries the image's URL token — `dt open container <name>` prints the URL that does.
52
+ port:
53
+ type: integer
54
+ description: The host port the editor is published on.
55
+ bind:
56
+ type: string
57
+ description: The host address it is bound to — loopback unless the host .env says otherwise.
58
+ # ---- storage ----
59
+ workspace_dir:
60
+ type: string
61
+ description: Where the workspace is mounted inside — `/workspaces/<name>`, the dev-container convention.
62
+ volumes:
63
+ type: object
64
+ description: The three named volumes — `workspace` (the repo), `home` (the person's logins and editor settings), `files` (FILES_FOLDER). Plain `rm` keeps them; `rm --force` removes them.
65
+ mounts:
66
+ type: array
67
+ items: { type: string }
68
+ description: Extra bind or volume mounts passed as `--mount <host-path|volume>:<container-path>[:ro]` — targets under /workspaces · /home/node · /files · /mnt only, none at or under another mount's target (the own volumes included), and no bind source inside another.
69
+ # ---- person ----
70
+ person:
71
+ type: string
72
+ description: Whose container this is — the git identity injected as env; the harness login happens INSIDE, once, and is never injected.
73
+ # ---- provenance ----
74
+ created:
75
+ type: string
76
+ format: date-time
77
+ description: When Docker created it.
78
+ order: 146
79
+ list_fields: [name, template, state, editor_url, person]
80
+ icon: deployed_code
81
+ group: system
@@ -0,0 +1,46 @@
1
+ name: images
2
+ description: >-
3
+ A template a workspace container is made from — a Docker image carrying the `dreamteamer.template`,
4
+ `dreamteamer.ports` and `dreamteamer.modules` labels — as the local Docker holds it.
5
+ use_when: >-
6
+ choosing what a container runs — `dt list images` shows the templates present, `dt add image
7
+ --template <t>` pulls one, `dt rm image <ref>` removes one; a template is an IMAGE WITH LABELS, not
8
+ a record here, so there is nothing to author — publish an image with the labels and it appears
9
+ # The second driver-backed collection (see containers.collection.yaml for what that means). `list` is
10
+ # GET /images/json filtered to the template label, `get` an inspect, `add --template` a pull, `rm` a
11
+ # delete. No records, no folder, no commit.
12
+ storage:
13
+ driver: docker
14
+ schema:
15
+ type: object
16
+ required: [image]
17
+ properties:
18
+ # ---- identity ----
19
+ image:
20
+ type: string
21
+ description: The image reference — `<registry>/<template>:<tag>`.
22
+ template:
23
+ type: string
24
+ description: The `dreamteamer.template` label — the word `--template` takes.
25
+ id:
26
+ type: string
27
+ description: Docker's image id.
28
+ # ---- what it runs ----
29
+ ports:
30
+ type: string
31
+ description: The `dreamteamer.ports` label — the in-container port the editor listens on (8080 by convention).
32
+ modules:
33
+ type: string
34
+ description: The `dreamteamer.modules` label — the modules the template installs, comma-separated.
35
+ # ---- size and time ----
36
+ size_mb:
37
+ type: integer
38
+ description: Image size in megabytes.
39
+ created:
40
+ type: string
41
+ format: date
42
+ description: When the image was built.
43
+ order: 147
44
+ list_fields: [template, image, size_mb, created]
45
+ icon: layers
46
+ group: system
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "A workspace compiler for coding agents — schema-validated records as plain files over git, compiled into every harness",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Gilad Khen <giladkhen@gmail.com>",
package/src/cli.js CHANGED
@@ -17,6 +17,7 @@ import { compile, staleness, warnIfStale, discoverModules, CHANNEL_LABEL, locati
17
17
  import { check } from './check.js';
18
18
  import { collectionCommand, emit, relationsCommand, parseArgs, refuseUnknownFlags } from './collections-cli.js';
19
19
  import { driverTarget, driverCommand, setup as hostSetup, parseFlags as hostFlags, DRIVER_VERBS, LIFECYCLE_VERBS, CONTAINER_FLAGS } from './containers.js';
20
+ import { archiveCommand } from './container-archive.js';
20
21
  import { init, installClone, update, listRepos } from './init.js';
21
22
  import { installCommand, describeCheckout, listWorktrees, worktreeCommand } from './checkout.js';
22
23
  import { proveCommand, readLedger, flagEnabled } from './prove.js';
@@ -247,19 +248,47 @@ workspace verbs:
247
248
  containers — a workspace as a running container (Docker Engine API over its socket, no dependency;
248
249
  these verbs work with NO workspace, so npm i -g dreamteamer and Docker Desktop are enough):
249
250
  setup make THIS MACHINE ready: checks Docker, writes ~/.dreamteamer/.env with its defaults
250
- (DT_PORT_BASE 8100 · DT_BIND 127.0.0.1 · DT_REGISTRY · DT_TEMPLATE_TAG), lists the
251
- templates present, pulls one on request [--template <t>] [--json]
251
+ (DT_PORT_BASE 8100 · DT_BIND 127.0.0.1 · DT_REGISTRY · DT_TEMPLATE_TAG ·
252
+ DT_DOCKER_TIMEOUT 30 — seconds a request to Docker may sit idle before the verb
253
+ fails), lists the templates present, pulls one on request [--template <t>] [--json]
252
254
  start container <name> --template <t> create-if-absent and start: a code-server editor at
253
- http://localhost:<port>/?folder=/workspace over a compiled workspace, three named volumes
254
- (workspace · home · files), image <DT_REGISTRY>/<template>:<tag> or DT_IMAGE_<template>.
255
- Idempotent. NO token is ever injected — log in INSIDE, once; the home volume keeps it.
255
+ http://localhost:<port>/ — the machine home — over a compiled workspace, three named
256
+ volumes (workspace · home · files), its own bridge network dreamteamer-<name>, image
257
+ <DT_REGISTRY>/<template>:<tag> or DT_IMAGE_<template>. The workspace is mounted at
258
+ /workspaces/<name>. Idempotent. NO credential is ever injected — log in INSIDE, once;
259
+ the home volume keeps it. An image with a URL token (hq 0.6+) prints the URL as
260
+ ?tkn=<token>, read from the container; the token is never written on the host.
261
+ [--rotate-token] replace the image's URL token and print the new URL
262
+ [--workspace [<w>]] open /workspaces/<w> (default: its own) instead of the home
263
+ [--repo <git url>] clone an EXISTING workspace into the volume on first start, instead
264
+ of laying the template down — how a person joins one on GitHub
265
+ [--mount <host-path|volume>:<container-path>[:ro]] extra mounts, repeatable; targets
266
+ under /workspaces · /home/node · /files · /mnt, none at or under
267
+ another mount's target, no bind source inside another
256
268
  [--name <git name>] [--email <git email>] [--no-open] [--json]
257
269
  stop container <name> stop it; every volume kept [--json]
258
- open container <name> print (and open) its editor URL [--no-open]
270
+ open container <name> print (and open) its URL, token included [--no-open] [--json]
271
+ [--workspace [<w>]]
272
+ [--vscode] print (and open) the Dev Containers attach URI instead — the host's own
273
+ VS Code inside the container, extensions from the image's metadata label
259
274
  list containers | images the record verbs, answered over Docker instead of a
260
275
  get container <name> | image <ref> folder — singular or plural, either spelling.
261
- rm container <name> [--force] plain rm keeps the volumes; --force removes them too
276
+ rm container <name> [--force] removes it and its network; keeps the volumes unless --force
262
277
  add image --template <t> pull a template's image; rm image <ref> removes one
278
+ export container <name> --out <file> its WORKSPACES as one file — every folder under
279
+ /workspaces, never the home or a login; node_modules and .files stay behind. Works on
280
+ a stopped container. Encrypted with the owner passphrase (DT_EXPORT_PASSPHRASE, else a
281
+ prompt — never a flag). [--workspace <w>]... only these [--no-encrypt] a plain .tar.gz
282
+ [--json] (both verbs) the summary as JSON on stdout, every other line on stderr
283
+ Secrets stay behind: .env and .env.* (not .example/.sample/.template), .envrc,
284
+ .npmrc, .netrc, .git-credentials, .pypirc, .docker/config.json, and the credentials in
285
+ each .git/config. [--with-secrets] carries them unchanged
286
+ import container <name> <file> unpack an export into a RUNNING container, owned by
287
+ node. Refuses a wrong passphrase, a damaged file, an entry leaving its workspace and a
288
+ workspace already holding files — each before anything is written.
289
+ [--workspace <w>]... only these [--replace] empty a workspace that holds files first
290
+ [--as <name>] land the ONE workspace in /workspaces/<name> — e.g. another container's
291
+ own volume folder (dt-new's name rule; never the container's own layer)
263
292
 
264
293
  changes what changed in every repo that holds records, as record events
265
294
  [--since <sha|YYYY-MM-DD>] (default: HEAD~1 — the last commit's own changes) [--json]
@@ -318,7 +347,7 @@ export const WORKSPACE_FLAGS = {
318
347
  // `start` is TWO forms: bare, the REST api (--port); with a `container <name>` target, the
319
348
  // lifecycle verb — whose flags are the driver's. One table, because `flags-honoured` reads it.
320
349
  start: ['port', ...CONTAINER_FLAGS], compile: ['watch'], check: [], status: ['strict'],
321
- setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open'],
350
+ setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open', 'vscode', 'workspace'],
322
351
  changes: ['since', 'json'], commit: ['dry-run', 'json'],
323
352
  export: EXPORT_FLAGS,
324
353
  // the UNION of every form's flags — the outer typo gate. Which flags each FORM takes is refused
@@ -791,6 +820,9 @@ function hostDispatch(cmd, rest) {
791
820
  return hostSetup(hostFlags(rest).flags);
792
821
  }
793
822
  const target = driverTarget(rest[0]);
823
+ // `export container` is the driver's; `export notebooklm` stays the workspace verb below
824
+ if (target && (cmd === 'export' || cmd === 'import')) return archiveCommand(cmd, target, rest.slice(1));
825
+ if (cmd === 'import') return Promise.reject(new Error('dt import container <name> <file> [--workspace <w>]... [--replace]'));
794
826
  if (target && DRIVER_VERBS.has(cmd)) return driverCommand(cmd, target, rest.slice(1));
795
827
  // A lifecycle verb aimed at anything else is refused by name: `dt start tasks` is not a
796
828
  // server and not a container, and "unknown collection" would send the reader the wrong way.
@@ -0,0 +1,356 @@
1
+ // container-archive.js — `dt export container` and `dt import container`: a machine's WORKSPACES out
2
+ // of one container and into another, as one file. The move primitive between two laptops (and, when
3
+ // the other side implements docs/container-export-format.md, between a laptop and a hosted machine).
4
+ //
5
+ // WHAT TRAVELS. The trees under /workspaces, one folder per workspace — never the home, so never a
6
+ // login: there is no flag that adds it. `node_modules` and `.files` folders stay behind at any depth
7
+ // (a reinstall and a files folder are both per-machine). Hard links, devices and fifos are skipped on
8
+ // the way out and REFUSED on the way in, with setuid bits and owners dropped both ways: this file
9
+ // writes every tar header itself rather than passing Docker's through.
10
+ //
11
+ // NO SECRETS BY DEFAULT (principle 8: an export strips them unless the owner asks). Every `.env` and
12
+ // `.git-credentials` stays behind, and each `.git/config` travels with the userinfo cut from its
13
+ // http(s) URLs and its `extraheader` lines (where CI tooling parks a token) dropped. `--with-secrets`
14
+ // carries all of it unchanged and says so. Encryption is on either way.
15
+ //
16
+ // WHY THE TAR IS PARSED HERE. The Engine API speaks tar both ways (GET/PUT …/archive) and cannot
17
+ // filter, re-root or check it. Node has gzip and AES-GCM but no tar, and the subset needed — ustar
18
+ // headers, PAX and GNU long names in, ustar plus PAX out — is ~60 lines, against a dependency.
19
+ //
20
+ // ENCRYPTED BY DEFAULT, under the owner's passphrase: scrypt (N=2^17) → AES-256-GCM over 64 KiB
21
+ // chunks, each with its own nonce and tag and the LAST one marked, so a flipped byte, a dropped chunk
22
+ // and a truncated file all fail authentication. The header carries an HMAC under the derived key, so
23
+ // a wrong passphrase is told apart from damage before a single byte is decrypted. The passphrase comes
24
+ // from DT_EXPORT_PASSPHRASE (the process env only, never ~/.dreamteamer/.env) or a no-echo prompt —
25
+ // never a flag, because every local user's `ps` reads argv.
26
+ //
27
+ // IMPORT WRITES NOTHING until the whole file has been read once: pass 1 authenticates every chunk and
28
+ // checks every entry (no absolute path, no `..`, no symlink leaving its workspace, no link or device),
29
+ // pass 2 uploads. Then `chown -R node:node` on the imported folders, as root, by exec.
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import zlib from 'node:zlib';
33
+ import crypto from 'node:crypto';
34
+ import { Readable } from 'node:stream';
35
+ import { pipeline } from 'node:stream/promises';
36
+ import { api, ok, inspectContainer, exec, parseFlags } from './containers.js';
37
+ import { emit } from './collections-cli.js';
38
+
39
+ const ROOT = '/workspaces';
40
+ const WS_NAME = /^[a-z0-9][a-z0-9_.-]*$/;
41
+ // `--as` names a NEW folder, so it takes dt-new's rule (hq image, new-workspace.sh), reserved words included
42
+ const NEW_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
43
+ const RESERVED = new Set(['files', 'lost-found', 'trash']);
44
+ const LEFT_BEHIND = new Set(['node_modules', '.files']);
45
+ // credential files by NAME, at any depth: `.env` and every `.env.*` but the three template spellings,
46
+ // and the dotfiles package managers and git keep tokens in — docs/container-export-format.md
47
+ const SECRET_FILES = new Set(['.env', '.envrc', '.npmrc', '.netrc', '.git-credentials', '.pypirc']);
48
+ const ENV_TEMPLATES = new Set(['.env.example', '.env.sample', '.env.template']);
49
+ export const isSecretFile = (segs) => { const b = segs[segs.length - 1]; return SECRET_FILES.has(b) || (b.startsWith('.env.') && !ENV_TEMPLATES.has(b)) || (b === 'config.json' && segs[segs.length - 2] === '.docker'); };
50
+ /** A `.git/config` minus its credentials: every `[credential]` / `[credential "…"]` section dropped
51
+ * (an inline `!` helper can hold a password), every `extraheader` line dropped (where CI parks a
52
+ * token), and the userinfo cut from every http(s) URL — values and `[url "…"]` section names alike. */
53
+ export function stripGitConfig(text) {
54
+ let drop = false;
55
+ const kept = text.split(/(?<=\n)/).filter((line) => {
56
+ const head = /^\s*\[\s*([^\]\s."]+)/.exec(line);
57
+ if (head) drop = head[1].toLowerCase() === 'credential';
58
+ return !drop && !/^\s*extraheader\s*=/i.test(line);
59
+ });
60
+ return kept.join('').replace(/(https?:\/\/)[^@/\s"\]]*@/gi, '$1');
61
+ }
62
+ const FILE = '0', DIR = '5', SYMLINK = '2';
63
+ const FLAGS = { export: ['workspace', 'out', 'no-encrypt', 'with-secrets', 'json'], import: ['workspace', 'replace', 'as', 'json'] };
64
+
65
+ // ---- tar: read any of ustar · PAX · GNU long names, write ustar (+ PAX when a name is long) -------
66
+ const str = (b, o, n) => { const e = b.indexOf(0, o); return b.toString('utf8', o, e === -1 || e > o + n ? o + n : e); };
67
+ const num = (b, o, n) => (b[o] & 0x80 ? b.readUIntBE(o + n - 6, 6) : parseInt(str(b, o, n).trim() || '0', 8)); // base-256 above 8 GiB
68
+ const sum = (h) => { let s = 0; for (let i = 0; i < 512; i++) s += i >= 148 && i < 156 ? 32 : h[i]; return s; };
69
+ const pad = (n) => (512 - (n % 512)) % 512;
70
+ export const TAR_END = Buffer.alloc(1024);
71
+
72
+ function header({ name, type, mode, size, mtime, linkname = '' }) {
73
+ const h = Buffer.alloc(512);
74
+ const oct = (v, o, n) => h.write(`${Math.floor(v).toString(8).padStart(n - 1, '0')}\0`, o, n, 'latin1');
75
+ h.write(name, 0, 100); oct(mode, 100, 8); oct(0, 108, 8); oct(0, 116, 8);
76
+ if (size < 8 ** 11) oct(size, 124, 12); else { h[124] = 0x80; h.writeUIntBE(size, 130, 6); }
77
+ oct(mtime, 136, 12); h.write(type, 156, 1, 'latin1'); h.write(linkname, 157, 100); h.write('ustar\u000000', 257, 8, 'latin1');
78
+ oct(sum(h), 148, 7); h[155] = 32;
79
+ if (Buffer.byteLength(name) <= 100 && Buffer.byteLength(linkname) <= 100) return h;
80
+ // a PAX record's length counts its own digits
81
+ const rec = (k, v) => { const body = ` ${k}=${v}\n`; const n = Buffer.byteLength(body); return `${n + String(n + String(n).length).length}${body}`; };
82
+ const pax = Buffer.from(rec('path', name) + (linkname ? rec('linkpath', linkname) : ''));
83
+ return Buffer.concat([header({ name: 'PaxHeader', type: 'x', mode: 0o644, size: pax.length, mtime }), pax, Buffer.alloc(pad(pax.length)), h]);
84
+ }
85
+
86
+ /** Re-emit a tar: `decide(entry)` answers the name to write it under, or null to drop it, or
87
+ * { name, rewrite } to replace a small file's body. Headers are rewritten (owner 0, permission bits
88
+ * only); bodies stream through. No end marker — the caller adds one. */
89
+ export async function* retar(source, decide) {
90
+ const it = source[Symbol.asyncIterator]();
91
+ let buf = Buffer.alloc(0);
92
+ const fill = async (n) => { while (buf.length < n) { const r = await it.next(); if (r.done) return false; buf = buf.length ? Buffer.concat([buf, r.value]) : r.value; } return true; };
93
+ const take = async (n) => { if (!(await fill(n))) throw new Error('the archive ends in the middle of an entry'); const b = buf.subarray(0, n); buf = buf.subarray(n); return b; };
94
+ let ext = {};
95
+ for (;;) {
96
+ if (!(await fill(512))) throw new Error('the archive ends without its end marker');
97
+ const h = await take(512);
98
+ if (h.every((b) => b === 0)) return;
99
+ if (num(h, 148, 8) !== sum(h)) throw new Error('an archive header fails its checksum');
100
+ const type = h[156] ? String.fromCharCode(h[156]) : FILE;
101
+ let size = num(h, 124, 12);
102
+ if ('xgLK'.includes(type)) {
103
+ if (size > 1 << 20) throw new Error('an archive extension header is over 1 MiB');
104
+ const body = (await take(size + pad(size))).subarray(0, size);
105
+ if (type === 'L') ext.path = str(body, 0, size);
106
+ else if (type === 'K') ext.linkpath = str(body, 0, size);
107
+ else if (type === 'x') for (const m of body.toString('utf8').matchAll(/\d+ ([^=]+)=([^\n]*)\n/g)) ext[m[1]] = m[2];
108
+ continue;
109
+ }
110
+ const prefix = str(h, 345, 155);
111
+ const e = { name: ext.path ?? (prefix ? `${prefix}/${str(h, 0, 100)}` : str(h, 0, 100)), linkname: ext.linkpath ?? str(h, 157, 100), type: type === '7' ? FILE : type, mode: num(h, 100, 8) & 0o777, mtime: num(h, 136, 12) };
112
+ size = ext.size !== undefined ? Number(ext.size) : size;
113
+ e.size = e.type === FILE ? size : 0;
114
+ ext = {};
115
+ const d = decide(e);
116
+ if (d?.rewrite && e.type === FILE && size <= 1 << 20) {
117
+ const body = d.rewrite((await take(size + pad(size))).subarray(0, size));
118
+ yield header({ ...e, name: d.name, size: body.length }); yield body; yield Buffer.alloc(pad(body.length));
119
+ continue;
120
+ }
121
+ const name = d?.name ?? d;
122
+ if (name) yield header({ ...e, name });
123
+ const keep = name && e.type === FILE; // only a file's body is written; any other entry's is read past
124
+ for (let left = size + pad(size); left > 0;) {
125
+ if (!(await fill(1))) throw new Error('the archive ends in the middle of a file');
126
+ const b = buf.subarray(0, Math.min(left, buf.length)); buf = buf.subarray(b.length); left -= b.length;
127
+ if (keep) yield b;
128
+ }
129
+ }
130
+ }
131
+
132
+ // ---- the sealed format (docs/container-export-format.md) ---------------------------------------
133
+ const MAGIC = Buffer.from('DTEXPORT');
134
+ const HEAD = 40, MAC = 32, CHUNK = 64 * 1024;
135
+ const KDF = { log2N: 17, r: 8, p: 1 }; // the ONLY parameters v1 writes or reads — see openSeal
136
+
137
+ function scryptKeys(pass, salt, log2N, r, p) {
138
+ return new Promise((resolve, reject) => crypto.scrypt(pass.normalize('NFC'), salt, 64, { N: 2 ** log2N, r, p, maxmem: 256 * 2 ** log2N * r * p }, (e, k) => (e ? reject(e) : resolve({ enc: k.subarray(0, 32), mac: k.subarray(32) }))));
139
+ }
140
+ const hmac = (key, b) => crypto.createHmac('sha256', key).update(b).digest();
141
+ const nonce = (prefix, i, last) => { const n = Buffer.alloc(12); prefix.copy(n); n.writeUInt32BE(i, 7); n[11] = last ? 1 : 0; return n; };
142
+
143
+ async function newSeal(pass) {
144
+ const h = Buffer.alloc(HEAD);
145
+ MAGIC.copy(h); h[8] = 1; h[9] = KDF.log2N; h[10] = KDF.r; h[11] = KDF.p; h.writeUInt32BE(CHUNK, 12);
146
+ crypto.randomFillSync(h, 16, 23); // salt 16..31 · nonce prefix 32..38 · 39 reserved
147
+ const keys = await scryptKeys(pass, h.subarray(16, 32), KDF.log2N, KDF.r, KDF.p);
148
+ return { keys, head: Buffer.concat([h, hmac(keys.mac, h)]) };
149
+ }
150
+
151
+ /** Parse and authenticate a header — a wrong passphrase fails HERE, before any chunk is opened.
152
+ * The KDF parameters are read BEFORE the MAC can be checked (the MAC needs the key they derive), so
153
+ * they are an attacker's to choose: only the exact values this version writes are accepted, before
154
+ * scrypt runs and before the passphrase is even asked for (`pass` may be a function that asks). */
155
+ export async function openSeal(file, pass) {
156
+ const fd = fs.openSync(file, 'r');
157
+ const h = Buffer.alloc(HEAD + MAC);
158
+ const n = fs.readSync(fd, h, 0, h.length, 0); fs.closeSync(fd);
159
+ if (n < h.length) throw new Error(`${file} is too short to be an encrypted export`);
160
+ if (h[8] !== 1) throw new Error(`${file} is export format version ${h[8]} — this engine reads version 1; upgrade dreamteamer`);
161
+ const [log2N, r, p, chunk] = [h[9], h[10], h[11], h.readUInt32BE(12)];
162
+ if (log2N !== KDF.log2N || r !== KDF.r || p !== KDF.p || chunk !== CHUNK) throw new Error(`${file}: its header asks for scrypt N=2^${log2N} r=${r} p=${p} and ${chunk}-byte chunks — version 1 is exactly N=2^${KDF.log2N} r=${KDF.r} p=${KDF.p}, ${CHUNK}; refusing these parameters before any work`);
163
+ const keys = await scryptKeys(typeof pass === 'function' ? await pass() : pass, h.subarray(16, 32), log2N, r, p);
164
+ if (!crypto.timingSafeEqual(hmac(keys.mac, h.subarray(0, HEAD)), h.subarray(HEAD))) throw new Error(`wrong passphrase for ${file} (or its header is damaged) — nothing was written`);
165
+ return { keys, prefix: h.subarray(32, 39), chunk };
166
+ }
167
+
168
+ async function* seal(src, { keys, head }) {
169
+ const prefix = head.subarray(32, 39);
170
+ const box = (pt, i, last) => { const c = crypto.createCipheriv('aes-256-gcm', keys.enc, nonce(prefix, i, last)); return Buffer.concat([c.update(pt), c.final(), c.getAuthTag()]); };
171
+ yield head;
172
+ let buf = Buffer.alloc(0), i = 0;
173
+ for await (const b of src) { buf = Buffer.concat([buf, b]); while (buf.length > CHUNK) { yield box(buf.subarray(0, CHUNK), i++, false); buf = buf.subarray(CHUNK); } }
174
+ yield box(buf, i, true); // a final chunk always exists, empty or not — its flag is what makes truncation visible
175
+ }
176
+
177
+ async function* unseal(src, { keys, prefix, chunk }) {
178
+ const open = (b, i, last) => {
179
+ const d = crypto.createDecipheriv('aes-256-gcm', keys.enc, nonce(prefix, i, last));
180
+ d.setAuthTag(b.subarray(b.length - 16));
181
+ try { return Buffer.concat([d.update(b.subarray(0, b.length - 16)), d.final()]); } catch { throw new Error(`the export is damaged or truncated (chunk ${i} fails authentication) — nothing was written`); }
182
+ };
183
+ let buf = Buffer.alloc(0), i = 0;
184
+ for await (const b of src) { buf = Buffer.concat([buf, b]); while (buf.length > chunk + 16) { yield open(buf.subarray(0, chunk + 16), i++, false); buf = buf.subarray(chunk + 16); } }
185
+ if (buf.length < 16) throw new Error('the export is truncated (its last chunk is missing) — nothing was written');
186
+ yield open(buf, i, true);
187
+ }
188
+
189
+ // ---- the passphrase: env or a no-echo prompt, never argv -----------------------------------------
190
+ async function passphrase({ confirm }) {
191
+ let v = process.env.DT_EXPORT_PASSPHRASE;
192
+ if (v === undefined) {
193
+ if (!process.stdin.isTTY) throw new Error('an encrypted export needs the owner passphrase — set DT_EXPORT_PASSPHRASE, or run in a terminal to be asked (it is never a flag); --no-encrypt writes a plain .tar.gz');
194
+ v = await ask('passphrase: ');
195
+ if (confirm && (await ask('again: ')) !== v) throw new Error('the two passphrases differ — nothing was written');
196
+ }
197
+ if (confirm && v.length < 8) throw new Error('the passphrase must be at least 8 characters');
198
+ if (!v) throw new Error('the passphrase is empty');
199
+ return v;
200
+ }
201
+
202
+ function ask(prompt) {
203
+ return new Promise((resolve, reject) => {
204
+ const s = process.stdin; let v = '';
205
+ const done = (f) => { s.off('data', on); s.setRawMode(false); s.pause(); process.stderr.write('\n'); f(); };
206
+ const on = (d) => {
207
+ for (const ch of d) {
208
+ if (ch === '\r' || ch === '\n') return done(() => resolve(v));
209
+ if (ch === '\u0003' || ch === '\u0004') return done(() => reject(new Error('cancelled — nothing was written')));
210
+ v = ch === '\u007f' || ch === '\b' ? v.slice(0, -1) : v + ch;
211
+ }
212
+ };
213
+ process.stderr.write(prompt); s.setRawMode(true); s.setEncoding('utf8'); s.on('data', on); s.resume();
214
+ });
215
+ }
216
+
217
+ // ---- export --------------------------------------------------------------------------------------
218
+ export async function exportContainer(name, flags, log = console.log) {
219
+ const out = typeof flags.out === 'string' ? path.resolve(flags.out) : null;
220
+ if (!out) throw new Error(`dt export container ${name} --out <file> — where the export is written`);
221
+ if (fs.existsSync(out)) throw new Error(`${out} already exists — name a new file`);
222
+ const only = flags.workspaces ?? [];
223
+ for (const w of only) if (typeof w !== 'string' || !WS_NAME.test(w)) throw new Error(`--workspace takes a workspace folder name under ${ROOT} — got "${w}"`);
224
+ const c = await inspectContainer(name);
225
+ if (!c) throw new Error(`no container "${name}" — dt list containers`);
226
+ const encrypt = flags['no-encrypt'] !== true;
227
+ const secrets = flags['with-secrets'] === true;
228
+ const sealer = encrypt ? await newSeal(await passphrase({ confirm: true })) : null;
229
+ // one read of the root, or one per named workspace so the others never leave the container
230
+ const sources = only.length ? only.map((w) => ({ at: `${ROOT}/${w}`, strip: '' })) : [{ at: ROOT, strip: `${path.posix.basename(ROOT)}/` }];
231
+ const stats = new Map(); const left = new Set(); const skipped = [];
232
+ const decide = (strip) => (e) => {
233
+ const rel = e.name.slice(strip.length).replace(/\/+$/, '');
234
+ if (!e.name.startsWith(strip) || !rel) return null;
235
+ const segs = rel.split('/');
236
+ if (segs.some((s) => LEFT_BEHIND.has(s))) { left.add(segs.slice(0, segs.findIndex((s) => LEFT_BEHIND.has(s)) + 1).join('/')); return null; }
237
+ if (!WS_NAME.test(segs[0]) || (segs.length === 1 && e.type !== DIR)) { if (segs.length === 1) skipped.push(`${rel} (not a workspace folder)`); return null; }
238
+ if (!secrets && isSecretFile(segs) && e.type !== DIR) { left.add(rel); return null; }
239
+ if (![FILE, DIR, SYMLINK].includes(e.type)) { skipped.push(`${rel} (${e.type === '1' ? 'hard link' : 'device or fifo'})`); return null; }
240
+ const s = stats.get(segs[0]) ?? { files: 0, bytes: 0 }; stats.set(segs[0], s);
241
+ if (e.type === FILE) { s.files++; s.bytes += e.size; }
242
+ if (!secrets && e.type === FILE && rel.endsWith('/.git/config')) return { name: rel, rewrite: (b) => Buffer.from(stripGitConfig(b.toString('utf8'))) };
243
+ return e.type === DIR ? `${rel}/` : rel;
244
+ };
245
+ async function* tar() {
246
+ for (const s of sources) {
247
+ const r = await api('GET', `/containers/${c.Id}/archive?path=${encodeURIComponent(s.at)}`, undefined, { stream: true });
248
+ if (r.status === 404) throw new Error(`${name} has no ${s.at}`);
249
+ ok(r, `read ${s.at} from ${name}`);
250
+ yield* retar(r.res, decide(s.strip));
251
+ }
252
+ yield TAR_END;
253
+ }
254
+ const tmp = `${out}.partial`;
255
+ // an interrupted export leaves nothing: not a half file that looks like a whole one, not ciphertext
256
+ const onSignal = (sig) => { fs.rmSync(tmp, { force: true }); process.exit(sig === 'SIGINT' ? 130 : 143); };
257
+ process.once('SIGINT', onSignal); process.once('SIGTERM', onSignal);
258
+ try {
259
+ await pipeline(Readable.from(tar()), zlib.createGzip(), ...(sealer ? [(src) => seal(src, sealer)] : []), fs.createWriteStream(tmp, { flags: 'wx', mode: 0o600 }));
260
+ fs.renameSync(tmp, out);
261
+ } catch (e) { fs.rmSync(tmp, { force: true }); throw e; } finally { process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); }
262
+ if (!stats.size) { fs.rmSync(out); throw new Error(`${name} holds no workspace folder under ${ROOT} — nothing was written`); }
263
+ for (const [w, s] of stats) log(` ${w} ${s.files} files · ${(s.bytes / 1e6).toFixed(1)} MB`);
264
+ if (left.size) log(` left behind: ${[...left].join(' · ')}`);
265
+ for (const s of skipped) log(` skipped: ${s}`);
266
+ if (secrets) log('⚠ --with-secrets: every credential file (.env*, .npmrc, .netrc, …) and .git/config credential is INCLUDED, unchanged — whoever opens this file holds them');
267
+ else log(' secrets left out: credential files (.env*, .envrc, .npmrc, .netrc, .git-credentials, .pypirc, .docker/config.json); credentials cut from .git/config (--with-secrets keeps them)');
268
+ log(`✔ exported ${stats.size} workspace(s) from ${name} to ${out} · ${encrypt ? 'encrypted with the owner passphrase' : 'NOT encrypted (--no-encrypt): a plain .tar.gz anyone holding the file can read'}`);
269
+ return { container: name, out, encrypted: encrypt, with_secrets: secrets, workspaces: [...stats].map(([w, s]) => ({ name: w, ...s })), left_behind: [...left], skipped };
270
+ }
271
+
272
+ // ---- import --------------------------------------------------------------------------------------
273
+ /** The name to upload an entry under — re-rooted at `as` when given — null to leave it out
274
+ * (`--workspace`), or a refusal. `found` collects the SOURCE workspace names. */
275
+ function checkEntry(e, only, found, as) {
276
+ const name = e.name.replace(/^(\.\/)+/, '');
277
+ const rel = name.replace(/\/+$/, '');
278
+ const segs = rel.split('/');
279
+ const refuse = (why) => { throw new Error(`the export holds "${e.name}", which ${why} — nothing was written`); };
280
+ if (name.startsWith('/')) refuse('is an absolute path');
281
+ if (segs.some((s) => s === '..' || s === '.' || s === '')) refuse(`leaves ${ROOT}`);
282
+ if (!WS_NAME.test(segs[0]) || (segs.length === 1 && e.type !== DIR)) refuse(`is not inside a workspace folder`);
283
+ if (![FILE, DIR, SYMLINK].includes(e.type)) refuse(`is a ${e.type === '1' ? 'hard link' : 'device, fifo or unknown entry'}`);
284
+ const dest = [as ?? segs[0], ...segs.slice(1)].join('/');
285
+ if (e.type === SYMLINK) {
286
+ const own = `${ROOT}/${as ?? segs[0]}`;
287
+ const to = path.posix.resolve(path.posix.dirname(`${ROOT}/${dest}`), e.linkname);
288
+ if (to !== own && !to.startsWith(`${own}/`)) refuse(`is a symlink to ${e.linkname}, outside its workspace`);
289
+ }
290
+ if (only.size && !only.has(segs[0])) return null;
291
+ found.add(segs[0]);
292
+ if (segs.length === 2 && segs[1] === 'package.json' && e.type === FILE) found.add(`${segs[0]}/package.json`);
293
+ return e.type === DIR ? `${dest}/` : dest;
294
+ }
295
+
296
+ export async function importContainer(name, file, flags, log = console.log) {
297
+ if (!file) throw new Error(`dt import container ${name} <file>`);
298
+ const c = await inspectContainer(name);
299
+ if (!c) throw new Error(`no container "${name}" — dt list containers`);
300
+ if (c.State?.Status !== 'running') throw new Error(`${name} is ${c.State?.Status} — import needs it running: dt start container ${name}`);
301
+ const head = Buffer.alloc(8); { const fd = fs.openSync(file, 'r'); fs.readSync(fd, head, 0, 8, 0); fs.closeSync(fd); }
302
+ const sealed = head.equals(MAGIC);
303
+ if (!sealed && !(head[0] === 0x1f && head[1] === 0x8b)) throw new Error(`${file} is neither a dreamteamer export nor a .tar.gz`);
304
+ const key = sealed ? await openSeal(file, () => passphrase({ confirm: false })) : null;
305
+ if (!sealed) log(`… ${file} is NOT encrypted — reading it as a plain .tar.gz`);
306
+ const only = new Set(flags.workspaces ?? []);
307
+ const as = flags.as;
308
+ if (as !== undefined && (typeof as !== 'string' || !NEW_NAME.test(as) || RESERVED.has(as))) throw new Error(`--as takes a workspace name: 1–40 of a-z, 0-9 and '-', starting with a letter or digit, not files · lost-found · trash (got "${as === true ? '' : as}")`);
309
+ const read = (found) => [fs.createReadStream(file, { start: sealed ? HEAD + MAC : 0 }), ...(key ? [(src) => unseal(src, key)] : []), zlib.createGunzip(),
310
+ async function* (src) { yield* retar(src, (e) => checkEntry(e, only, found, as)); yield TAR_END; }];
311
+ // pass 1: every chunk authenticated and every entry checked before anything is written
312
+ const found = new Set();
313
+ await pipeline(...read(found), async (src) => { for await (const _ of src); });
314
+ const installs = [...found].filter((w) => w.endsWith('/package.json')).map((w) => `${ROOT}/${as ?? w.split('/')[0]}`);
315
+ for (const w of [...found]) if (w.includes('/')) found.delete(w);
316
+ const missing = [...only].filter((w) => !found.has(w));
317
+ if (missing.length) throw new Error(`the export holds no workspace ${missing.join(', ')} — it holds ${[...found].join(', ') || 'none'}`);
318
+ if (!found.size) throw new Error(`${file} holds no workspace`);
319
+ if (as && found.size !== 1) throw new Error(`--as names ONE workspace, and the export holds ${[...found].join(', ')} — pick it with --workspace <w>`);
320
+ const targets = as ? [`${ROOT}/${as}`] : [...found].map((w) => `${ROOT}/${w}`);
321
+ // a folder on the container's own layer vanishes with the container — the rule dt-new keeps
322
+ const mounts = (await exec(name, ['cat', '/proc/mounts'])).stdout.split('\n').map((l) => l.split(' ')).filter((m) => m[1]);
323
+ for (const t of targets) {
324
+ const m = mounts.filter(([, at]) => t === at || t.startsWith(at === '/' ? '/' : `${at}/`)).sort((a, b) => b[1].length - a[1].length)[0];
325
+ if (!m || ['overlay', 'tmpfs', 'ramfs'].includes(m[2])) throw new Error(`${t} would land on ${m ? m[2] : 'an unknown filesystem'}, not a volume, and vanish with the container — import into the container whose own workspace it is (dt start container <w> makes ${ROOT}/<w> a volume). Nothing was written`);
326
+ }
327
+ const full = [];
328
+ for (const t of targets) { const r = await exec(name, ['find', t, '-mindepth', '1', '-maxdepth', '1', '-print', '-quit']); if (r.code === 0 && r.stdout.trim()) full.push(t); }
329
+ if (full.length && flags.replace !== true) throw new Error(`${full.join(', ')} already hold${full.length === 1 ? 's' : ''} files in ${name} — --replace empties and replaces ${full.length === 1 ? 'it' : 'them'}. Nothing was written`);
330
+ for (const t of full) { const r = await exec(name, ['find', t, '-mindepth', '1', '-delete'], { user: 'root' }); if (r.code !== 0) throw new Error(`emptying ${t} failed (exit ${r.code}) — ${r.stderr.trim()}`); }
331
+ // pass 2: the same checks again on the way up, so a file changed between the passes is still refused
332
+ await pipeline(...read(new Set()), async (src) => ok(await api('PUT', `/containers/${c.Id}/archive?path=${encodeURIComponent(ROOT)}&noOverwriteDirNonDir=true`, undefined, { send: Readable.from(src) }), `write ${ROOT} in ${name}`));
333
+ const own = await exec(name, ['chown', '-R', 'node:node', ...targets], { user: 'root' });
334
+ if (own.code !== 0) throw new Error(`chown of ${targets.join(' ')} failed (exit ${own.code}) — ${own.stderr.trim()}`);
335
+ log(`✔ imported ${targets.join(', ')} into ${name}${full.length ? ` · replaced ${full.join(', ')}` : ''} · owned by node`);
336
+ // principle 3: an install runs code the workspace chose (lifecycle scripts, a pinned engine), so
337
+ // import names the step and the person takes it
338
+ if (installs.length) log(`next, in ${installs.join(', ')}: npm ci && npx dreamteamer compile — import ran neither (both run code the workspace chose)`);
339
+ return { container: name, imported: targets, replaced: full, next: installs };
340
+ }
341
+
342
+ /** `dt export|import container <name> …` → exit code. */
343
+ export async function archiveCommand(verb, target, args) {
344
+ const { flags, pos } = parseFlags(args);
345
+ if ('passphrase' in flags) throw new Error('the passphrase is never a flag (every local user\'s `ps` reads argv) — set DT_EXPORT_PASSPHRASE, or let the prompt ask');
346
+ const bad = Object.keys(flags).find((f) => f !== 'workspaces' && !FLAGS[verb].includes(f));
347
+ if (bad) throw new Error(`unknown flag "--${bad}" on \`dt ${verb} container\` — known: ${FLAGS[verb].map((f) => `--${f}`).join(', ')}`);
348
+ const name = target.id ?? pos.shift();
349
+ if (target.collection !== 'containers' || !name) throw new Error(verb === 'export' ? 'dt export container <name> [--workspace <w>]... --out <file> [--no-encrypt]' : 'dt import container <name> <file> [--workspace <w>]... [--replace]');
350
+ // the driver's rule: under --json stdout carries ONLY the JSON, every human line goes to stderr
351
+ const json = flags.json === true;
352
+ const say = json ? console.error : console.log;
353
+ const r = verb === 'export' ? await exportContainer(name, flags, say) : await importContainer(name, pos[0], flags, say);
354
+ if (json) emit(JSON.stringify(r, null, 2));
355
+ return 0;
356
+ }
package/src/containers.js CHANGED
@@ -21,6 +21,14 @@
21
21
  // `.dreamteamer/collections` either — these two nouns resolve HERE, ahead of workspace discovery,
22
22
  // so `dt list containers` answers identically inside a workspace and on a bare host.
23
23
  //
24
+ // EVERY REQUEST CARRIES A TIMER. Docker Desktop paused, or still starting, ACCEPTS the socket and
25
+ // says nothing — and a client with no timer then hangs every verb, and everything waiting on it,
26
+ // forever (measured 2026-09-24: a fake that accepts and never answers held `dt list containers`
27
+ // until the harness killed it at 20 s). So `api()` sets an IDLE timer of `DT_DOCKER_TIMEOUT`
28
+ // seconds (default 30, host `.env` or env) on the socket: idle, not total, so a pull that keeps
29
+ // streaming progress lines is never cut off, while a silent daemon fails the verb with the knob
30
+ // named. `DT_HEALTH_TIMEOUT` (default 90) bounds the other wait, code-server's /healthz.
31
+ //
24
32
  // TEST KNOBS, stated once: `DT_DOCKER_SOCKET` points the client at any socket (a fake in tests);
25
33
  // `DT_HOME` relocates `~/.dreamteamer`; `DT_HEALTH_TIMEOUT=0` skips the wait for code-server's
26
34
  // /healthz. None is documented in help — they are how the suite drives this file without Docker.
@@ -29,6 +37,7 @@ import fs from 'node:fs';
29
37
  import os from 'node:os';
30
38
  import path from 'node:path';
31
39
  import { execFileSync, spawn } from 'node:child_process';
40
+ import { pipeline } from 'node:stream/promises';
32
41
  import { parseEnvValues } from './env-vars.js';
33
42
  import { emit } from './collections-cli.js';
34
43
 
@@ -54,8 +63,9 @@ export function driverTarget(word) {
54
63
  export const HOST_DEFAULTS = {
55
64
  DT_PORT_BASE: '8100', // NOT 8080: that is code-server's in-container port, `dt start`'s REST default, and the old dev image's exposed port — three things on one number
56
65
  DT_BIND: '127.0.0.1', // loopback only; a remote tier puts auth in front before this changes
57
- DT_REGISTRY: 'dreamteamer', // `<registry>/<template>:<tag>` is the image a template name resolves to
58
- DT_TEMPLATE_TAG: 'latest',
66
+ DT_REGISTRY: 'ghcr.io/dreamteamer', // `<registry>/<template>:<tag>` is the image a template name resolves to — the public images repo publishes here
67
+ DT_TEMPLATE_TAG: '0.6.0', // the image release this engine is tested against, bumped with it; `setup` never writes it, so an upgrade moves it
68
+ DT_DOCKER_TIMEOUT: '30', // seconds a request to Docker may sit IDLE before the verb fails — see the header
59
69
  };
60
70
 
61
71
  export function hostDir() { return process.env.DT_HOME ?? path.join(os.homedir(), '.dreamteamer'); }
@@ -87,18 +97,31 @@ function unreachable(sock) {
87
97
 
88
98
  /** One request. Resolves { status, body } where body is parsed JSON when the response is JSON,
89
99
  * else the raw text. `onLine` receives each JSON line of a streaming response (a pull). */
90
- export function api(method, urlPath, body, { onLine } = {}) {
100
+ /** Seconds a request may sit idle before it fails; 0 disables — `DT_DOCKER_TIMEOUT`, env over file over default. */
101
+ export function dockerTimeoutSeconds() {
102
+ const n = Number(hostEnv().DT_DOCKER_TIMEOUT);
103
+ return Number.isFinite(n) && n >= 0 ? n : Number(HOST_DEFAULTS.DT_DOCKER_TIMEOUT);
104
+ }
105
+
106
+ /** `send` streams a body (a tar upload) instead of JSON; `stream` resolves a 2xx with the response
107
+ * itself, unread (a tar download) — both under the same idle timer, which keeps running while the
108
+ * bytes move. */
109
+ export function api(method, urlPath, body, { onLine, raw, send, stream } = {}) {
91
110
  const sock = socketPath();
111
+ const seconds = dockerTimeoutSeconds();
92
112
  return new Promise((resolve, reject) => {
93
113
  const payload = body === undefined ? undefined : JSON.stringify(body);
94
114
  const req = http.request({
95
115
  socketPath: sock, method, path: urlPath,
96
- headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
116
+ headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : send ? { 'Content-Type': 'application/x-tar' } : {},
97
117
  }, (res) => {
118
+ if (stream && res.statusCode < 300) return resolve({ status: res.statusCode, res });
98
119
  let text = '';
99
120
  let pending = '';
100
- res.setEncoding('utf8');
121
+ const bufs = []; // `raw`: an exec's multiplexed stream is binary framing, so it stays bytes
122
+ if (!raw) res.setEncoding('utf8');
101
123
  res.on('data', (chunk) => {
124
+ if (raw) { bufs.push(chunk); return; }
102
125
  text += chunk;
103
126
  if (!onLine) return;
104
127
  pending += chunk;
@@ -107,6 +130,7 @@ export function api(method, urlPath, body, { onLine } = {}) {
107
130
  for (const l of lines) if (l.trim()) { try { onLine(JSON.parse(l)); } catch { /* not JSON */ } }
108
131
  });
109
132
  res.on('end', () => {
133
+ if (raw) return resolve({ status: res.statusCode, body: Buffer.concat(bufs) });
110
134
  const isJson = /json/.test(res.headers['content-type'] ?? '');
111
135
  let parsed = text;
112
136
  if (isJson && !onLine) { try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } }
@@ -114,19 +138,23 @@ export function api(method, urlPath, body, { onLine } = {}) {
114
138
  });
115
139
  });
116
140
  req.on('error', (e) => reject(e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? unreachable(sock) : e));
141
+ // idle-based: fires only when NOTHING has moved on the socket for `seconds` — a streaming
142
+ // pull resets it with every progress line, a paused daemon never does
143
+ if (seconds) req.setTimeout(seconds * 1000, () => req.destroy(new Error(`${method} ${urlPath}: Docker did not answer within ${seconds}s — is Docker Desktop paused or still starting? DT_DOCKER_TIMEOUT=<seconds> in ${path.join(hostDir(), '.env')} changes the wait (0 disables it)`)));
144
+ if (send) return pipeline(send, req).catch((e) => req.destroy(e));
117
145
  if (payload) req.write(payload);
118
146
  req.end();
119
147
  });
120
148
  }
121
149
 
122
150
  /** Throw the daemon's own sentence on a non-2xx, so a refusal reads as Docker's rather than ours. */
123
- function ok(res, what) {
151
+ export function ok(res, what) {
124
152
  if (res.status >= 200 && res.status < 400) return res.body;
125
153
  const msg = res.body && typeof res.body === 'object' && res.body.message ? res.body.message : String(res.body ?? '').trim();
126
154
  throw new Error(`${what}: Docker answered ${res.status}${msg ? ` — ${msg}` : ''}`);
127
155
  }
128
156
 
129
- const LABEL = { workspace: 'dreamteamer.workspace', template: 'dreamteamer.template', person: 'dreamteamer.person', ports: 'dreamteamer.ports', modules: 'dreamteamer.modules' };
157
+ const LABEL = { workspace: 'dreamteamer.workspace', template: 'dreamteamer.template', person: 'dreamteamer.person', ports: 'dreamteamer.ports', modules: 'dreamteamer.modules', workdir: 'dreamteamer.workdir' };
130
158
  const filters = (label) => encodeURIComponent(JSON.stringify({ label: [label] }));
131
159
 
132
160
  // ---- images ------------------------------------------------------------------------------------
@@ -181,7 +209,38 @@ const containerRow = (c) => {
181
209
  created: c.Created ? new Date(c.Created * 1000).toISOString().slice(0, 16).replace('T', ' ') : '', id: c.Id,
182
210
  };
183
211
  };
184
- const editorUrl = (port) => `http://localhost:${port}/?folder=/workspace`;
212
+ /** The dev-container convention: the workspace is mounted at `/workspaces/<name>`, so the folder the
213
+ * editor opens, the URL, and a VS Code attach all name the workspace rather than a fixed word. */
214
+ export const workspaceDir = (name) => `/workspaces/${name}`;
215
+ /** A bare URL opens the machine home (`/opt/dt-launcher`: every workspace, create, clone) — `?folder=`
216
+ * only when the caller asks for one workspace (`--workspace`). */
217
+ const editorUrl = (port, folder) => `http://localhost:${port}/${folder ? `?folder=${folder}` : ''}`;
218
+ /** Where `--mount` may land: the image's own trees. Anywhere else (`/etc`, `/usr/local/bin`, `/opt`)
219
+ * replaces what the image runs — a mount there is a way round the image, not a mount. */
220
+ export const MOUNT_ROOTS = ['/workspaces', '/home/node', '/files', '/mnt'];
221
+ /** `--mount <host-path|volume>:<container-path>[:ro]` → a Docker Mount. A source starting with `/`,
222
+ * `~` or `.` is a bind mount of a host path (resolved against cwd); anything else is a named volume. */
223
+ export function parseMount(spec) {
224
+ const parts = String(spec).split(':');
225
+ if (parts.length < 2 || parts.length > 3 || !parts[0] || !parts[1].startsWith('/')) throw new Error(`--mount takes <host-path|volume>:<container-path>[:ro] — got "${spec}"`);
226
+ const [src, rawTarget, mode] = parts;
227
+ const target = path.posix.normalize(rawTarget).replace(/(.)\/$/, '$1'); // `/mnt/../etc` is `/etc`
228
+ if (!MOUNT_ROOTS.some((r) => target === r || target.startsWith(`${r}/`))) throw new Error(`--mount "${spec}": the target must be under ${MOUNT_ROOTS.join(' · ')} — ${target} is not`);
229
+ if (mode !== undefined && mode !== 'ro' && mode !== 'rw') throw new Error(`--mount "${spec}": the third part is ro or rw`);
230
+ const isPath = /^[/~.]/.test(src);
231
+ const source = isPath ? path.resolve(src.replace(/^~(?=\/|$)/, os.homedir())) : src;
232
+ if (!isPath && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(src)) throw new Error(`--mount "${spec}": "${src}" is neither a path nor a volume name`);
233
+ return { Type: isPath ? 'bind' : 'volume', Source: source, Target: target, ReadOnly: mode === 'ro' };
234
+ }
235
+ /** The URI VS Code on the host opens to attach to this container (Dev Containers extension). The
236
+ * container name is hex-encoded, as the extension spells it. */
237
+ export const attachUri = (name) => `vscode-remote://attached-container+${Buffer.from(name, 'utf8').toString('hex')}${workspaceDir(name)}`;
238
+ /** Each container gets its OWN user-defined bridge, so it shares no network with another
239
+ * workspace and its network carries its name. That is NOT isolation on its own: measured 2026-09-28
240
+ * on Docker Desktop 29.3.1, a container on one user-defined bridge reaches another's by IP and via
241
+ * host.docker.internal:<its published port>. What isolates is the image's egress firewall (see
242
+ * CapAdd below); the bridge still helps where the platform does separate bridges. */
243
+ export const networkName = (name) => `dreamteamer-${name}`;
185
244
  const volumeNames = (name) => ({ workspace: `dreamteamer-${name}-workspace`, home: `dreamteamer-${name}-home`, files: `dreamteamer-${name}-files` });
186
245
 
187
246
  export async function listContainers() {
@@ -200,14 +259,21 @@ export async function inspectContainer(name) {
200
259
  /** The shape `dt get container <name>` prints: the categorised view over `docker inspect`. */
201
260
  export function containerDetail(c) {
202
261
  const binding = Object.values(c.HostConfig?.PortBindings ?? {}).flat()[0];
262
+ const name = c.Name.replace(/^\//, '');
263
+ const wsDir = c.Config.Labels?.[LABEL.workdir] ?? workspaceDir(name);
264
+ const own = new Set([wsDir, '/home/node', '/files']);
203
265
  const mounts = Object.fromEntries((c.Mounts ?? []).filter((m) => m.Type === 'volume').map((m) => [m.Destination, m.Name]));
204
266
  return {
205
- name: c.Name.replace(/^\//, ''), id: c.Id.slice(0, 12),
267
+ name, id: c.Id.slice(0, 12),
206
268
  template: c.Config.Labels[LABEL.template] ?? '', image: c.Config.Image,
207
269
  state: c.State?.Status, started: c.State?.StartedAt, restarts: c.RestartCount ?? 0,
208
270
  bind: binding?.HostIp ?? '', port: binding ? Number(binding.HostPort) : undefined,
209
271
  editor_url: binding ? editorUrl(binding.HostPort) : '',
210
- volumes: { workspace: mounts['/workspace'] ?? '', home: mounts['/home/node'] ?? '', files: mounts['/files'] ?? '' },
272
+ attach_uri: attachUri(name),
273
+ workspace_dir: wsDir,
274
+ volumes: { workspace: mounts[wsDir] ?? '', home: mounts['/home/node'] ?? '', files: mounts['/files'] ?? '' },
275
+ mounts: (c.Mounts ?? []).filter((m) => !own.has(m.Destination)).map((m) => `${m.Type === 'bind' ? m.Source : m.Name}:${m.Destination}${m.RW === false ? ':ro' : ''}`),
276
+ repo: (c.Config.Env ?? []).find((e) => e.startsWith('DT_REPO='))?.slice(8) ?? '',
211
277
  person: c.Config.Labels[LABEL.person] ?? '', created: c.Created, labels: c.Config.Labels,
212
278
  };
213
279
  }
@@ -237,7 +303,8 @@ function person(flags, env) {
237
303
  }
238
304
 
239
305
  /** Create-if-absent and start. Idempotent: a second call on an existing name starts it and prints
240
- * the same URL. Never injects a token — the person logs in INSIDE, once, and the home volume keeps it. */
306
+ * the same URL. Never injects a credential — the person logs in INSIDE, once, and the home volume
307
+ * keeps it. The one secret that crosses is the image's own URL token, read back OUT (launchUrl). */
241
308
  export async function startContainer(name, flags, log = console.log) {
242
309
  if (!/^[a-z0-9][a-z0-9_.-]*$/.test(name)) throw new Error(`"${name}" is not a container name — lowercase letters, digits, "-", "_" and "."; e.g. hq-dana`);
243
310
  const env = hostEnv();
@@ -254,11 +321,51 @@ export async function startContainer(name, flags, log = console.log) {
254
321
  const port = await allocatePort(env);
255
322
  const who = person(flags, env);
256
323
  const vols = volumeNames(name);
324
+ const wsDir = workspaceDir(name);
325
+ // `--mount` adds bind or volume mounts beside the three the container always has; a mount aimed
326
+ // at one of those three targets is refused rather than silently shadowing the volume.
327
+ const extra = (flags.mount ?? []).map(parseMount);
328
+ for (const m of extra) if ([wsDir, '/workspaces', '/home/node', '/files'].includes(m.Target)) throw new Error(`--mount cannot target ${m.Target} — that is one of the container's own volumes, or holds them (${wsDir} · /home/node · /files)`);
329
+ // No mount point INSIDE another mount, the three own volumes included. runc resolves a mount
330
+ // destination through symlinks in the rootfs, and a mounted volume is writable by whoever runs
331
+ // in it: `evil -> /opt` inside /workspaces/<name> makes a mount at /workspaces/<name>/evil land
332
+ // on the image's real /opt (measured with plain Docker). Only the image's own filesystem — no
333
+ // symlinks on these roots, which the image pins with a test — may lie between / and a mount point.
334
+ const under = (t, r) => t === r || t.startsWith(`${r}/`);
335
+ const targets = [wsDir, '/home/node', '/files', ...extra.map((m) => m.Target)];
336
+ extra.forEach((m, i) => { for (const [j, t] of targets.entries()) if (j !== i + 3 && (under(m.Target, t) || under(t, m.Target))) throw new Error(`--mount ${m.Source}:${m.Target} lies at or under ${t}, another mount of this container — a mount point inside a mount resolves through whatever symlinks were written there; mount it beside, e.g. /mnt/<name>`); });
337
+ // A bind whose source lies inside another bind's (or IS it) reaches the same files twice — the
338
+ // way a `:ro` mount of a folder is undone by a writable mount of the folder it sits in.
339
+ // "Inside" is decided by IDENTITY, not spelling: realpath keeps the case it was given, so on a
340
+ // case-insensitive volume (APFS, NTFS by default) `/x/INNER` passes a path comparison against `/x`.
341
+ // So walk up a's real path and compare each ancestor's dev+ino with b's — the filesystem's own
342
+ // rules (case, Unicode normalisation) answer, none guessed here. A source that does not exist has
343
+ // no inode (Docker refuses the bind anyway); it falls back to a path compare, case-folded where
344
+ // the platform's default volume folds.
345
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
346
+ const ident = (p) => { try { const s = fs.statSync(p, { bigint: true }); return `${s.dev}:${s.ino}`; } catch { return null; } };
347
+ const fold = (p) => (['darwin', 'win32'].includes(process.platform) ? p.toLowerCase() : p);
348
+ const inside = (a, b) => {
349
+ const want = ident(b.real);
350
+ if (!want) return !path.relative(fold(b.real), fold(a.real)).startsWith('..');
351
+ for (let p = a.real; ; p = path.dirname(p)) { if (ident(p) === want) return true; if (path.dirname(p) === p) return false; }
352
+ };
353
+ const binds = extra.filter((m) => m.Type === 'bind').map((m) => ({ m, real: real(m.Source) }));
354
+ for (const a of binds) for (const b of binds) if (a !== b && inside(a, b)) throw new Error(`--mount ${a.m.Source}:${a.m.Target} lies inside ${b.m.Source} (mounted at ${b.m.Target}) — one container reaches a host folder through one mount`);
355
+ // `--repo <url>` clones an EXISTING workspace into the workspace volume on first start instead of
356
+ // laying the template down — the way a person joins a workspace that already lives on GitHub.
357
+ const repo = typeof flags.repo === 'string' ? flags.repo : undefined;
358
+ if (repo !== undefined && !/^(https?:\/\/|git@|ssh:\/\/|file:\/\/|\/)/.test(repo)) throw new Error(`--repo takes a git URL or an absolute path — got "${repo}"`);
359
+ const network = await ensureNetwork(name);
257
360
  const body = {
258
361
  Image: ref,
259
- Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name },
362
+ Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name, [LABEL.workdir]: wsDir },
260
363
  Env: [
261
- `DT_WORKSPACE=${name}`, `DT_TEMPLATE=${template}`, 'FILES_FOLDER=/files',
364
+ `DT_WORKSPACE=${name}`, `DT_TEMPLATE=${template}`, `DT_WORKSPACE_DIR=${wsDir}`, 'FILES_FOLDER=/files',
365
+ // the editor listens on every interface INSIDE the container so the port mapping reaches
366
+ // it; the host side stays DT_BIND (loopback) — and the image's proxy checks a token
367
+ 'DT_LOCAL_BIND=0.0.0.0',
368
+ ...(repo ? [`DT_REPO=${repo}`] : []),
262
369
  ...(who.name ? [`GIT_AUTHOR_NAME=${who.name}`, `GIT_COMMITTER_NAME=${who.name}`] : []),
263
370
  ...(who.email ? [`GIT_AUTHOR_EMAIL=${who.email}`, `GIT_COMMITTER_EMAIL=${who.email}`] : []),
264
371
  ],
@@ -266,16 +373,37 @@ export async function startContainer(name, flags, log = console.log) {
266
373
  HostConfig: {
267
374
  PortBindings: { [`${inner}/tcp`]: [{ HostIp: env.DT_BIND, HostPort: String(port) }] },
268
375
  Mounts: [
269
- { Type: 'volume', Source: vols.workspace, Target: '/workspace' },
376
+ { Type: 'volume', Source: vols.workspace, Target: wsDir },
270
377
  { Type: 'volume', Source: vols.home, Target: '/home/node' },
271
378
  { Type: 'volume', Source: vols.files, Target: '/files' },
379
+ ...extra,
272
380
  ],
273
381
  RestartPolicy: { Name: 'unless-stopped' },
382
+ NetworkMode: network.net, // its own bridge ONLY — never the default one; naming, not the isolation
383
+ // NET_ADMIN, and nothing else, for the image's ROOT phase: the entrypoint applies the local
384
+ // egress policy (what the container may reach — not another workspace, not the host's
385
+ // published ports) before it drops to the editor and agents, which run with NO capabilities,
386
+ // so none of them can change the rules. Separate bridges alone do not isolate (above).
387
+ CapAdd: ['NET_ADMIN'],
274
388
  },
275
389
  };
276
- ok(await api('POST', `/containers/create?name=${encodeURIComponent(name)}`, body), `create container ${name}`);
390
+ // A create Docker refuses must not leave this attempt's debris: the network ensureNetwork just
391
+ // made, and the named volumes Docker makes during create. Only what did NOT exist before is
392
+ // removed — a pre-existing volume holds someone's work. Docker refuses to remove either while a
393
+ // container uses it, which covers the create that did land before its answer was lost.
394
+ const fresh = [];
395
+ try {
396
+ for (const m of body.HostConfig.Mounts) if (m.Type === 'volume' && (await api('GET', `/volumes/${encodeURIComponent(m.Source)}`)).status === 404) fresh.push(m.Source);
397
+ ok(await api('POST', `/containers/create?name=${encodeURIComponent(name)}`, body), `create container ${name}`);
398
+ } catch (e) {
399
+ try {
400
+ for (const v of fresh) await api('DELETE', `/volumes/${encodeURIComponent(v)}`);
401
+ if (network.created) await api('DELETE', `/networks/${encodeURIComponent(network.net)}`);
402
+ } catch { /* the create's own failure is the one to report */ }
403
+ throw e;
404
+ }
277
405
  c = await inspectContainer(name);
278
- log(`✔ created ${name} from ${ref} · ${env.DT_BIND}:${port} → ${inner} · volumes ${Object.values(vols).join(', ')}`);
406
+ log(`✔ created ${name} from ${ref} · ${env.DT_BIND}:${port} → ${inner} · ${wsDir} · volumes ${Object.values(vols).join(', ')}${extra.length ? ` · mounts ${extra.map((m) => `${m.Source}→${m.Target}${m.ReadOnly ? ' (ro)' : ''}`).join(', ')}` : ''}${repo ? ` · clones ${repo} on first start` : ''}`);
279
407
  }
280
408
  if (c.State?.Status !== 'running') {
281
409
  const res = await api('POST', `/containers/${c.Id}/start`);
@@ -284,11 +412,56 @@ export async function startContainer(name, flags, log = console.log) {
284
412
  }
285
413
  const detail = containerDetail(c);
286
414
  await waitHealthy(detail, log);
287
- log(`✔ container ${name} · ${detail.state} · ${detail.editor_url}`);
288
- if (!flags['no-open'] && detail.editor_url) openUrl(detail.editor_url);
415
+ const url = await launchUrl(detail, flags);
416
+ log(`✔ container ${name} · ${detail.state} · ${url.text}`);
417
+ if (!flags['no-open'] && detail.port) openUrl(url, log);
289
418
  return detail;
290
419
  }
291
420
 
421
+ async function ensureNetwork(name) {
422
+ const net = networkName(name);
423
+ const res = await api('GET', `/networks/${encodeURIComponent(net)}`);
424
+ if (res.status === 404) { ok(await api('POST', '/networks/create', { Name: net, Driver: 'bridge', Labels: { dreamteamer: '1', 'dreamteamer.name': name } }), `create network ${net}`); return { net, created: true }; }
425
+ if (ok(res, `get network ${net}`).Labels?.['dreamteamer.name'] !== name) throw new Error(`a Docker network "${net}" exists that dreamteamer did not make — remove or rename it (docker network rm ${net})`);
426
+ return { net, created: false };
427
+ }
428
+
429
+ /** Run `cmd` (an argv, never a shell line) in a running container as `user` — one Docker exec under
430
+ * the same idle timer as every request. Answers { code, stdout, stderr }; a non-zero exit is the
431
+ * caller's to judge. */
432
+ export async function exec(name, cmd, { user } = {}) {
433
+ const what = `exec ${cmd[0]} in ${name}`;
434
+ const { Id } = ok(await api('POST', `/containers/${encodeURIComponent(name)}/exec`, { Cmd: cmd, AttachStdout: true, AttachStderr: true, ...(user ? { User: user } : {}) }), what);
435
+ const res = await api('POST', `/exec/${Id}/start`, { Detach: false, Tty: false }, { raw: true });
436
+ ok({ status: res.status, body: res.body.toString('utf8') }, what);
437
+ // Tty:false answers Docker's multiplexed stream: per frame an 8-byte header — stream (1 out, 2 err),
438
+ // three zero bytes, a big-endian length — then that many bytes
439
+ const out = { stdout: '', stderr: '' };
440
+ for (let b = res.body, i = 0, n; i + 8 <= b.length; i += 8 + n) { n = b.readUInt32BE(i + 4); out[b[i] === 2 ? 'stderr' : 'stdout'] += b.subarray(i + 8, i + 8 + n).toString('utf8'); }
441
+ return { code: ok(await api('GET', `/exec/${Id}/json`), what).ExitCode, ...out };
442
+ }
443
+
444
+ /** The URL to print and open. An image that lists `url-token` in /opt/dt-image/features (hq 0.6+)
445
+ * holds a secret its proxy checks; it is read by exec as root, lives in this process only, and
446
+ * reaches the person as `?tkn=` on the ONE line that prints the URL. An older image has no file and
447
+ * gets the plain URL. `text` is that line's URL; `secret` says the token rides on it. */
448
+ async function launchUrl(detail, flags) {
449
+ const folder = flags.workspace === true ? detail.workspace_dir : typeof flags.workspace === 'string' ? workspaceDir(flags.workspace) : undefined;
450
+ if (folder && !/^\/workspaces\/[a-z0-9][a-z0-9_.-]*$/.test(folder)) throw new Error(`--workspace takes a workspace folder name under /workspaces — got "${flags.workspace}"`);
451
+ const plain = detail.port ? editorUrl(detail.port, folder) : '';
452
+ const features = await exec(detail.name, ['cat', '/opt/dt-image/features']);
453
+ const tokened = features.code === 0 && features.stdout.split('\n').some((l) => l.trim() === 'url-token');
454
+ const rotate = flags['rotate-token'] === true;
455
+ if (!tokened) {
456
+ if (rotate) throw new Error(`${detail.name} runs an image with no URL token (no url-token in /opt/dt-image/features) — --rotate-token needs hq 0.6 or later`);
457
+ return { text: plain, secret: false };
458
+ }
459
+ const r = await exec(detail.name, ['dt-url-token', rotate ? 'rotate' : 'show'], { user: 'root' });
460
+ const token = r.stdout.trim();
461
+ if (r.code !== 0 || !/^[A-Za-z0-9_-]{16,}$/.test(token)) throw new Error(`dt-url-token ${rotate ? 'rotate' : 'show'} in ${detail.name} failed (exit ${r.code})${r.stderr.trim() ? ` — ${r.stderr.trim()}` : ''}`);
462
+ return { text: `${plain}${folder ? '&' : '?'}tkn=${token}`, secret: true };
463
+ }
464
+
292
465
  /** Poll code-server's /healthz so the URL printed is one that already answers. */
293
466
  async function waitHealthy(detail, log) {
294
467
  const seconds = process.env.DT_HEALTH_TIMEOUT !== undefined ? Number(process.env.DT_HEALTH_TIMEOUT) : 90;
@@ -307,9 +480,19 @@ async function waitHealthy(detail, log) {
307
480
  log(`⚠ the editor did not answer within ${seconds}s — \`docker logs ${detail.name}\` says why`);
308
481
  }
309
482
 
310
- function openUrl(url) {
311
- const cmd = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url];
312
- try { spawn(cmd[0], cmd.slice(1), { stdio: 'ignore', detached: true }).unref(); } catch { /* printing the URL is the contract; opening it is a courtesy */ }
483
+ /** Printing the URL is the contract; opening it is a courtesy. A tokened URL never becomes a process
484
+ * ARGUMENT (every local user's `ps` reads those): on macOS it reaches `osascript` on stdin; where no
485
+ * opener takes stdin (xdg-open, start) it is printed and left for the person to open. */
486
+ function openUrl({ text, secret }, log) {
487
+ try {
488
+ if (secret && process.platform === 'darwin') {
489
+ const p = spawn('osascript', ['-'], { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
490
+ p.on('error', () => {}); p.stdin.end(`open location "${text}"\n`); p.unref(); return;
491
+ }
492
+ if (secret) { log(' (open the URL above yourself — this platform\'s opener would put the token in a process argument)'); return; }
493
+ const cmd = process.platform === 'darwin' ? ['open', text] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', text] : ['xdg-open', text];
494
+ spawn(cmd[0], cmd.slice(1), { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
495
+ } catch { /* the URL is printed either way */ }
313
496
  }
314
497
 
315
498
  export async function stopContainer(name) {
@@ -325,14 +508,17 @@ export async function removeContainer(name, { force = false } = {}, log = consol
325
508
  const c = await inspectContainer(name);
326
509
  if (!c) throw new Error(`no container "${name}" — dt list containers`);
327
510
  const vols = containerDetail(c).volumes;
328
- if (c.State?.Status === 'running') await api('POST', `/containers/${c.Id}/stop?t=10`);
511
+ // `restarting` too: a crash-looping container refuses DELETE until it is stopped (measured, Docker Desktop)
512
+ if (['running', 'restarting'].includes(c.State?.Status)) await api('POST', `/containers/${c.Id}/stop?t=10`);
329
513
  ok(await api('DELETE', `/containers/${c.Id}?v=false`), `rm container ${name}`);
514
+ const net = await api('GET', `/networks/${encodeURIComponent(networkName(name))}`);
515
+ if (net.status === 200 && net.body?.Labels?.['dreamteamer.name'] === name) ok(await api('DELETE', `/networks/${encodeURIComponent(networkName(name))}`), `rm network ${networkName(name)}`);
330
516
  const named = Object.values(vols).filter(Boolean);
331
517
  if (force) {
332
518
  for (const v of named) { const r = await api('DELETE', `/volumes/${encodeURIComponent(v)}`); if (r.status !== 204 && r.status !== 404) ok(r, `rm volume ${v}`); }
333
- log(`✔ removed ${name} and its volumes ${named.join(', ')}`);
519
+ log(`✔ removed ${name}, its network and its volumes ${named.join(', ')}`);
334
520
  } else {
335
- log(`✔ removed ${name} · kept volumes ${named.join(', ')} (dt rm container ${name} --force removes them too; dt start container ${name} --template <t> reattaches them)`);
521
+ log(`✔ removed ${name} and its network · kept volumes ${named.join(', ')} (dt rm container ${name} --force removes them too; dt start container ${name} --template <t> reattaches them)`);
336
522
  }
337
523
  }
338
524
 
@@ -342,7 +528,7 @@ export async function setup(flags, log = console.log) {
342
528
  const file = path.join(dir, '.env');
343
529
  fs.mkdirSync(dir, { recursive: true });
344
530
  const existing = fs.existsSync(file) ? Object.fromEntries(parseEnvValues(fs.readFileSync(file, 'utf8'))) : {};
345
- const missing = Object.entries(HOST_DEFAULTS).filter(([k]) => !(k in existing));
531
+ const missing = Object.entries(HOST_DEFAULTS).filter(([k]) => k !== 'DT_TEMPLATE_TAG' && !(k in existing));
346
532
  if (missing.length) {
347
533
  const header = fs.existsSync(file) ? '' : '# dreamteamer host configuration — read by `dt setup`, `dt start container` and friends.\n# DT_IMAGE_<template>=<ref> pins a template to an image; DT_PERSON_NAME / DT_PERSON_EMAIL are the git identity containers get.\n';
348
534
  fs.appendFileSync(file, header + missing.map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
@@ -385,6 +571,9 @@ export async function driverCommand(verb, target, args) {
385
571
  const id = target.id ?? pos[0];
386
572
  const json = flags.json === true;
387
573
  const col = target.collection;
574
+ // under --json stdout is the JSON document and nothing else — every human line (the URL carrying
575
+ // the image's token among them) goes to stderr, so a script parsing or logging stdout never holds it
576
+ const say = json ? console.error : console.log;
388
577
  if (!DRIVER_VERBS.has(verb)) throw new Error(`\`${verb}\` is not a verb on ${col} — list · get · add · rm${col === 'containers' ? ' · start · stop · open' : ''}`);
389
578
  if (col === 'images') {
390
579
  if (LIFECYCLE_VERBS.has(verb)) throw new Error(`\`${verb}\` is a container verb — an image is started by starting a container from it: dt start container <name> --template <t>`);
@@ -397,9 +586,27 @@ export async function driverCommand(verb, target, args) {
397
586
  if (verb === 'list') { const rows = await listContainers(); json ? emit(JSON.stringify(rows, null, 2)) : console.log(table(rows, ['name', 'template', 'state', 'editor_url', 'person', 'created'])); return 0; }
398
587
  if (!id) throw new Error(`dt ${verb} container <name>${verb === 'start' || verb === 'add' ? ' --template <t>' : ''}`);
399
588
  if (verb === 'get') { const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}" — dt list containers`); emit(JSON.stringify(json ? c : containerDetail(c), null, 2)); return 0; }
400
- if (verb === 'start' || verb === 'add') { const d = await startContainer(id, flags); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
401
- if (verb === 'stop') { const d = await stopContainer(id); console.log(`✔ stopped ${id} · volumes kept`); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
402
- if (verb === 'open') { const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}"`); const d = containerDetail(c); if (!d.editor_url) throw new Error(`${id} publishes no port`); console.log(d.editor_url); if (!flags['no-open']) openUrl(d.editor_url); return 0; }
589
+ if (verb === 'start' || verb === 'add') { const d = await startContainer(id, flags, say); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
590
+ if (verb === 'stop') { const d = await stopContainer(id); say(`✔ stopped ${id} · volumes kept`); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
591
+ if (verb === 'open') {
592
+ const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}"`);
593
+ const d = containerDetail(c);
594
+ if (flags.vscode) {
595
+ // Dev Containers attach: the host's own VS Code opens the workspace INSIDE the container, and
596
+ // installs the extensions the image's `devcontainer.metadata` label names into the container's
597
+ // VS Code Server — a second extension host beside code-server's, over the same files.
598
+ say(d.attach_uri);
599
+ if (!flags['no-open']) { try { spawn('code', ['--folder-uri', d.attach_uri], { stdio: 'ignore', detached: true }).unref(); } catch { /* the URI is printed either way */ } }
600
+ if (json) emit(JSON.stringify(d, null, 2));
601
+ return 0;
602
+ }
603
+ if (!d.editor_url) throw new Error(`${id} publishes no port`);
604
+ if (d.state !== 'running') throw new Error(`${id} is ${d.state} — dt start container ${id} starts it and prints its URL`);
605
+ const url = await launchUrl(d, flags);
606
+ say(url.text); if (!flags['no-open']) openUrl(url, say);
607
+ if (json) emit(JSON.stringify(d, null, 2)); // containerDetail: its editor_url never carries the token
608
+ return 0;
609
+ }
403
610
  if (verb === 'rm') { await removeContainer(id, { force: flags.force === true }); return 0; }
404
611
  return 1;
405
612
  }
@@ -408,15 +615,21 @@ export async function driverCommand(verb, target, args) {
408
615
  * record parser's promotion rules — a repeated flag on these verbs is a mistake, not an array. */
409
616
  export function parseFlags(args) {
410
617
  const flags = {}; const pos = [];
618
+ // `--mount` repeats; `--workspace` repeats on export/import, which read every value from
619
+ // `workspaces` while start/open keep the last. Any other repeat is a mistake and the LAST wins.
620
+ const put = (k, v) => {
621
+ if (k === 'mount') flags.mount = [...(flags.mount ?? []), v]; else flags[k] = v;
622
+ if (k === 'workspace') flags.workspaces = [...(flags.workspaces ?? []), v];
623
+ };
411
624
  for (let i = 0; i < args.length; i++) {
412
625
  const a = args[i];
413
626
  if (!a.startsWith('--')) { pos.push(a); continue; }
414
627
  const eq = a.indexOf('=');
415
- if (eq > -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
416
- else if (i + 1 < args.length && !args[i + 1].startsWith('--')) flags[a.slice(2)] = args[++i];
417
- else flags[a.slice(2)] = true;
628
+ if (eq > -1) put(a.slice(2, eq), a.slice(eq + 1));
629
+ else if (i + 1 < args.length && !args[i + 1].startsWith('--')) put(a.slice(2), args[++i]);
630
+ else put(a.slice(2), true);
418
631
  }
419
632
  return { flags, pos };
420
633
  }
421
634
 
422
- export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force'];
635
+ export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force', 'mount', 'repo', 'vscode', 'rotate-token', 'workspace', 'out', 'no-encrypt', 'replace', 'with-secrets', 'as'];
package/src/harnesses.js CHANGED
@@ -295,6 +295,7 @@ function buildCollectionsIndex(entries) {
295
295
  // the domain listing — the visible failure rather than the silent one.
296
296
  systemGroup: d.group === 'system',
297
297
  generated: d.storage?.base === 'runtime',
298
+ driver: d.storage?.driver ?? null,
298
299
  description: flat(d.description),
299
300
  useWhen: flat(d.use_when),
300
301
  module: d.module ?? '',
@@ -418,7 +419,10 @@ function collectionsSection(index, modules, workspaceModule) {
418
419
  }
419
420
  const sys = index.filter((c) => c.systemGroup);
420
421
  const system = sys.filter((c) => c.generated).map((c) => c.name);
421
- const kept = sys.filter((c) => !c.generated).map((c) => c.name);
422
+ const kept = sys.filter((c) => !c.generated && !c.driver).map((c) => c.name);
423
+ // A DRIVER collection is neither build output nor files: its verbs are answered by a driver over
424
+ // something that runs (Docker), so it gets its own clause rather than being called either.
425
+ const driven = sys.filter((c) => c.driver).map((c) => `${c.name} (${c.driver})`);
422
426
  // ⚠ THIS LINE IS THE FIRST THING A SESSION READS about the system collections, and until 0.19.0
423
427
  // it said "schema-ops only", which named an internal module and a grammar that no longer exists.
424
428
  // It now names the VERBS and the one policy difference, because an agent that knows the verbs
@@ -428,7 +432,7 @@ function collectionsSection(index, modules, workspaceModule) {
428
432
  // told "it is build output" — a sentence that was false of it and is false of the next data-backed
429
433
  // system collection too, since the split is derived rather than naming one.
430
434
  if (sys.length) {
431
- lines.push('', `- system collections — the SAME verbs (add · set · rm · rename · list · get), plus \`dt add-field\`/\`set-field\`/\`rm-field\`/\`rename-field <collection>\`. A system write COMMITS ITSELF, in the repo holding the source; a record write does not (\`dt commit\` publishes).${system.length ? ` Never hand-edit \`.dreamteamer/\` — it is build output: ${system.join(' · ')}.` : ''}${kept.length ? ` Machinery whose records are real files you edit like any other: ${kept.join(' · ')}` : ''}`);
435
+ lines.push('', `- system collections — the SAME verbs (add · set · rm · rename · list · get), plus \`dt add-field\`/\`set-field\`/\`rm-field\`/\`rename-field <collection>\`. A system write COMMITS ITSELF, in the repo holding the source; a record write does not (\`dt commit\` publishes).${system.length ? ` Never hand-edit \`.dreamteamer/\` — it is build output: ${system.join(' · ')}.` : ''}${kept.length ? ` Machinery whose records are real files you edit like any other: ${kept.join(' · ')}.` : ''}${driven.length ? ` Answered by a DRIVER, not files — nothing under data/, nothing to commit, the same verbs plus start · stop · open: ${driven.join(' · ')}` : ''}`);
432
436
  }
433
437
  return lines;
434
438
  }
package/src/schema-ops.js CHANGED
@@ -754,6 +754,7 @@ const COLLECTION_SETTABLE = {
754
754
  use_when: (v) => String(v),
755
755
  title: (v) => String(v),
756
756
  title_template: (v) => String(v),
757
+ singular: (v) => String(v), // the word the CLI accepts beside the name; compile refuses a collision
757
758
  icon: (v) => String(v),
758
759
  // The collection's partition. `group=system` is the reserved value: it moves the collection out
759
760
  // of the block's domain listing and onto a surface's schema surface, and changes nothing about
package/src/server.js CHANGED
@@ -22,6 +22,7 @@ import { sortRows } from './temporal.js';
22
22
  import { placementKey } from './fractional-index.js';
23
23
  import { commandsFor, recordResolver } from './record-commands.js';
24
24
  import { distinctValues } from './field-values.js';
25
+ import { listContainers, listImages, inspectContainer, inspectImage, containerDetail } from './containers.js';
25
26
 
26
27
 
27
28
  export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
@@ -77,6 +78,33 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
77
78
  // wildcard, a literal and a second wildcard in one pattern, so `/collections/a/b/records/c` has
78
79
  // several readings and the router picks one. Encoding keeps the boundary explicit at the caller,
79
80
  // which is the same reason references declare their namespace instead of having it inferred.
81
+ // A DRIVER collection (`storage.driver: docker`) has no records on disk: list and get are answered
82
+ // by the driver, and every write is refused with the verb that does it — the same interception the
83
+ // CLI performs, at the same surface, so the extension's tree and record views work without either
84
+ // side learning Docker. Wrapped in `driven` so an unreachable daemon is a 502 with its sentence,
85
+ // not a 500 from the store looking for a folder that never exists.
86
+ const driverOf = (name) => store.descriptors.get(name)?.storage?.driver;
87
+ const driven = (fn) => (req, res, next) => {
88
+ if (!driverOf(req.params.name)) return next();
89
+ fn(req, res).catch((e) => res.status(502).json({ error: e.message }));
90
+ };
91
+ api.get('/collections/:name/records', driven(async (req, res) => {
92
+ const rows = req.params.name === 'images' ? await listImages() : await listContainers();
93
+ res.json({ records: rows.map((r) => ({ ...r, id: r.name ?? r.image })), total: rows.length });
94
+ }));
95
+ api.get('/collections/:name/records/*id', driven(async (req, res) => {
96
+ const id = idParam(req);
97
+ const fields = req.params.name === 'images' ? await inspectImage(id) : (await inspectContainer(id).then((c) => c && containerDetail(c)));
98
+ if (!fields) return res.status(404).json({ error: `no ${req.params.name === 'images' ? 'image' : 'container'} "${id}"` });
99
+ res.json({ id, fields, path: null });
100
+ }));
101
+ for (const [method, route] of [['post', '/collections/:name/records'], ['patch', '/collections/:name/records/*id'], ['delete', '/collections/:name/records/*id'], ['patch', '/collections/:name/position/*id'], ['post', '/collections/:name/rename']]) {
102
+ api[method](route, (req, res, next) => {
103
+ if (!driverOf(req.params.name)) return next();
104
+ const noun = req.params.name === 'images' ? 'image' : 'container';
105
+ res.status(405).json({ error: `"${req.params.name}" is a ${driverOf(req.params.name)} driver collection — it is written with the CLI, not as a record: dt start ${noun} <name> --template <t> · dt stop ${noun} <name> · dt rm ${noun} <name>` });
106
+ });
107
+ }
80
108
  api.get('/collections/:name/records', (req, res) => {
81
109
  const d = store.descriptor(req.params.name);
82
110
  const bf = bodyField(d);