dreamteamer 0.26.0 → 0.28.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.
@@ -0,0 +1,422 @@
1
+ // containers.js — `containers` and `images` as verbs over the Docker Engine API. The one storage
2
+ // DRIVER in core: a workspace becomes a running container (`dt start container <name> --template
3
+ // <t>`), the person opens code-server at a loopback URL, and the same record verbs — list · get ·
4
+ // add · rm — answer over Docker instead of over a folder of files.
5
+ //
6
+ // WHY THIS IS CORE AND NOT A MODULE. A module ships collections, skills, commands and views INTO a
7
+ // workspace; every one of them needs a compiled runtime to exist. This runs BEFORE any workspace
8
+ // exists — `npm i -g dreamteamer && dt setup && dt start container …` on a machine with nothing
9
+ // but Docker Desktop — and a module has no place to stand there. That is the "could a module do it"
10
+ // question, answered: no, because the thing being made IS the workspace.
11
+ //
12
+ // WHY THERE IS NO DEPENDENCY. The Engine API is HTTP over a Unix socket (a named pipe on Windows),
13
+ // and `node:http` takes `socketPath`. Measured 2026-09-24 against Docker Desktop 29.3.1 / API 1.54:
14
+ // /version, /containers/json and /images/json all answered from a bare `node -e`. The ONE call the
15
+ // API makes awkward is `POST /build`, which wants a tar stream Node core cannot produce — so
16
+ // templates ship PREBUILT (a registry pull is `POST /images/create`, streamed JSON lines) and a
17
+ // local build is the `docker` CLI Docker Desktop installs anyway, never this file.
18
+ //
19
+ // WHAT A DRIVER COLLECTION IS NOT. Not records: nothing under `data/`, nothing `dt commit` sees,
20
+ // nothing `dt check` reads, no history (Docker keeps its own). It is not compiled into
21
+ // `.dreamteamer/collections` either — these two nouns resolve HERE, ahead of workspace discovery,
22
+ // so `dt list containers` answers identically inside a workspace and on a bare host.
23
+ //
24
+ // TEST KNOBS, stated once: `DT_DOCKER_SOCKET` points the client at any socket (a fake in tests);
25
+ // `DT_HOME` relocates `~/.dreamteamer`; `DT_HEALTH_TIMEOUT=0` skips the wait for code-server's
26
+ // /healthz. None is documented in help — they are how the suite drives this file without Docker.
27
+ import http from 'node:http';
28
+ import fs from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { execFileSync, spawn } from 'node:child_process';
32
+ import { parseEnvValues } from './env-vars.js';
33
+ import { emit } from './collections-cli.js';
34
+
35
+ // ---- the two nouns, singular and plural --------------------------------------------------------
36
+ // The operator's spelling is `dt start container hq-dana` and `dt list containers`; both resolve.
37
+ // This is the singular map for the driver collections ONLY — the general rule ("every record verb
38
+ // accepts the singular, derived from the descriptor") is a filed feature, not this file's job.
39
+ export const NOUNS = { containers: 'containers', container: 'containers', images: 'images', image: 'images' };
40
+ export const DRIVER_VERBS = new Set(['list', 'get', 'add', 'rm', 'start', 'stop', 'open']);
41
+ export const LIFECYCLE_VERBS = new Set(['start', 'stop', 'open']);
42
+
43
+ /** `containers`, `container`, `containers/<id>` → { collection, id }; null when the word is not ours. */
44
+ export function driverTarget(word) {
45
+ if (!word || word.startsWith('--')) return null;
46
+ const slash = word.indexOf('/');
47
+ const noun = slash === -1 ? word : word.slice(0, slash);
48
+ const collection = NOUNS[noun];
49
+ if (!collection) return null;
50
+ return { collection, id: slash === -1 ? undefined : word.slice(slash + 1) };
51
+ }
52
+
53
+ // ---- host configuration: ~/.dreamteamer/.env ---------------------------------------------------
54
+ export const HOST_DEFAULTS = {
55
+ 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
+ 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',
59
+ };
60
+
61
+ export function hostDir() { return process.env.DT_HOME ?? path.join(os.homedir(), '.dreamteamer'); }
62
+
63
+ /** Defaults, then the file, then the process env — the same precedence a shell would give. */
64
+ export function hostEnv() {
65
+ const file = path.join(hostDir(), '.env');
66
+ // parseEnvValues answers a Map — spread it as entries, or the file silently contributes nothing.
67
+ const fromFile = fs.existsSync(file) ? Object.fromEntries(parseEnvValues(fs.readFileSync(file, 'utf8'))) : {};
68
+ const out = { ...HOST_DEFAULTS, ...fromFile };
69
+ for (const k of Object.keys(process.env)) if (k.startsWith('DT_') && process.env[k] !== undefined) out[k] = process.env[k];
70
+ return out;
71
+ }
72
+
73
+ // ---- the Engine API client ---------------------------------------------------------------------
74
+ export function socketPath() {
75
+ if (process.env.DT_DOCKER_SOCKET) return process.env.DT_DOCKER_SOCKET;
76
+ if (process.platform === 'win32') return '//./pipe/docker_engine';
77
+ for (const p of ['/var/run/docker.sock', path.join(os.homedir(), '.docker', 'run', 'docker.sock')]) {
78
+ try { if (fs.statSync(p).isSocket()) return p; } catch { /* next */ }
79
+ }
80
+ return '/var/run/docker.sock';
81
+ }
82
+
83
+ function unreachable(sock) {
84
+ const hint = process.platform === 'darwin' ? ' — is Docker Desktop running? `open -a Docker` starts it' : ' — is the Docker daemon running?';
85
+ return new Error(`Docker is not reachable at ${sock}${hint}. \`dt setup\` checks this and says what is missing.`);
86
+ }
87
+
88
+ /** One request. Resolves { status, body } where body is parsed JSON when the response is JSON,
89
+ * else the raw text. `onLine` receives each JSON line of a streaming response (a pull). */
90
+ export function api(method, urlPath, body, { onLine } = {}) {
91
+ const sock = socketPath();
92
+ return new Promise((resolve, reject) => {
93
+ const payload = body === undefined ? undefined : JSON.stringify(body);
94
+ const req = http.request({
95
+ socketPath: sock, method, path: urlPath,
96
+ headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
97
+ }, (res) => {
98
+ let text = '';
99
+ let pending = '';
100
+ res.setEncoding('utf8');
101
+ res.on('data', (chunk) => {
102
+ text += chunk;
103
+ if (!onLine) return;
104
+ pending += chunk;
105
+ const lines = pending.split('\n');
106
+ pending = lines.pop();
107
+ for (const l of lines) if (l.trim()) { try { onLine(JSON.parse(l)); } catch { /* not JSON */ } }
108
+ });
109
+ res.on('end', () => {
110
+ const isJson = /json/.test(res.headers['content-type'] ?? '');
111
+ let parsed = text;
112
+ if (isJson && !onLine) { try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } }
113
+ resolve({ status: res.statusCode, body: parsed });
114
+ });
115
+ });
116
+ req.on('error', (e) => reject(e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? unreachable(sock) : e));
117
+ if (payload) req.write(payload);
118
+ req.end();
119
+ });
120
+ }
121
+
122
+ /** 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) {
124
+ if (res.status >= 200 && res.status < 400) return res.body;
125
+ const msg = res.body && typeof res.body === 'object' && res.body.message ? res.body.message : String(res.body ?? '').trim();
126
+ throw new Error(`${what}: Docker answered ${res.status}${msg ? ` — ${msg}` : ''}`);
127
+ }
128
+
129
+ const LABEL = { workspace: 'dreamteamer.workspace', template: 'dreamteamer.template', person: 'dreamteamer.person', ports: 'dreamteamer.ports', modules: 'dreamteamer.modules' };
130
+ const filters = (label) => encodeURIComponent(JSON.stringify({ label: [label] }));
131
+
132
+ // ---- images ------------------------------------------------------------------------------------
133
+ export function imageRef(template, env = hostEnv()) {
134
+ // `DT_IMAGE_<template>=<ref>` pins one template to any image, which is how a local build or a
135
+ // private registry is reached without the registry default changing for every other template.
136
+ return env[`DT_IMAGE_${template}`] ?? `${env.DT_REGISTRY}/${template}:${env.DT_TEMPLATE_TAG}`;
137
+ }
138
+
139
+ const imageRow = (i) => ({
140
+ image: (i.RepoTags ?? []).find((t) => t !== '<none>:<none>') ?? i.Id.slice(7, 19),
141
+ template: i.Labels?.[LABEL.template] ?? '',
142
+ ports: i.Labels?.[LABEL.ports] ?? '',
143
+ modules: i.Labels?.[LABEL.modules] ?? '',
144
+ size_mb: Math.round((i.Size ?? 0) / 1e6),
145
+ created: i.Created ? new Date(i.Created * 1000).toISOString().slice(0, 10) : '',
146
+ id: i.Id,
147
+ });
148
+
149
+ export async function listImages() {
150
+ const body = ok(await api('GET', `/images/json?filters=${filters(LABEL.template)}`), 'list images');
151
+ return body.map(imageRow).sort((a, b) => a.image.localeCompare(b.image));
152
+ }
153
+
154
+ export async function inspectImage(ref) {
155
+ const res = await api('GET', `/images/${encodeURIComponent(ref)}/json`);
156
+ return res.status === 404 ? null : ok(res, `get image ${ref}`);
157
+ }
158
+
159
+ export async function pullImage(ref, log = console.error) {
160
+ const [name, tag] = splitTag(ref);
161
+ let last = '';
162
+ const res = await api('POST', `/images/create?fromImage=${encodeURIComponent(name)}&tag=${encodeURIComponent(tag)}`, undefined, {
163
+ onLine: (l) => { if (l.status && l.status !== last) { last = l.status; log(` … ${l.status}${l.id ? ` ${l.id}` : ''}`); } if (l.error) throw new Error(l.error); },
164
+ });
165
+ if (res.status !== 200) throw new Error(`pull ${ref}: Docker answered ${res.status} — ${String(res.body).trim()}. A local build is \`docker build -t ${ref} …\`, or pin DT_IMAGE_<template> in ${path.join(hostDir(), '.env')}`);
166
+ }
167
+
168
+ function splitTag(ref) {
169
+ const at = ref.lastIndexOf(':');
170
+ const slash = ref.lastIndexOf('/');
171
+ return at > slash ? [ref.slice(0, at), ref.slice(at + 1)] : [ref, 'latest'];
172
+ }
173
+
174
+ // ---- containers --------------------------------------------------------------------------------
175
+ const containerRow = (c) => {
176
+ const name = (c.Names?.[0] ?? '').replace(/^\//, '');
177
+ const port = (c.Ports ?? []).find((p) => p.PublicPort)?.PublicPort;
178
+ return {
179
+ name, template: c.Labels?.[LABEL.template] ?? '', state: c.State, status: c.Status,
180
+ editor_url: port ? editorUrl(port) : '', image: c.Image, person: c.Labels?.[LABEL.person] ?? '',
181
+ created: c.Created ? new Date(c.Created * 1000).toISOString().slice(0, 16).replace('T', ' ') : '', id: c.Id,
182
+ };
183
+ };
184
+ const editorUrl = (port) => `http://localhost:${port}/?folder=/workspace`;
185
+ const volumeNames = (name) => ({ workspace: `dreamteamer-${name}-workspace`, home: `dreamteamer-${name}-home`, files: `dreamteamer-${name}-files` });
186
+
187
+ export async function listContainers() {
188
+ const body = ok(await api('GET', `/containers/json?all=1&filters=${filters(LABEL.workspace)}`), 'list containers');
189
+ return body.map(containerRow).sort((a, b) => a.name.localeCompare(b.name));
190
+ }
191
+
192
+ export async function inspectContainer(name) {
193
+ const res = await api('GET', `/containers/${encodeURIComponent(name)}/json`);
194
+ if (res.status === 404) return null;
195
+ const c = ok(res, `get container ${name}`);
196
+ if (!c.Config?.Labels?.[LABEL.workspace]) throw new Error(`"${name}" is a Docker container but not a dreamteamer workspace (no ${LABEL.workspace} label) — this verb only touches containers it made`);
197
+ return c;
198
+ }
199
+
200
+ /** The shape `dt get container <name>` prints: the categorised view over `docker inspect`. */
201
+ export function containerDetail(c) {
202
+ const binding = Object.values(c.HostConfig?.PortBindings ?? {}).flat()[0];
203
+ const mounts = Object.fromEntries((c.Mounts ?? []).filter((m) => m.Type === 'volume').map((m) => [m.Destination, m.Name]));
204
+ return {
205
+ name: c.Name.replace(/^\//, ''), id: c.Id.slice(0, 12),
206
+ template: c.Config.Labels[LABEL.template] ?? '', image: c.Config.Image,
207
+ state: c.State?.Status, started: c.State?.StartedAt, restarts: c.RestartCount ?? 0,
208
+ bind: binding?.HostIp ?? '', port: binding ? Number(binding.HostPort) : undefined,
209
+ editor_url: binding ? editorUrl(binding.HostPort) : '',
210
+ volumes: { workspace: mounts['/workspace'] ?? '', home: mounts['/home/node'] ?? '', files: mounts['/files'] ?? '' },
211
+ person: c.Config.Labels[LABEL.person] ?? '', created: c.Created, labels: c.Config.Labels,
212
+ };
213
+ }
214
+
215
+ /** The lowest host port from DT_PORT_BASE upward that no dreamteamer container already holds. */
216
+ export async function allocatePort(env = hostEnv()) {
217
+ const base = Number(env.DT_PORT_BASE);
218
+ if (!Number.isInteger(base) || base < 1024) throw new Error(`DT_PORT_BASE must be an integer port above 1023 (got "${env.DT_PORT_BASE}")`);
219
+ const all = ok(await api('GET', `/containers/json?all=1&filters=${filters(LABEL.workspace)}`), 'list containers');
220
+ const taken = new Set(all.flatMap((c) => (c.Ports ?? []).map((p) => p.PublicPort)).filter(Boolean));
221
+ // A stopped container publishes nothing in /containers/json, so read its binding from inspect —
222
+ // otherwise the second workspace lands on the first one's port the moment the first is stopped.
223
+ for (const c of all) if (c.State !== 'running') {
224
+ const d = await api('GET', `/containers/${c.Id}/json`);
225
+ for (const b of Object.values(d.body?.HostConfig?.PortBindings ?? {}).flat()) taken.add(Number(b.HostPort));
226
+ }
227
+ let port = base;
228
+ while (taken.has(port)) port++;
229
+ return port;
230
+ }
231
+
232
+ function person(flags, env) {
233
+ const git = (k) => { try { return execFileSync('git', ['config', '--global', k], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
234
+ const name = flags.name ?? env.DT_PERSON_NAME ?? git('user.name');
235
+ const email = flags.email ?? env.DT_PERSON_EMAIL ?? git('user.email');
236
+ return { name, email };
237
+ }
238
+
239
+ /** 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. */
241
+ export async function startContainer(name, flags, log = console.log) {
242
+ 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
+ const env = hostEnv();
244
+ let c = await inspectContainer(name);
245
+ if (!c) {
246
+ const template = typeof flags.template === 'string' ? flags.template : undefined;
247
+ if (!template) throw new Error(`container "${name}" does not exist yet — name the template that makes it: dt start container ${name} --template <t> (dt list images shows the templates present)`);
248
+ const ref = imageRef(template, env);
249
+ let img = await inspectImage(ref);
250
+ if (!img) { log(`… pulling ${ref}`); await pullImage(ref, log); img = await inspectImage(ref); }
251
+ if (!img) throw new Error(`image ${ref} is still absent after the pull`);
252
+ const labels = img.Config?.Labels ?? {};
253
+ const inner = Number(labels[LABEL.ports] ?? 8080);
254
+ const port = await allocatePort(env);
255
+ const who = person(flags, env);
256
+ const vols = volumeNames(name);
257
+ const body = {
258
+ Image: ref,
259
+ Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name },
260
+ Env: [
261
+ `DT_WORKSPACE=${name}`, `DT_TEMPLATE=${template}`, 'FILES_FOLDER=/files',
262
+ ...(who.name ? [`GIT_AUTHOR_NAME=${who.name}`, `GIT_COMMITTER_NAME=${who.name}`] : []),
263
+ ...(who.email ? [`GIT_AUTHOR_EMAIL=${who.email}`, `GIT_COMMITTER_EMAIL=${who.email}`] : []),
264
+ ],
265
+ ExposedPorts: { [`${inner}/tcp`]: {} },
266
+ HostConfig: {
267
+ PortBindings: { [`${inner}/tcp`]: [{ HostIp: env.DT_BIND, HostPort: String(port) }] },
268
+ Mounts: [
269
+ { Type: 'volume', Source: vols.workspace, Target: '/workspace' },
270
+ { Type: 'volume', Source: vols.home, Target: '/home/node' },
271
+ { Type: 'volume', Source: vols.files, Target: '/files' },
272
+ ],
273
+ RestartPolicy: { Name: 'unless-stopped' },
274
+ },
275
+ };
276
+ ok(await api('POST', `/containers/create?name=${encodeURIComponent(name)}`, body), `create container ${name}`);
277
+ c = await inspectContainer(name);
278
+ log(`✔ created ${name} from ${ref} · ${env.DT_BIND}:${port} → ${inner} · volumes ${Object.values(vols).join(', ')}`);
279
+ }
280
+ if (c.State?.Status !== 'running') {
281
+ const res = await api('POST', `/containers/${c.Id}/start`);
282
+ if (res.status !== 204 && res.status !== 304) ok(res, `start container ${name}`);
283
+ c = await inspectContainer(name);
284
+ }
285
+ const detail = containerDetail(c);
286
+ 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);
289
+ return detail;
290
+ }
291
+
292
+ /** Poll code-server's /healthz so the URL printed is one that already answers. */
293
+ async function waitHealthy(detail, log) {
294
+ const seconds = process.env.DT_HEALTH_TIMEOUT !== undefined ? Number(process.env.DT_HEALTH_TIMEOUT) : 90;
295
+ if (!seconds || !detail.port) return;
296
+ const until = Date.now() + seconds * 1000;
297
+ let told = false;
298
+ while (Date.now() < until) {
299
+ const up = await new Promise((r) => {
300
+ const req = http.get({ host: detail.bind || '127.0.0.1', port: detail.port, path: '/healthz', timeout: 2000 }, (res) => { res.resume(); r(res.statusCode < 500); });
301
+ req.on('error', () => r(false)); req.on('timeout', () => { req.destroy(); r(false); });
302
+ });
303
+ if (up) return;
304
+ if (!told) { log('… waiting for the editor to answer (first start compiles the workspace)'); told = true; }
305
+ await new Promise((r) => setTimeout(r, 1500));
306
+ }
307
+ log(`⚠ the editor did not answer within ${seconds}s — \`docker logs ${detail.name}\` says why`);
308
+ }
309
+
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 */ }
313
+ }
314
+
315
+ export async function stopContainer(name) {
316
+ const c = await inspectContainer(name);
317
+ if (!c) throw new Error(`no container "${name}" — dt list containers`);
318
+ const res = await api('POST', `/containers/${c.Id}/stop?t=10`);
319
+ if (res.status !== 204 && res.status !== 304) ok(res, `stop container ${name}`);
320
+ return containerDetail(await inspectContainer(name));
321
+ }
322
+
323
+ /** Plain `rm` keeps the three volumes — the workspace, the login, the files. `--force` removes them too. */
324
+ export async function removeContainer(name, { force = false } = {}, log = console.log) {
325
+ const c = await inspectContainer(name);
326
+ if (!c) throw new Error(`no container "${name}" — dt list containers`);
327
+ const vols = containerDetail(c).volumes;
328
+ if (c.State?.Status === 'running') await api('POST', `/containers/${c.Id}/stop?t=10`);
329
+ ok(await api('DELETE', `/containers/${c.Id}?v=false`), `rm container ${name}`);
330
+ const named = Object.values(vols).filter(Boolean);
331
+ if (force) {
332
+ 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(', ')}`);
334
+ } 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)`);
336
+ }
337
+ }
338
+
339
+ // ---- setup: the host's board -------------------------------------------------------------------
340
+ export async function setup(flags, log = console.log) {
341
+ const dir = hostDir();
342
+ const file = path.join(dir, '.env');
343
+ fs.mkdirSync(dir, { recursive: true });
344
+ const existing = fs.existsSync(file) ? Object.fromEntries(parseEnvValues(fs.readFileSync(file, 'utf8'))) : {};
345
+ const missing = Object.entries(HOST_DEFAULTS).filter(([k]) => !(k in existing));
346
+ if (missing.length) {
347
+ 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
+ fs.appendFileSync(file, header + missing.map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
349
+ }
350
+ const board = [];
351
+ board.push(`host env ${file} · ${missing.length ? `${missing.length} default(s) written` : 'present'}`);
352
+ let docker;
353
+ try {
354
+ const v = ok(await api('GET', '/version'), 'docker version');
355
+ docker = `docker ${v.Version} · api ${v.ApiVersion} · ${v.Os}/${v.Arch} · ${socketPath()}`;
356
+ } catch (e) {
357
+ board.push(`docker ✖ ${e.message}`);
358
+ for (const l of board) log(l);
359
+ return 1;
360
+ }
361
+ board.push(docker);
362
+ const images = await listImages();
363
+ board.push(`templates ${images.length ? images.map((i) => `${i.template} (${i.image})`).join(', ') : 'none present — dt add image --template <t> pulls one'}`);
364
+ const template = typeof flags.template === 'string' ? flags.template : undefined;
365
+ if (template) {
366
+ const ref = imageRef(template);
367
+ if (!(await inspectImage(ref))) { log(`… pulling ${ref}`); await pullImage(ref, log); board.push(`pulled ${ref}`); } else board.push(`present ${ref}`);
368
+ }
369
+ const running = (await listContainers()).filter((c) => c.state === 'running');
370
+ board.push(`containers ${running.length} running${running.length ? ' — ' + running.map((c) => `${c.name} ${c.editor_url}`).join(', ') : ''}`);
371
+ for (const l of board) log(l);
372
+ return 0;
373
+ }
374
+
375
+ // ---- the verb surface `cli.js` hands over ------------------------------------------------------
376
+ const table = (rows, cols) => {
377
+ if (!rows.length) return '(none)';
378
+ const w = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)));
379
+ return rows.map((r) => cols.map((c, i) => String(r[c] ?? '').padEnd(w[i])).join(' ').trimEnd()).join('\n');
380
+ };
381
+
382
+ /** `dt <verb> <container|containers|image|images>[/<id>] [<id>] [flags]` → exit code. */
383
+ export async function driverCommand(verb, target, args) {
384
+ const { flags, pos } = parseFlags(args);
385
+ const id = target.id ?? pos[0];
386
+ const json = flags.json === true;
387
+ const col = target.collection;
388
+ 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
+ if (col === 'images') {
390
+ 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>`);
391
+ if (verb === 'list') { const rows = await listImages(); json ? emit(JSON.stringify(rows, null, 2)) : console.log(table(rows, ['template', 'image', 'size_mb', 'ports', 'created'])); return 0; }
392
+ if (verb === 'get') { if (!id) throw new Error('dt get image <ref>'); const i = await inspectImage(id); if (!i) throw new Error(`no image "${id}"`); emit(JSON.stringify(json ? i : imageRow({ ...i, Labels: i.Config?.Labels, Created: Date.parse(i.Created) / 1000 }), null, 2)); return 0; }
393
+ if (verb === 'add') { const t = typeof flags.template === 'string' ? flags.template : undefined; if (!t) throw new Error('dt add image --template <t> pulls the template\'s image'); const ref = imageRef(t); console.log(`… pulling ${ref}`); await pullImage(ref); console.log(`✔ ${ref}`); return 0; }
394
+ if (verb === 'rm') { if (!id) throw new Error('dt rm image <ref>'); ok(await api('DELETE', `/images/${encodeURIComponent(id)}${flags.force ? '?force=true' : ''}`), `rm image ${id}`); console.log(`✔ removed image ${id}`); return 0; }
395
+ }
396
+ // containers
397
+ 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
+ if (!id) throw new Error(`dt ${verb} container <name>${verb === 'start' || verb === 'add' ? ' --template <t>' : ''}`);
399
+ 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; }
403
+ if (verb === 'rm') { await removeContainer(id, { force: flags.force === true }); return 0; }
404
+ return 1;
405
+ }
406
+
407
+ /** A local flag parser: `--k v`, `--k=v`, bare `--k` → true. Kept here rather than importing the
408
+ * record parser's promotion rules — a repeated flag on these verbs is a mistake, not an array. */
409
+ export function parseFlags(args) {
410
+ const flags = {}; const pos = [];
411
+ for (let i = 0; i < args.length; i++) {
412
+ const a = args[i];
413
+ if (!a.startsWith('--')) { pos.push(a); continue; }
414
+ 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;
418
+ }
419
+ return { flags, pos };
420
+ }
421
+
422
+ export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force'];
package/src/harnesses.js CHANGED
@@ -25,6 +25,12 @@ export const STAMP = '<!-- generated by dreamteamer compile — do not edit; sou
25
25
  export const BEGIN = '<!-- dreamteamer:begin (generated — do not edit inside this block) -->';
26
26
  export const END = '<!-- dreamteamer:end -->';
27
27
 
28
+ // A SECOND managed block, deliberately not folded into the orientation one. Separate delimiters
29
+ // keep the orientation block's asserted size budget meaningful, and let `dreamteamer.md` be removed
30
+ // on its own without the removal touching anything generated from the schema.
31
+ export const INSTRUCTIONS_BEGIN = '<!-- dreamteamer:instructions:begin -->';
32
+ export const INSTRUCTIONS_END = '<!-- dreamteamer:instructions:end -->';
33
+
28
34
  export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout = 'flat', namespaces = [], version = 'unknown', workspaceModule = '' }) {
29
35
  const outputs = [];
30
36
  // ⚠ SEPARATE from `outputs`: these are USER-OWNED root files carrying a managed block, and the
@@ -92,9 +98,34 @@ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sou
92
98
  block('GEMINI.md', on('gemini-cli') ? orientationBlock('gemini', skillsIndex, sourceLayout, namespaces, version, entries, workspaceModule) : null);
93
99
  if (on('gemini-cli')) summary.push('gemini-cli → GEMINI.md block');
94
100
 
101
+ // ---- the operator's own rules, one source, every harness -------------------------
102
+ // Rendered VERBATIM. Nothing here reformats, wraps or summarises it: it is the operator's prose,
103
+ // and the whole value is that what he wrote is what every agent reads.
104
+ //
105
+ // ⚠ `enabled ? instructions : null` mirrors the orientation calls above, and the null branch is
106
+ // load-bearing twice: a harness switched off has its block removed, and so does a workspace that
107
+ // deletes its dreamteamer.md — `instructions` is null in that case and the same branch runs.
108
+ //
109
+ // ⚠ NOT through the local `block()` helper: that pushes the filename onto `blocks`, and these
110
+ // three files are already on it from their orientation calls above — pushing twice would hand
111
+ // git the same pathspec twice (schema-ops.regeneratedOutputs is the reader).
112
+ // ⚠ `|| null`, NOT `?? null`. `.trimEnd()` on a whitespace-only source yields `''`, which is not
113
+ // nullish — so the three Markdown files got an empty BEGIN/END pair while the cursor rule, which
114
+ // tests the string for truthiness below, omitted the part entirely. An empty source means no
115
+ // block, everywhere.
116
+ const instructions = entries.get('instructions.md')?.bytes?.toString('utf8').trimEnd() || null;
117
+ const instructionsBlock = (file, enabled) =>
118
+ writeBlock(root, file, enabled ? instructions : null, { begin: INSTRUCTIONS_BEGIN, end: INSTRUCTIONS_END, above: BEGIN });
119
+ instructionsBlock('CLAUDE.md', on('claude-code'));
120
+ instructionsBlock('AGENTS.md', on('codex') || on('pi'));
121
+ instructionsBlock('GEMINI.md', on('gemini-cli'));
122
+
95
123
  // ---- cursor: native .mdc rule (alwaysApply) ---------------------------------------
96
124
  if (on('cursor')) {
97
- const mdc = `---\ndescription: dreamteamer workspace orientation (generated)\nalwaysApply: true\n---\n\n${orientationBlock('cursor', skillsIndex, sourceLayout, namespaces, version, entries, workspaceModule)}\n\n${STAMP}\n`;
125
+ // ⚠ `.mdc` is written WHOLE by `write()`, not through `writeBlock`, so removal is automatic: a
126
+ // compile with no dreamteamer.md simply rewrites the file without the part.
127
+ const instructionsPart = instructions ? `${INSTRUCTIONS_BEGIN}\n${instructions}\n${INSTRUCTIONS_END}\n\n` : '';
128
+ const mdc = `---\ndescription: dreamteamer workspace orientation (generated)\nalwaysApply: true\n---\n\n${instructionsPart}${orientationBlock('cursor', skillsIndex, sourceLayout, namespaces, version, entries, workspaceModule)}\n\n${STAMP}\n`;
98
129
  write('.cursor/rules/dreamteamer.mdc', Buffer.from(mdc));
99
130
  summary.push('cursor → .cursor/rules/dreamteamer.mdc');
100
131
  }
@@ -142,7 +173,16 @@ const NOTEBOOK_PLANS = [
142
173
  function notebooklmBlock(entries, version) {
143
174
  const index = buildCollectionsIndex(entries);
144
175
  const modules = buildModulesIndex(entries);
145
- const data = index.filter((c) => !c.system);
176
+ // ⚠ `generated`, NOT `systemGroup` — the same split the orientation block's system line makes,
177
+ // asked for the opposite reason. There the question is presentational ("group it out of the
178
+ // domain listing"); HERE it is a STORAGE question, because every number and name below has to
179
+ // describe what `dt export notebooklm` will actually ship, and the export ships by storage
180
+ // (`storage.base !== 'runtime'` — export-notebooklm.js). Reading the partition here made this file
181
+ // say "exports 1 collections" and list only `notes` for a workspace whose export shipped
182
+ // `repos.md` as a second source and headed a `## module: System` group for it in the schema map
183
+ // — so the persona generated from this line would not know about a source it had been given.
184
+ // The count and the export are pinned to each other by a test (notebooklm-harness.test.js).
185
+ const data = index.filter((c) => !c.generated);
146
186
  const withheldCollections = data.filter((c) => c.sensitive).map((c) => c.name);
147
187
  const withheldFields = data.flatMap((c) => c.sensitiveFields.map((f) => `${c.name}.${f}`));
148
188
  const exported = data.filter((c) => !c.sensitive);
@@ -245,10 +285,16 @@ function buildCollectionsIndex(entries) {
245
285
  try { d = load(e.bytes.toString('utf8')) ?? {}; } catch { /* unparseable descriptor */ }
246
286
  index.push({
247
287
  name: d.name ?? m[1],
248
- // DERIVED, never a hardcoded name list: `runtime` is exactly the schema-ops set
249
- // (collections, commands, skills, agents, ui-views, command-bindings,
250
- // collection-templates, modules) and stays right in a workspace shipping others.
251
- system: d.storage?.base === 'runtime',
288
+ // ⚠ TWO QUESTIONS, KEPT SEPARATE. `systemGroup` answers "is this machinery, group it out
289
+ // of the domain listing"; `generated` answers "is this build output the hand-edit warning
290
+ // is about" — they agreed until `repos`, machinery whose records are still real files
291
+ // under `data/`. The partition is AUTHORED (`group: system` on the descriptor, ten core
292
+ // collections carry it), never derived from storage: a workspace collection may be its
293
+ // own apparatus, and a runtime-stored one may not be. An unparseable descriptor (`d = {}`
294
+ // from the catch above) carries neither key and answers false to both, which leaves it in
295
+ // the domain listing — the visible failure rather than the silent one.
296
+ systemGroup: d.group === 'system',
297
+ generated: d.storage?.base === 'runtime',
252
298
  description: flat(d.description),
253
299
  useWhen: flat(d.use_when),
254
300
  module: d.module ?? '',
@@ -354,10 +400,10 @@ function collectionsSection(index, modules, workspaceModule) {
354
400
  'collection\'s `write:` line names only what the store REFUSES — required fields that have no',
355
401
  'default (a defaulted one is filled in for you), closed enums with their size, one example.',
356
402
  ];
357
- const data = index.filter((c) => !c.system);
403
+ const data = index.filter((c) => !c.systemGroup);
358
404
  const isWs = (m) => m.path === `modules/${workspaceModule}/`;
359
405
  const groups = modules
360
- .filter((m) => { const own = index.filter((c) => c.module === m.id); return own.some((c) => !c.system) || (!own.length && (m.skills.length || m.commands.length || m.bin.length || m.proofs.length)); })
406
+ .filter((m) => { const own = index.filter((c) => c.module === m.id); return own.some((c) => !c.systemGroup) || (!own.length && (m.skills.length || m.commands.length || m.bin.length || m.proofs.length)); })
361
407
  .sort((a, b) => (isWs(b) - isWs(a)) || a.title.localeCompare(b.title));
362
408
  for (const m of groups) {
363
409
  const where = [`\`${m.id}\``, m.path ? m.path.replace(/\/$/, '') : 'the workspace root', ...(m.namespaces.length ? [`namespaces: ${m.namespaces.join(' · ')}`] : [])];
@@ -370,13 +416,19 @@ function collectionsSection(index, modules, workspaceModule) {
370
416
  if (c.write) lines.push(` write: ${c.write}`);
371
417
  }
372
418
  }
373
- const system = index.filter((c) => c.system).map((c) => c.name);
419
+ const sys = index.filter((c) => c.systemGroup);
420
+ const system = sys.filter((c) => c.generated).map((c) => c.name);
421
+ const kept = sys.filter((c) => !c.generated).map((c) => c.name);
374
422
  // ⚠ THIS LINE IS THE FIRST THING A SESSION READS about the system collections, and until 0.19.0
375
423
  // it said "schema-ops only", which named an internal module and a grammar that no longer exists.
376
424
  // It now names the VERBS and the one policy difference, because an agent that knows the verbs
377
- // exist still has to be told that these commit and records do not.
378
- if (system.length) {
379
- 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). Never hand-edit \`.dreamteamer/\` — it is build output: ${system.join(' · ')}`);
425
+ // exist still has to be told that these commit and records do not. And it is PARTITIONED on
426
+ // `generated`, not `systemGroup`: the partition only answers the grouping question, so a system
427
+ // collection whose records are real files (`repos`) gets its own trailing clause instead of being
428
+ // told "it is build output" — a sentence that was false of it and is false of the next data-backed
429
+ // system collection too, since the split is derived rather than naming one.
430
+ 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(' · ')}` : ''}`);
380
432
  }
381
433
  return lines;
382
434
  }
