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