@dreamteamer/web 0.0.0-stage → 0.3.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 +287 -2
- package/UPDATING.md +45 -0
- package/bin/dt-web-hook.mjs +21 -0
- package/bin/dt-web.mjs +14 -0
- package/commands/web/show.command.md +6 -0
- package/package.json +42 -5
- package/skills/serving-the-web-ui/SKILL.md +104 -0
- package/src/cli.js +425 -0
- package/src/dispatch.js +16 -0
- package/src/extension.js +47 -0
- package/src/handoff.js +67 -0
- package/src/headers.js +30 -0
- package/src/hook.js +150 -0
- package/src/module-frames.js +43 -0
- package/src/pairing.js +28 -0
- package/src/pane-store.js +116 -0
- package/src/runtime.js +244 -0
- package/src/server.js +633 -0
- package/src/sessions.js +37 -0
- package/src/state.js +143 -0
- package/src/tokens.js +24 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// `dt web` and `dt-web <root>` (cr-modules-017 §8): one argv grammar for both entries; `main` resolves to the exit
|
|
2
|
+
// code. Every verb that needs the server starts it detached when it is not running (`ensureRunning`) — `open`, `show`,
|
|
3
|
+
// `select`, `selection`, `token`, `start`; `status`, `stop`, `list`, `hook` and `revoke` never start one. Every verb
|
|
4
|
+
// knows the server by its proof (runtime.js `probeServer`), never by a live pid: a runtime file whose process is gone,
|
|
5
|
+
// refuses the connection or gives a wrong proof is stale — status says not running, open starts a fresh server, stop
|
|
6
|
+
// removes the file and signals nobody. A live process that gives no proof in time is NOT ANSWERING: every verb says so,
|
|
7
|
+
// exits 1 and changes nothing — no file removed, no second server — and only `stop --force` signals it, when its
|
|
8
|
+
// recorded start time is that process's own.
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { alive, claimStart, clearStale, FORCE_GRACE_MS, movedLine, notAnsweringLine, ownStart, privateDir, probeServer, readState, removeState, runtimeDir, sameStart, stateFile, workspaceKey, writeState } from './runtime.js';
|
|
15
|
+
import { nameNow, readRegistry, readWorkspaceState, register, stateDir, writeWorkspaceState } from './state.js';
|
|
16
|
+
import { hookInstalled, installHook } from './hook.js';
|
|
17
|
+
import { workspaceName } from './pane-store.js';
|
|
18
|
+
|
|
19
|
+
const BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'dt-web.mjs');
|
|
20
|
+
// the browser page @dreamteamer/ui builds, served as it is
|
|
21
|
+
const appDirOf = () => path.join(path.dirname(createRequire(import.meta.url).resolve('@dreamteamer/ui/package.json')), 'dist', 'app');
|
|
22
|
+
export const USAGE = 'usage: dt web [open [<ref>|<collection>|<ui-view>] [--read|--edit|--admin] [--browser]] · show <target> · select <ref>… | --clear · selection [--json] · hook --install · token [--read|--edit] [--name <n>] · list · revoke [--token <n>] · status · stop [--revoke] [--force] · start [--foreground] [--port <n>] [--idle <minutes>] [--owner-pid <pid>]';
|
|
23
|
+
const VERBS = new Set(['open', 'show', 'select', 'selection', 'hook', 'token', 'list', 'revoke', 'status', 'stop', 'start']);
|
|
24
|
+
/** Each verb's flags: `level` (one of --read/--edit/--admin), `true` (a switch) or `value` (takes one). */
|
|
25
|
+
const FLAGS = {
|
|
26
|
+
open: { '--read': 'level', '--edit': 'level', '--admin': 'level', '--browser': true },
|
|
27
|
+
show: {},
|
|
28
|
+
select: { '--clear': true },
|
|
29
|
+
selection: { '--json': true },
|
|
30
|
+
hook: { '--install': true },
|
|
31
|
+
token: { '--read': 'level', '--edit': 'level', '--name': 'value' },
|
|
32
|
+
list: {},
|
|
33
|
+
revoke: { '--token': 'value' },
|
|
34
|
+
status: {},
|
|
35
|
+
stop: { '--revoke': true, '--force': true },
|
|
36
|
+
start: { '--foreground': true, '--port': 'value', '--idle': 'value', '--owner-pid': 'value' },
|
|
37
|
+
};
|
|
38
|
+
const SWITCH = { '--browser': 'browser', '--clear': 'clear', '--json': 'json', '--install': 'install', '--revoke': 'revoke', '--force': 'force', '--foreground': 'foreground' };
|
|
39
|
+
const START_WAIT_MS = 10_000;
|
|
40
|
+
const LEVEL_WORD = (l) => l[0].toUpperCase() + l.slice(1);
|
|
41
|
+
const bad = (why) => { const e = new Error(`${why} — ${USAGE}`); e.usage = true; return e; };
|
|
42
|
+
const wholeNumber = (flag, v, max = Infinity) => {
|
|
43
|
+
if (v === undefined || !/^\d+$/.test(v) || Number(v) > max) throw bad(`${flag} needs a whole number${max < Infinity ? ` up to ${max}` : ''}`);
|
|
44
|
+
return Number(v);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** → { cmd, target?, refs?, clear?, level?, name?, token?, revoke?, browser?, foreground?, port?, idle?, ownerPid?, json?, install? } */
|
|
48
|
+
export function parseArgs(argv, env = process.env) {
|
|
49
|
+
const args = [...argv];
|
|
50
|
+
const cmd = args.length && !args[0].startsWith('-') ? args.shift() : 'open';
|
|
51
|
+
if (!VERBS.has(cmd)) throw bad(`dt web has no verb "${cmd}"`);
|
|
52
|
+
const out = { cmd };
|
|
53
|
+
const positional = [];
|
|
54
|
+
const flags = FLAGS[cmd];
|
|
55
|
+
for (let i = 0; i < args.length; i++) {
|
|
56
|
+
const a = args[i];
|
|
57
|
+
if (!a.startsWith('-')) { positional.push(a); continue; }
|
|
58
|
+
const eq = a.indexOf('=');
|
|
59
|
+
const name = eq < 0 ? a : a.slice(0, eq);
|
|
60
|
+
const inline = eq < 0 ? undefined : a.slice(eq + 1);
|
|
61
|
+
const kind = Object.hasOwn(flags, name) ? flags[name] : undefined;
|
|
62
|
+
if (!kind) throw bad(`dt web ${cmd} does not take "${a}"`);
|
|
63
|
+
if (kind === 'level') {
|
|
64
|
+
if (out.level && out.level !== name.slice(2)) throw bad('one level flag at a time');
|
|
65
|
+
out.level = name.slice(2);
|
|
66
|
+
} else if (kind === true) {
|
|
67
|
+
out[SWITCH[name]] = true;
|
|
68
|
+
} else {
|
|
69
|
+
const v = inline ?? args[++i];
|
|
70
|
+
if (v === undefined || v === '') throw bad(`${name} needs a value`);
|
|
71
|
+
if (name === '--name') out.name = v;
|
|
72
|
+
if (name === '--token') out.token = v;
|
|
73
|
+
if (name === '--port') out.port = wholeNumber('--port', v, 65535);
|
|
74
|
+
if (name === '--idle') out.idle = wholeNumber('--idle', v);
|
|
75
|
+
if (name === '--owner-pid') out.ownerPid = wholeNumber('--owner-pid', v);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (cmd === 'open' && positional.length > 1) throw bad('dt web open takes one target');
|
|
79
|
+
if (cmd === 'show' && positional.length !== 1) throw bad('dt web show takes one target');
|
|
80
|
+
if (cmd === 'select' && (out.clear ? positional.length > 0 : positional.length === 0)) throw bad('dt web select takes refs or --clear');
|
|
81
|
+
if (cmd === 'hook' && !out.install) throw bad('dt web hook takes --install');
|
|
82
|
+
if (!['open', 'show', 'select'].includes(cmd) && positional.length) throw bad(`dt web ${cmd} takes no "${positional[0]}"`);
|
|
83
|
+
if ((cmd === 'open' || cmd === 'show') && positional[0] !== undefined) out.target = positional[0];
|
|
84
|
+
if (cmd === 'select' && !out.clear) out.refs = positional;
|
|
85
|
+
if (cmd === 'start' && out.port === undefined && env.DT_WEB_PORT) { out.port = wholeNumber('DT_WEB_PORT', env.DT_WEB_PORT, 65535); out.portFromEnv = true; }
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
90
|
+
/** A request to the running server under its control token: → { status, body } (status 0 when it did not answer). */
|
|
91
|
+
async function ask(rt, method, p, body, timeoutMs = 10_000) {
|
|
92
|
+
try {
|
|
93
|
+
const r = await fetch(`http://127.0.0.1:${rt.port}${p}`, {
|
|
94
|
+
method,
|
|
95
|
+
headers: { authorization: `Bearer ${rt.controlToken}`, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
|
|
96
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
97
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
98
|
+
});
|
|
99
|
+
let json = null;
|
|
100
|
+
try { json = await r.json(); } catch { /* not JSON */ }
|
|
101
|
+
return { status: r.status, body: json };
|
|
102
|
+
} catch { return { status: 0, body: null }; }
|
|
103
|
+
}
|
|
104
|
+
const fileOf = (root, env) => stateFile(root, runtimeDir(env));
|
|
105
|
+
/** The workspace's server when it proves it is the runtime file's: `{ port, controlToken, pid, movedFrom }`, else null. */
|
|
106
|
+
/** What a verb that needs the server throws when a live server does not answer: it prints the line and exits 1. */
|
|
107
|
+
class NotAnswering extends Error { constructor(p) { super(notAnsweringLine(p)); this.notAnswering = true; } }
|
|
108
|
+
const current = async (root, env, timeoutMs = 2000, { refuseSilent = true } = {}) => {
|
|
109
|
+
const p = await probeServer(fileOf(root, env), { timeoutMs });
|
|
110
|
+
if (p?.state === 'silent' && refuseSilent) throw new NotAnswering(p);
|
|
111
|
+
return p?.proven && typeof p.token === 'string' ? { port: p.port, controlToken: p.token, pid: p.pid, movedFrom: p.movedFrom, owner: p.owner ?? null, idle: p.idle } : null;
|
|
112
|
+
};
|
|
113
|
+
const failed = (log, r) => {
|
|
114
|
+
// the server refused a state write on the dir's mode: the verb fails as open does on the same dir — the sentence
|
|
115
|
+
// naming the dir and the fix, exit 2
|
|
116
|
+
if (r.body?.code === 'EDTWEBDIR') throw Object.assign(new Error(r.body.error), { code: r.body.code });
|
|
117
|
+
log(`✖ ${r.body?.error ?? (r.status ? `the server answered ${r.status}` : 'the server did not answer')}`);
|
|
118
|
+
return 1;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The workspace's server, started detached when it is not running (or is on its way down): `dt-web <root> start
|
|
123
|
+
* --foreground [args…]` with its output in `<runtime dir>/<key>.log`, waited for up to ten seconds — or until the child
|
|
124
|
+
* exits, when the line it ended on is the error. `bin` is this package's dt-web, or — for a handoff — the one the target
|
|
125
|
+
* workspace installed; `args` the flags `dt web start` was given (`--port`, `--idle`, `--owner-pid`).
|
|
126
|
+
* @returns {Promise<{ port: number, controlToken: string }>}
|
|
127
|
+
*/
|
|
128
|
+
export async function ensureRunning(root, { log = console.log, env = process.env, appDir, bin = BIN, args = [] } = {}) {
|
|
129
|
+
// a live server that does not answer throws NotAnswering here: never a second server beside it
|
|
130
|
+
const now = await current(root, env);
|
|
131
|
+
if (now) return now;
|
|
132
|
+
// no server, or a stale runtime file (a server on its way down, a hard-killed one whose pid or port another program
|
|
133
|
+
// holds): the child clears what is stale and starts
|
|
134
|
+
const dir = runtimeDir(env);
|
|
135
|
+
privateDir(dir, 'DT_WEB_RUNTIME_DIR');
|
|
136
|
+
const logFile = path.join(dir, `${workspaceKey(root)}.log`);
|
|
137
|
+
const fd = fs.openSync(logFile, 'a', 0o600);
|
|
138
|
+
const from = fs.fstatSync(fd).size;
|
|
139
|
+
const child = spawn(process.execPath, [bin, root, 'start', '--foreground', ...args], {
|
|
140
|
+
detached: true,
|
|
141
|
+
stdio: ['ignore', fd, fd],
|
|
142
|
+
env: { ...env, ...(appDir ? { DT_WEB_APP_DIR: appDir } : {}) },
|
|
143
|
+
});
|
|
144
|
+
let exited = null;
|
|
145
|
+
child.once('exit', (code) => { exited = code; });
|
|
146
|
+
child.unref();
|
|
147
|
+
fs.closeSync(fd);
|
|
148
|
+
const until = Date.now() + START_WAIT_MS;
|
|
149
|
+
while (Date.now() < until) {
|
|
150
|
+
const rt = await current(root, env, 1000, { refuseSilent: false });
|
|
151
|
+
if (rt) {
|
|
152
|
+
if (rt.movedFrom) log(movedLine(rt.movedFrom, rt.port));
|
|
153
|
+
return rt;
|
|
154
|
+
}
|
|
155
|
+
// a child that has exited will not come up itself. When another live process holds the start lock, that child
|
|
156
|
+
// lost the race to a start at the same instant: the winner is found above on a later turn. Otherwise the line
|
|
157
|
+
// it ended on is why it did not start, said now rather than after the whole wait.
|
|
158
|
+
if (exited !== null && !startingElsewhere(fileOf(root, env), child.pid)) {
|
|
159
|
+
const again = await current(root, env, 1000, { refuseSilent: false });
|
|
160
|
+
if (again) return again;
|
|
161
|
+
const said = logTail(logFile, from);
|
|
162
|
+
throw new Error(`dt web did not start${said ? `: ${said.replace(/^✖ /, '')}` : ` (exit ${exited})`} — its log: ${logFile}`);
|
|
163
|
+
}
|
|
164
|
+
await sleep(100);
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`dt web did not start within ${START_WAIT_MS / 1000} s — its log: ${logFile}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Another live process holds this workspace's start lock (runtime.js claimStart): a server is on its way up. */
|
|
170
|
+
function startingElsewhere(file, childPid) {
|
|
171
|
+
let holder = null;
|
|
172
|
+
try { holder = Number(fs.readFileSync(`${file}.lock`, 'utf8')); } catch { return false; }
|
|
173
|
+
return Number.isInteger(holder) && holder > 0 && holder !== childPid && alive(holder);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The last line the child wrote to its log since `from` (the log is kept across starts). */
|
|
177
|
+
function logTail(file, from) {
|
|
178
|
+
try {
|
|
179
|
+
const text = fs.readFileSync(file).subarray(from).toString('utf8');
|
|
180
|
+
return text.trim().split('\n').filter((l) => l.trim() && !/^\s+at /.test(l)).at(-1)?.trim() ?? '';
|
|
181
|
+
} catch { return ''; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The `start` flags as given (`--port` from DT_WEB_PORT too) — what the detached child is started with. */
|
|
185
|
+
const startFlags = (o) => [
|
|
186
|
+
...(o.port !== undefined ? ['--port', String(o.port)] : []),
|
|
187
|
+
...(o.idle !== undefined ? ['--idle', String(o.idle)] : []),
|
|
188
|
+
...(o.ownerPid !== undefined ? ['--owner-pid', String(o.ownerPid)] : []),
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
export async function main(root, argv, { log = console.log, env = process.env, appDir = env.DT_WEB_APP_DIR || undefined } = {}) {
|
|
192
|
+
const o = parseArgs(argv, env);
|
|
193
|
+
const sdir = stateDir(env);
|
|
194
|
+
const ensure = () => ensureRunning(root, { log, env, appDir });
|
|
195
|
+
try {
|
|
196
|
+
return await verb(root, o, { log, env, appDir, sdir, ensure });
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (!e?.notAnswering) throw e;
|
|
199
|
+
log(e.message);
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function verb(root, o, { log, env, appDir, sdir, ensure }) {
|
|
205
|
+
|
|
206
|
+
if (o.cmd === 'status') {
|
|
207
|
+
const now = await probeServer(fileOf(root, env));
|
|
208
|
+
if (now?.state === 'silent') { log(notAnsweringLine(now)); return 1; }
|
|
209
|
+
if (!now?.proven) { log('not running'); return 1; }
|
|
210
|
+
log(`running at http://127.0.0.1:${now.port} · pid ${now.pid} · level ${LEVEL_WORD(readWorkspaceState(root, sdir).level)}${now.owner ? ` · owner ${now.owner}` : ''} · since ${now.started}`);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
if (o.cmd === 'stop') {
|
|
214
|
+
const file = fileOf(root, env);
|
|
215
|
+
// with --force a server that is not answering is stopped without the revoke: its sessions live in its memory and
|
|
216
|
+
// end with it, and the revoke would only meet the not-answering line
|
|
217
|
+
let now = o.force ? await probeServer(file) : undefined;
|
|
218
|
+
if (o.revoke && now?.state !== 'silent') { const code = await revoke(root, {}, { log, env }); if (code) return code; now = undefined; }
|
|
219
|
+
if (now === undefined) now = await probeServer(file);
|
|
220
|
+
if (!now) { log('not running'); return o.revoke ? 0 : 1; }
|
|
221
|
+
if (now.state === 'silent') {
|
|
222
|
+
if (!o.force) { log(notAnsweringLine(now)); return 1; }
|
|
223
|
+
return forceStop(now, { log });
|
|
224
|
+
}
|
|
225
|
+
if (!now.proven) {
|
|
226
|
+
clearStale(now.file, now);
|
|
227
|
+
log(`not running — removed a stale runtime file: pid ${now.pid} is not this workspace's server, and was not signalled`);
|
|
228
|
+
return o.revoke ? 0 : 1;
|
|
229
|
+
}
|
|
230
|
+
process.kill(now.pid, 'SIGTERM');
|
|
231
|
+
// stopped = the server removed its runtime file (a process its parent has not reaped yet still answers kill 0)
|
|
232
|
+
const gone = () => !readState(now.file) || !alive(now.pid);
|
|
233
|
+
for (let i = 0; i < 50 && !gone(); i++) await sleep(100);
|
|
234
|
+
log(gone() ? 'stopped' : `pid ${now.pid} did not stop`);
|
|
235
|
+
return gone() ? 0 : 1;
|
|
236
|
+
}
|
|
237
|
+
if (o.cmd === 'list') {
|
|
238
|
+
const rows = readRegistry(sdir);
|
|
239
|
+
if (!rows.length) { log('no workspace was opened with dt web on this machine'); return 0; }
|
|
240
|
+
for (const e of rows) {
|
|
241
|
+
const here = fs.existsSync(e.root);
|
|
242
|
+
const p = here ? await probeServer(fileOf(e.root, env), { timeoutMs: 1000 }) : null;
|
|
243
|
+
const r = p?.proven || p?.state === 'silent' ? p : null;
|
|
244
|
+
const port = r?.port ?? (here ? readWorkspaceState(e.root, sdir).port : null);
|
|
245
|
+
log(`${nameNow(e)} · ${port ?? '-'} · ${p?.state === 'silent' ? 'not answering' : r ? 'running' : 'stopped'} · ${e.root}`);
|
|
246
|
+
}
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
if (o.cmd === 'hook') {
|
|
250
|
+
const r = installHook(root);
|
|
251
|
+
const rel = path.relative(root, r.file);
|
|
252
|
+
log(r.wrote ? `✔ the pane hook is in ${rel}: ${r.command}` : `the pane hook is already in ${rel}: ${r.command}`);
|
|
253
|
+
if (!r.ignored) log(`add ${rel} to .gitignore — it holds this machine's paths`);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
if (o.cmd === 'revoke') return revoke(root, { token: o.token }, { log, env });
|
|
257
|
+
if (o.cmd === 'start') {
|
|
258
|
+
if (o.foreground) return foreground(root, o, { log, env, appDir });
|
|
259
|
+
// a server already running keeps its port, idle stop and owner: a flag asking for another is refused, never dropped
|
|
260
|
+
const was = await current(root, env);
|
|
261
|
+
if (was) {
|
|
262
|
+
const differs = [
|
|
263
|
+
// --port 0 asks for no particular port, so any running server's port agrees with it
|
|
264
|
+
o.port !== undefined && o.port !== 0 && o.port !== was.port && `${o.portFromEnv ? 'DT_WEB_PORT=' : '--port '}${o.port}`,
|
|
265
|
+
o.idle !== undefined && o.idle !== was.idle && `--idle ${o.idle}`,
|
|
266
|
+
o.ownerPid !== undefined && o.ownerPid !== was.owner && `--owner-pid ${o.ownerPid}`,
|
|
267
|
+
].filter(Boolean);
|
|
268
|
+
if (differs.length) {
|
|
269
|
+
log(`already running at http://127.0.0.1:${was.port} — ${differs.join(', ')} applies only to a server this verb starts: dt web stop, then dt web start again`);
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
log(`running at http://127.0.0.1:${was.port}`);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
const rt = await ensureRunning(root, { log, env, appDir, args: startFlags(o) });
|
|
276
|
+
log(`running at http://127.0.0.1:${rt.port}`);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
if (o.cmd === 'open') {
|
|
280
|
+
await register({ root, name: workspaceName(root) }, sdir);
|
|
281
|
+
const rt = await ensure();
|
|
282
|
+
if (o.level) {
|
|
283
|
+
const r = await ask(rt, 'POST', '/_dt/level', { level: o.level });
|
|
284
|
+
if (r.status !== 200) return failed(log, r);
|
|
285
|
+
}
|
|
286
|
+
const pair = await ask(rt, 'POST', '/_dt/pair', o.target !== undefined ? { target: o.target } : {});
|
|
287
|
+
if (pair.status !== 200) return failed(log, pair);
|
|
288
|
+
log(`✔ open in the pane: ${pair.body.url}`);
|
|
289
|
+
log(`level: ${LEVEL_WORD(readWorkspaceState(root, sdir).level)}`);
|
|
290
|
+
if (!hookInstalled(root)) log('the chat cannot see the pane yet — run: dt web hook --install');
|
|
291
|
+
if (o.browser) openBrowser(pair.body.url);
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
if (o.cmd === 'show' || o.cmd === 'select') {
|
|
295
|
+
const rt = await ensure();
|
|
296
|
+
const body = o.cmd === 'show' ? { type: 'navigate', target: o.target } : o.clear ? { type: 'select', clear: true } : { type: 'select', refs: o.refs };
|
|
297
|
+
const r = await ask(rt, 'POST', '/_dt/push', body);
|
|
298
|
+
if (r.status !== 200) return failed(log, r);
|
|
299
|
+
if (r.body.delivered === 0) { log(`no page is connected — run: dt web open${o.cmd === 'show' ? ` ${o.target}` : ''}`); return 1; }
|
|
300
|
+
log(o.cmd === 'show' ? `✔ the pane shows ${r.body.route}` : `✔ ${r.body.refs.length} selected in the pane`);
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
if (o.cmd === 'selection') {
|
|
304
|
+
const rt = await ensure();
|
|
305
|
+
const r = await ask(rt, 'GET', '/_dt/pane');
|
|
306
|
+
if (r.status !== 200) return failed(log, r);
|
|
307
|
+
if (o.json) { log(JSON.stringify({ place: r.body.place, view: r.body.view, refs: r.body.refs, connected: r.body.connected }, null, 2)); return 0; }
|
|
308
|
+
log(`showing: ${r.body.place ?? '-'}`);
|
|
309
|
+
for (const ref of r.body.refs) log(ref);
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
if (o.cmd === 'token') {
|
|
313
|
+
const rt = await ensure();
|
|
314
|
+
const r = await ask(rt, 'POST', '/_dt/token', { name: o.name ?? 'default', level: o.level ?? 'read' });
|
|
315
|
+
if (r.status !== 200) return failed(log, r);
|
|
316
|
+
log(`API ${r.body.api}`);
|
|
317
|
+
log(`token ${r.body.token}`);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
throw bad(`dt web has no verb "${o.cmd}"`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** A pid that is running: alive, and not a zombie its parent has yet to reap. */
|
|
324
|
+
const running = (pid) => {
|
|
325
|
+
if (!alive(pid)) return false;
|
|
326
|
+
try { return !String(spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, env: { ...process.env, LC_ALL: 'C' } }).stdout).trim().startsWith('Z'); } catch { return true; }
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* `dt web stop --force` on a server that is not answering. Only a process shown to be this server — the start the
|
|
331
|
+
* runtime file recorded (`pstart`) is its own, within START_TOLERANCE_MS — is signalled: SIGTERM, then SIGCONT so a
|
|
332
|
+
* suspended process can act on it; after FORCE_GRACE_MS, with the start shown again, SIGKILL. A process that ends
|
|
333
|
+
* without removing its runtime file and lock (SIGKILL) has them removed here.
|
|
334
|
+
*/
|
|
335
|
+
async function forceStop(now, { log }) {
|
|
336
|
+
const { pid } = now;
|
|
337
|
+
if (now.sameProcess !== true) {
|
|
338
|
+
log(`✖ cannot prove pid ${pid} is this workspace's server — it was not signalled (its start ${Number.isFinite(now.pstart) ? 'could not be read' : 'is not recorded in the runtime file'}); check it yourself: ps -p ${pid}`);
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
log(`pid ${pid} is not answering; its start is this server's — sending SIGTERM, then SIGCONT`);
|
|
342
|
+
try { process.kill(pid, 'SIGTERM'); process.kill(pid, 'SIGCONT'); } catch { /* it ended */ }
|
|
343
|
+
const until = Date.now() + FORCE_GRACE_MS;
|
|
344
|
+
while (Date.now() < until && running(pid)) await sleep(100);
|
|
345
|
+
if (running(pid)) {
|
|
346
|
+
if (sameStart(pid, now.pstart) !== true) {
|
|
347
|
+
log(`✖ pid ${pid} did not stop within ${FORCE_GRACE_MS / 1000} s, and its start can no longer be shown to be this server's — not killed`);
|
|
348
|
+
return 1;
|
|
349
|
+
}
|
|
350
|
+
log(`pid ${pid} did not stop within ${FORCE_GRACE_MS / 1000} s — sending SIGKILL`);
|
|
351
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* it ended */ }
|
|
352
|
+
for (let i = 0; i < 50 && running(pid); i++) await sleep(100);
|
|
353
|
+
if (running(pid)) { log(`✖ pid ${pid} did not stop`); return 1; }
|
|
354
|
+
}
|
|
355
|
+
clearStale(now.file, now);
|
|
356
|
+
log('stopped');
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** `dt web revoke [--token <n>]`: through the server when it runs, else in the state file under the start lock. */
|
|
361
|
+
async function revoke(root, { token }, { log, env }) {
|
|
362
|
+
const said = (n) => {
|
|
363
|
+
log(token !== undefined ? (n ? `✔ revoked the token ${token}` : `no token named ${token}`) : `✔ revoked ${n} session(s)`);
|
|
364
|
+
return token !== undefined && !n ? 1 : 0;
|
|
365
|
+
};
|
|
366
|
+
const rt = await current(root, env);
|
|
367
|
+
if (rt) {
|
|
368
|
+
const r = await ask(rt, 'POST', '/_dt/revoke', token !== undefined ? { token } : {});
|
|
369
|
+
if (r.status !== 200) return failed(log, r);
|
|
370
|
+
return said(r.body.revoked);
|
|
371
|
+
}
|
|
372
|
+
// no server, no session: they live in its memory only, so a stopped server has signed every page out already
|
|
373
|
+
if (token === undefined) return said(0);
|
|
374
|
+
const lock = claimStart(stateFile(root, runtimeDir(env)));
|
|
375
|
+
if (!lock.ok) { log(`dt web is starting${lock.pid ? ` (pid ${lock.pid})` : ''} — run dt web revoke again`); return 1; }
|
|
376
|
+
try {
|
|
377
|
+
const dir = stateDir(env);
|
|
378
|
+
const state = readWorkspaceState(root, dir);
|
|
379
|
+
const n = state.clientTokens.length;
|
|
380
|
+
state.clientTokens = state.clientTokens.filter((t) => t.name !== token);
|
|
381
|
+
writeWorkspaceState(root, state, dir);
|
|
382
|
+
return said(n - state.clientTokens.length);
|
|
383
|
+
} finally { lock.release(); }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** `dt web start --foreground`: serve in this process — what the detached child runs, and a launch.json entry. */
|
|
387
|
+
async function foreground(root, o, { log, env, appDir = appDirOf() }) {
|
|
388
|
+
const file = fileOf(root, env);
|
|
389
|
+
const was = await probeServer(file);
|
|
390
|
+
if (was?.proven) { log(`already running at http://127.0.0.1:${was.port} (pid ${was.pid})`); return 1; }
|
|
391
|
+
if (was?.state === 'silent') { log(notAnsweringLine(was)); return 1; }
|
|
392
|
+
// a runtime file whose process lives but cannot prove it is this server: stale, with its lock when that pid holds it
|
|
393
|
+
if (was) clearStale(file, was);
|
|
394
|
+
const { startWeb } = await import('./server.js');
|
|
395
|
+
const lock = claimStart(file);
|
|
396
|
+
if (!lock.ok) { log(`already running or starting${lock.pid ? ` (pid ${lock.pid})` : ''} — dt web status`); return 1; }
|
|
397
|
+
let web;
|
|
398
|
+
try {
|
|
399
|
+
web = await startWeb({ root, appDir, port: o.port ?? 0, idleMinutes: o.idle ?? 60, ownerPid: o.ownerPid, stateDir: stateDir(env), onStop: () => { removeState(file); lock.release(); } });
|
|
400
|
+
} catch (e) {
|
|
401
|
+
lock.release();
|
|
402
|
+
// a port the person named is never swapped for another (server.js moves only a stored one)
|
|
403
|
+
if (e?.code === 'EADDRINUSE' && o.port) throw new Error(`port ${o.port} is taken by another program — pick another --port, or leave it out`);
|
|
404
|
+
throw e;
|
|
405
|
+
}
|
|
406
|
+
writeState(file, { pid: process.pid, port: web.port, started: new Date().toISOString(), owner: o.ownerPid ?? null, idle: o.idle ?? 60, token: web.controlToken, instance: web.instance, pstart: ownStart(), ...(web.movedFrom ? { movedFrom: web.movedFrom } : {}) });
|
|
407
|
+
// the handlers before the first line: a caller may signal the moment it reads it
|
|
408
|
+
const stop = () => web.close('signal');
|
|
409
|
+
process.once('SIGINT', stop);
|
|
410
|
+
process.once('SIGTERM', stop);
|
|
411
|
+
log(`✔ dt web serving ${workspaceName(root)} at http://127.0.0.1:${web.port} · pid ${process.pid}`);
|
|
412
|
+
if (web.movedFrom) log(movedLine(web.movedFrom, web.port));
|
|
413
|
+
const why = await web.closed;
|
|
414
|
+
log(`stopped: ${why}`);
|
|
415
|
+
removeState(file);
|
|
416
|
+
lock.release();
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function openBrowser(url) {
|
|
421
|
+
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
422
|
+
try { spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* print-only */ }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export { readState };
|
package/src/dispatch.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The one listener's prefix dispatcher (cr-modules-017 §1). A raw pathname — never decoded, never case-folded, never
|
|
2
|
+
// normalised — names exactly one kind or none; what is not named here is 404 before any auth or handler runs. A
|
|
3
|
+
// path reaches the API only when it begins `/api/` as written.
|
|
4
|
+
export const PREFIXES = [
|
|
5
|
+
{ kind: 'sign-in', prefix: '/sign-in/' }, { kind: 'shell', prefix: '/app/' }, { kind: 'root', exact: '/' },
|
|
6
|
+
{ kind: 'api', prefix: '/api/' }, { kind: 'dt', prefix: '/_dt/' }, { kind: 'files', prefix: '/_files/' }, { kind: 'frames', prefix: '/_frames/' },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
/** @param {string} pathname the request's path as it arrived, query removed @returns {string|null} */
|
|
10
|
+
export function classify(pathname) {
|
|
11
|
+
if (typeof pathname !== 'string') return null;
|
|
12
|
+
for (const p of PREFIXES) {
|
|
13
|
+
if (p.exact !== undefined ? pathname === p.exact : pathname.startsWith(p.prefix)) return p.kind;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
package/src/extension.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The engine extension: `dt web …` (cli.js's grammar), `dt serve` — a client token of the pane's own server, the
|
|
2
|
+
// one process (cr-modules-017 §8) — and a `web` row in `dt doctor`: running or not, the level, and the hook.
|
|
3
|
+
import { main, parseArgs, USAGE } from './cli.js';
|
|
4
|
+
import { hookInstalled } from './hook.js';
|
|
5
|
+
import { probeServer, stateFile } from './runtime.js';
|
|
6
|
+
import { readWorkspaceState } from './state.js';
|
|
7
|
+
|
|
8
|
+
export const apiVersion = 1;
|
|
9
|
+
export const SERVE_IS_TOKEN = 'dt serve is dt web token now: loopback only, a client token of the pane\'s server — dt web token [--read|--edit]';
|
|
10
|
+
const WEB_USAGE = ` web [open|show|select|selection|hook|token|list|revoke|status|stop|start] … the pane: the UI over this workspace on loopback, its level, its selection and the chat's hook (${USAGE.replace(/^usage: /, '')})`;
|
|
11
|
+
const SERVE_USAGE = ' serve [--read|--edit] [--name <n>] a client token and the API base of this workspace\'s dt web server (dt web token)';
|
|
12
|
+
|
|
13
|
+
export default function activate() {
|
|
14
|
+
return {
|
|
15
|
+
commands: {
|
|
16
|
+
web: {
|
|
17
|
+
usage: WEB_USAGE,
|
|
18
|
+
async run(ws, argv) { parseArgs(argv); return main(ws.root, argv); },
|
|
19
|
+
},
|
|
20
|
+
serve: {
|
|
21
|
+
usage: SERVE_USAGE,
|
|
22
|
+
async run(ws, argv) {
|
|
23
|
+
if (argv.some((a) => /^--(host|port|token)(=|$)/.test(a))) { console.error(`✖ ${SERVE_IS_TOKEN}`); return 2; }
|
|
24
|
+
const args = ['token', ...argv];
|
|
25
|
+
if (!argv.some((a) => /^--name(=|$)/.test(a))) args.push('--name', 'serve');
|
|
26
|
+
parseArgs(args);
|
|
27
|
+
return main(ws.root, args);
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
async doctor({ root }) {
|
|
32
|
+
// running = the server proves it is the runtime file's, never a live pid alone (runtime.js `probeServer`)
|
|
33
|
+
const p = await probeServer(stateFile(root));
|
|
34
|
+
const r = p?.proven ? p : null;
|
|
35
|
+
let level = 'edit';
|
|
36
|
+
try { level = readWorkspaceState(root).level; } catch { /* unreadable: the default */ }
|
|
37
|
+
const word = level[0].toUpperCase() + level.slice(1);
|
|
38
|
+
const rows = [p?.state === 'silent'
|
|
39
|
+
? { label: 'web', state: 'warn', detail: `not answering: pid ${p.pid} at http://127.0.0.1:${p.port} is alive but gave no identity proof within 2 s · level ${word}`, fix: 'dt web stop --force' }
|
|
40
|
+
: { label: 'web', state: 'ok', detail: `${r ? `running at http://127.0.0.1:${r.port} · pid ${r.pid}` : 'not running'} · level ${word}` }];
|
|
41
|
+
rows.push(hookInstalled(root)
|
|
42
|
+
? { label: 'web hook', state: 'ok', detail: 'the chat sees the pane (.claude/settings.local.json)' }
|
|
43
|
+
: { label: 'web hook', state: 'warn', detail: 'the chat cannot see the pane', fix: 'dt web hook --install' });
|
|
44
|
+
return rows;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/handoff.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The workspace switcher's server half (cr-modules-017 §7): the workspaces `dt web open` registered on this machine,
|
|
2
|
+
// and the handoff of the pane to one of them. A handoff reaches only a REGISTERED id; it starts the target through
|
|
3
|
+
// the dt-web the target itself has installed, asks the target (its control token, the same user) for a pairing capped
|
|
4
|
+
// at the lower of this page's level and the target's stored level — never writing that stored level (the 03:45
|
|
5
|
+
// revision) — and leaves a movedTo the hook answers once.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { lowerLevel } from '@dreamteamer/protocol/pane-scope';
|
|
9
|
+
import { ensureRunning } from './cli.js';
|
|
10
|
+
import { probeServer, runtimeDir as defaultRuntimeDir, stateFile } from './runtime.js';
|
|
11
|
+
import { lookup, nameNow, readRegistry, readWorkspaceState } from './state.js';
|
|
12
|
+
|
|
13
|
+
const TARGET_BIN = path.join('node_modules', '@dreamteamer', 'web', 'bin', 'dt-web.mjs');
|
|
14
|
+
/** Running = the server its runtime file names proves it is that server (runtime.js `probeServer`), not a live pid. */
|
|
15
|
+
const isRunning = async (root, rdir) => {
|
|
16
|
+
try { return (await probeServer(stateFile(root, rdir), { timeoutMs: 1000 }))?.proven === true; } catch { return false; }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** `GET /_dt/workspaces`: `[{ id, name, running, current }]` — never a root path. */
|
|
20
|
+
export async function listWorkspaces({ selfId, stateDir, runtimeDir = defaultRuntimeDir() }) {
|
|
21
|
+
return Promise.all(readRegistry(stateDir).map(async (e) => ({ id: e.id, name: nameNow(e), running: fs.existsSync(e.root) && await isRunning(e.root, runtimeDir), current: e.id === selfId })));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What a registered root lacks to be handed the pane, or null. */
|
|
25
|
+
export function missingFor(root) {
|
|
26
|
+
let pkg = null;
|
|
27
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* none */ }
|
|
28
|
+
if (!pkg || typeof pkg !== 'object' || !('dreamteamer' in pkg)) return 'its folder, or its package.json with a "dreamteamer" key, is gone';
|
|
29
|
+
if (!fs.existsSync(path.join(root, TARGET_BIN))) return `${TARGET_BIN.split(path.sep).join('/')} is not installed there (npm i @dreamteamer/web)`;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `POST /_dt/handoff { id }`. → `{ status, body }`: 200 `{ url }`, 404 for an id never registered, 409 for a root
|
|
35
|
+
* that cannot serve, 400 for this workspace itself, 502 when the target did not pair.
|
|
36
|
+
* @param {{ id: unknown, pageLevel: 'read'|'edit'|'admin', selfId: string, stateDir: string, runtimeDir?: string,
|
|
37
|
+
* moved: (to: { name: string, at: string }) => void, now?: () => number }} o
|
|
38
|
+
*/
|
|
39
|
+
export async function handoff({ id, pageLevel, selfId, stateDir, runtimeDir = defaultRuntimeDir(), moved, now = Date.now }) {
|
|
40
|
+
const found = typeof id === 'string' && /^[0-9a-f]{16}$/.test(id) ? lookup(id, stateDir) : null;
|
|
41
|
+
if (!found) return { status: 404, body: { error: `no workspace ${String(id)} was opened on this machine` } };
|
|
42
|
+
const entry = { ...found, name: nameNow(found) };
|
|
43
|
+
if (entry.id === selfId) return { status: 400, body: { error: `the pane already shows ${entry.name}` } };
|
|
44
|
+
const missing = missingFor(entry.root);
|
|
45
|
+
if (missing) return { status: 409, body: { error: `${entry.name} cannot take the pane: ${missing}` } };
|
|
46
|
+
const env = { ...process.env, DT_WEB_RUNTIME_DIR: runtimeDir, DT_WEB_STATE_DIR: stateDir };
|
|
47
|
+
let rt;
|
|
48
|
+
try {
|
|
49
|
+
rt = await ensureRunning(entry.root, { env, bin: path.join(entry.root, TARGET_BIN), log: () => {} });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return { status: 502, body: { error: `${entry.name} did not start: ${String(e?.message ?? e).split('\n')[0]}` } };
|
|
52
|
+
}
|
|
53
|
+
const cap = lowerLevel(pageLevel, readWorkspaceState(entry.root, stateDir).level);
|
|
54
|
+
let url = null;
|
|
55
|
+
try {
|
|
56
|
+
const r = await fetch(`http://127.0.0.1:${rt.port}/_dt/pair`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { authorization: `Bearer ${rt.controlToken}`, 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ cap }),
|
|
60
|
+
signal: AbortSignal.timeout(10_000),
|
|
61
|
+
});
|
|
62
|
+
if (r.ok) url = (await r.json())?.url ?? null;
|
|
63
|
+
} catch { /* below */ }
|
|
64
|
+
if (typeof url !== 'string' || !url.startsWith(`http://127.0.0.1:${rt.port}/sign-in/`)) return { status: 502, body: { error: `${entry.name} did not pair` } };
|
|
65
|
+
moved({ name: entry.name, at: new Date(now()).toISOString() });
|
|
66
|
+
return { status: 200, body: { url } };
|
|
67
|
+
}
|
package/src/headers.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The headers every response carries, and the policies (README § security; cr-modules-017 §1 with the 03:45
|
|
2
|
+
// revision's form-action 'none' and the plan's reading 6: record files frame inline under /_files/).
|
|
3
|
+
export function baseHeaders(res) {
|
|
4
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
5
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
6
|
+
res.removeHeader('X-Powered-By');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The app page's policy on the one listener, `host` the Host the request arrived on: scripts are this page's nonce
|
|
10
|
+
* plus the app path only, so nothing under /_files/ or /_frames/ can run as app code. */
|
|
11
|
+
export function appCsp(nonce, host) {
|
|
12
|
+
return [
|
|
13
|
+
"default-src 'none'",
|
|
14
|
+
`script-src 'nonce-${nonce}' http://${host}/app/`,
|
|
15
|
+
"style-src 'self' 'unsafe-inline'",
|
|
16
|
+
"img-src 'self' data: blob:",
|
|
17
|
+
"font-src 'self' data:",
|
|
18
|
+
"connect-src 'self'",
|
|
19
|
+
`frame-src http://${host}/_frames/ http://${host}/_files/`,
|
|
20
|
+
"form-action 'none'",
|
|
21
|
+
"base-uri 'none'",
|
|
22
|
+
"object-src 'none'",
|
|
23
|
+
"frame-ancestors 'none'",
|
|
24
|
+
].join('; ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A record file under /_files/: script off, a unique origin. */
|
|
28
|
+
export const FILE_CSP = 'sandbox';
|
|
29
|
+
/** A module frame document under /_frames/: scripts, never the page's origin. */
|
|
30
|
+
export const FRAME_CSP_PREFIX = 'sandbox allow-scripts';
|