@@ -527,22 +579,35 @@ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat', namespaces
527
579
 
528
580
  // managed block in a USER-OWNED root file. content=null removes the block; a file left
529
581
  // empty (or whitespace) after removal is deleted — we created it, we clean it up.
530
- function writeBlock(root, filename, content) {
582
+ function writeBlock(root, filename, content, { begin = BEGIN, end = END, above } = {}) {
531
583
  const file = path.join(root, filename);
532
584
  const exists = fs.existsSync(file);
533
585
  if (content == null) {
534
586
  if (!exists) return;
535
587
  let text = fs.readFileSync(file, 'utf8');
536
- if (!text.includes(BEGIN)) return;
537
- text = text.replace(new RegExp(`\\n?\\n?${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}\\n?`), '\n');
588
+ if (!text.includes(begin)) return;
589
+ text = text.replace(new RegExp(`\\n?\\n?${escapeRe(begin)}[\\s\\S]*?${escapeRe(end)}\\n?`), '\n');
538
590
  if (text.trim() === '') fs.rmSync(file);
539
591
  else fs.writeFileSync(file, text);
540
592
  return;
541
593
  }
542
- const block = `${BEGIN}\n${content}\n${END}`;
594
+ const block = `${begin}\n${content}\n${end}`;
595
+ // ⚠ A FUNCTION REPLACEMENT, never the string. `String.prototype.replace` reads `$&`, `` $` ``,
596
+ // `$'` and `$1` out of a STRING replacement and substitutes around the match — so a block whose
597
+ // content carries any of them is silently rewritten on the way in, and `$'…'` is ordinary bash in
598
+ // a file about shell commands. Measured: `use $'\n'` rendered as `use ` + the entire preamble.
599
+ // A function replacement is taken literally, which is what "verbatim" has to mean.
600
+ const insert = (replacement) => () => replacement;
543
601
  let text = exists ? fs.readFileSync(file, 'utf8') : '';
544
- if (text.includes(BEGIN)) text = text.replace(new RegExp(`${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}`), block);
545
- else text = (text.trimEnd() + '\n\n' + block + '\n').replace(/^\n+/, '');
602
+ if (text.includes(begin)) {
603
+ text = text.replace(new RegExp(`${escapeRe(begin)}[\\s\\S]*?${escapeRe(end)}`), insert(block));
604
+ } else if (above && text.includes(above)) {
605
+ // ⚠ ORDER IS THE CONTRACT, not a preference. The rules must be read BEFORE the schema
606
+ // orientation, and a harness that truncates a long context file truncates the tail.
607
+ text = text.replace(above, insert(`${block}\n\n${above}`));
608
+ } else {
609
+ text = (text.trimEnd() + '\n\n' + block + '\n').replace(/^\n+/, '');
610
+ }
546
611
  fs.writeFileSync(file, text);
547
612
  }
548
613
 
package/src/init.js CHANGED
@@ -8,6 +8,7 @@ import { discoverModules, KINDS } from './compile.js';
8
8
  import { KNOWN_HARNESSES } from './harnesses.js';
9
9
  import { Store } from './store.js';
10
10
  import { envContext, renderTemplate } from './env-vars.js';
11
+ import { ensureEditorRecommendation } from './workspace.js';
11
12
 
12
13
  // git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
13
14
  // child's stderr to ours unless told otherwise, so a handled "not a git repository" still
@@ -147,8 +148,10 @@ export function init({ flags = {} } = {}) {
147
148
  // A workspace that needs people as records ships its own collection (a module's `contacts` already
148
149
  // does), and reads the operator from git where it needs one. `teams` went the same way 2026-07-31.
149
150
 
150
- // .gitignore + .env.example (append-if-missing, never clobber)
151
+ // .gitignore + .env.example (append-if-missing, never clobber) + the editor recommendation, so
152
+ // the first window opened on this workspace offers the extension instead of leaving it to be found
151
153
  appendMissing(path.join(root, '.gitignore'), GITIGNORE);
154
+ ensureEditorRecommendation(root);
152
155
  if (!fs.existsSync(path.join(root, '.env.example'))) fs.writeFileSync(path.join(root, '.env.example'), ENV_EXAMPLE);
153
156
 
154
157
  // one init commit (if we're in a git repo)