dreamteamer 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/collections/collections.collection.yaml +14 -0
- package/package.json +4 -3
- package/skills/using-dreamteamer/SKILL.md +1 -0
- package/skills/using-dreamteamer/references/getting-started.md +21 -0
- package/src/cli.js +89 -12
- package/src/collections-cli.js +10 -0
- package/src/commit.js +3 -2
- package/src/compile.js +115 -5
- package/src/containers.js +422 -0
- package/src/harnesses.js +51 -7
- package/src/init.js +4 -1
- package/src/ref.js +28 -6
- package/src/workspace.js +50 -0
package/README.md
CHANGED
|
@@ -150,6 +150,9 @@ adapters: Claude Code, Codex, Pi, Gemini CLI, Cursor. Author a skill once; every
|
|
|
150
150
|
|
|
151
151
|
## The editor
|
|
152
152
|
|
|
153
|
+
Extension id `dreamteamer.dreamteamer-vscode` (Marketplace · Open VSX). `init` and `compile` write the
|
|
154
|
+
`.vscode/extensions.json` recommendation, and `dt status` reports whether it is active.
|
|
155
|
+
|
|
153
156
|
[dreamteamer-vscode](https://github.com/dreamteamer/dreamteamer-vscode) gives you tables, boards,
|
|
154
157
|
calendars, maps, forms and a data-model designer over the same files — and it loads **the engine your
|
|
155
158
|
workspace pins**, so the editor, the CLI and any agent session are provably running the same code.
|
|
@@ -4,10 +4,24 @@ id: { generate: "{{ name | slug }}" }
|
|
|
4
4
|
schema:
|
|
5
5
|
type: object
|
|
6
6
|
required: [name, schema]
|
|
7
|
+
# The fields fall into five groups, in this order: IDENTITY (name · singular · title ·
|
|
8
|
+
# title_template — what the collection and a record of it are called), RETRIEVAL (description ·
|
|
9
|
+
# use_when — what brings a session here), SHAPE (extends · schema · id · sensitive), PLACEMENT
|
|
10
|
+
# (storage · module · group — where records live and who owns them), PRESENTATION (order ·
|
|
11
|
+
# list_fields · sort_field · icon · ui — how the surfaces show it). A new field joins one of
|
|
12
|
+
# these; a field that fits none is a sign it belongs on a record, not on the collection.
|
|
7
13
|
properties:
|
|
8
14
|
name:
|
|
9
15
|
type: string
|
|
10
16
|
description: The collection id — must equal the filename, and must be unique across every installed module.
|
|
17
|
+
singular:
|
|
18
|
+
type: string
|
|
19
|
+
description: >-
|
|
20
|
+
The word the CLI accepts beside `name` — `dt add task …` for `tasks`. DERIVED by inflection
|
|
21
|
+
when absent (`tasks` → `task`, `companies` → `company`, `rnd/projects` → `rnd/project`) and
|
|
22
|
+
authored only where inflection is wrong (`people` → `person`, `meeting-analyses` →
|
|
23
|
+
`meeting-analysis`). Typed input only: a REFERENCE inside a record still spells the full
|
|
24
|
+
name. compile refuses two collections whose name or singular coincide.
|
|
11
25
|
description:
|
|
12
26
|
type: string
|
|
13
27
|
description: >-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dreamteamer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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>",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"node": ">=20"
|
|
30
30
|
},
|
|
31
31
|
"bin": {
|
|
32
|
-
"dreamteamer": "bin/dreamteamer.js"
|
|
32
|
+
"dreamteamer": "bin/dreamteamer.js",
|
|
33
|
+
"dt": "bin/dreamteamer.js"
|
|
33
34
|
},
|
|
34
35
|
"files": [
|
|
35
36
|
"NOTICE",
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
"express": "^5.2.1",
|
|
50
51
|
"fractional-indexing": "^4.0.0",
|
|
51
52
|
"js-yaml": "^4.1.0",
|
|
52
|
-
"yaml": "2.8.
|
|
53
|
+
"yaml": "2.8.4"
|
|
53
54
|
},
|
|
54
55
|
"dreamteamer": {
|
|
55
56
|
"title": "System"
|
|
@@ -59,6 +59,7 @@ their flags, on one page (there is no per-verb `--help`).
|
|
|
59
59
|
the verb names, as a map (semantics and flags live in `help`; a test holds this list to the
|
|
60
60
|
dispatch, so it cannot drift):
|
|
61
61
|
|
|
62
|
+
- a collection may be spelled in the SINGULAR on any of these (`dt add task "call the bank"` — one bare positional is the title); references inside records still spell the full name
|
|
62
63
|
- read & measure — `list` `get` `values` `history` `diff` `next` `relations` `resolve`
|
|
63
64
|
- write & publish — `add` `set` `rm` `rename` `move` `revert` `commit`
|
|
64
65
|
- fields (sources, through the compile gate) — `add-field` `set-field` `rm-field` `rename-field` (system entities — modules, collections, skills, ui-views… — take the RECORD verbs above)
|
|
@@ -112,3 +112,24 @@ prefers. Nothing about the code moves; `check` and `compile` read only what desc
|
|
|
112
112
|
| hand-writing the first descriptor | `dt add collections` is compile-gated and publishes itself; hand-written sources owe `dt compile` |
|
|
113
113
|
| rewriting existing files to fit a guessed schema | describe reality, compile, `check` — then decide which violations are worth fixing in the data |
|
|
114
114
|
| waiting for a UI before starting | the CLI and the records are the complete system; any surface renders them later, unchanged |
|
|
115
|
+
|
|
116
|
+
## The editor
|
|
117
|
+
|
|
118
|
+
The VS Code-family extension is **`dreamteamer.dreamteamer-vscode`** (Marketplace and Open VSX). It
|
|
119
|
+
loads the engine the workspace pins, so the editor, the CLI and an agent session run the same code.
|
|
120
|
+
`init` writes `.vscode/extensions.json` recommending it and `compile` keeps that file current, so a
|
|
121
|
+
VS Code, Cursor or code-server window opened on the workspace offers to install it — that prompt is
|
|
122
|
+
the intended path. From a terminal:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
code --install-extension dreamteamer.dreamteamer-vscode # VS Code (on some machines `code` is Cursor)
|
|
126
|
+
code-server --install-extension dreamteamer.dreamteamer-vscode \
|
|
127
|
+
--extensions-dir <the dir the running server was started with> # match its --extensions-dir, or the window never sees it
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
⚠ Inside code-server's own terminal (or an agent it started) that command fails with `error not spawned
|
|
131
|
+
with IPC`: the shell inherits code-server's `VSCODE_*` / `CODE_SERVER_PARENT_PID` variables and the
|
|
132
|
+
CLI thinks it is a forked child. Prefix it with `env -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_SIGPIPE
|
|
133
|
+
-u VSCODE_HANDLES_UNCAUGHT_ERRORS -u VSCODE_NLS_CONFIG -u VSCODE_CWD -u VSCODE_RECONNECTION_GRACE_TIME
|
|
134
|
+
-u CODE_SERVER_PARENT_PID`, or let the recommendation prompt do the install. Whether the extension is
|
|
135
|
+
ACTIVE is `dt status`'s `editor:` line — it reads the marker the extension writes on activation.
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { findWorkspace } from './workspace.js';
|
|
|
16
16
|
import { compile, staleness, warnIfStale, discoverModules, CHANNEL_LABEL, locationOf, KINDS } from './compile.js';
|
|
17
17
|
import { check } from './check.js';
|
|
18
18
|
import { collectionCommand, emit, relationsCommand, parseArgs, refuseUnknownFlags } from './collections-cli.js';
|
|
19
|
+
import { driverTarget, driverCommand, setup as hostSetup, parseFlags as hostFlags, DRIVER_VERBS, LIFECYCLE_VERBS, CONTAINER_FLAGS } from './containers.js';
|
|
19
20
|
import { init, installClone, update, listRepos } from './init.js';
|
|
20
21
|
import { installCommand, describeCheckout, listWorktrees, worktreeCommand } from './checkout.js';
|
|
21
22
|
import { proveCommand, readLedger, flagEnabled } from './prove.js';
|
|
@@ -23,7 +24,7 @@ import { landCommand } from './land.js';
|
|
|
23
24
|
import { deriveEvents } from './events.js';
|
|
24
25
|
import { commitPending } from './commit.js';
|
|
25
26
|
import { Store } from './store.js';
|
|
26
|
-
import { splitRef } from './ref.js';
|
|
27
|
+
import { splitRef, canonicalCollection } from './ref.js';
|
|
27
28
|
import { envContext, renderTemplate } from './env-vars.js';
|
|
28
29
|
import { exportCommand, EXPORT_FLAGS } from './export-notebooklm.js';
|
|
29
30
|
|
|
@@ -57,8 +58,12 @@ the longest DECLARED collection prefix, so finance/transactions/2026/03/coffee i
|
|
|
57
58
|
case-insensitive variants; date-times sort and
|
|
58
59
|
compare as instants, across offsets)
|
|
59
60
|
get <collection>/<id> [--json]
|
|
60
|
-
add <collection> --<field> <value> … [--id <explicit-id>]
|
|
61
|
-
(
|
|
61
|
+
add <collection> ["<title>"] --<field> <value> … [--id <explicit-id>]
|
|
62
|
+
(ONE bare positional fills the collection's title
|
|
63
|
+
field — the one its title_template names — so
|
|
64
|
+
dt add task "call the bank" is
|
|
65
|
+
dt add tasks --name "call the bank".
|
|
66
|
+
a codec-file collection takes --from <path>
|
|
62
67
|
instead — the file IS the record, fields derive;
|
|
63
68
|
--force replaces an existing file record.
|
|
64
69
|
A repeated --<field> is one ELEMENT of an array
|
|
@@ -164,6 +169,12 @@ collection: the ENGINE does not read one, and \`rename-field\` was the only capa
|
|
|
164
169
|
title_template, id.generate, a ui-view's options.columns and filter,
|
|
165
170
|
and a command-binding's can-enter/can-exit. ONE commit)
|
|
166
171
|
|
|
172
|
+
Every <collection> above may be spelled in the SINGULAR: dt add task …, dt get task/<id>,
|
|
173
|
+
dt list meeting-analysis, dt add-field task …. The singular is derived from the descriptor
|
|
174
|
+
(tasks → task, companies → company) and authored on it as singular: where inflection is
|
|
175
|
+
wrong (people → person); compile refuses two collections whose words collide. A REFERENCE
|
|
176
|
+
inside a record still names the collection in full — tasks/kickoff, never task/kickoff.
|
|
177
|
+
|
|
167
178
|
Every verb that MOVES records or CLEARS values takes --dry-run and prints its plan first:
|
|
168
179
|
records N · refs M · descriptors K · values cleared V
|
|
169
180
|
|
|
@@ -232,6 +243,24 @@ workspace verbs:
|
|
|
232
243
|
and one \`proofs:\` line counting each proof's LAST verdict on this machine
|
|
233
244
|
[--strict] exit 1 when any proof's ledger tail is a FAIL
|
|
234
245
|
start serve the clean REST api at /api [--port <n>]
|
|
246
|
+
|
|
247
|
+
containers — a workspace as a running container (Docker Engine API over its socket, no dependency;
|
|
248
|
+
these verbs work with NO workspace, so npm i -g dreamteamer and Docker Desktop are enough):
|
|
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), lists the
|
|
251
|
+
templates present, pulls one on request [--template <t>] [--json]
|
|
252
|
+
start container <name> --template <t> create-if-absent and start: a code-server editor at
|
|
253
|
+
http://localhost:<port>/?folder=/workspace over a compiled workspace, three named volumes
|
|
254
|
+
(workspace · home · files), image <DT_REGISTRY>/<template>:<tag> or DT_IMAGE_<template>.
|
|
255
|
+
Idempotent. NO token is ever injected — log in INSIDE, once; the home volume keeps it.
|
|
256
|
+
[--name <git name>] [--email <git email>] [--no-open] [--json]
|
|
257
|
+
stop container <name> stop it; every volume kept [--json]
|
|
258
|
+
open container <name> print (and open) its editor URL [--no-open]
|
|
259
|
+
list containers | images the record verbs, answered over Docker instead of a
|
|
260
|
+
get container <name> | image <ref> folder — singular or plural, either spelling.
|
|
261
|
+
rm container <name> [--force] plain rm keeps the volumes; --force removes them too
|
|
262
|
+
add image --template <t> pull a template's image; rm image <ref> removes one
|
|
263
|
+
|
|
235
264
|
changes what changed in every repo that holds records, as record events
|
|
236
265
|
[--since <sha|YYYY-MM-DD>] (default: HEAD~1 — the last commit's own changes) [--json]
|
|
237
266
|
commit publish records already written to disk: samples git status over every
|
|
@@ -286,7 +315,10 @@ export const GLOBAL_FLAGS = ['vault'];
|
|
|
286
315
|
export const WORKSPACE_FLAGS = {
|
|
287
316
|
init: ['name', 'data-path', 'harnesses', 'workspace-module'], update: [],
|
|
288
317
|
install: ['clone', 'dry-run', 'json', 'link-env', 'all', 'hook', 'print-adapters'],
|
|
289
|
-
start
|
|
318
|
+
// `start` is TWO forms: bare, the REST api (--port); with a `container <name>` target, the
|
|
319
|
+
// lifecycle verb — whose flags are the driver's. One table, because `flags-honoured` reads it.
|
|
320
|
+
start: ['port', ...CONTAINER_FLAGS], compile: ['watch'], check: [], status: ['strict'],
|
|
321
|
+
setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open'],
|
|
290
322
|
changes: ['since', 'json'], commit: ['dry-run', 'json'],
|
|
291
323
|
export: EXPORT_FLAGS,
|
|
292
324
|
// the UNION of every form's flags — the outer typo gate. Which flags each FORM takes is refused
|
|
@@ -299,6 +331,15 @@ export const WORKSPACE_FLAGS = {
|
|
|
299
331
|
export function run(argv) {
|
|
300
332
|
const [cmd, ...rest] = argv;
|
|
301
333
|
try {
|
|
334
|
+
// HOST VERBS resolve BEFORE workspace discovery: `setup`, and any verb whose target is a
|
|
335
|
+
// driver collection (`containers`, `images`, singular or plural). They answer identically on a
|
|
336
|
+
// bare machine — `npm i -g dreamteamer` and Docker Desktop, nothing else — and inside a
|
|
337
|
+
// workspace, because the thing they make IS the workspace (src/containers.js).
|
|
338
|
+
const host = hostDispatch(cmd, rest);
|
|
339
|
+
if (host) {
|
|
340
|
+
host.then((code) => process.exit(code)).catch((e) => { console.error(`✖ ${e.message}`); process.exit(1); });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
302
343
|
if (cmd in WORKSPACE_FLAGS) {
|
|
303
344
|
const bad = rest.filter((a) => a.startsWith('--')).map((a) => a.slice(2).split('=')[0]).find((f) => !WORKSPACE_FLAGS[cmd].includes(f));
|
|
304
345
|
if (bad) throw new Error(`unknown flag "--${bad}" on \`dt ${cmd}\`\n known: ${WORKSPACE_FLAGS[cmd].map((f) => `--${f}`).join(', ') || '(none — this verb takes no flags)'}`);
|
|
@@ -315,7 +356,9 @@ export function run(argv) {
|
|
|
315
356
|
for (let i = 0; i < rest.length; i++) if (rest[i].startsWith('--')) flags[rest[i].slice(2)] = rest[i + 1];
|
|
316
357
|
process.exit(init({ flags }));
|
|
317
358
|
}
|
|
318
|
-
if (!cmd) {
|
|
359
|
+
if (!cmd || cmd === 'help') {
|
|
360
|
+
// `help` works OUTSIDE a workspace too — the host verbs above do, and a person who just ran
|
|
361
|
+
// `npm i -g dreamteamer` on a bare machine has nothing else to read.
|
|
319
362
|
emit(USAGE);
|
|
320
363
|
process.exit(0);
|
|
321
364
|
}
|
|
@@ -507,6 +550,16 @@ export function run(argv) {
|
|
|
507
550
|
process.exit(1);
|
|
508
551
|
}
|
|
509
552
|
console.log(`compiled: ${s.manifest.compiled}`);
|
|
553
|
+
// THE EDITOR, from the marker the extension writes on activation (`.dreamteamer/editor.json`).
|
|
554
|
+
// Without it an agent working inside the editor could not tell whether the extension was
|
|
555
|
+
// installed, active, or refusing the engine — the operator had to report "no icon".
|
|
556
|
+
try {
|
|
557
|
+
const marker = path.join(ws.root, '.dreamteamer', 'editor.json');
|
|
558
|
+
if (fs.existsSync(marker)) {
|
|
559
|
+
const e = JSON.parse(fs.readFileSync(marker, 'utf8'));
|
|
560
|
+
console.log(`editor: ${e.extension ?? 'dreamteamer-vscode'} ${e.version ?? '?'} · ${e.state ?? 'active'} ${e.activated ?? ''} · engine ${e.engine ?? '?'}${e.host ? ` · ${e.host}` : ''}`);
|
|
561
|
+
} else console.log('editor: not detected — the extension dreamteamer.dreamteamer-vscode writes .dreamteamer/editor.json when it activates on this workspace');
|
|
562
|
+
} catch { console.log('editor: marker unreadable — .dreamteamer/editor.json is not JSON'); }
|
|
510
563
|
// provenance is LIVE discovery (not the manifest) — shows what the next compile would use
|
|
511
564
|
const { modules, shadows } = discoverModules(ws.root, ws.pkg);
|
|
512
565
|
const shadowed = new Map(shadows.map((sh) => [sh.name, sh]));
|
|
@@ -661,7 +714,8 @@ export function run(argv) {
|
|
|
661
714
|
if (!target || target.startsWith('--')) {
|
|
662
715
|
throw new Error(`dt ${cmd} needs a collection: dreamteamer ${cmd} <collection> --name <field> …`);
|
|
663
716
|
}
|
|
664
|
-
|
|
717
|
+
// the singular is legal here too: `dt add-field task --name due …`
|
|
718
|
+
process.exit(collectionCommand(ws, canonicalCollection(new Store(ws).descriptors, target) ?? target, cmd, flagArgs));
|
|
665
719
|
}
|
|
666
720
|
case 'relations':
|
|
667
721
|
warnIfStale(ws.root);
|
|
@@ -728,6 +782,25 @@ export function run(argv) {
|
|
|
728
782
|
}
|
|
729
783
|
}
|
|
730
784
|
|
|
785
|
+
/** The verbs that run with no workspace. Returns a promise of an exit code, or null when the
|
|
786
|
+
* command is not ours and the ordinary workspace dispatch should take it. */
|
|
787
|
+
function hostDispatch(cmd, rest) {
|
|
788
|
+
if (cmd === 'setup') {
|
|
789
|
+
const bad = rest.filter((a) => a.startsWith('--')).map((a) => a.slice(2).split('=')[0]).find((f) => !WORKSPACE_FLAGS.setup.includes(f));
|
|
790
|
+
if (bad) throw new Error(`unknown flag "--${bad}" on \`dt setup\`\n known: ${WORKSPACE_FLAGS.setup.map((f) => `--${f}`).join(', ')}`);
|
|
791
|
+
return hostSetup(hostFlags(rest).flags);
|
|
792
|
+
}
|
|
793
|
+
const target = driverTarget(rest[0]);
|
|
794
|
+
if (target && DRIVER_VERBS.has(cmd)) return driverCommand(cmd, target, rest.slice(1));
|
|
795
|
+
// A lifecycle verb aimed at anything else is refused by name: `dt start tasks` is not a
|
|
796
|
+
// server and not a container, and "unknown collection" would send the reader the wrong way.
|
|
797
|
+
if (LIFECYCLE_VERBS.has(cmd) && rest[0] && !rest[0].startsWith('--')) {
|
|
798
|
+
return Promise.reject(new Error(`\`${cmd}\` is a container lifecycle verb — "${rest[0]}" is not a container. dt ${cmd} container <name>${cmd === 'start' ? ' --template <t>' : ''}${cmd === 'start' ? '; a bare `dt start` serves the REST api' : ''}`));
|
|
799
|
+
}
|
|
800
|
+
if (cmd === 'stop' || cmd === 'open') return Promise.reject(new Error(`dt ${cmd} container <name> — see \`dreamteamer help\``));
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
|
|
731
804
|
/** Translate `dt <verb> <target> …` into the noun-verb call the implementation layer takes. */
|
|
732
805
|
function dispatchRecordVerb(ws, verb, args) {
|
|
733
806
|
const [target, ...rest] = args;
|
|
@@ -735,18 +808,22 @@ function dispatchRecordVerb(ws, verb, args) {
|
|
|
735
808
|
// A flag in the target slot is a word-order mistake, not a collection: without this,
|
|
736
809
|
// `dt list --json contacts` reported `unknown collection "--json"` and dumped every name.
|
|
737
810
|
if (target.startsWith('--')) throw new Error(`dt ${verb} takes its target BEFORE the flags: dreamteamer ${verb} <target> ${target} …`);
|
|
738
|
-
|
|
811
|
+
// A collection may be named by its declared name OR its singular (`dt add task …`); the
|
|
812
|
+
// canonical name is what every layer below sees. An unknown word passes through unchanged so
|
|
813
|
+
// the store's own "unknown collection" sentence, which lists what exists, is the one printed.
|
|
814
|
+
const { descriptors } = new Store(ws);
|
|
815
|
+
const canonical = canonicalCollection(descriptors, target) ?? target;
|
|
816
|
+
if (COLLECTION_VERBS.has(verb)) return collectionCommand(ws, canonical, verb, rest);
|
|
739
817
|
if (REF_VERBS.has(verb)) {
|
|
740
|
-
const { collection, id } = splitRef(
|
|
818
|
+
const { collection, id } = splitRef(descriptors, target);
|
|
741
819
|
return collectionCommand(ws, collection, verb, [id, ...rest]);
|
|
742
820
|
}
|
|
743
821
|
// EITHER_VERBS from here: a bare collection is legal for both — `move <collection> --init`,
|
|
744
822
|
// `next <collection>`.
|
|
745
|
-
|
|
746
|
-
if (descriptors.has(target)) {
|
|
823
|
+
if (descriptors.has(canonical)) {
|
|
747
824
|
return verb === 'move'
|
|
748
|
-
? collectionCommand(ws,
|
|
749
|
-
: collectionCommand(ws, 'commands', 'for', [
|
|
825
|
+
? collectionCommand(ws, canonical, 'move', rest)
|
|
826
|
+
: collectionCommand(ws, 'commands', 'for', [canonical, ...rest]);
|
|
750
827
|
}
|
|
751
828
|
const { collection, id } = splitRef(descriptors, target);
|
|
752
829
|
if (verb === 'move') return collectionCommand(ws, collection, 'move', [id, ...rest]);
|
package/src/collections-cli.js
CHANGED
|
@@ -179,6 +179,16 @@ export function collectionCommand(ws, collection, verb, args) {
|
|
|
179
179
|
return 0;
|
|
180
180
|
}
|
|
181
181
|
if (flags.from) throw new Error(`--from imports a file as a record, and "${collection}" is not a \`codec: file\` collection`);
|
|
182
|
+
// ONE bare positional is the record's title — the field `title_template` names — so
|
|
183
|
+
// `dt add task "call the bank"` reads as a sentence. Two positionals is a mistake (a flag
|
|
184
|
+
// value that lost its flag), and so is giving the title twice; both are refused by name.
|
|
185
|
+
if (pos.length > 1) throw new Error(`dt add ${collection} takes ONE positional (the title) and flags for the rest — got ${pos.length}: ${pos.map((p) => `"${p}"`).join(' ')}`);
|
|
186
|
+
if (pos.length === 1) {
|
|
187
|
+
const titleField = /\{\{\s*([A-Za-z_][\w]*)/.exec(d.title_template ?? '')?.[1];
|
|
188
|
+
if (!titleField || titleField === 'id') throw new Error(`"${collection}" labels its records by id, so there is no title field for "${pos[0]}" to fill — pass fields as --<field> <value>`);
|
|
189
|
+
if (titleField in flags) throw new Error(`the title was given twice — "${pos[0]}" and --${titleField} ${JSON.stringify(flags[titleField])}`);
|
|
190
|
+
flags[titleField] = pos[0];
|
|
191
|
+
}
|
|
182
192
|
const fields = coerceArrays(d, stripMeta(flags));
|
|
183
193
|
const { id, file, idFallback } = store.add(collection, fields, { id: flags.id });
|
|
184
194
|
flags.json
|
package/src/commit.js
CHANGED
|
@@ -7,7 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import { pathToRecord } from './events.js';
|
|
8
8
|
import { parseRecordText } from './records.js';
|
|
9
9
|
import { relationsOf } from './relations.js';
|
|
10
|
-
import { splitRef } from './ref.js';
|
|
10
|
+
import { splitRef, canonicalCollection } from './ref.js';
|
|
11
11
|
|
|
12
12
|
// git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
|
|
13
13
|
// child's stderr to ours unless told otherwise, so a handled "not a git repository" still
|
|
@@ -42,7 +42,8 @@ function parseTargets(descriptors, only) {
|
|
|
42
42
|
const whole = new Set();
|
|
43
43
|
const records = new Map();
|
|
44
44
|
for (const target of only) {
|
|
45
|
-
|
|
45
|
+
const asCollection = canonicalCollection(descriptors, target);
|
|
46
|
+
if (asCollection) { whole.add(asCollection); scope.add(asCollection); continue; }
|
|
46
47
|
const { collection, id } = splitRef(descriptors, target);
|
|
47
48
|
records.set(`${collection}/${id}`, { collection, id });
|
|
48
49
|
scope.add(collection);
|
package/src/compile.js
CHANGED
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
baseNameOf, singular, namespaceOf } from './namespace.js';
|
|
17
17
|
// circular on paper in earlier versions — safe: both sides only
|
|
18
18
|
// call at run time, same pattern as store.js ↔ compile.js.
|
|
19
|
-
import { runHarnessAdapters } from './harnesses.js';
|
|
19
|
+
import { runHarnessAdapters, BEGIN, END, INSTRUCTIONS_BEGIN, INSTRUCTIONS_END } from './harnesses.js';
|
|
20
|
+
import { ensureEditorRecommendation, ensureEnvExample } from './workspace.js';
|
|
20
21
|
import { satisfies } from './semver.js';
|
|
21
22
|
import { parseEnvValues } from './env-vars.js';
|
|
22
23
|
import { DERIVED_KINDS, readManifest, runtimeDir, engineId, engineVersion } from './runtime.js';
|
|
@@ -676,7 +677,9 @@ export function compile({ root, pkg }) {
|
|
|
676
677
|
// what every message in this engine already calls it. Defined HERE, above the namespace pass,
|
|
677
678
|
// because a namespace error has to name the module by the id the fix is typed with.
|
|
678
679
|
const moduleId = (n) => slug(String(n).replace(/^@[^/]+\//, ''));
|
|
680
|
+
const channelOf = new Map(sources.map((s) => [s.name, s.channel]));
|
|
679
681
|
const declaredEnv = new Map(); // env key -> [module names]
|
|
682
|
+
const envMeta = new Map(); // env key -> { description, example } — the first module to say wins
|
|
680
683
|
const moduleIgnores = new Map(); // module name -> non-source folders it declares (strayKindDirs)
|
|
681
684
|
const moduleDeps = new Map(); // module name -> [module names] — HARD, must be acyclic
|
|
682
685
|
const modulePeers = new Map(); // module name -> [collection names] — SOFT, cannot cycle
|
|
@@ -717,9 +720,17 @@ export function compile({ root, pkg }) {
|
|
|
717
720
|
if (ok === false) console.warn(`⚠ module ${source.name} declares engine "${range}" — running engine is ${engineVer} (out of range; compile continues)`);
|
|
718
721
|
else if (ok === null) console.warn(`⚠ module ${source.name}: engine range "${range}" not understood by the built-in checker (see src/semver.js) — not verified`);
|
|
719
722
|
}
|
|
720
|
-
|
|
723
|
+
// `dreamteamer.env`: a bare key name, or `{ name, description, example }` so the warning and
|
|
724
|
+
// `.env.example` can say what the key IS and what a value looks like — a bare `WORK_CALENDARS`
|
|
725
|
+
// told a first-run operator nothing about ids, addresses or display names (2026-09-24).
|
|
726
|
+
const envDecl = mpkg.dreamteamer?.env ?? [];
|
|
727
|
+
if (!Array.isArray(envDecl)) fail(`module "${source.name}": dreamteamer.env must be a list of key names or { name, description, example } objects (got ${JSON.stringify(envDecl)})`);
|
|
728
|
+
for (const entry of envDecl) {
|
|
729
|
+
const k = typeof entry === 'string' ? entry : entry?.name;
|
|
730
|
+
if (typeof k !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) fail(`module "${source.name}": dreamteamer.env entry ${JSON.stringify(entry)} — a key is an identifier (A-Z, 0-9, _) as a string or as { name, description, example }`);
|
|
721
731
|
if (!declaredEnv.has(k)) declaredEnv.set(k, []);
|
|
722
732
|
declaredEnv.get(k).push(source.name);
|
|
733
|
+
if (typeof entry === 'object' && !envMeta.has(k)) envMeta.set(k, { description: entry.description ? String(entry.description) : undefined, example: entry.example !== undefined ? String(entry.example) : undefined });
|
|
723
734
|
}
|
|
724
735
|
// Gathered here because mpkg is already parsed; refused below, next to the workspace's own
|
|
725
736
|
// declaration. The classic layout pushes the ROOT itself as an inline source, whose
|
|
@@ -758,7 +769,8 @@ export function compile({ root, pkg }) {
|
|
|
758
769
|
const present = new Set([...parsedEnv].filter(([, v]) => v.trim() !== '').map(([k]) => k));
|
|
759
770
|
for (const [k, mods] of declaredEnv) {
|
|
760
771
|
if (present.has(k)) continue;
|
|
761
|
-
|
|
772
|
+
const about = envMeta.get(k)?.description ? ` (${envMeta.get(k).description})` : '';
|
|
773
|
+
for (const mod of mods) console.warn(`⚠ module ${mod} declares env key ${k}${about} — missing from .env (see .env.example)`);
|
|
762
774
|
}
|
|
763
775
|
for (const k of declaredVars) {
|
|
764
776
|
if (present.has(k)) continue;
|
|
@@ -767,6 +779,17 @@ export function compile({ root, pkg }) {
|
|
|
767
779
|
}
|
|
768
780
|
}
|
|
769
781
|
|
|
782
|
+
// Two root files kept current on every compile, both cheap and both about the FIRST run of a
|
|
783
|
+
// stranger: `.env.example` lists every declared key with its description, so the warning above
|
|
784
|
+
// points at a file that actually names them; `.vscode/extensions.json` recommends the editor
|
|
785
|
+
// extension, so the first window offers it. Both are append/merge-only — nothing authored moves.
|
|
786
|
+
{
|
|
787
|
+
const added = ensureEnvExample(root, [...declaredEnv].map(([key, mods]) => ({ key, modules: mods, ...(envMeta.get(key) ?? {}) })),
|
|
788
|
+
'# secrets for skills and modules go here (copy to .env; .env is never committed).\n# modules declare the env keys they require in their package.json dreamteamer.env list.\n');
|
|
789
|
+
if (added.length) console.log(`✔ .env.example now names ${added.join(', ')}`);
|
|
790
|
+
ensureEditorRecommendation(root);
|
|
791
|
+
}
|
|
792
|
+
|
|
770
793
|
// ---- local-assets and postinstall: what `dt install` will do to a checkout -------
|
|
771
794
|
// `local-assets` are the gitignored heavy folders a checkout SHARES by symlink instead of
|
|
772
795
|
// duplicating — a browser profile dir, a model cache. Declared, never discovered. Every
|
|
@@ -1093,6 +1116,7 @@ export function compile({ root, pkg }) {
|
|
|
1093
1116
|
let mergedCount = 0;
|
|
1094
1117
|
let templatedCount = 0;
|
|
1095
1118
|
const storageEntries = []; // {name, path, base} per collection — checked for overlap after the loop
|
|
1119
|
+
const wordEntries = []; // {name, word: singular} per collection — checked for collisions after the loop
|
|
1096
1120
|
// Merged descriptors are held, NOT dumped, until every one of them exists: a relation spans two
|
|
1097
1121
|
// collections, and the second is not merged yet when the first is reached. So this loop resolves
|
|
1098
1122
|
// and validates each descriptor on its own, `materializeRelations` runs over the whole set, and
|
|
@@ -1285,8 +1309,14 @@ export function compile({ root, pkg }) {
|
|
|
1285
1309
|
if (raw === '*') {
|
|
1286
1310
|
// The workspace module is the orchestrating parent and may reference anything —
|
|
1287
1311
|
// including modules that do not exist yet, which is what `tasks.item` means.
|
|
1288
|
-
// Anywhere else a wildcard is a cross-module surface no declaration can cover
|
|
1289
|
-
|
|
1312
|
+
// Anywhere else a wildcard is a cross-module surface no declaration can cover — and
|
|
1313
|
+
// it is the MODULE AUTHOR's to cover, so the warning is raised only where the author
|
|
1314
|
+
// is: a module in this tree (inline). A module installed from npm or a clone is
|
|
1315
|
+
// somebody else's source; warning its consumers about it on every compile told a
|
|
1316
|
+
// first-run operator four things they could not fix (2026-09-24). The module's own
|
|
1317
|
+
// CI, compiling it alone, still sees them.
|
|
1318
|
+
const authoredHere = groupModules.some((m) => (channelOf.get(m) ?? 'inline') === 'inline');
|
|
1319
|
+
if (!groupModules.includes(wsModuleName) && authoredHere) {
|
|
1290
1320
|
console.warn(`⚠ collection ${name}: field "${at}" uses x-reference: '*' outside the workspace module — an unverifiable cross-module surface; name the collections it may target`);
|
|
1291
1321
|
}
|
|
1292
1322
|
continue;
|
|
@@ -1380,6 +1410,16 @@ export function compile({ root, pkg }) {
|
|
|
1380
1410
|
// for `meta.title_field`, promoted to an authorable field. Reference fields pointing here
|
|
1381
1411
|
// inherit it (presentation.js), which is what replaces 51 hand-written `x-display` lines.
|
|
1382
1412
|
merged.title_template ??= `{{ ${['title', 'name', 'subject'].find((f) => f in labelProps) ?? 'id'} }}`;
|
|
1413
|
+
// The word the CLI accepts beside the name (`dt add task …`). DERIVED by the same inflection
|
|
1414
|
+
// the storage suffix already uses, with the namespace kept (`rnd/projects` → `rnd/project`),
|
|
1415
|
+
// so the two never disagree; AUTHORED where inflection is wrong (`people` → `person`).
|
|
1416
|
+
// Collisions are refused after the loop, once every descriptor has one.
|
|
1417
|
+
if (merged.singular !== undefined && (typeof merged.singular !== 'string' || !merged.singular.trim())) fail(`collection "${name}": \`singular\` must be a non-empty string`);
|
|
1418
|
+
if (merged.singular === undefined) {
|
|
1419
|
+
const ns = namespaceOf(name, namespaces);
|
|
1420
|
+
merged.singular = ns ? `${ns}/${singular(baseNameOf(name, namespaces))}` : singular(name);
|
|
1421
|
+
}
|
|
1422
|
+
wordEntries.push({ name, word: merged.singular });
|
|
1383
1423
|
for (const [fieldName, prop] of Object.entries(labelProps)) {
|
|
1384
1424
|
if (!prop || typeof prop !== 'object' || Array.isArray(prop)) continue;
|
|
1385
1425
|
prop.title ??= titleCase(fieldName);
|
|
@@ -1445,6 +1485,19 @@ export function compile({ root, pkg }) {
|
|
|
1445
1485
|
// `owns-data` module prefix and any authored override all already applied). See
|
|
1446
1486
|
// namespace.storageOverlaps for what this silently did before it was checked.
|
|
1447
1487
|
for (const p of storageOverlaps(storageEntries)) fail(p);
|
|
1488
|
+
// Two collections that answer to one word would make `dt add <word>` a coin toss, so the set of
|
|
1489
|
+
// words — every name and every singular — must be injective. Refused with both names, because
|
|
1490
|
+
// the fix is an authored `singular:` on one of them and the author needs to know which two.
|
|
1491
|
+
{
|
|
1492
|
+
const owners = new Map(); // word -> name
|
|
1493
|
+
for (const { name } of wordEntries) owners.set(name, name);
|
|
1494
|
+
for (const { name, word } of wordEntries) {
|
|
1495
|
+
if (word === name) continue;
|
|
1496
|
+
const other = owners.get(word);
|
|
1497
|
+
if (other && other !== name) fail(`collections "${name}" and "${other}" both answer to the word "${word}" (a name or a singular) — author \`singular:\` on one of them so \`dt add ${word}\` names exactly one collection`);
|
|
1498
|
+
owners.set(word, name);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1448
1501
|
|
|
1449
1502
|
// ---- modules, projected ---------------------------------------------------------
|
|
1450
1503
|
// One record per discovered module, written from what discovery and the package pass already
|
|
@@ -1526,6 +1579,19 @@ export function compile({ root, pkg }) {
|
|
|
1526
1579
|
counts.modules = (counts.modules ?? 0) + 1;
|
|
1527
1580
|
}
|
|
1528
1581
|
|
|
1582
|
+
// ---- the workspace's own hand-written instructions -------------------------------
|
|
1583
|
+
// ONE source, rendered verbatim into every harness's instruction file. It is registered as a
|
|
1584
|
+
// manifest entry for exactly one reason: `staleness` walks manifest sources, so a file that is
|
|
1585
|
+
// not one can be edited forever without `dt status` ever saying the harness files lag it — and a
|
|
1586
|
+
// silent lag on the file carrying the operator's rules is the worst possible thing to be silent
|
|
1587
|
+
// about. The runtime copy is never read by anything; the manifest ENTRY is the whole point.
|
|
1588
|
+
const instructionsPath = path.join(root, INSTRUCTIONS_SOURCE);
|
|
1589
|
+
if (fs.existsSync(instructionsPath)) {
|
|
1590
|
+
const bytes = fs.readFileSync(instructionsPath);
|
|
1591
|
+
refuseManagedMarkers(bytes.toString('utf8'), rel(instructionsPath));
|
|
1592
|
+
entries.set('instructions.md', { sources: [{ path: rel(instructionsPath), hash: sha256(bytes) }], bytes });
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1529
1595
|
// ---- unresolved references are compile errors (an agent's declared skills)
|
|
1530
1596
|
const skillIds = new Set([...entries.keys()].filter((k) => k.startsWith('skills/')).map((k) => k.split('/')[1]));
|
|
1531
1597
|
for (const [rt, e] of entries) {
|
|
@@ -1877,6 +1943,14 @@ export function staleness(root) {
|
|
|
1877
1943
|
}
|
|
1878
1944
|
}
|
|
1879
1945
|
}
|
|
1946
|
+
// ⚠ `dreamteamer.md` is a compile source that is NOT under a KIND directory, so the walk above
|
|
1947
|
+
// cannot reach it — and its CREATION is the one moment that matters most: day one in an adopting
|
|
1948
|
+
// workspace, when no harness file carries an instructions block yet. Every later EDIT was already
|
|
1949
|
+
// caught by the manifest-source walk at the top of this function; only the first write was silent,
|
|
1950
|
+
// and it reported `.dreamteamer is fresh` while the rules reached no agent at all.
|
|
1951
|
+
if (fs.existsSync(path.join(root, INSTRUCTIONS_SOURCE)) && !known.has(INSTRUCTIONS_SOURCE)) {
|
|
1952
|
+
stale.push(`${INSTRUCTIONS_SOURCE} (new, uncompiled)`);
|
|
1953
|
+
}
|
|
1880
1954
|
return { compiled: true, stale, manifest };
|
|
1881
1955
|
}
|
|
1882
1956
|
|
|
@@ -1965,6 +2039,42 @@ function descriptorAjv() {
|
|
|
1965
2039
|
return _descriptorAjv;
|
|
1966
2040
|
}
|
|
1967
2041
|
|
|
2042
|
+
// ⚠ A MANAGED MARKER INSIDE `dreamteamer.md` IS A REFUSAL, not something to escape around.
|
|
2043
|
+
// The file is rendered VERBATIM into a managed block, and `writeBlock` finds that block by the FIRST
|
|
2044
|
+
// occurrence of its begin marker anywhere in the file — so a marker quoted inside the rendered text
|
|
2045
|
+
// is found before the real delimiter. Both directions were measured on a fixture:
|
|
2046
|
+
//
|
|
2047
|
+
// - quoting the ORIENTATION pair: the orientation pass rewrites the quoted region, the instructions
|
|
2048
|
+
// pass that runs immediately after restores it from source, and the real orientation block is
|
|
2049
|
+
// never touched again. It silently keeps describing the schema of the day it was written, while
|
|
2050
|
+
// `compile` exits 0 and `status` reports the runtime fresh.
|
|
2051
|
+
// - quoting the INSTRUCTIONS end marker: the block is closed at the quote and a second end line is
|
|
2052
|
+
// appended, so all three committed root files grow by ~40 bytes and one duplicated line per
|
|
2053
|
+
// compile, without ever reaching a fixed point.
|
|
2054
|
+
//
|
|
2055
|
+
// Escaping the markers on the way out is the alternative, and it is not one: the whole promise of
|
|
2056
|
+
// this file is that what was written is what every agent reads, and an escaped marker is not that.
|
|
2057
|
+
// A rule ABOUT the block describes it instead of quoting it.
|
|
2058
|
+
/** The one hand-written root source. Named once: `compile` reads it and `staleness` looks for it. */
|
|
2059
|
+
export const INSTRUCTIONS_SOURCE = 'dreamteamer.md';
|
|
2060
|
+
|
|
2061
|
+
const MANAGED_MARKERS = [
|
|
2062
|
+
['the orientation block', BEGIN],
|
|
2063
|
+
['the orientation block', END],
|
|
2064
|
+
['the instructions block', INSTRUCTIONS_BEGIN],
|
|
2065
|
+
['the instructions block', INSTRUCTIONS_END],
|
|
2066
|
+
];
|
|
2067
|
+
|
|
2068
|
+
function refuseManagedMarkers(text, srcPath) {
|
|
2069
|
+
const lines = text.split('\n');
|
|
2070
|
+
for (const [i, line] of lines.entries()) {
|
|
2071
|
+
for (const [which, marker] of MANAGED_MARKERS) {
|
|
2072
|
+
if (!line.includes(marker)) continue;
|
|
2073
|
+
fail(`${srcPath}:${i + 1}: contains the managed marker ${marker}, which delimits ${which} in the harness files. This source is rendered verbatim into that block, so the quoted copy is found before the real delimiter and the block is rewritten around the wrong place. Describe the block instead of quoting its marker.`);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
1968
2078
|
function fail(msg) {
|
|
1969
2079
|
throw new CompileError(`compile error: ${msg}`);
|
|
1970
2080
|
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// containers.js — `containers` and `images` as verbs over the Docker Engine API. The one storage
|
|
2
|
+
// DRIVER in core: a workspace becomes a running container (`dt start container <name> --template
|
|
3
|
+
// <t>`), the person opens code-server at a loopback URL, and the same record verbs — list · get ·
|
|
4
|
+
// add · rm — answer over Docker instead of over a folder of files.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS IS CORE AND NOT A MODULE. A module ships collections, skills, commands and views INTO a
|
|
7
|
+
// workspace; every one of them needs a compiled runtime to exist. This runs BEFORE any workspace
|
|
8
|
+
// exists — `npm i -g dreamteamer && dt setup && dt start container …` on a machine with nothing
|
|
9
|
+
// but Docker Desktop — and a module has no place to stand there. That is the "could a module do it"
|
|
10
|
+
// question, answered: no, because the thing being made IS the workspace.
|
|
11
|
+
//
|
|
12
|
+
// WHY THERE IS NO DEPENDENCY. The Engine API is HTTP over a Unix socket (a named pipe on Windows),
|
|
13
|
+
// and `node:http` takes `socketPath`. Measured 2026-09-24 against Docker Desktop 29.3.1 / API 1.54:
|
|
14
|
+
// /version, /containers/json and /images/json all answered from a bare `node -e`. The ONE call the
|
|
15
|
+
// API makes awkward is `POST /build`, which wants a tar stream Node core cannot produce — so
|
|
16
|
+
// templates ship PREBUILT (a registry pull is `POST /images/create`, streamed JSON lines) and a
|
|
17
|
+
// local build is the `docker` CLI Docker Desktop installs anyway, never this file.
|
|
18
|
+
//
|
|
19
|
+
// WHAT A DRIVER COLLECTION IS NOT. Not records: nothing under `data/`, nothing `dt commit` sees,
|
|
20
|
+
// nothing `dt check` reads, no history (Docker keeps its own). It is not compiled into
|
|
21
|
+
// `.dreamteamer/collections` either — these two nouns resolve HERE, ahead of workspace discovery,
|
|
22
|
+
// so `dt list containers` answers identically inside a workspace and on a bare host.
|
|
23
|
+
//
|
|
24
|
+
// TEST KNOBS, stated once: `DT_DOCKER_SOCKET` points the client at any socket (a fake in tests);
|
|
25
|
+
// `DT_HOME` relocates `~/.dreamteamer`; `DT_HEALTH_TIMEOUT=0` skips the wait for code-server's
|
|
26
|
+
// /healthz. None is documented in help — they are how the suite drives this file without Docker.
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
32
|
+
import { parseEnvValues } from './env-vars.js';
|
|
33
|
+
import { emit } from './collections-cli.js';
|
|
34
|
+
|
|
35
|
+
// ---- the two nouns, singular and plural --------------------------------------------------------
|
|
36
|
+
// The operator's spelling is `dt start container hq-dana` and `dt list containers`; both resolve.
|
|
37
|
+
// This is the singular map for the driver collections ONLY — the general rule ("every record verb
|
|
38
|
+
// accepts the singular, derived from the descriptor") is a filed feature, not this file's job.
|
|
39
|
+
export const NOUNS = { containers: 'containers', container: 'containers', images: 'images', image: 'images' };
|
|
40
|
+
export const DRIVER_VERBS = new Set(['list', 'get', 'add', 'rm', 'start', 'stop', 'open']);
|
|
41
|
+
export const LIFECYCLE_VERBS = new Set(['start', 'stop', 'open']);
|
|
42
|
+
|
|
43
|
+
/** `containers`, `container`, `containers/<id>` → { collection, id }; null when the word is not ours. */
|
|
44
|
+
export function driverTarget(word) {
|
|
45
|
+
if (!word || word.startsWith('--')) return null;
|
|
46
|
+
const slash = word.indexOf('/');
|
|
47
|
+
const noun = slash === -1 ? word : word.slice(0, slash);
|
|
48
|
+
const collection = NOUNS[noun];
|
|
49
|
+
if (!collection) return null;
|
|
50
|
+
return { collection, id: slash === -1 ? undefined : word.slice(slash + 1) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- host configuration: ~/.dreamteamer/.env ---------------------------------------------------
|
|
54
|
+
export const HOST_DEFAULTS = {
|
|
55
|
+
DT_PORT_BASE: '8100', // NOT 8080: that is code-server's in-container port, `dt start`'s REST default, and the old dev image's exposed port — three things on one number
|
|
56
|
+
DT_BIND: '127.0.0.1', // loopback only; a remote tier puts auth in front before this changes
|
|
57
|
+
DT_REGISTRY: 'dreamteamer', // `<registry>/<template>:<tag>` is the image a template name resolves to
|
|
58
|
+
DT_TEMPLATE_TAG: 'latest',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function hostDir() { return process.env.DT_HOME ?? path.join(os.homedir(), '.dreamteamer'); }
|
|
62
|
+
|
|
63
|
+
/** Defaults, then the file, then the process env — the same precedence a shell would give. */
|
|
64
|
+
export function hostEnv() {
|
|
65
|
+
const file = path.join(hostDir(), '.env');
|
|
66
|
+
// parseEnvValues answers a Map — spread it as entries, or the file silently contributes nothing.
|
|
67
|
+
const fromFile = fs.existsSync(file) ? Object.fromEntries(parseEnvValues(fs.readFileSync(file, 'utf8'))) : {};
|
|
68
|
+
const out = { ...HOST_DEFAULTS, ...fromFile };
|
|
69
|
+
for (const k of Object.keys(process.env)) if (k.startsWith('DT_') && process.env[k] !== undefined) out[k] = process.env[k];
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- the Engine API client ---------------------------------------------------------------------
|
|
74
|
+
export function socketPath() {
|
|
75
|
+
if (process.env.DT_DOCKER_SOCKET) return process.env.DT_DOCKER_SOCKET;
|
|
76
|
+
if (process.platform === 'win32') return '//./pipe/docker_engine';
|
|
77
|
+
for (const p of ['/var/run/docker.sock', path.join(os.homedir(), '.docker', 'run', 'docker.sock')]) {
|
|
78
|
+
try { if (fs.statSync(p).isSocket()) return p; } catch { /* next */ }
|
|
79
|
+
}
|
|
80
|
+
return '/var/run/docker.sock';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function unreachable(sock) {
|
|
84
|
+
const hint = process.platform === 'darwin' ? ' — is Docker Desktop running? `open -a Docker` starts it' : ' — is the Docker daemon running?';
|
|
85
|
+
return new Error(`Docker is not reachable at ${sock}${hint}. \`dt setup\` checks this and says what is missing.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One request. Resolves { status, body } where body is parsed JSON when the response is JSON,
|
|
89
|
+
* else the raw text. `onLine` receives each JSON line of a streaming response (a pull). */
|
|
90
|
+
export function api(method, urlPath, body, { onLine } = {}) {
|
|
91
|
+
const sock = socketPath();
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
94
|
+
const req = http.request({
|
|
95
|
+
socketPath: sock, method, path: urlPath,
|
|
96
|
+
headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
|
|
97
|
+
}, (res) => {
|
|
98
|
+
let text = '';
|
|
99
|
+
let pending = '';
|
|
100
|
+
res.setEncoding('utf8');
|
|
101
|
+
res.on('data', (chunk) => {
|
|
102
|
+
text += chunk;
|
|
103
|
+
if (!onLine) return;
|
|
104
|
+
pending += chunk;
|
|
105
|
+
const lines = pending.split('\n');
|
|
106
|
+
pending = lines.pop();
|
|
107
|
+
for (const l of lines) if (l.trim()) { try { onLine(JSON.parse(l)); } catch { /* not JSON */ } }
|
|
108
|
+
});
|
|
109
|
+
res.on('end', () => {
|
|
110
|
+
const isJson = /json/.test(res.headers['content-type'] ?? '');
|
|
111
|
+
let parsed = text;
|
|
112
|
+
if (isJson && !onLine) { try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } }
|
|
113
|
+
resolve({ status: res.statusCode, body: parsed });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
req.on('error', (e) => reject(e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? unreachable(sock) : e));
|
|
117
|
+
if (payload) req.write(payload);
|
|
118
|
+
req.end();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Throw the daemon's own sentence on a non-2xx, so a refusal reads as Docker's rather than ours. */
|
|
123
|
+
function ok(res, what) {
|
|
124
|
+
if (res.status >= 200 && res.status < 400) return res.body;
|
|
125
|
+
const msg = res.body && typeof res.body === 'object' && res.body.message ? res.body.message : String(res.body ?? '').trim();
|
|
126
|
+
throw new Error(`${what}: Docker answered ${res.status}${msg ? ` — ${msg}` : ''}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const LABEL = { workspace: 'dreamteamer.workspace', template: 'dreamteamer.template', person: 'dreamteamer.person', ports: 'dreamteamer.ports', modules: 'dreamteamer.modules' };
|
|
130
|
+
const filters = (label) => encodeURIComponent(JSON.stringify({ label: [label] }));
|
|
131
|
+
|
|
132
|
+
// ---- images ------------------------------------------------------------------------------------
|
|
133
|
+
export function imageRef(template, env = hostEnv()) {
|
|
134
|
+
// `DT_IMAGE_<template>=<ref>` pins one template to any image, which is how a local build or a
|
|
135
|
+
// private registry is reached without the registry default changing for every other template.
|
|
136
|
+
return env[`DT_IMAGE_${template}`] ?? `${env.DT_REGISTRY}/${template}:${env.DT_TEMPLATE_TAG}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const imageRow = (i) => ({
|
|
140
|
+
image: (i.RepoTags ?? []).find((t) => t !== '<none>:<none>') ?? i.Id.slice(7, 19),
|
|
141
|
+
template: i.Labels?.[LABEL.template] ?? '',
|
|
142
|
+
ports: i.Labels?.[LABEL.ports] ?? '',
|
|
143
|
+
modules: i.Labels?.[LABEL.modules] ?? '',
|
|
144
|
+
size_mb: Math.round((i.Size ?? 0) / 1e6),
|
|
145
|
+
created: i.Created ? new Date(i.Created * 1000).toISOString().slice(0, 10) : '',
|
|
146
|
+
id: i.Id,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export async function listImages() {
|
|
150
|
+
const body = ok(await api('GET', `/images/json?filters=${filters(LABEL.template)}`), 'list images');
|
|
151
|
+
return body.map(imageRow).sort((a, b) => a.image.localeCompare(b.image));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function inspectImage(ref) {
|
|
155
|
+
const res = await api('GET', `/images/${encodeURIComponent(ref)}/json`);
|
|
156
|
+
return res.status === 404 ? null : ok(res, `get image ${ref}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function pullImage(ref, log = console.error) {
|
|
160
|
+
const [name, tag] = splitTag(ref);
|
|
161
|
+
let last = '';
|
|
162
|
+
const res = await api('POST', `/images/create?fromImage=${encodeURIComponent(name)}&tag=${encodeURIComponent(tag)}`, undefined, {
|
|
163
|
+
onLine: (l) => { if (l.status && l.status !== last) { last = l.status; log(` … ${l.status}${l.id ? ` ${l.id}` : ''}`); } if (l.error) throw new Error(l.error); },
|
|
164
|
+
});
|
|
165
|
+
if (res.status !== 200) throw new Error(`pull ${ref}: Docker answered ${res.status} — ${String(res.body).trim()}. A local build is \`docker build -t ${ref} …\`, or pin DT_IMAGE_<template> in ${path.join(hostDir(), '.env')}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function splitTag(ref) {
|
|
169
|
+
const at = ref.lastIndexOf(':');
|
|
170
|
+
const slash = ref.lastIndexOf('/');
|
|
171
|
+
return at > slash ? [ref.slice(0, at), ref.slice(at + 1)] : [ref, 'latest'];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- containers --------------------------------------------------------------------------------
|
|
175
|
+
const containerRow = (c) => {
|
|
176
|
+
const name = (c.Names?.[0] ?? '').replace(/^\//, '');
|
|
177
|
+
const port = (c.Ports ?? []).find((p) => p.PublicPort)?.PublicPort;
|
|
178
|
+
return {
|
|
179
|
+
name, template: c.Labels?.[LABEL.template] ?? '', state: c.State, status: c.Status,
|
|
180
|
+
editor_url: port ? editorUrl(port) : '', image: c.Image, person: c.Labels?.[LABEL.person] ?? '',
|
|
181
|
+
created: c.Created ? new Date(c.Created * 1000).toISOString().slice(0, 16).replace('T', ' ') : '', id: c.Id,
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
const editorUrl = (port) => `http://localhost:${port}/?folder=/workspace`;
|
|
185
|
+
const volumeNames = (name) => ({ workspace: `dreamteamer-${name}-workspace`, home: `dreamteamer-${name}-home`, files: `dreamteamer-${name}-files` });
|
|
186
|
+
|
|
187
|
+
export async function listContainers() {
|
|
188
|
+
const body = ok(await api('GET', `/containers/json?all=1&filters=${filters(LABEL.workspace)}`), 'list containers');
|
|
189
|
+
return body.map(containerRow).sort((a, b) => a.name.localeCompare(b.name));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function inspectContainer(name) {
|
|
193
|
+
const res = await api('GET', `/containers/${encodeURIComponent(name)}/json`);
|
|
194
|
+
if (res.status === 404) return null;
|
|
195
|
+
const c = ok(res, `get container ${name}`);
|
|
196
|
+
if (!c.Config?.Labels?.[LABEL.workspace]) throw new Error(`"${name}" is a Docker container but not a dreamteamer workspace (no ${LABEL.workspace} label) — this verb only touches containers it made`);
|
|
197
|
+
return c;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The shape `dt get container <name>` prints: the categorised view over `docker inspect`. */
|
|
201
|
+
export function containerDetail(c) {
|
|
202
|
+
const binding = Object.values(c.HostConfig?.PortBindings ?? {}).flat()[0];
|
|
203
|
+
const mounts = Object.fromEntries((c.Mounts ?? []).filter((m) => m.Type === 'volume').map((m) => [m.Destination, m.Name]));
|
|
204
|
+
return {
|
|
205
|
+
name: c.Name.replace(/^\//, ''), id: c.Id.slice(0, 12),
|
|
206
|
+
template: c.Config.Labels[LABEL.template] ?? '', image: c.Config.Image,
|
|
207
|
+
state: c.State?.Status, started: c.State?.StartedAt, restarts: c.RestartCount ?? 0,
|
|
208
|
+
bind: binding?.HostIp ?? '', port: binding ? Number(binding.HostPort) : undefined,
|
|
209
|
+
editor_url: binding ? editorUrl(binding.HostPort) : '',
|
|
210
|
+
volumes: { workspace: mounts['/workspace'] ?? '', home: mounts['/home/node'] ?? '', files: mounts['/files'] ?? '' },
|
|
211
|
+
person: c.Config.Labels[LABEL.person] ?? '', created: c.Created, labels: c.Config.Labels,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The lowest host port from DT_PORT_BASE upward that no dreamteamer container already holds. */
|
|
216
|
+
export async function allocatePort(env = hostEnv()) {
|
|
217
|
+
const base = Number(env.DT_PORT_BASE);
|
|
218
|
+
if (!Number.isInteger(base) || base < 1024) throw new Error(`DT_PORT_BASE must be an integer port above 1023 (got "${env.DT_PORT_BASE}")`);
|
|
219
|
+
const all = ok(await api('GET', `/containers/json?all=1&filters=${filters(LABEL.workspace)}`), 'list containers');
|
|
220
|
+
const taken = new Set(all.flatMap((c) => (c.Ports ?? []).map((p) => p.PublicPort)).filter(Boolean));
|
|
221
|
+
// A stopped container publishes nothing in /containers/json, so read its binding from inspect —
|
|
222
|
+
// otherwise the second workspace lands on the first one's port the moment the first is stopped.
|
|
223
|
+
for (const c of all) if (c.State !== 'running') {
|
|
224
|
+
const d = await api('GET', `/containers/${c.Id}/json`);
|
|
225
|
+
for (const b of Object.values(d.body?.HostConfig?.PortBindings ?? {}).flat()) taken.add(Number(b.HostPort));
|
|
226
|
+
}
|
|
227
|
+
let port = base;
|
|
228
|
+
while (taken.has(port)) port++;
|
|
229
|
+
return port;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function person(flags, env) {
|
|
233
|
+
const git = (k) => { try { return execFileSync('git', ['config', '--global', k], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
234
|
+
const name = flags.name ?? env.DT_PERSON_NAME ?? git('user.name');
|
|
235
|
+
const email = flags.email ?? env.DT_PERSON_EMAIL ?? git('user.email');
|
|
236
|
+
return { name, email };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Create-if-absent and start. Idempotent: a second call on an existing name starts it and prints
|
|
240
|
+
* the same URL. Never injects a token — the person logs in INSIDE, once, and the home volume keeps it. */
|
|
241
|
+
export async function startContainer(name, flags, log = console.log) {
|
|
242
|
+
if (!/^[a-z0-9][a-z0-9_.-]*$/.test(name)) throw new Error(`"${name}" is not a container name — lowercase letters, digits, "-", "_" and "."; e.g. hq-dana`);
|
|
243
|
+
const env = hostEnv();
|
|
244
|
+
let c = await inspectContainer(name);
|
|
245
|
+
if (!c) {
|
|
246
|
+
const template = typeof flags.template === 'string' ? flags.template : undefined;
|
|
247
|
+
if (!template) throw new Error(`container "${name}" does not exist yet — name the template that makes it: dt start container ${name} --template <t> (dt list images shows the templates present)`);
|
|
248
|
+
const ref = imageRef(template, env);
|
|
249
|
+
let img = await inspectImage(ref);
|
|
250
|
+
if (!img) { log(`… pulling ${ref}`); await pullImage(ref, log); img = await inspectImage(ref); }
|
|
251
|
+
if (!img) throw new Error(`image ${ref} is still absent after the pull`);
|
|
252
|
+
const labels = img.Config?.Labels ?? {};
|
|
253
|
+
const inner = Number(labels[LABEL.ports] ?? 8080);
|
|
254
|
+
const port = await allocatePort(env);
|
|
255
|
+
const who = person(flags, env);
|
|
256
|
+
const vols = volumeNames(name);
|
|
257
|
+
const body = {
|
|
258
|
+
Image: ref,
|
|
259
|
+
Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name },
|
|
260
|
+
Env: [
|
|
261
|
+
`DT_WORKSPACE=${name}`, `DT_TEMPLATE=${template}`, 'FILES_FOLDER=/files',
|
|
262
|
+
...(who.name ? [`GIT_AUTHOR_NAME=${who.name}`, `GIT_COMMITTER_NAME=${who.name}`] : []),
|
|
263
|
+
...(who.email ? [`GIT_AUTHOR_EMAIL=${who.email}`, `GIT_COMMITTER_EMAIL=${who.email}`] : []),
|
|
264
|
+
],
|
|
265
|
+
ExposedPorts: { [`${inner}/tcp`]: {} },
|
|
266
|
+
HostConfig: {
|
|
267
|
+
PortBindings: { [`${inner}/tcp`]: [{ HostIp: env.DT_BIND, HostPort: String(port) }] },
|
|
268
|
+
Mounts: [
|
|
269
|
+
{ Type: 'volume', Source: vols.workspace, Target: '/workspace' },
|
|
270
|
+
{ Type: 'volume', Source: vols.home, Target: '/home/node' },
|
|
271
|
+
{ Type: 'volume', Source: vols.files, Target: '/files' },
|
|
272
|
+
],
|
|
273
|
+
RestartPolicy: { Name: 'unless-stopped' },
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
ok(await api('POST', `/containers/create?name=${encodeURIComponent(name)}`, body), `create container ${name}`);
|
|
277
|
+
c = await inspectContainer(name);
|
|
278
|
+
log(`✔ created ${name} from ${ref} · ${env.DT_BIND}:${port} → ${inner} · volumes ${Object.values(vols).join(', ')}`);
|
|
279
|
+
}
|
|
280
|
+
if (c.State?.Status !== 'running') {
|
|
281
|
+
const res = await api('POST', `/containers/${c.Id}/start`);
|
|
282
|
+
if (res.status !== 204 && res.status !== 304) ok(res, `start container ${name}`);
|
|
283
|
+
c = await inspectContainer(name);
|
|
284
|
+
}
|
|
285
|
+
const detail = containerDetail(c);
|
|
286
|
+
await waitHealthy(detail, log);
|
|
287
|
+
log(`✔ container ${name} · ${detail.state} · ${detail.editor_url}`);
|
|
288
|
+
if (!flags['no-open'] && detail.editor_url) openUrl(detail.editor_url);
|
|
289
|
+
return detail;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Poll code-server's /healthz so the URL printed is one that already answers. */
|
|
293
|
+
async function waitHealthy(detail, log) {
|
|
294
|
+
const seconds = process.env.DT_HEALTH_TIMEOUT !== undefined ? Number(process.env.DT_HEALTH_TIMEOUT) : 90;
|
|
295
|
+
if (!seconds || !detail.port) return;
|
|
296
|
+
const until = Date.now() + seconds * 1000;
|
|
297
|
+
let told = false;
|
|
298
|
+
while (Date.now() < until) {
|
|
299
|
+
const up = await new Promise((r) => {
|
|
300
|
+
const req = http.get({ host: detail.bind || '127.0.0.1', port: detail.port, path: '/healthz', timeout: 2000 }, (res) => { res.resume(); r(res.statusCode < 500); });
|
|
301
|
+
req.on('error', () => r(false)); req.on('timeout', () => { req.destroy(); r(false); });
|
|
302
|
+
});
|
|
303
|
+
if (up) return;
|
|
304
|
+
if (!told) { log('… waiting for the editor to answer (first start compiles the workspace)'); told = true; }
|
|
305
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
306
|
+
}
|
|
307
|
+
log(`⚠ the editor did not answer within ${seconds}s — \`docker logs ${detail.name}\` says why`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function openUrl(url) {
|
|
311
|
+
const cmd = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url];
|
|
312
|
+
try { spawn(cmd[0], cmd.slice(1), { stdio: 'ignore', detached: true }).unref(); } catch { /* printing the URL is the contract; opening it is a courtesy */ }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function stopContainer(name) {
|
|
316
|
+
const c = await inspectContainer(name);
|
|
317
|
+
if (!c) throw new Error(`no container "${name}" — dt list containers`);
|
|
318
|
+
const res = await api('POST', `/containers/${c.Id}/stop?t=10`);
|
|
319
|
+
if (res.status !== 204 && res.status !== 304) ok(res, `stop container ${name}`);
|
|
320
|
+
return containerDetail(await inspectContainer(name));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Plain `rm` keeps the three volumes — the workspace, the login, the files. `--force` removes them too. */
|
|
324
|
+
export async function removeContainer(name, { force = false } = {}, log = console.log) {
|
|
325
|
+
const c = await inspectContainer(name);
|
|
326
|
+
if (!c) throw new Error(`no container "${name}" — dt list containers`);
|
|
327
|
+
const vols = containerDetail(c).volumes;
|
|
328
|
+
if (c.State?.Status === 'running') await api('POST', `/containers/${c.Id}/stop?t=10`);
|
|
329
|
+
ok(await api('DELETE', `/containers/${c.Id}?v=false`), `rm container ${name}`);
|
|
330
|
+
const named = Object.values(vols).filter(Boolean);
|
|
331
|
+
if (force) {
|
|
332
|
+
for (const v of named) { const r = await api('DELETE', `/volumes/${encodeURIComponent(v)}`); if (r.status !== 204 && r.status !== 404) ok(r, `rm volume ${v}`); }
|
|
333
|
+
log(`✔ removed ${name} and its volumes ${named.join(', ')}`);
|
|
334
|
+
} else {
|
|
335
|
+
log(`✔ removed ${name} · kept volumes ${named.join(', ')} (dt rm container ${name} --force removes them too; dt start container ${name} --template <t> reattaches them)`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- setup: the host's board -------------------------------------------------------------------
|
|
340
|
+
export async function setup(flags, log = console.log) {
|
|
341
|
+
const dir = hostDir();
|
|
342
|
+
const file = path.join(dir, '.env');
|
|
343
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
344
|
+
const existing = fs.existsSync(file) ? Object.fromEntries(parseEnvValues(fs.readFileSync(file, 'utf8'))) : {};
|
|
345
|
+
const missing = Object.entries(HOST_DEFAULTS).filter(([k]) => !(k in existing));
|
|
346
|
+
if (missing.length) {
|
|
347
|
+
const header = fs.existsSync(file) ? '' : '# dreamteamer host configuration — read by `dt setup`, `dt start container` and friends.\n# DT_IMAGE_<template>=<ref> pins a template to an image; DT_PERSON_NAME / DT_PERSON_EMAIL are the git identity containers get.\n';
|
|
348
|
+
fs.appendFileSync(file, header + missing.map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
|
|
349
|
+
}
|
|
350
|
+
const board = [];
|
|
351
|
+
board.push(`host env ${file} · ${missing.length ? `${missing.length} default(s) written` : 'present'}`);
|
|
352
|
+
let docker;
|
|
353
|
+
try {
|
|
354
|
+
const v = ok(await api('GET', '/version'), 'docker version');
|
|
355
|
+
docker = `docker ${v.Version} · api ${v.ApiVersion} · ${v.Os}/${v.Arch} · ${socketPath()}`;
|
|
356
|
+
} catch (e) {
|
|
357
|
+
board.push(`docker ✖ ${e.message}`);
|
|
358
|
+
for (const l of board) log(l);
|
|
359
|
+
return 1;
|
|
360
|
+
}
|
|
361
|
+
board.push(docker);
|
|
362
|
+
const images = await listImages();
|
|
363
|
+
board.push(`templates ${images.length ? images.map((i) => `${i.template} (${i.image})`).join(', ') : 'none present — dt add image --template <t> pulls one'}`);
|
|
364
|
+
const template = typeof flags.template === 'string' ? flags.template : undefined;
|
|
365
|
+
if (template) {
|
|
366
|
+
const ref = imageRef(template);
|
|
367
|
+
if (!(await inspectImage(ref))) { log(`… pulling ${ref}`); await pullImage(ref, log); board.push(`pulled ${ref}`); } else board.push(`present ${ref}`);
|
|
368
|
+
}
|
|
369
|
+
const running = (await listContainers()).filter((c) => c.state === 'running');
|
|
370
|
+
board.push(`containers ${running.length} running${running.length ? ' — ' + running.map((c) => `${c.name} ${c.editor_url}`).join(', ') : ''}`);
|
|
371
|
+
for (const l of board) log(l);
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---- the verb surface `cli.js` hands over ------------------------------------------------------
|
|
376
|
+
const table = (rows, cols) => {
|
|
377
|
+
if (!rows.length) return '(none)';
|
|
378
|
+
const w = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)));
|
|
379
|
+
return rows.map((r) => cols.map((c, i) => String(r[c] ?? '').padEnd(w[i])).join(' ').trimEnd()).join('\n');
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/** `dt <verb> <container|containers|image|images>[/<id>] [<id>] [flags]` → exit code. */
|
|
383
|
+
export async function driverCommand(verb, target, args) {
|
|
384
|
+
const { flags, pos } = parseFlags(args);
|
|
385
|
+
const id = target.id ?? pos[0];
|
|
386
|
+
const json = flags.json === true;
|
|
387
|
+
const col = target.collection;
|
|
388
|
+
if (!DRIVER_VERBS.has(verb)) throw new Error(`\`${verb}\` is not a verb on ${col} — list · get · add · rm${col === 'containers' ? ' · start · stop · open' : ''}`);
|
|
389
|
+
if (col === 'images') {
|
|
390
|
+
if (LIFECYCLE_VERBS.has(verb)) throw new Error(`\`${verb}\` is a container verb — an image is started by starting a container from it: dt start container <name> --template <t>`);
|
|
391
|
+
if (verb === 'list') { const rows = await listImages(); json ? emit(JSON.stringify(rows, null, 2)) : console.log(table(rows, ['template', 'image', 'size_mb', 'ports', 'created'])); return 0; }
|
|
392
|
+
if (verb === 'get') { if (!id) throw new Error('dt get image <ref>'); const i = await inspectImage(id); if (!i) throw new Error(`no image "${id}"`); emit(JSON.stringify(json ? i : imageRow({ ...i, Labels: i.Config?.Labels, Created: Date.parse(i.Created) / 1000 }), null, 2)); return 0; }
|
|
393
|
+
if (verb === 'add') { const t = typeof flags.template === 'string' ? flags.template : undefined; if (!t) throw new Error('dt add image --template <t> pulls the template\'s image'); const ref = imageRef(t); console.log(`… pulling ${ref}`); await pullImage(ref); console.log(`✔ ${ref}`); return 0; }
|
|
394
|
+
if (verb === 'rm') { if (!id) throw new Error('dt rm image <ref>'); ok(await api('DELETE', `/images/${encodeURIComponent(id)}${flags.force ? '?force=true' : ''}`), `rm image ${id}`); console.log(`✔ removed image ${id}`); return 0; }
|
|
395
|
+
}
|
|
396
|
+
// containers
|
|
397
|
+
if (verb === 'list') { const rows = await listContainers(); json ? emit(JSON.stringify(rows, null, 2)) : console.log(table(rows, ['name', 'template', 'state', 'editor_url', 'person', 'created'])); return 0; }
|
|
398
|
+
if (!id) throw new Error(`dt ${verb} container <name>${verb === 'start' || verb === 'add' ? ' --template <t>' : ''}`);
|
|
399
|
+
if (verb === 'get') { const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}" — dt list containers`); emit(JSON.stringify(json ? c : containerDetail(c), null, 2)); return 0; }
|
|
400
|
+
if (verb === 'start' || verb === 'add') { const d = await startContainer(id, flags); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
|
|
401
|
+
if (verb === 'stop') { const d = await stopContainer(id); console.log(`✔ stopped ${id} · volumes kept`); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
|
|
402
|
+
if (verb === 'open') { const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}"`); const d = containerDetail(c); if (!d.editor_url) throw new Error(`${id} publishes no port`); console.log(d.editor_url); if (!flags['no-open']) openUrl(d.editor_url); return 0; }
|
|
403
|
+
if (verb === 'rm') { await removeContainer(id, { force: flags.force === true }); return 0; }
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A local flag parser: `--k v`, `--k=v`, bare `--k` → true. Kept here rather than importing the
|
|
408
|
+
* record parser's promotion rules — a repeated flag on these verbs is a mistake, not an array. */
|
|
409
|
+
export function parseFlags(args) {
|
|
410
|
+
const flags = {}; const pos = [];
|
|
411
|
+
for (let i = 0; i < args.length; i++) {
|
|
412
|
+
const a = args[i];
|
|
413
|
+
if (!a.startsWith('--')) { pos.push(a); continue; }
|
|
414
|
+
const eq = a.indexOf('=');
|
|
415
|
+
if (eq > -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
416
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith('--')) flags[a.slice(2)] = args[++i];
|
|
417
|
+
else flags[a.slice(2)] = true;
|
|
418
|
+
}
|
|
419
|
+
return { flags, pos };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force'];
|
package/src/harnesses.js
CHANGED
|
@@ -25,6 +25,12 @@ export const STAMP = '<!-- generated by dreamteamer compile — do not edit; sou
|
|
|
25
25
|
export const BEGIN = '<!-- dreamteamer:begin (generated — do not edit inside this block) -->';
|
|
26
26
|
export const END = '<!-- dreamteamer:end -->';
|
|
27
27
|
|
|
28
|
+
// A SECOND managed block, deliberately not folded into the orientation one. Separate delimiters
|
|
29
|
+
// keep the orientation block's asserted size budget meaningful, and let `dreamteamer.md` be removed
|
|
30
|
+
// on its own without the removal touching anything generated from the schema.
|
|
31
|
+
export const INSTRUCTIONS_BEGIN = '<!-- dreamteamer:instructions:begin -->';
|
|
32
|
+
export const INSTRUCTIONS_END = '<!-- dreamteamer:instructions:end -->';
|
|
33
|
+
|
|
28
34
|
export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sourceLayout = 'flat', namespaces = [], version = 'unknown', workspaceModule = '' }) {
|
|
29
35
|
const outputs = [];
|
|
30
36
|
// ⚠ SEPARATE from `outputs`: these are USER-OWNED root files carrying a managed block, and the
|
|
@@ -92,9 +98,34 @@ export function runHarnessAdapters({ root, entries, harnesses, prevManifest, sou
|
|
|
92
98
|
block('GEMINI.md', on('gemini-cli') ? orientationBlock('gemini', skillsIndex, sourceLayout, namespaces, version, entries, workspaceModule) : null);
|
|
93
99
|
if (on('gemini-cli')) summary.push('gemini-cli → GEMINI.md block');
|
|
94
100
|
|
|
101
|
+
// ---- the operator's own rules, one source, every harness -------------------------
|
|
102
|
+
// Rendered VERBATIM. Nothing here reformats, wraps or summarises it: it is the operator's prose,
|
|
103
|
+
// and the whole value is that what he wrote is what every agent reads.
|
|
104
|
+
//
|
|
105
|
+
// ⚠ `enabled ? instructions : null` mirrors the orientation calls above, and the null branch is
|
|
106
|
+
// load-bearing twice: a harness switched off has its block removed, and so does a workspace that
|
|
107
|
+
// deletes its dreamteamer.md — `instructions` is null in that case and the same branch runs.
|
|
108
|
+
//
|
|
109
|
+
// ⚠ NOT through the local `block()` helper: that pushes the filename onto `blocks`, and these
|
|
110
|
+
// three files are already on it from their orientation calls above — pushing twice would hand
|
|
111
|
+
// git the same pathspec twice (schema-ops.regeneratedOutputs is the reader).
|
|
112
|
+
// ⚠ `|| null`, NOT `?? null`. `.trimEnd()` on a whitespace-only source yields `''`, which is not
|
|
113
|
+
// nullish — so the three Markdown files got an empty BEGIN/END pair while the cursor rule, which
|
|
114
|
+
// tests the string for truthiness below, omitted the part entirely. An empty source means no
|
|
115
|
+
// block, everywhere.
|
|
116
|
+
const instructions = entries.get('instructions.md')?.bytes?.toString('utf8').trimEnd() || null;
|
|
117
|
+
const instructionsBlock = (file, enabled) =>
|
|
118
|
+
writeBlock(root, file, enabled ? instructions : null, { begin: INSTRUCTIONS_BEGIN, end: INSTRUCTIONS_END, above: BEGIN });
|
|
119
|
+
instructionsBlock('CLAUDE.md', on('claude-code'));
|
|
120
|
+
instructionsBlock('AGENTS.md', on('codex') || on('pi'));
|
|
121
|
+
instructionsBlock('GEMINI.md', on('gemini-cli'));
|
|
122
|
+
|
|
95
123
|
// ---- cursor: native .mdc rule (alwaysApply) ---------------------------------------
|
|
96
124
|
if (on('cursor')) {
|
|
97
|
-
|
|
125
|
+
// ⚠ `.mdc` is written WHOLE by `write()`, not through `writeBlock`, so removal is automatic: a
|
|
126
|
+
// compile with no dreamteamer.md simply rewrites the file without the part.
|
|
127
|
+
const instructionsPart = instructions ? `${INSTRUCTIONS_BEGIN}\n${instructions}\n${INSTRUCTIONS_END}\n\n` : '';
|
|
128
|
+
const mdc = `---\ndescription: dreamteamer workspace orientation (generated)\nalwaysApply: true\n---\n\n${instructionsPart}${orientationBlock('cursor', skillsIndex, sourceLayout, namespaces, version, entries, workspaceModule)}\n\n${STAMP}\n`;
|
|
98
129
|
write('.cursor/rules/dreamteamer.mdc', Buffer.from(mdc));
|
|
99
130
|
summary.push('cursor → .cursor/rules/dreamteamer.mdc');
|
|
100
131
|
}
|
|
@@ -548,22 +579,35 @@ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat', namespaces
|
|
|
548
579
|
|
|
549
580
|
// managed block in a USER-OWNED root file. content=null removes the block; a file left
|
|
550
581
|
// empty (or whitespace) after removal is deleted — we created it, we clean it up.
|
|
551
|
-
function writeBlock(root, filename, content) {
|
|
582
|
+
function writeBlock(root, filename, content, { begin = BEGIN, end = END, above } = {}) {
|
|
552
583
|
const file = path.join(root, filename);
|
|
553
584
|
const exists = fs.existsSync(file);
|
|
554
585
|
if (content == null) {
|
|
555
586
|
if (!exists) return;
|
|
556
587
|
let text = fs.readFileSync(file, 'utf8');
|
|
557
|
-
if (!text.includes(
|
|
558
|
-
text = text.replace(new RegExp(`\\n?\\n?${escapeRe(
|
|
588
|
+
if (!text.includes(begin)) return;
|
|
589
|
+
text = text.replace(new RegExp(`\\n?\\n?${escapeRe(begin)}[\\s\\S]*?${escapeRe(end)}\\n?`), '\n');
|
|
559
590
|
if (text.trim() === '') fs.rmSync(file);
|
|
560
591
|
else fs.writeFileSync(file, text);
|
|
561
592
|
return;
|
|
562
593
|
}
|
|
563
|
-
const block = `${
|
|
594
|
+
const block = `${begin}\n${content}\n${end}`;
|
|
595
|
+
// ⚠ A FUNCTION REPLACEMENT, never the string. `String.prototype.replace` reads `$&`, `` $` ``,
|
|
596
|
+
// `$'` and `$1` out of a STRING replacement and substitutes around the match — so a block whose
|
|
597
|
+
// content carries any of them is silently rewritten on the way in, and `$'…'` is ordinary bash in
|
|
598
|
+
// a file about shell commands. Measured: `use $'\n'` rendered as `use ` + the entire preamble.
|
|
599
|
+
// A function replacement is taken literally, which is what "verbatim" has to mean.
|
|
600
|
+
const insert = (replacement) => () => replacement;
|
|
564
601
|
let text = exists ? fs.readFileSync(file, 'utf8') : '';
|
|
565
|
-
if (text.includes(
|
|
566
|
-
|
|
602
|
+
if (text.includes(begin)) {
|
|
603
|
+
text = text.replace(new RegExp(`${escapeRe(begin)}[\\s\\S]*?${escapeRe(end)}`), insert(block));
|
|
604
|
+
} else if (above && text.includes(above)) {
|
|
605
|
+
// ⚠ ORDER IS THE CONTRACT, not a preference. The rules must be read BEFORE the schema
|
|
606
|
+
// orientation, and a harness that truncates a long context file truncates the tail.
|
|
607
|
+
text = text.replace(above, insert(`${block}\n\n${above}`));
|
|
608
|
+
} else {
|
|
609
|
+
text = (text.trimEnd() + '\n\n' + block + '\n').replace(/^\n+/, '');
|
|
610
|
+
}
|
|
567
611
|
fs.writeFileSync(file, text);
|
|
568
612
|
}
|
|
569
613
|
|
package/src/init.js
CHANGED
|
@@ -8,6 +8,7 @@ import { discoverModules, KINDS } from './compile.js';
|
|
|
8
8
|
import { KNOWN_HARNESSES } from './harnesses.js';
|
|
9
9
|
import { Store } from './store.js';
|
|
10
10
|
import { envContext, renderTemplate } from './env-vars.js';
|
|
11
|
+
import { ensureEditorRecommendation } from './workspace.js';
|
|
11
12
|
|
|
12
13
|
// git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
|
|
13
14
|
// child's stderr to ours unless told otherwise, so a handled "not a git repository" still
|
|
@@ -147,8 +148,10 @@ export function init({ flags = {} } = {}) {
|
|
|
147
148
|
// A workspace that needs people as records ships its own collection (a module's `contacts` already
|
|
148
149
|
// does), and reads the operator from git where it needs one. `teams` went the same way 2026-07-31.
|
|
149
150
|
|
|
150
|
-
// .gitignore + .env.example (append-if-missing, never clobber)
|
|
151
|
+
// .gitignore + .env.example (append-if-missing, never clobber) + the editor recommendation, so
|
|
152
|
+
// the first window opened on this workspace offers the extension instead of leaving it to be found
|
|
151
153
|
appendMissing(path.join(root, '.gitignore'), GITIGNORE);
|
|
154
|
+
ensureEditorRecommendation(root);
|
|
152
155
|
if (!fs.existsSync(path.join(root, '.env.example'))) fs.writeFileSync(path.join(root, '.env.example'), ENV_EXAMPLE);
|
|
153
156
|
|
|
154
157
|
// one init commit (if we're in a git repo)
|
package/src/ref.js
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// The words a collection answers to on the command line: its declared name and its `singular`
|
|
2
|
+
// (compile stamps one on every descriptor — derived by inflection, authorable where inflection is
|
|
3
|
+
// wrong — and refuses two collections whose words collide). `dt add task …` and `dt add tasks …`
|
|
4
|
+
// are the same call. ⚠ TYPED INPUT ONLY: a reference VALUE inside a record (`tasks/kickoff`) is
|
|
5
|
+
// parsed by namespace.js's parseRef against declared names and never learns the singular, so
|
|
6
|
+
// `task/kickoff` in a field stays the dangling reference `check` reports it as.
|
|
7
|
+
function* words(descriptors) {
|
|
8
|
+
for (const [name, d] of descriptors) {
|
|
9
|
+
yield [name, name];
|
|
10
|
+
if (d?.singular && d.singular !== name) yield [d.singular, name];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The declared collection a typed word names — the name itself or its singular — else null. */
|
|
15
|
+
export function canonicalCollection(descriptors, word) {
|
|
16
|
+
if (descriptors.has(word)) return word;
|
|
17
|
+
for (const [w, name] of words(descriptors)) if (w === word) return name;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// split "<collection>/<id>" against the DECLARED collections and their singulars — longest prefix
|
|
22
|
+
// at a "/" boundary, because both collection names and ids may contain slashes (namespaces;
|
|
23
|
+
// path-shaped ids). The collection returned is always the declared NAME, whichever word was typed.
|
|
3
24
|
export function splitRef(descriptors, ref) {
|
|
4
25
|
let best = null;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
26
|
+
let bestName = null;
|
|
27
|
+
for (const [w, name] of words(descriptors)) {
|
|
28
|
+
if (ref === w || ref.startsWith(w + '/')) {
|
|
29
|
+
if (!best || w.length > best.length) { best = w; bestName = name; }
|
|
8
30
|
}
|
|
9
31
|
}
|
|
10
32
|
if (!best) throw new Error(`unknown collection in reference "${ref}" (known: ${[...descriptors.keys()].sort().join(', ')})`);
|
|
11
33
|
if (ref === best) throw new Error(`reference "${ref}" names a collection but no record id`);
|
|
12
|
-
return { collection:
|
|
34
|
+
return { collection: bestName, id: ref.slice(best.length + 1) };
|
|
13
35
|
}
|
|
14
36
|
|
|
15
37
|
/**
|
package/src/workspace.js
CHANGED
|
@@ -49,3 +49,53 @@ export function nestedAsModule(outer, inner) {
|
|
|
49
49
|
.split(path.sep)
|
|
50
50
|
.some((seg) => MODULE_SEGMENTS.has(seg));
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/** The VS Code-family extension for this workspace, as the editor learns of it: a recommendation
|
|
54
|
+
* in `.vscode/extensions.json`, which VS Code, Cursor and code-server all read on open and offer to
|
|
55
|
+
* install. Written by `init` and kept current by `compile`, because a compiled workspace that never
|
|
56
|
+
* names its editor left a first-run agent searching a registry for the id (measured 2026-09-24:
|
|
57
|
+
* three install attempts, two of them into a directory the running editor did not read). An
|
|
58
|
+
* existing file is MERGED, never replaced: a file the operator authored (comments included) is left
|
|
59
|
+
* alone whenever it already names the extension; one that does not is re-read as JSON and gains the
|
|
60
|
+
* id, and one that is neither is left with a warning rather than clobbered. */
|
|
61
|
+
export const EDITOR_EXTENSION_ID = 'dreamteamer.dreamteamer-vscode';
|
|
62
|
+
export function ensureEditorRecommendation(root, warn = console.warn) {
|
|
63
|
+
const file = path.join(root, '.vscode', 'extensions.json');
|
|
64
|
+
if (!fs.existsSync(file)) {
|
|
65
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
66
|
+
fs.writeFileSync(file, JSON.stringify({ recommendations: [EDITOR_EXTENSION_ID] }, null, '\t') + '\n');
|
|
67
|
+
return 'written';
|
|
68
|
+
}
|
|
69
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
70
|
+
if (text.includes(EDITOR_EXTENSION_ID)) return 'present';
|
|
71
|
+
let parsed;
|
|
72
|
+
try { parsed = JSON.parse(text); } catch {
|
|
73
|
+
warn(`⚠ .vscode/extensions.json does not recommend ${EDITOR_EXTENSION_ID} and is not plain JSON, so it was left alone — add the id to its "recommendations" by hand`);
|
|
74
|
+
return 'left';
|
|
75
|
+
}
|
|
76
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { warn(`⚠ .vscode/extensions.json is not an object — left alone; add ${EDITOR_EXTENSION_ID} to "recommendations" by hand`); return 'left'; }
|
|
77
|
+
parsed.recommendations = [...(Array.isArray(parsed.recommendations) ? parsed.recommendations : []), EDITOR_EXTENSION_ID];
|
|
78
|
+
fs.writeFileSync(file, JSON.stringify(parsed, null, '\t') + '\n');
|
|
79
|
+
return 'merged';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `.env.example` lists every env key the installed modules declare — the file the missing-key
|
|
83
|
+
* warning points at, which used to carry two comment lines and none of the keys it was cited for.
|
|
84
|
+
* Append-only and idempotent: a key already named in the file (as `KEY=` or `# KEY`) is not added
|
|
85
|
+
* again, and nothing an operator wrote is touched. Values never appear here — an example does. */
|
|
86
|
+
export function ensureEnvExample(root, entries, header = '') {
|
|
87
|
+
if (!entries.length) return [];
|
|
88
|
+
const file = path.join(root, '.env.example');
|
|
89
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : header;
|
|
90
|
+
const named = new Set([...existing.matchAll(/^\s*#?\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map((m) => m[1]));
|
|
91
|
+
const added = [];
|
|
92
|
+
let out = existing;
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
if (named.has(e.key)) continue;
|
|
95
|
+
const who = e.modules?.length ? ` (module ${e.modules.join(', ')})` : '';
|
|
96
|
+
out = out.trimEnd() + `\n\n# ${e.description ?? `declared by ${e.modules.join(', ')}`}${e.description ? who : ''}\n${e.key}=${e.example ?? ''}\n`;
|
|
97
|
+
added.push(e.key);
|
|
98
|
+
}
|
|
99
|
+
if (added.length) fs.writeFileSync(file, out.replace(/^\n+/, ''));
|
|
100
|
+
return added;
|
|
101
|
+
}
|