dreamteamer 0.28.0 → 0.29.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 +11 -4
- package/src/containers.js +86 -16
- 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 editor — `http://localhost:<port>/?folder=<workspace dir>`; the host port comes from DT_PORT_BASE upward, bound to DT_BIND (127.0.0.1).
|
|
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]`.
|
|
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.29.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
|
@@ -247,15 +247,22 @@ workspace verbs:
|
|
|
247
247
|
containers — a workspace as a running container (Docker Engine API over its socket, no dependency;
|
|
248
248
|
these verbs work with NO workspace, so npm i -g dreamteamer and Docker Desktop are enough):
|
|
249
249
|
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
|
-
|
|
250
|
+
(DT_PORT_BASE 8100 · DT_BIND 127.0.0.1 · DT_REGISTRY · DT_TEMPLATE_TAG ·
|
|
251
|
+
DT_DOCKER_TIMEOUT 30 — seconds a request to Docker may sit idle before the verb
|
|
252
|
+
fails), lists the templates present, pulls one on request [--template <t>] [--json]
|
|
252
253
|
start container <name> --template <t> create-if-absent and start: a code-server editor at
|
|
253
254
|
http://localhost:<port>/?folder=/workspace over a compiled workspace, three named volumes
|
|
254
255
|
(workspace · home · files), image <DT_REGISTRY>/<template>:<tag> or DT_IMAGE_<template>.
|
|
255
|
-
Idempotent. NO token is ever injected —
|
|
256
|
+
The workspace is mounted at /workspaces/<name>. Idempotent. NO token is ever injected —
|
|
257
|
+
log in INSIDE, once; the home volume keeps it.
|
|
258
|
+
[--repo <git url>] clone an EXISTING workspace into the volume on first start, instead
|
|
259
|
+
of laying the template down — how a person joins one on GitHub
|
|
260
|
+
[--mount <host-path|volume>:<container-path>[:ro]] extra mounts, repeatable
|
|
256
261
|
[--name <git name>] [--email <git email>] [--no-open] [--json]
|
|
257
262
|
stop container <name> stop it; every volume kept [--json]
|
|
258
263
|
open container <name> print (and open) its editor URL [--no-open]
|
|
264
|
+
[--vscode] print (and open) the Dev Containers attach URI instead — the host's own
|
|
265
|
+
VS Code inside the container, extensions from the image's metadata label
|
|
259
266
|
list containers | images the record verbs, answered over Docker instead of a
|
|
260
267
|
get container <name> | image <ref> folder — singular or plural, either spelling.
|
|
261
268
|
rm container <name> [--force] plain rm keeps the volumes; --force removes them too
|
|
@@ -318,7 +325,7 @@ export const WORKSPACE_FLAGS = {
|
|
|
318
325
|
// `start` is TWO forms: bare, the REST api (--port); with a `container <name>` target, the
|
|
319
326
|
// lifecycle verb — whose flags are the driver's. One table, because `flags-honoured` reads it.
|
|
320
327
|
start: ['port', ...CONTAINER_FLAGS], compile: ['watch'], check: [], status: ['strict'],
|
|
321
|
-
setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open'],
|
|
328
|
+
setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open', 'vscode'],
|
|
322
329
|
changes: ['since', 'json'], commit: ['dry-run', 'json'],
|
|
323
330
|
export: EXPORT_FLAGS,
|
|
324
331
|
// the UNION of every form's flags — the outer typo gate. Which flags each FORM takes is refused
|
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.
|
|
@@ -54,8 +62,9 @@ export function driverTarget(word) {
|
|
|
54
62
|
export const HOST_DEFAULTS = {
|
|
55
63
|
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
64
|
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
|
|
65
|
+
DT_REGISTRY: 'ghcr.io/dreamteamer', // `<registry>/<template>:<tag>` is the image a template name resolves to — the public images repo publishes here
|
|
58
66
|
DT_TEMPLATE_TAG: 'latest',
|
|
67
|
+
DT_DOCKER_TIMEOUT: '30', // seconds a request to Docker may sit IDLE before the verb fails — see the header
|
|
59
68
|
};
|
|
60
69
|
|
|
61
70
|
export function hostDir() { return process.env.DT_HOME ?? path.join(os.homedir(), '.dreamteamer'); }
|
|
@@ -87,8 +96,15 @@ function unreachable(sock) {
|
|
|
87
96
|
|
|
88
97
|
/** One request. Resolves { status, body } where body is parsed JSON when the response is JSON,
|
|
89
98
|
* else the raw text. `onLine` receives each JSON line of a streaming response (a pull). */
|
|
99
|
+
/** Seconds a request may sit idle before it fails; 0 disables — `DT_DOCKER_TIMEOUT`, env over file over default. */
|
|
100
|
+
export function dockerTimeoutSeconds() {
|
|
101
|
+
const n = Number(hostEnv().DT_DOCKER_TIMEOUT);
|
|
102
|
+
return Number.isFinite(n) && n >= 0 ? n : Number(HOST_DEFAULTS.DT_DOCKER_TIMEOUT);
|
|
103
|
+
}
|
|
104
|
+
|
|
90
105
|
export function api(method, urlPath, body, { onLine } = {}) {
|
|
91
106
|
const sock = socketPath();
|
|
107
|
+
const seconds = dockerTimeoutSeconds();
|
|
92
108
|
return new Promise((resolve, reject) => {
|
|
93
109
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
94
110
|
const req = http.request({
|
|
@@ -114,6 +130,9 @@ export function api(method, urlPath, body, { onLine } = {}) {
|
|
|
114
130
|
});
|
|
115
131
|
});
|
|
116
132
|
req.on('error', (e) => reject(e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? unreachable(sock) : e));
|
|
133
|
+
// idle-based: fires only when NOTHING has moved on the socket for `seconds` — a streaming
|
|
134
|
+
// pull resets it with every progress line, a paused daemon never does
|
|
135
|
+
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)`)));
|
|
117
136
|
if (payload) req.write(payload);
|
|
118
137
|
req.end();
|
|
119
138
|
});
|
|
@@ -126,7 +145,7 @@ function ok(res, what) {
|
|
|
126
145
|
throw new Error(`${what}: Docker answered ${res.status}${msg ? ` — ${msg}` : ''}`);
|
|
127
146
|
}
|
|
128
147
|
|
|
129
|
-
const LABEL = { workspace: 'dreamteamer.workspace', template: 'dreamteamer.template', person: 'dreamteamer.person', ports: 'dreamteamer.ports', modules: 'dreamteamer.modules' };
|
|
148
|
+
const LABEL = { workspace: 'dreamteamer.workspace', template: 'dreamteamer.template', person: 'dreamteamer.person', ports: 'dreamteamer.ports', modules: 'dreamteamer.modules', workdir: 'dreamteamer.workdir' };
|
|
130
149
|
const filters = (label) => encodeURIComponent(JSON.stringify({ label: [label] }));
|
|
131
150
|
|
|
132
151
|
// ---- images ------------------------------------------------------------------------------------
|
|
@@ -177,11 +196,29 @@ const containerRow = (c) => {
|
|
|
177
196
|
const port = (c.Ports ?? []).find((p) => p.PublicPort)?.PublicPort;
|
|
178
197
|
return {
|
|
179
198
|
name, template: c.Labels?.[LABEL.template] ?? '', state: c.State, status: c.Status,
|
|
180
|
-
editor_url: port ? editorUrl(port) : '', image: c.Image, person: c.Labels?.[LABEL.person] ?? '',
|
|
199
|
+
editor_url: port ? editorUrl(port, name) : '', image: c.Image, person: c.Labels?.[LABEL.person] ?? '',
|
|
181
200
|
created: c.Created ? new Date(c.Created * 1000).toISOString().slice(0, 16).replace('T', ' ') : '', id: c.Id,
|
|
182
201
|
};
|
|
183
202
|
};
|
|
184
|
-
|
|
203
|
+
/** The dev-container convention: the workspace is mounted at `/workspaces/<name>`, so the folder the
|
|
204
|
+
* editor opens, the URL, and a VS Code attach all name the workspace rather than a fixed word. */
|
|
205
|
+
export const workspaceDir = (name) => `/workspaces/${name}`;
|
|
206
|
+
const editorUrl = (port, name) => `http://localhost:${port}/?folder=${workspaceDir(name)}`;
|
|
207
|
+
/** `--mount <host-path|volume>:<container-path>[:ro]` → a Docker Mount. A source starting with `/`,
|
|
208
|
+
* `~` or `.` is a bind mount of a host path (resolved against cwd); anything else is a named volume. */
|
|
209
|
+
export function parseMount(spec) {
|
|
210
|
+
const parts = String(spec).split(':');
|
|
211
|
+
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, target, mode] = parts;
|
|
213
|
+
if (mode !== undefined && mode !== 'ro' && mode !== 'rw') throw new Error(`--mount "${spec}": the third part is ro or rw`);
|
|
214
|
+
const isPath = /^[/~.]/.test(src);
|
|
215
|
+
const source = isPath ? path.resolve(src.replace(/^~(?=\/|$)/, os.homedir())) : src;
|
|
216
|
+
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`);
|
|
217
|
+
return { Type: isPath ? 'bind' : 'volume', Source: source, Target: target, ReadOnly: mode === 'ro' };
|
|
218
|
+
}
|
|
219
|
+
/** The URI VS Code on the host opens to attach to this container (Dev Containers extension). The
|
|
220
|
+
* container name is hex-encoded, as the extension spells it. */
|
|
221
|
+
export const attachUri = (name) => `vscode-remote://attached-container+${Buffer.from(name, 'utf8').toString('hex')}${workspaceDir(name)}`;
|
|
185
222
|
const volumeNames = (name) => ({ workspace: `dreamteamer-${name}-workspace`, home: `dreamteamer-${name}-home`, files: `dreamteamer-${name}-files` });
|
|
186
223
|
|
|
187
224
|
export async function listContainers() {
|
|
@@ -200,14 +237,21 @@ export async function inspectContainer(name) {
|
|
|
200
237
|
/** The shape `dt get container <name>` prints: the categorised view over `docker inspect`. */
|
|
201
238
|
export function containerDetail(c) {
|
|
202
239
|
const binding = Object.values(c.HostConfig?.PortBindings ?? {}).flat()[0];
|
|
240
|
+
const name = c.Name.replace(/^\//, '');
|
|
241
|
+
const wsDir = c.Config.Labels?.[LABEL.workdir] ?? workspaceDir(name);
|
|
242
|
+
const own = new Set([wsDir, '/home/node', '/files']);
|
|
203
243
|
const mounts = Object.fromEntries((c.Mounts ?? []).filter((m) => m.Type === 'volume').map((m) => [m.Destination, m.Name]));
|
|
204
244
|
return {
|
|
205
|
-
name
|
|
245
|
+
name, id: c.Id.slice(0, 12),
|
|
206
246
|
template: c.Config.Labels[LABEL.template] ?? '', image: c.Config.Image,
|
|
207
247
|
state: c.State?.Status, started: c.State?.StartedAt, restarts: c.RestartCount ?? 0,
|
|
208
248
|
bind: binding?.HostIp ?? '', port: binding ? Number(binding.HostPort) : undefined,
|
|
209
|
-
editor_url: binding ? editorUrl(binding.HostPort) : '',
|
|
210
|
-
|
|
249
|
+
editor_url: binding ? editorUrl(binding.HostPort, name) : '',
|
|
250
|
+
attach_uri: attachUri(name),
|
|
251
|
+
workspace_dir: wsDir,
|
|
252
|
+
volumes: { workspace: mounts[wsDir] ?? '', home: mounts['/home/node'] ?? '', files: mounts['/files'] ?? '' },
|
|
253
|
+
mounts: (c.Mounts ?? []).filter((m) => !own.has(m.Destination)).map((m) => `${m.Type === 'bind' ? m.Source : m.Name}:${m.Destination}${m.RW === false ? ':ro' : ''}`),
|
|
254
|
+
repo: (c.Config.Env ?? []).find((e) => e.startsWith('DT_REPO='))?.slice(8) ?? '',
|
|
211
255
|
person: c.Config.Labels[LABEL.person] ?? '', created: c.Created, labels: c.Config.Labels,
|
|
212
256
|
};
|
|
213
257
|
}
|
|
@@ -254,11 +298,21 @@ export async function startContainer(name, flags, log = console.log) {
|
|
|
254
298
|
const port = await allocatePort(env);
|
|
255
299
|
const who = person(flags, env);
|
|
256
300
|
const vols = volumeNames(name);
|
|
301
|
+
const wsDir = workspaceDir(name);
|
|
302
|
+
// `--mount` adds bind or volume mounts beside the three the container always has; a mount aimed
|
|
303
|
+
// at one of those three targets is refused rather than silently shadowing the volume.
|
|
304
|
+
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)`);
|
|
306
|
+
// `--repo <url>` clones an EXISTING workspace into the workspace volume on first start instead of
|
|
307
|
+
// laying the template down — the way a person joins a workspace that already lives on GitHub.
|
|
308
|
+
const repo = typeof flags.repo === 'string' ? flags.repo : undefined;
|
|
309
|
+
if (repo !== undefined && !/^(https?:\/\/|git@|ssh:\/\/|file:\/\/|\/)/.test(repo)) throw new Error(`--repo takes a git URL or an absolute path — got "${repo}"`);
|
|
257
310
|
const body = {
|
|
258
311
|
Image: ref,
|
|
259
|
-
Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name },
|
|
312
|
+
Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name, [LABEL.workdir]: wsDir },
|
|
260
313
|
Env: [
|
|
261
|
-
`DT_WORKSPACE=${name}`, `DT_TEMPLATE=${template}`, 'FILES_FOLDER=/files',
|
|
314
|
+
`DT_WORKSPACE=${name}`, `DT_TEMPLATE=${template}`, `DT_WORKSPACE_DIR=${wsDir}`, 'FILES_FOLDER=/files',
|
|
315
|
+
...(repo ? [`DT_REPO=${repo}`] : []),
|
|
262
316
|
...(who.name ? [`GIT_AUTHOR_NAME=${who.name}`, `GIT_COMMITTER_NAME=${who.name}`] : []),
|
|
263
317
|
...(who.email ? [`GIT_AUTHOR_EMAIL=${who.email}`, `GIT_COMMITTER_EMAIL=${who.email}`] : []),
|
|
264
318
|
],
|
|
@@ -266,16 +320,17 @@ export async function startContainer(name, flags, log = console.log) {
|
|
|
266
320
|
HostConfig: {
|
|
267
321
|
PortBindings: { [`${inner}/tcp`]: [{ HostIp: env.DT_BIND, HostPort: String(port) }] },
|
|
268
322
|
Mounts: [
|
|
269
|
-
{ Type: 'volume', Source: vols.workspace, Target:
|
|
323
|
+
{ Type: 'volume', Source: vols.workspace, Target: wsDir },
|
|
270
324
|
{ Type: 'volume', Source: vols.home, Target: '/home/node' },
|
|
271
325
|
{ Type: 'volume', Source: vols.files, Target: '/files' },
|
|
326
|
+
...extra,
|
|
272
327
|
],
|
|
273
328
|
RestartPolicy: { Name: 'unless-stopped' },
|
|
274
329
|
},
|
|
275
330
|
};
|
|
276
331
|
ok(await api('POST', `/containers/create?name=${encodeURIComponent(name)}`, body), `create container ${name}`);
|
|
277
332
|
c = await inspectContainer(name);
|
|
278
|
-
log(`✔ created ${name} from ${ref} · ${env.DT_BIND}:${port} → ${inner} · volumes ${Object.values(vols).join(', ')}`);
|
|
333
|
+
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
334
|
}
|
|
280
335
|
if (c.State?.Status !== 'running') {
|
|
281
336
|
const res = await api('POST', `/containers/${c.Id}/start`);
|
|
@@ -399,7 +454,20 @@ export async function driverCommand(verb, target, args) {
|
|
|
399
454
|
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
455
|
if (verb === 'start' || verb === 'add') { const d = await startContainer(id, flags); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
|
|
401
456
|
if (verb === 'stop') { const d = await stopContainer(id); console.log(`✔ stopped ${id} · volumes kept`); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
|
|
402
|
-
if (verb === 'open') {
|
|
457
|
+
if (verb === 'open') {
|
|
458
|
+
const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}"`);
|
|
459
|
+
const d = containerDetail(c);
|
|
460
|
+
if (flags.vscode) {
|
|
461
|
+
// Dev Containers attach: the host's own VS Code opens the workspace INSIDE the container, and
|
|
462
|
+
// installs the extensions the image's `devcontainer.metadata` label names into the container's
|
|
463
|
+
// VS Code Server — a second extension host beside code-server's, over the same files.
|
|
464
|
+
console.log(d.attach_uri);
|
|
465
|
+
if (!flags['no-open']) { try { spawn('code', ['--folder-uri', d.attach_uri], { stdio: 'ignore', detached: true }).unref(); } catch { /* the URI is printed either way */ } }
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
if (!d.editor_url) throw new Error(`${id} publishes no port`);
|
|
469
|
+
console.log(d.editor_url); if (!flags['no-open']) openUrl(d.editor_url); return 0;
|
|
470
|
+
}
|
|
403
471
|
if (verb === 'rm') { await removeContainer(id, { force: flags.force === true }); return 0; }
|
|
404
472
|
return 1;
|
|
405
473
|
}
|
|
@@ -408,15 +476,17 @@ export async function driverCommand(verb, target, args) {
|
|
|
408
476
|
* record parser's promotion rules — a repeated flag on these verbs is a mistake, not an array. */
|
|
409
477
|
export function parseFlags(args) {
|
|
410
478
|
const flags = {}; const pos = [];
|
|
479
|
+
// `--mount` is the one flag that repeats — every other repeat is a mistake and the LAST wins.
|
|
480
|
+
const put = (k, v) => { if (k === 'mount') flags.mount = [...(flags.mount ?? []), v]; else flags[k] = v; };
|
|
411
481
|
for (let i = 0; i < args.length; i++) {
|
|
412
482
|
const a = args[i];
|
|
413
483
|
if (!a.startsWith('--')) { pos.push(a); continue; }
|
|
414
484
|
const eq = a.indexOf('=');
|
|
415
|
-
if (eq > -1)
|
|
416
|
-
else if (i + 1 < args.length && !args[i + 1].startsWith('--'))
|
|
417
|
-
else
|
|
485
|
+
if (eq > -1) put(a.slice(2, eq), a.slice(eq + 1));
|
|
486
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith('--')) put(a.slice(2), args[++i]);
|
|
487
|
+
else put(a.slice(2), true);
|
|
418
488
|
}
|
|
419
489
|
return { flags, pos };
|
|
420
490
|
}
|
|
421
491
|
|
|
422
|
-
export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force'];
|
|
492
|
+
export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force', 'mount', 'repo', 'vscode'];
|
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);
|