dreamteamer 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/collections/collections.collection.yaml +27 -4
- package/collections/repos.collection.yaml +6 -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 +83 -18
- package/src/init.js +4 -1
- package/src/presentation.js +23 -4
- package/src/ref.js +28 -6
- package/src/schema-ops.js +3 -0
- package/src/workspace.js +50 -0
|
@@ -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
|
}
|
|
@@ -142,7 +173,16 @@ const NOTEBOOK_PLANS = [
|
|
|
142
173
|
function notebooklmBlock(entries, version) {
|
|
143
174
|
const index = buildCollectionsIndex(entries);
|
|
144
175
|
const modules = buildModulesIndex(entries);
|
|
145
|
-
|
|
176
|
+
// ⚠ `generated`, NOT `systemGroup` — the same split the orientation block's system line makes,
|
|
177
|
+
// asked for the opposite reason. There the question is presentational ("group it out of the
|
|
178
|
+
// domain listing"); HERE it is a STORAGE question, because every number and name below has to
|
|
179
|
+
// describe what `dt export notebooklm` will actually ship, and the export ships by storage
|
|
180
|
+
// (`storage.base !== 'runtime'` — export-notebooklm.js). Reading the partition here made this file
|
|
181
|
+
// say "exports 1 collections" and list only `notes` for a workspace whose export shipped
|
|
182
|
+
// `repos.md` as a second source and headed a `## module: System` group for it in the schema map
|
|
183
|
+
// — so the persona generated from this line would not know about a source it had been given.
|
|
184
|
+
// The count and the export are pinned to each other by a test (notebooklm-harness.test.js).
|
|
185
|
+
const data = index.filter((c) => !c.generated);
|
|
146
186
|
const withheldCollections = data.filter((c) => c.sensitive).map((c) => c.name);
|
|
147
187
|
const withheldFields = data.flatMap((c) => c.sensitiveFields.map((f) => `${c.name}.${f}`));
|
|
148
188
|
const exported = data.filter((c) => !c.sensitive);
|
|
@@ -245,10 +285,16 @@ function buildCollectionsIndex(entries) {
|
|
|
245
285
|
try { d = load(e.bytes.toString('utf8')) ?? {}; } catch { /* unparseable descriptor */ }
|
|
246
286
|
index.push({
|
|
247
287
|
name: d.name ?? m[1],
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
288
|
+
// ⚠ TWO QUESTIONS, KEPT SEPARATE. `systemGroup` answers "is this machinery, group it out
|
|
289
|
+
// of the domain listing"; `generated` answers "is this build output the hand-edit warning
|
|
290
|
+
// is about" — they agreed until `repos`, machinery whose records are still real files
|
|
291
|
+
// under `data/`. The partition is AUTHORED (`group: system` on the descriptor, ten core
|
|
292
|
+
// collections carry it), never derived from storage: a workspace collection may be its
|
|
293
|
+
// own apparatus, and a runtime-stored one may not be. An unparseable descriptor (`d = {}`
|
|
294
|
+
// from the catch above) carries neither key and answers false to both, which leaves it in
|
|
295
|
+
// the domain listing — the visible failure rather than the silent one.
|
|
296
|
+
systemGroup: d.group === 'system',
|
|
297
|
+
generated: d.storage?.base === 'runtime',
|
|
252
298
|
description: flat(d.description),
|
|
253
299
|
useWhen: flat(d.use_when),
|
|
254
300
|
module: d.module ?? '',
|
|
@@ -354,10 +400,10 @@ function collectionsSection(index, modules, workspaceModule) {
|
|
|
354
400
|
'collection\'s `write:` line names only what the store REFUSES — required fields that have no',
|
|
355
401
|
'default (a defaulted one is filled in for you), closed enums with their size, one example.',
|
|
356
402
|
];
|
|
357
|
-
const data = index.filter((c) => !c.
|
|
403
|
+
const data = index.filter((c) => !c.systemGroup);
|
|
358
404
|
const isWs = (m) => m.path === `modules/${workspaceModule}/`;
|
|
359
405
|
const groups = modules
|
|
360
|
-
.filter((m) => { const own = index.filter((c) => c.module === m.id); return own.some((c) => !c.
|
|
406
|
+
.filter((m) => { const own = index.filter((c) => c.module === m.id); return own.some((c) => !c.systemGroup) || (!own.length && (m.skills.length || m.commands.length || m.bin.length || m.proofs.length)); })
|
|
361
407
|
.sort((a, b) => (isWs(b) - isWs(a)) || a.title.localeCompare(b.title));
|
|
362
408
|
for (const m of groups) {
|
|
363
409
|
const where = [`\`${m.id}\``, m.path ? m.path.replace(/\/$/, '') : 'the workspace root', ...(m.namespaces.length ? [`namespaces: ${m.namespaces.join(' · ')}`] : [])];
|
|
@@ -370,13 +416,19 @@ function collectionsSection(index, modules, workspaceModule) {
|
|
|
370
416
|
if (c.write) lines.push(` write: ${c.write}`);
|
|
371
417
|
}
|
|
372
418
|
}
|
|
373
|
-
const
|
|
419
|
+
const sys = index.filter((c) => c.systemGroup);
|
|
420
|
+
const system = sys.filter((c) => c.generated).map((c) => c.name);
|
|
421
|
+
const kept = sys.filter((c) => !c.generated).map((c) => c.name);
|
|
374
422
|
// ⚠ THIS LINE IS THE FIRST THING A SESSION READS about the system collections, and until 0.19.0
|
|
375
423
|
// it said "schema-ops only", which named an internal module and a grammar that no longer exists.
|
|
376
424
|
// It now names the VERBS and the one policy difference, because an agent that knows the verbs
|
|
377
|
-
// exist still has to be told that these commit and records do not.
|
|
378
|
-
|
|
379
|
-
|
|
425
|
+
// exist still has to be told that these commit and records do not. And it is PARTITIONED on
|
|
426
|
+
// `generated`, not `systemGroup`: the partition only answers the grouping question, so a system
|
|
427
|
+
// collection whose records are real files (`repos`) gets its own trailing clause instead of being
|
|
428
|
+
// told "it is build output" — a sentence that was false of it and is false of the next data-backed
|
|
429
|
+
// system collection too, since the split is derived rather than naming one.
|
|
430
|
+
if (sys.length) {
|
|
431
|
+
lines.push('', `- system collections — the SAME verbs (add · set · rm · rename · list · get), plus \`dt add-field\`/\`set-field\`/\`rm-field\`/\`rename-field <collection>\`. A system write COMMITS ITSELF, in the repo holding the source; a record write does not (\`dt commit\` publishes).${system.length ? ` Never hand-edit \`.dreamteamer/\` — it is build output: ${system.join(' · ')}.` : ''}${kept.length ? ` Machinery whose records are real files you edit like any other: ${kept.join(' · ')}` : ''}`);
|
|
380
432
|
}
|
|
381
433
|
return lines;
|
|
382
434
|
}
|
|
@@ -527,22 +579,35 @@ function orientationBlock(flavor, skillsIndex, sourceLayout = 'flat', namespaces
|
|
|
527
579
|
|
|
528
580
|
// managed block in a USER-OWNED root file. content=null removes the block; a file left
|
|
529
581
|
// empty (or whitespace) after removal is deleted — we created it, we clean it up.
|
|
530
|
-
function writeBlock(root, filename, content) {
|
|
582
|
+
function writeBlock(root, filename, content, { begin = BEGIN, end = END, above } = {}) {
|
|
531
583
|
const file = path.join(root, filename);
|
|
532
584
|
const exists = fs.existsSync(file);
|
|
533
585
|
if (content == null) {
|
|
534
586
|
if (!exists) return;
|
|
535
587
|
let text = fs.readFileSync(file, 'utf8');
|
|
536
|
-
if (!text.includes(
|
|
537
|
-
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');
|
|
538
590
|
if (text.trim() === '') fs.rmSync(file);
|
|
539
591
|
else fs.writeFileSync(file, text);
|
|
540
592
|
return;
|
|
541
593
|
}
|
|
542
|
-
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;
|
|
543
601
|
let text = exists ? fs.readFileSync(file, 'utf8') : '';
|
|
544
|
-
if (text.includes(
|
|
545
|
-
|
|
602
|
+
if (text.includes(begin)) {
|
|
603
|
+
text = text.replace(new RegExp(`${escapeRe(begin)}[\\s\\S]*?${escapeRe(end)}`), insert(block));
|
|
604
|
+
} else if (above && text.includes(above)) {
|
|
605
|
+
// ⚠ ORDER IS THE CONTRACT, not a preference. The rules must be read BEFORE the schema
|
|
606
|
+
// orientation, and a harness that truncates a long context file truncates the tail.
|
|
607
|
+
text = text.replace(above, insert(`${block}\n\n${above}`));
|
|
608
|
+
} else {
|
|
609
|
+
text = (text.trimEnd() + '\n\n' + block + '\n').replace(/^\n+/, '');
|
|
610
|
+
}
|
|
546
611
|
fs.writeFileSync(file, text);
|
|
547
612
|
}
|
|
548
613
|
|
package/src/init.js
CHANGED
|
@@ -8,6 +8,7 @@ import { discoverModules, KINDS } from './compile.js';
|
|
|
8
8
|
import { KNOWN_HARNESSES } from './harnesses.js';
|
|
9
9
|
import { Store } from './store.js';
|
|
10
10
|
import { envContext, renderTemplate } from './env-vars.js';
|
|
11
|
+
import { ensureEditorRecommendation } from './workspace.js';
|
|
11
12
|
|
|
12
13
|
// git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
|
|
13
14
|
// child's stderr to ours unless told otherwise, so a handled "not a git repository" still
|
|
@@ -147,8 +148,10 @@ export function init({ flags = {} } = {}) {
|
|
|
147
148
|
// A workspace that needs people as records ships its own collection (a module's `contacts` already
|
|
148
149
|
// does), and reads the operator from git where it needs one. `teams` went the same way 2026-07-31.
|
|
149
150
|
|
|
150
|
-
// .gitignore + .env.example (append-if-missing, never clobber)
|
|
151
|
+
// .gitignore + .env.example (append-if-missing, never clobber) + the editor recommendation, so
|
|
152
|
+
// the first window opened on this workspace offers the extension instead of leaving it to be found
|
|
151
153
|
appendMissing(path.join(root, '.gitignore'), GITIGNORE);
|
|
154
|
+
ensureEditorRecommendation(root);
|
|
152
155
|
if (!fs.existsSync(path.join(root, '.env.example'))) fs.writeFileSync(path.join(root, '.env.example'), ENV_EXAMPLE);
|
|
153
156
|
|
|
154
157
|
// one init commit (if we're in a git repo)
|