dreamteamer 0.29.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.
- package/collections/containers.collection.yaml +2 -2
- package/package.json +1 -1
- package/src/cli.js +33 -8
- package/src/container-archive.js +356 -0
- package/src/containers.js +171 -28
|
@@ -48,7 +48,7 @@ schema:
|
|
|
48
48
|
# ---- network ----
|
|
49
49
|
editor_url:
|
|
50
50
|
type: string
|
|
51
|
-
description: Where the person opens the
|
|
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
52
|
port:
|
|
53
53
|
type: integer
|
|
54
54
|
description: The host port the editor is published on.
|
|
@@ -65,7 +65,7 @@ schema:
|
|
|
65
65
|
mounts:
|
|
66
66
|
type: array
|
|
67
67
|
items: { type: string }
|
|
68
|
-
description: Extra bind or volume mounts passed as `--mount <host-path|volume>:<container-path>[:ro]
|
|
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
69
|
# ---- person ----
|
|
70
70
|
person:
|
|
71
71
|
type: string
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dreamteamer",
|
|
3
|
-
"version": "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';
|
|
@@ -251,22 +252,43 @@ these verbs work with NO workspace, so npm i -g dreamteamer and Docker Desktop a
|
|
|
251
252
|
DT_DOCKER_TIMEOUT 30 — seconds a request to Docker may sit idle before the verb
|
|
252
253
|
fails), lists the templates present, pulls one on request [--template <t>] [--json]
|
|
253
254
|
start container <name> --template <t> create-if-absent and start: a code-server editor at
|
|
254
|
-
http://localhost:<port
|
|
255
|
-
(workspace · home · files),
|
|
256
|
-
The workspace is mounted at
|
|
257
|
-
log in INSIDE, once;
|
|
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
|
|
258
263
|
[--repo <git url>] clone an EXISTING workspace into the volume on first start, instead
|
|
259
264
|
of laying the template down — how a person joins one on GitHub
|
|
260
|
-
[--mount <host-path|volume>:<container-path>[:ro]] extra mounts, repeatable
|
|
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
|
|
261
268
|
[--name <git name>] [--email <git email>] [--no-open] [--json]
|
|
262
269
|
stop container <name> stop it; every volume kept [--json]
|
|
263
|
-
open container <name> print (and open) its
|
|
270
|
+
open container <name> print (and open) its URL, token included [--no-open] [--json]
|
|
271
|
+
[--workspace [<w>]]
|
|
264
272
|
[--vscode] print (and open) the Dev Containers attach URI instead — the host's own
|
|
265
273
|
VS Code inside the container, extensions from the image's metadata label
|
|
266
274
|
list containers | images the record verbs, answered over Docker instead of a
|
|
267
275
|
get container <name> | image <ref> folder — singular or plural, either spelling.
|
|
268
|
-
rm container <name> [--force]
|
|
276
|
+
rm container <name> [--force] removes it and its network; keeps the volumes unless --force
|
|
269
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)
|
|
270
292
|
|
|
271
293
|
changes what changed in every repo that holds records, as record events
|
|
272
294
|
[--since <sha|YYYY-MM-DD>] (default: HEAD~1 — the last commit's own changes) [--json]
|
|
@@ -325,7 +347,7 @@ export const WORKSPACE_FLAGS = {
|
|
|
325
347
|
// `start` is TWO forms: bare, the REST api (--port); with a `container <name>` target, the
|
|
326
348
|
// lifecycle verb — whose flags are the driver's. One table, because `flags-honoured` reads it.
|
|
327
349
|
start: ['port', ...CONTAINER_FLAGS], compile: ['watch'], check: [], status: ['strict'],
|
|
328
|
-
setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open', 'vscode'],
|
|
350
|
+
setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open', 'vscode', 'workspace'],
|
|
329
351
|
changes: ['since', 'json'], commit: ['dry-run', 'json'],
|
|
330
352
|
export: EXPORT_FLAGS,
|
|
331
353
|
// the UNION of every form's flags — the outer typo gate. Which flags each FORM takes is refused
|
|
@@ -798,6 +820,9 @@ function hostDispatch(cmd, rest) {
|
|
|
798
820
|
return hostSetup(hostFlags(rest).flags);
|
|
799
821
|
}
|
|
800
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]'));
|
|
801
826
|
if (target && DRIVER_VERBS.has(cmd)) return driverCommand(cmd, target, rest.slice(1));
|
|
802
827
|
// A lifecycle verb aimed at anything else is refused by name: `dt start tasks` is not a
|
|
803
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
|
@@ -37,6 +37,7 @@ import fs from 'node:fs';
|
|
|
37
37
|
import os from 'node:os';
|
|
38
38
|
import path from 'node:path';
|
|
39
39
|
import { execFileSync, spawn } from 'node:child_process';
|
|
40
|
+
import { pipeline } from 'node:stream/promises';
|
|
40
41
|
import { parseEnvValues } from './env-vars.js';
|
|
41
42
|
import { emit } from './collections-cli.js';
|
|
42
43
|
|
|
@@ -63,7 +64,7 @@ export const HOST_DEFAULTS = {
|
|
|
63
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
|
|
64
65
|
DT_BIND: '127.0.0.1', // loopback only; a remote tier puts auth in front before this changes
|
|
65
66
|
DT_REGISTRY: 'ghcr.io/dreamteamer', // `<registry>/<template>:<tag>` is the image a template name resolves to — the public images repo publishes here
|
|
66
|
-
DT_TEMPLATE_TAG: '
|
|
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
|
|
67
68
|
DT_DOCKER_TIMEOUT: '30', // seconds a request to Docker may sit IDLE before the verb fails — see the header
|
|
68
69
|
};
|
|
69
70
|
|
|
@@ -102,19 +103,25 @@ export function dockerTimeoutSeconds() {
|
|
|
102
103
|
return Number.isFinite(n) && n >= 0 ? n : Number(HOST_DEFAULTS.DT_DOCKER_TIMEOUT);
|
|
103
104
|
}
|
|
104
105
|
|
|
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 } = {}) {
|
|
106
110
|
const sock = socketPath();
|
|
107
111
|
const seconds = dockerTimeoutSeconds();
|
|
108
112
|
return new Promise((resolve, reject) => {
|
|
109
113
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
110
114
|
const req = http.request({
|
|
111
115
|
socketPath: sock, method, path: urlPath,
|
|
112
|
-
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' } : {},
|
|
113
117
|
}, (res) => {
|
|
118
|
+
if (stream && res.statusCode < 300) return resolve({ status: res.statusCode, res });
|
|
114
119
|
let text = '';
|
|
115
120
|
let pending = '';
|
|
116
|
-
|
|
121
|
+
const bufs = []; // `raw`: an exec's multiplexed stream is binary framing, so it stays bytes
|
|
122
|
+
if (!raw) res.setEncoding('utf8');
|
|
117
123
|
res.on('data', (chunk) => {
|
|
124
|
+
if (raw) { bufs.push(chunk); return; }
|
|
118
125
|
text += chunk;
|
|
119
126
|
if (!onLine) return;
|
|
120
127
|
pending += chunk;
|
|
@@ -123,6 +130,7 @@ export function api(method, urlPath, body, { onLine } = {}) {
|
|
|
123
130
|
for (const l of lines) if (l.trim()) { try { onLine(JSON.parse(l)); } catch { /* not JSON */ } }
|
|
124
131
|
});
|
|
125
132
|
res.on('end', () => {
|
|
133
|
+
if (raw) return resolve({ status: res.statusCode, body: Buffer.concat(bufs) });
|
|
126
134
|
const isJson = /json/.test(res.headers['content-type'] ?? '');
|
|
127
135
|
let parsed = text;
|
|
128
136
|
if (isJson && !onLine) { try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } }
|
|
@@ -133,13 +141,14 @@ export function api(method, urlPath, body, { onLine } = {}) {
|
|
|
133
141
|
// idle-based: fires only when NOTHING has moved on the socket for `seconds` — a streaming
|
|
134
142
|
// pull resets it with every progress line, a paused daemon never does
|
|
135
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));
|
|
136
145
|
if (payload) req.write(payload);
|
|
137
146
|
req.end();
|
|
138
147
|
});
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
/** Throw the daemon's own sentence on a non-2xx, so a refusal reads as Docker's rather than ours. */
|
|
142
|
-
function ok(res, what) {
|
|
151
|
+
export function ok(res, what) {
|
|
143
152
|
if (res.status >= 200 && res.status < 400) return res.body;
|
|
144
153
|
const msg = res.body && typeof res.body === 'object' && res.body.message ? res.body.message : String(res.body ?? '').trim();
|
|
145
154
|
throw new Error(`${what}: Docker answered ${res.status}${msg ? ` — ${msg}` : ''}`);
|
|
@@ -196,20 +205,27 @@ const containerRow = (c) => {
|
|
|
196
205
|
const port = (c.Ports ?? []).find((p) => p.PublicPort)?.PublicPort;
|
|
197
206
|
return {
|
|
198
207
|
name, template: c.Labels?.[LABEL.template] ?? '', state: c.State, status: c.Status,
|
|
199
|
-
editor_url: port ? editorUrl(port
|
|
208
|
+
editor_url: port ? editorUrl(port) : '', image: c.Image, person: c.Labels?.[LABEL.person] ?? '',
|
|
200
209
|
created: c.Created ? new Date(c.Created * 1000).toISOString().slice(0, 16).replace('T', ' ') : '', id: c.Id,
|
|
201
210
|
};
|
|
202
211
|
};
|
|
203
212
|
/** The dev-container convention: the workspace is mounted at `/workspaces/<name>`, so the folder the
|
|
204
213
|
* editor opens, the URL, and a VS Code attach all name the workspace rather than a fixed word. */
|
|
205
214
|
export const workspaceDir = (name) => `/workspaces/${name}`;
|
|
206
|
-
|
|
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'];
|
|
207
221
|
/** `--mount <host-path|volume>:<container-path>[:ro]` → a Docker Mount. A source starting with `/`,
|
|
208
222
|
* `~` or `.` is a bind mount of a host path (resolved against cwd); anything else is a named volume. */
|
|
209
223
|
export function parseMount(spec) {
|
|
210
224
|
const parts = String(spec).split(':');
|
|
211
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}"`);
|
|
212
|
-
const [src,
|
|
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`);
|
|
213
229
|
if (mode !== undefined && mode !== 'ro' && mode !== 'rw') throw new Error(`--mount "${spec}": the third part is ro or rw`);
|
|
214
230
|
const isPath = /^[/~.]/.test(src);
|
|
215
231
|
const source = isPath ? path.resolve(src.replace(/^~(?=\/|$)/, os.homedir())) : src;
|
|
@@ -219,6 +235,12 @@ export function parseMount(spec) {
|
|
|
219
235
|
/** The URI VS Code on the host opens to attach to this container (Dev Containers extension). The
|
|
220
236
|
* container name is hex-encoded, as the extension spells it. */
|
|
221
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}`;
|
|
222
244
|
const volumeNames = (name) => ({ workspace: `dreamteamer-${name}-workspace`, home: `dreamteamer-${name}-home`, files: `dreamteamer-${name}-files` });
|
|
223
245
|
|
|
224
246
|
export async function listContainers() {
|
|
@@ -246,7 +268,7 @@ export function containerDetail(c) {
|
|
|
246
268
|
template: c.Config.Labels[LABEL.template] ?? '', image: c.Config.Image,
|
|
247
269
|
state: c.State?.Status, started: c.State?.StartedAt, restarts: c.RestartCount ?? 0,
|
|
248
270
|
bind: binding?.HostIp ?? '', port: binding ? Number(binding.HostPort) : undefined,
|
|
249
|
-
editor_url: binding ? editorUrl(binding.HostPort
|
|
271
|
+
editor_url: binding ? editorUrl(binding.HostPort) : '',
|
|
250
272
|
attach_uri: attachUri(name),
|
|
251
273
|
workspace_dir: wsDir,
|
|
252
274
|
volumes: { workspace: mounts[wsDir] ?? '', home: mounts['/home/node'] ?? '', files: mounts['/files'] ?? '' },
|
|
@@ -281,7 +303,8 @@ function person(flags, env) {
|
|
|
281
303
|
}
|
|
282
304
|
|
|
283
305
|
/** Create-if-absent and start. Idempotent: a second call on an existing name starts it and prints
|
|
284
|
-
* the same URL. Never injects a
|
|
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). */
|
|
285
308
|
export async function startContainer(name, flags, log = console.log) {
|
|
286
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`);
|
|
287
310
|
const env = hostEnv();
|
|
@@ -302,16 +325,46 @@ export async function startContainer(name, flags, log = console.log) {
|
|
|
302
325
|
// `--mount` adds bind or volume mounts beside the three the container always has; a mount aimed
|
|
303
326
|
// at one of those three targets is refused rather than silently shadowing the volume.
|
|
304
327
|
const extra = (flags.mount ?? []).map(parseMount);
|
|
305
|
-
for (const m of extra) if ([wsDir, '/home/node', '/files'].includes(m.Target)) throw new Error(`--mount cannot target ${m.Target} — that is one of the container's own volumes (${wsDir} · /home/node · /files)`);
|
|
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`);
|
|
306
355
|
// `--repo <url>` clones an EXISTING workspace into the workspace volume on first start instead of
|
|
307
356
|
// laying the template down — the way a person joins a workspace that already lives on GitHub.
|
|
308
357
|
const repo = typeof flags.repo === 'string' ? flags.repo : undefined;
|
|
309
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);
|
|
310
360
|
const body = {
|
|
311
361
|
Image: ref,
|
|
312
362
|
Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name, [LABEL.workdir]: wsDir },
|
|
313
363
|
Env: [
|
|
314
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',
|
|
315
368
|
...(repo ? [`DT_REPO=${repo}`] : []),
|
|
316
369
|
...(who.name ? [`GIT_AUTHOR_NAME=${who.name}`, `GIT_COMMITTER_NAME=${who.name}`] : []),
|
|
317
370
|
...(who.email ? [`GIT_AUTHOR_EMAIL=${who.email}`, `GIT_COMMITTER_EMAIL=${who.email}`] : []),
|
|
@@ -326,9 +379,29 @@ export async function startContainer(name, flags, log = console.log) {
|
|
|
326
379
|
...extra,
|
|
327
380
|
],
|
|
328
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'],
|
|
329
388
|
},
|
|
330
389
|
};
|
|
331
|
-
|
|
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
|
+
}
|
|
332
405
|
c = await inspectContainer(name);
|
|
333
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` : ''}`);
|
|
334
407
|
}
|
|
@@ -339,11 +412,56 @@ export async function startContainer(name, flags, log = console.log) {
|
|
|
339
412
|
}
|
|
340
413
|
const detail = containerDetail(c);
|
|
341
414
|
await waitHealthy(detail, log);
|
|
342
|
-
|
|
343
|
-
|
|
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);
|
|
344
418
|
return detail;
|
|
345
419
|
}
|
|
346
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
|
+
|
|
347
465
|
/** Poll code-server's /healthz so the URL printed is one that already answers. */
|
|
348
466
|
async function waitHealthy(detail, log) {
|
|
349
467
|
const seconds = process.env.DT_HEALTH_TIMEOUT !== undefined ? Number(process.env.DT_HEALTH_TIMEOUT) : 90;
|
|
@@ -362,9 +480,19 @@ async function waitHealthy(detail, log) {
|
|
|
362
480
|
log(`⚠ the editor did not answer within ${seconds}s — \`docker logs ${detail.name}\` says why`);
|
|
363
481
|
}
|
|
364
482
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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 */ }
|
|
368
496
|
}
|
|
369
497
|
|
|
370
498
|
export async function stopContainer(name) {
|
|
@@ -380,14 +508,17 @@ export async function removeContainer(name, { force = false } = {}, log = consol
|
|
|
380
508
|
const c = await inspectContainer(name);
|
|
381
509
|
if (!c) throw new Error(`no container "${name}" — dt list containers`);
|
|
382
510
|
const vols = containerDetail(c).volumes;
|
|
383
|
-
|
|
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`);
|
|
384
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)}`);
|
|
385
516
|
const named = Object.values(vols).filter(Boolean);
|
|
386
517
|
if (force) {
|
|
387
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}`); }
|
|
388
|
-
log(`✔ removed ${name} and its volumes ${named.join(', ')}`);
|
|
519
|
+
log(`✔ removed ${name}, its network and its volumes ${named.join(', ')}`);
|
|
389
520
|
} else {
|
|
390
|
-
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)`);
|
|
391
522
|
}
|
|
392
523
|
}
|
|
393
524
|
|
|
@@ -397,7 +528,7 @@ export async function setup(flags, log = console.log) {
|
|
|
397
528
|
const file = path.join(dir, '.env');
|
|
398
529
|
fs.mkdirSync(dir, { recursive: true });
|
|
399
530
|
const existing = fs.existsSync(file) ? Object.fromEntries(parseEnvValues(fs.readFileSync(file, 'utf8'))) : {};
|
|
400
|
-
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));
|
|
401
532
|
if (missing.length) {
|
|
402
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';
|
|
403
534
|
fs.appendFileSync(file, header + missing.map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
|
|
@@ -440,6 +571,9 @@ export async function driverCommand(verb, target, args) {
|
|
|
440
571
|
const id = target.id ?? pos[0];
|
|
441
572
|
const json = flags.json === true;
|
|
442
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;
|
|
443
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' : ''}`);
|
|
444
578
|
if (col === 'images') {
|
|
445
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>`);
|
|
@@ -452,8 +586,8 @@ export async function driverCommand(verb, target, args) {
|
|
|
452
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; }
|
|
453
587
|
if (!id) throw new Error(`dt ${verb} container <name>${verb === 'start' || verb === 'add' ? ' --template <t>' : ''}`);
|
|
454
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; }
|
|
455
|
-
if (verb === 'start' || verb === 'add') { const d = await startContainer(id, flags); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
|
|
456
|
-
if (verb === 'stop') { const d = await stopContainer(id);
|
|
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; }
|
|
457
591
|
if (verb === 'open') {
|
|
458
592
|
const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}"`);
|
|
459
593
|
const d = containerDetail(c);
|
|
@@ -461,12 +595,17 @@ export async function driverCommand(verb, target, args) {
|
|
|
461
595
|
// Dev Containers attach: the host's own VS Code opens the workspace INSIDE the container, and
|
|
462
596
|
// installs the extensions the image's `devcontainer.metadata` label names into the container's
|
|
463
597
|
// VS Code Server — a second extension host beside code-server's, over the same files.
|
|
464
|
-
|
|
598
|
+
say(d.attach_uri);
|
|
465
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));
|
|
466
601
|
return 0;
|
|
467
602
|
}
|
|
468
603
|
if (!d.editor_url) throw new Error(`${id} publishes no port`);
|
|
469
|
-
|
|
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;
|
|
470
609
|
}
|
|
471
610
|
if (verb === 'rm') { await removeContainer(id, { force: flags.force === true }); return 0; }
|
|
472
611
|
return 1;
|
|
@@ -476,8 +615,12 @@ export async function driverCommand(verb, target, args) {
|
|
|
476
615
|
* record parser's promotion rules — a repeated flag on these verbs is a mistake, not an array. */
|
|
477
616
|
export function parseFlags(args) {
|
|
478
617
|
const flags = {}; const pos = [];
|
|
479
|
-
// `--mount`
|
|
480
|
-
|
|
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
|
+
};
|
|
481
624
|
for (let i = 0; i < args.length; i++) {
|
|
482
625
|
const a = args[i];
|
|
483
626
|
if (!a.startsWith('--')) { pos.push(a); continue; }
|
|
@@ -489,4 +632,4 @@ export function parseFlags(args) {
|
|
|
489
632
|
return { flags, pos };
|
|
490
633
|
}
|
|
491
634
|
|
|
492
|
-
export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force', 'mount', 'repo', 'vscode'];
|
|
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'];
|