@seanmars/tospec 0.23.0 → 0.24.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/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
- package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
- package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
- package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
- package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
- package/assets/dashboard/app.js +218 -1180
- package/assets/dashboard/index.html +31 -22
- package/assets/dashboard/lib/charts.js +355 -0
- package/assets/dashboard/lib/derive.js +369 -0
- package/assets/dashboard/lib/dom.js +121 -0
- package/assets/dashboard/lib/routes.js +52 -0
- package/assets/dashboard/lib/store.js +324 -0
- package/assets/dashboard/style.css +3103 -664
- package/assets/dashboard/views/alerts.js +170 -0
- package/assets/dashboard/views/change.js +232 -0
- package/assets/dashboard/views/docs.js +214 -0
- package/assets/dashboard/views/home.js +357 -0
- package/assets/dashboard/views/project.js +168 -0
- package/assets/dashboard/views/session.js +270 -0
- package/assets/dashboard/views/sidebar.js +131 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +74 -95
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/dashboard-relay.d.ts +11 -0
- package/dist/commands/dashboard-relay.d.ts.map +1 -0
- package/dist/commands/dashboard-relay.js +88 -0
- package/dist/commands/dashboard-relay.js.map +1 -0
- package/dist/commands/dashboard.d.ts +62 -102
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +375 -609
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/core/dashboard-hub.d.ts +258 -0
- package/dist/core/dashboard-hub.d.ts.map +1 -0
- package/dist/core/dashboard-hub.js +643 -0
- package/dist/core/dashboard-hub.js.map +1 -0
- package/dist/core/dashboard-routes.d.ts +56 -0
- package/dist/core/dashboard-routes.d.ts.map +1 -0
- package/dist/core/dashboard-routes.js +329 -0
- package/dist/core/dashboard-routes.js.map +1 -0
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +4 -1
- package/dist/core/init.js.map +1 -1
- package/dist/core/local-server.d.ts +1 -1
- package/dist/core/local-server.js +1 -1
- package/dist/core/shared/dashboard-mod.d.ts +24 -0
- package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
- package/dist/core/shared/dashboard-mod.js +44 -0
- package/dist/core/shared/dashboard-mod.js.map +1 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +4 -1
- package/dist/core/update.js.map +1 -1
- package/package.json +3 -2
- package/assets/dashboard/chart.umd.js +0 -14
|
@@ -1,172 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `tospec dashboard` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* `tospec dashboard start|stop|status` — one local hub for every tospec
|
|
3
|
+
* project. It reads no project files: Claude Code sessions push snapshots and
|
|
4
|
+
* activity through the `tospec-dashboard` mod (see `core/dashboard-hub.ts`),
|
|
5
|
+
* and the browser reads those. A task checkbox is relayed to a live session,
|
|
6
|
+
* which applies it with the checks in `core/dashboard-routes.ts`. Beyond that
|
|
7
|
+
* the page can only answer a question a session asked (an AskUserQuestion, a
|
|
8
|
+
* permission), and only from a hub bound to loopback; it never types a prompt.
|
|
6
9
|
*/
|
|
7
10
|
import * as http from 'node:http';
|
|
8
|
-
import { promises as fs, readdirSync,
|
|
11
|
+
import { promises as fs, readdirSync, rmSync } from 'node:fs';
|
|
9
12
|
import * as path from 'node:path';
|
|
10
|
-
import * as os from 'node:os';
|
|
11
|
-
import * as crypto from 'node:crypto';
|
|
12
13
|
import { spawn } from 'node:child_process';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { collectOverview, collectChangeDetail, collectArchive, collectDecisions, readSyncConclusion, } from '../core/dashboard-data.js';
|
|
16
|
-
import { collectActivity } from '../core/dashboard-activity.js';
|
|
14
|
+
import { DashboardHub, HubRequestError, } from '../core/dashboard-hub.js';
|
|
15
|
+
import { DASHBOARD_PROTOCOL } from '../core/dashboard-routes.js';
|
|
17
16
|
import { getGlobalConfigDir } from '../core/global-config.js';
|
|
18
|
-
import { acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader,
|
|
19
|
-
|
|
20
|
-
const DEFAULT_PORT = 5620;
|
|
17
|
+
import { LOOPBACK_HOSTS, acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader, listenOnce, normalizeHost, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
|
|
18
|
+
export const DEFAULT_PORT = 5620;
|
|
21
19
|
const DEFAULT_HOST = '127.0.0.1';
|
|
22
20
|
// Readiness signal the detached child writes to stderr once bound; emitted only
|
|
23
|
-
// when
|
|
21
|
+
// when TOSPEC_DASHBOARD_DETACHED marks it as the child `dashboard start` spawned.
|
|
24
22
|
const LISTENING_PREFIX = 'LISTENING ';
|
|
25
|
-
|
|
26
|
-
function stripErrorPrefix(text) {
|
|
27
|
-
return text.replace(/^Error:\s*/, '');
|
|
28
|
-
}
|
|
23
|
+
const DETACHED_ENV = 'TOSPEC_DASHBOARD_DETACHED';
|
|
29
24
|
// The child signals right after binding, so slower than a cold start plus a
|
|
30
25
|
// bind means it is stuck, not busy.
|
|
31
26
|
const READINESS_TIMEOUT_MS = 10_000;
|
|
27
|
+
// `status` asks a hub on this machine; one that takes longer is not answering.
|
|
28
|
+
const STATUS_TIMEOUT_MS = 3_000;
|
|
29
|
+
// How often online/offline transitions are checked and broadcast.
|
|
30
|
+
const SWEEP_INTERVAL_MS = 5_000;
|
|
31
|
+
// A browser POST carries one task tick or one answer (up to four questions of
|
|
32
|
+
// CJK text); a mod push carries a whole project.
|
|
33
|
+
const BROWSER_BODY_LIMIT = 64 * 1024;
|
|
34
|
+
const SNAPSHOT_BODY_LIMIT = 64 * 1024 * 1024;
|
|
35
|
+
const REPLY_BODY_LIMIT = 16 * 1024 * 1024;
|
|
36
|
+
const ACTIVITY_BODY_LIMIT = 1024 * 1024;
|
|
32
37
|
/** This command's shipped frontend; `serveAsset` is confined to it. */
|
|
33
38
|
const ASSETS_DIR = packageAssetsDir('dashboard');
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*
|
|
38
|
-
* WHATWG URL normalizes a literal `..` out of the pathname but does not
|
|
39
|
-
* percent-decode, so `%2e%2e%2f` survives until `decodeURIComponent` turns it
|
|
40
|
-
* back into `../../` — a traversal. Malformed escapes (`%zz`) are the client's
|
|
41
|
-
* mistake, so 400 rather than a 500 from the router.
|
|
42
|
-
*/
|
|
43
|
-
function decodePathSegment(raw, label, res) {
|
|
44
|
-
let decoded;
|
|
45
|
-
try {
|
|
46
|
-
decoded = decodeURIComponent(raw);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
sendJson(res, 400, { error: `malformed ${label}` });
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
if (!decoded || decoded.includes('/') || decoded.includes('\\') || decoded.includes('..')) {
|
|
53
|
-
sendJson(res, 403, { error: 'forbidden' });
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
return decoded;
|
|
57
|
-
}
|
|
58
|
-
async function handleChangeDetail(projectRoot, rawName, res) {
|
|
59
|
-
const name = decodePathSegment(rawName, 'change name', res);
|
|
60
|
-
if (name === null)
|
|
61
|
-
return;
|
|
62
|
-
try {
|
|
63
|
-
sendJson(res, 200, await collectChangeDetail(projectRoot, name));
|
|
64
|
-
}
|
|
65
|
-
catch (err) {
|
|
66
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
-
sendJson(res, /not found/i.test(message) ? 404 : 500, { error: message });
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
async function handleSpec(projectRoot, rawId, res) {
|
|
71
|
-
const id = decodePathSegment(rawId, 'spec id', res);
|
|
72
|
-
if (id === null)
|
|
73
|
-
return;
|
|
74
|
-
const specPath = path.join(projectRoot, 'tospec', 'specs', id, 'spec.md');
|
|
75
|
-
let content;
|
|
76
|
-
try {
|
|
77
|
-
content = await fs.readFile(specPath, 'utf-8');
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return sendJson(res, 404, { error: 'not found' });
|
|
81
|
-
}
|
|
82
|
-
const spec = new MarkdownParser(content).parseSpec(id);
|
|
83
|
-
sendJson(res, 200, { id, requirements: spec.requirements });
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Git activity + health signals (ADR 001). `change` feeds a `git log` path
|
|
87
|
-
* filter, so reject anything that could escape `tospec/changes/` before it
|
|
88
|
-
* reaches git. `days` is capped at 3650 to bound the zero-filled series.
|
|
89
|
-
*/
|
|
90
|
-
async function handleActivity(projectRoot, changeParam, daysParam, res) {
|
|
91
|
-
const change = changeParam ?? undefined;
|
|
92
|
-
if (change && (change.includes('/') || change.includes('\\') || change.includes('..'))) {
|
|
93
|
-
return sendJson(res, 403, { error: 'forbidden' });
|
|
94
|
-
}
|
|
95
|
-
// Absent or garbage stays undefined → collectActivity's default.
|
|
96
|
-
let window;
|
|
97
|
-
if (daysParam === 'all') {
|
|
98
|
-
window = 'all';
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
const parsed = Number(daysParam);
|
|
102
|
-
if (Number.isInteger(parsed) && parsed >= 1)
|
|
103
|
-
window = Math.min(parsed, 3650);
|
|
104
|
-
}
|
|
105
|
-
sendJson(res, 200, await collectActivity(projectRoot, change, window));
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Trust boundary (D5): resolves a client-supplied path to an absolute `.md` file
|
|
109
|
-
* under `<root>/<prefix>`, or null when it escapes.
|
|
110
|
-
*/
|
|
111
|
-
function resolveTospecFile(projectRoot, file, prefix) {
|
|
112
|
-
const resolved = path.resolve(projectRoot, file.replace(/\\/g, '/'));
|
|
113
|
-
if (!resolved.startsWith(path.resolve(projectRoot) + path.sep))
|
|
114
|
-
return null;
|
|
115
|
-
const posixRel = path.relative(projectRoot, resolved).replace(/\\/g, '/');
|
|
116
|
-
if (!posixRel.startsWith(prefix) || !posixRel.endsWith('.md'))
|
|
117
|
-
return null;
|
|
118
|
-
return resolved;
|
|
119
|
-
}
|
|
120
|
-
const ARCHIVE_SEGMENT = 'archive';
|
|
121
|
-
/**
|
|
122
|
-
* True for a path inside `tospec/changes/archive/`. Not folded into
|
|
123
|
-
* `resolveTospecFile`: `/api/render` shares that helper and legitimately renders
|
|
124
|
-
* archived documents, so the rejection belongs on the write path.
|
|
125
|
-
*/
|
|
126
|
-
function isArchivedChangePath(projectRoot, resolvedAbsPath) {
|
|
127
|
-
const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
|
|
128
|
-
const parts = posixRel.split('/');
|
|
129
|
-
return parts[0] === 'tospec' && parts[1] === 'changes' && parts[2] === ARCHIVE_SEGMENT;
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Maps a resolved task-file path (`tospec/changes/<name>/...`) back to its
|
|
133
|
-
* change name, or null when it doesn't match. An archived change is one level
|
|
134
|
-
* deeper, so `parts[2]` is the literal `archive`, never a change name.
|
|
135
|
-
*/
|
|
136
|
-
function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
|
|
137
|
-
const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
|
|
138
|
-
const parts = posixRel.split('/');
|
|
139
|
-
if (parts.length < 4 || parts[0] !== 'tospec' || parts[1] !== 'changes')
|
|
140
|
-
return null;
|
|
141
|
-
if (parts[2] === ARCHIVE_SEGMENT)
|
|
142
|
-
return null;
|
|
143
|
-
return parts[2] || null;
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Renders through `renderMarkdownSafe`, not `marked.parse`: the client assigns
|
|
147
|
-
* this with `innerHTML`, so anything the renderer passes through runs inside
|
|
148
|
-
* the CSRF boundary `handleTaskUpdate` relies on.
|
|
149
|
-
*/
|
|
150
|
-
async function handleRender(projectRoot, fileParam, res) {
|
|
151
|
-
if (!fileParam) {
|
|
152
|
-
return sendJson(res, 400, { error: 'missing file parameter' });
|
|
153
|
-
}
|
|
154
|
-
const resolved = resolveTospecFile(projectRoot, fileParam, 'tospec/');
|
|
155
|
-
if (!resolved) {
|
|
156
|
-
return sendJson(res, 403, { error: 'forbidden' });
|
|
157
|
-
}
|
|
158
|
-
let content;
|
|
159
|
-
try {
|
|
160
|
-
content = await fs.readFile(resolved, 'utf-8');
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return sendJson(res, 404, { error: 'not found' });
|
|
164
|
-
}
|
|
165
|
-
const html = await renderMarkdownSafe(content);
|
|
166
|
-
sendJson(res, 200, { html });
|
|
39
|
+
/** Drops one leading `Error: ` the child added, so wrapping doesn't stack prefixes. */
|
|
40
|
+
function stripErrorPrefix(text) {
|
|
41
|
+
return text.replace(/^Error:\s*/, '');
|
|
167
42
|
}
|
|
168
43
|
/** Reads a JSON request body, capped so a stray client can't exhaust memory. */
|
|
169
|
-
async function readJsonBody(req, limit
|
|
44
|
+
async function readJsonBody(req, limit) {
|
|
170
45
|
// Bytes first, decoded once: `string += chunk` decodes each chunk alone, so a
|
|
171
46
|
// multi-byte character split across two chunks (a CJK filename) came out as
|
|
172
47
|
// U+FFFD and the request addressed a file that does not exist.
|
|
@@ -176,7 +51,7 @@ async function readJsonBody(req, limit = 8192) {
|
|
|
176
51
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
177
52
|
size += buffer.length;
|
|
178
53
|
if (size > limit)
|
|
179
|
-
throw new
|
|
54
|
+
throw new HubRequestError('body too large', 413);
|
|
180
55
|
chunks.push(buffer);
|
|
181
56
|
}
|
|
182
57
|
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
@@ -184,103 +59,9 @@ async function readJsonBody(req, limit = 8192) {
|
|
|
184
59
|
return JSON.parse(raw);
|
|
185
60
|
}
|
|
186
61
|
catch {
|
|
187
|
-
throw new
|
|
62
|
+
throw new HubRequestError('invalid json', 400);
|
|
188
63
|
}
|
|
189
64
|
}
|
|
190
|
-
/**
|
|
191
|
-
* Ticks a task checkbox: `{ file, line, done }`, `file` root-relative and `line`
|
|
192
|
-
* 1-based. Writes are confined to `<root>/tospec/changes/`, so the dashboard can
|
|
193
|
-
* never edit specs.
|
|
194
|
-
*
|
|
195
|
-
* Requiring `content-type: application/json` is load-bearing: it pushes any
|
|
196
|
-
* cross-origin POST into a CORS preflight this server does not answer.
|
|
197
|
-
*/
|
|
198
|
-
async function handleTaskUpdate(projectRoot, req, res) {
|
|
199
|
-
if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
|
|
200
|
-
return sendJson(res, 415, { error: 'expected application/json' });
|
|
201
|
-
}
|
|
202
|
-
let body;
|
|
203
|
-
try {
|
|
204
|
-
body = await readJsonBody(req);
|
|
205
|
-
}
|
|
206
|
-
catch (err) {
|
|
207
|
-
return sendJson(res, 400, { error: err instanceof Error ? err.message : 'bad request' });
|
|
208
|
-
}
|
|
209
|
-
const { file, line, done } = (body ?? {});
|
|
210
|
-
if (typeof file !== 'string' || !Number.isInteger(line) || line < 1) {
|
|
211
|
-
return sendJson(res, 400, { error: 'expected { file: string, line: int >= 1, done: bool }' });
|
|
212
|
-
}
|
|
213
|
-
if (typeof done !== 'boolean') {
|
|
214
|
-
return sendJson(res, 400, { error: 'expected { file: string, line: int >= 1, done: bool }' });
|
|
215
|
-
}
|
|
216
|
-
const resolved = resolveTospecFile(projectRoot, file, 'tospec/changes/');
|
|
217
|
-
if (!resolved) {
|
|
218
|
-
return sendJson(res, 403, { error: 'forbidden' });
|
|
219
|
-
}
|
|
220
|
-
// Before the sync gate: that gate asks whether a change is *becoming*
|
|
221
|
-
// immutable; an archived one already is.
|
|
222
|
-
if (isArchivedChangePath(projectRoot, resolved)) {
|
|
223
|
-
return sendJson(res, 403, { error: 'change is archived; tasks are immutable' });
|
|
224
|
-
}
|
|
225
|
-
// The checks above are lexical. A task file that is itself a symlink out of
|
|
226
|
-
// the project passes both, and the write would land outside the root, so the
|
|
227
|
-
// fence is re-checked on the real path. Before the tracked-file check below:
|
|
228
|
-
// that check canonicalizes its own side, so the symlink used to fail there
|
|
229
|
-
// as "not the tracked file" — refused, but for the wrong reason. A file that
|
|
230
|
-
// is gone by now is the 404 the write would have reported.
|
|
231
|
-
let realFile;
|
|
232
|
-
try {
|
|
233
|
-
const realRoot = await fs.realpath(projectRoot);
|
|
234
|
-
realFile = await fs.realpath(resolved);
|
|
235
|
-
if (!realFile.startsWith(realRoot + path.sep)) {
|
|
236
|
-
return sendJson(res, 403, { error: 'forbidden' });
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
catch (err) {
|
|
240
|
-
if (err.code === 'ENOENT')
|
|
241
|
-
return sendJson(res, 404, { error: 'not found' });
|
|
242
|
-
throw err;
|
|
243
|
-
}
|
|
244
|
-
// A file directly under tospec/changes/ belongs to no change, so neither the
|
|
245
|
-
// sync gate nor the tracked-file check below can apply to it. Skipping both
|
|
246
|
-
// left any markdown there writable by a raw POST.
|
|
247
|
-
const changeName = changeNameFromTaskFile(projectRoot, resolved);
|
|
248
|
-
if (!changeName) {
|
|
249
|
-
return sendJson(res, 403, { error: 'forbidden' });
|
|
250
|
-
}
|
|
251
|
-
// Freeze task edits once a change is sync certified. The frontend disable is
|
|
252
|
-
// only UX; this gate is what protects certified content from a raw POST. A
|
|
253
|
-
// read error reads as not certified.
|
|
254
|
-
const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
|
|
255
|
-
if ((await readSyncConclusion(changeDir)) === 'PASS') {
|
|
256
|
-
return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
|
|
257
|
-
}
|
|
258
|
-
// Confined to the schema's tracked task file, via the resolver the read
|
|
259
|
-
// side uses. The path check above only proves the file sits inside a change
|
|
260
|
-
// directory, so any other markdown there with a checkbox on the addressed
|
|
261
|
-
// line was writable too, invisibly. The resolver canonicalizes the files it
|
|
262
|
-
// finds and leaves its `tasks.md` fallback as joined, so both spellings of
|
|
263
|
-
// the request path are compared.
|
|
264
|
-
const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
|
|
265
|
-
if (!tracked.includes(realFile) && !tracked.includes(path.resolve(resolved))) {
|
|
266
|
-
return sendJson(res, 409, {
|
|
267
|
-
error: `${path.basename(resolved)} is not this change's tracked task file`,
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
try {
|
|
271
|
-
await setTaskDone(resolved, line, done);
|
|
272
|
-
}
|
|
273
|
-
catch (err) {
|
|
274
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
275
|
-
// Stale client view or a deleted file — neither is a server fault, so no 500.
|
|
276
|
-
if (/not a task/.test(message))
|
|
277
|
-
return sendJson(res, 409, { error: message });
|
|
278
|
-
if (/ENOENT/.test(message))
|
|
279
|
-
return sendJson(res, 404, { error: 'not found' });
|
|
280
|
-
throw err;
|
|
281
|
-
}
|
|
282
|
-
sendJson(res, 200, { file, line, done });
|
|
283
|
-
}
|
|
284
65
|
function handleEvents(req, res, clients) {
|
|
285
66
|
res.writeHead(200, {
|
|
286
67
|
'content-type': 'text/event-stream',
|
|
@@ -291,18 +72,22 @@ function handleEvents(req, res, clients) {
|
|
|
291
72
|
clients.add(res);
|
|
292
73
|
req.on('close', () => clients.delete(res));
|
|
293
74
|
}
|
|
294
|
-
function
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
75
|
+
function broadcast(clients, event) {
|
|
76
|
+
const { type, ...data } = event;
|
|
77
|
+
const line = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
78
|
+
for (const client of clients)
|
|
79
|
+
client.write(line);
|
|
298
80
|
}
|
|
299
81
|
/**
|
|
300
82
|
* Second layer behind `renderMarkdownSafe`, applied to every response.
|
|
301
83
|
*
|
|
302
84
|
* `script-src 'self'` is the load-bearing directive: it refuses the inline
|
|
303
|
-
* handlers and `javascript:` URLs a markdown file would need to reach
|
|
304
|
-
*
|
|
305
|
-
* for Chart.js
|
|
85
|
+
* handlers and `javascript:` URLs a markdown file would need to reach the task
|
|
86
|
+
* relay from inside this origin. `style-src` still carries `'unsafe-inline'`,
|
|
87
|
+
* added for Chart.js, which the dashboard no longer loads (its charts are SVG
|
|
88
|
+
* styled from the stylesheet, and the app sets styles through the CSSOM).
|
|
89
|
+
* Dropping it is a separate hardening step; a style injection cannot call the
|
|
90
|
+
* write endpoint either way.
|
|
306
91
|
*/
|
|
307
92
|
function applyContentSecurityPolicy(res) {
|
|
308
93
|
res.setHeader('content-security-policy', [
|
|
@@ -317,7 +102,104 @@ function applyContentSecurityPolicy(res) {
|
|
|
317
102
|
"frame-ancestors 'none'",
|
|
318
103
|
].join('; '));
|
|
319
104
|
}
|
|
320
|
-
|
|
105
|
+
/** `/api/p/<key>/<rest>` → the project key and the per-project path `/api/<rest>?…`. */
|
|
106
|
+
function matchProjectPath(url) {
|
|
107
|
+
const match = /^\/api\/p\/([0-9a-f]{12})(\/.*)$/.exec(url.pathname);
|
|
108
|
+
if (!match)
|
|
109
|
+
return null;
|
|
110
|
+
return { key: match[1], inner: `/api${match[2]}${url.search}` };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* `/api/p/<key>/sessions/<id>/<action>`: the hub's own answers about one
|
|
114
|
+
* session, matched before the per-project relay, which would otherwise carry
|
|
115
|
+
* them to the project as a file read.
|
|
116
|
+
*/
|
|
117
|
+
function matchSessionPath(url) {
|
|
118
|
+
const match = /^\/api\/p\/([0-9a-f]{12})\/sessions\/([^/]+)\/(log|answer)$/.exec(url.pathname);
|
|
119
|
+
if (!match)
|
|
120
|
+
return null;
|
|
121
|
+
try {
|
|
122
|
+
return { key: match[1], session: decodeURIComponent(match[2]), action: match[3] };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function fieldsOf(body) {
|
|
129
|
+
return typeof body === 'object' && body !== null ? body : {};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Every POST must be JSON. That is the CSRF fence for the whole write surface:
|
|
133
|
+
* a cross-origin page can only send `application/json` through a CORS preflight
|
|
134
|
+
* this server never answers, so no web page can push a snapshot, take a queued
|
|
135
|
+
* command, tick a task, or answer for a session.
|
|
136
|
+
*/
|
|
137
|
+
async function handlePost(hub, req, res, url) {
|
|
138
|
+
if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
|
|
139
|
+
return sendJson(res, 415, { error: 'expected application/json' });
|
|
140
|
+
}
|
|
141
|
+
const pathname = url.pathname;
|
|
142
|
+
if (pathname === '/api/mod/snapshot') {
|
|
143
|
+
const body = fieldsOf(await readJsonBody(req, SNAPSHOT_BODY_LIMIT));
|
|
144
|
+
return sendJson(res, 200, { project: hub.acceptSnapshot(body.session, body.snapshot) });
|
|
145
|
+
}
|
|
146
|
+
if (pathname === '/api/mod/poll') {
|
|
147
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
148
|
+
const hangUp = new AbortController();
|
|
149
|
+
res.on('close', () => hangUp.abort());
|
|
150
|
+
const commands = await hub.poll(body.project, body.session, hangUp.signal);
|
|
151
|
+
if (commands === null)
|
|
152
|
+
return sendJson(res, 404, { error: 'unknown project' });
|
|
153
|
+
return sendJson(res, 200, { commands });
|
|
154
|
+
}
|
|
155
|
+
if (pathname === '/api/mod/reply') {
|
|
156
|
+
const body = fieldsOf(await readJsonBody(req, REPLY_BODY_LIMIT));
|
|
157
|
+
const settled = hub.reply(body.id, { status: body.status, body: body.body });
|
|
158
|
+
return sendJson(res, settled ? 200 : 404, settled ? { ok: true } : { error: 'no such request' });
|
|
159
|
+
}
|
|
160
|
+
if (pathname === '/api/mod/leave') {
|
|
161
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
162
|
+
hub.leave(body.project, body.session);
|
|
163
|
+
return sendJson(res, 200, { ok: true });
|
|
164
|
+
}
|
|
165
|
+
if (pathname === '/api/mod/activity') {
|
|
166
|
+
const body = fieldsOf(await readJsonBody(req, ACTIVITY_BODY_LIMIT));
|
|
167
|
+
const known = hub.reportActivity(body.project, body.session, body);
|
|
168
|
+
return sendJson(res, known ? 200 : 404, known ? { ok: true } : { error: 'unknown project' });
|
|
169
|
+
}
|
|
170
|
+
if (pathname === '/api/mod/ask') {
|
|
171
|
+
const body = fieldsOf(await readJsonBody(req, ACTIVITY_BODY_LIMIT));
|
|
172
|
+
return sendJson(res, 200, hub.openAsk(body.project, body.session, body.ask));
|
|
173
|
+
}
|
|
174
|
+
if (pathname === '/api/mod/ask/wait') {
|
|
175
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
176
|
+
const hangUp = new AbortController();
|
|
177
|
+
res.on('close', () => hangUp.abort());
|
|
178
|
+
return sendJson(res, 200, await hub.waitAsk(body.id, hangUp.signal));
|
|
179
|
+
}
|
|
180
|
+
if (pathname === '/api/mod/ask/close') {
|
|
181
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
182
|
+
hub.closeAsk(body.id);
|
|
183
|
+
return sendJson(res, 200, { ok: true });
|
|
184
|
+
}
|
|
185
|
+
const session = matchSessionPath(url);
|
|
186
|
+
if (session && session.action === 'answer') {
|
|
187
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
188
|
+
const answer = hub.answerAsk(session.key, session.session, body.id, body.answer);
|
|
189
|
+
return sendJson(res, answer.status, answer.body);
|
|
190
|
+
}
|
|
191
|
+
const scoped = matchProjectPath(url);
|
|
192
|
+
if (scoped && scoped.inner === '/api/task') {
|
|
193
|
+
const body = await readJsonBody(req, BROWSER_BODY_LIMIT);
|
|
194
|
+
const answer = await hub.write(scoped.key, { method: 'POST', path: '/api/task', body });
|
|
195
|
+
return sendJson(res, answer.status, answer.body);
|
|
196
|
+
}
|
|
197
|
+
if (scoped && scoped.inner === '/api/refresh') {
|
|
198
|
+
return sendJson(res, 202, { queued: hub.requestRefresh(scoped.key) });
|
|
199
|
+
}
|
|
200
|
+
sendJson(res, 404, { error: 'not found' });
|
|
201
|
+
}
|
|
202
|
+
async function handleRequest(hub, req, res, clients, boundHost, acceptAnyHost) {
|
|
321
203
|
try {
|
|
322
204
|
// Host allowlist runs before any route (DNS-rebinding guard).
|
|
323
205
|
if (!isAllowedHostHeader(req.headers.host, boundHost, acceptAnyHost)) {
|
|
@@ -326,14 +208,29 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
326
208
|
applyContentSecurityPolicy(res);
|
|
327
209
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
328
210
|
const pathname = url.pathname;
|
|
329
|
-
if (req.method === 'POST'
|
|
330
|
-
return await
|
|
211
|
+
if (req.method === 'POST') {
|
|
212
|
+
return await handlePost(hub, req, res, url);
|
|
331
213
|
}
|
|
332
214
|
if (req.method !== 'GET') {
|
|
333
215
|
return sendJson(res, 405, { error: 'method not allowed' });
|
|
334
216
|
}
|
|
335
217
|
if (pathname === '/api/events')
|
|
336
218
|
return handleEvents(req, res, clients);
|
|
219
|
+
// The mod's cheap reachability probe, so a session with no hub to talk to
|
|
220
|
+
// does not run the tospec CLI just to find that out.
|
|
221
|
+
if (pathname === '/api/health')
|
|
222
|
+
return sendJson(res, 200, { protocol: DASHBOARD_PROTOCOL });
|
|
223
|
+
// `interactive` tells the page whether to offer the answer controls.
|
|
224
|
+
if (pathname === '/api/projects') {
|
|
225
|
+
return sendJson(res, 200, { projects: hub.listProjects(), interactive: hub.interactive });
|
|
226
|
+
}
|
|
227
|
+
const session = matchSessionPath(url);
|
|
228
|
+
if (session) {
|
|
229
|
+
if (session.action !== 'log')
|
|
230
|
+
return sendJson(res, 405, { error: 'method not allowed' });
|
|
231
|
+
const log = hub.sessionLog(session.key, session.session);
|
|
232
|
+
return log ? sendJson(res, 200, log) : sendJson(res, 404, { error: 'That session is not connected to this dashboard.' });
|
|
233
|
+
}
|
|
337
234
|
// `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
|
|
338
235
|
if (pathname === '/' || pathname === '/index.html') {
|
|
339
236
|
return await serveAsset(res, ASSETS_DIR, 'index.html');
|
|
@@ -341,89 +238,76 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
341
238
|
if (pathname.startsWith('/assets/')) {
|
|
342
239
|
return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
|
|
343
240
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return sendJson(res, 200, await collectArchive(projectRoot));
|
|
349
|
-
}
|
|
350
|
-
if (pathname === '/api/decisions') {
|
|
351
|
-
return sendJson(res, 200, await collectDecisions(projectRoot));
|
|
352
|
-
}
|
|
353
|
-
if (pathname === '/api/activity') {
|
|
354
|
-
return await handleActivity(projectRoot, url.searchParams.get('change'), url.searchParams.get('days'), res);
|
|
355
|
-
}
|
|
356
|
-
if (pathname.startsWith('/api/changes/')) {
|
|
357
|
-
return await handleChangeDetail(projectRoot, pathname.slice('/api/changes/'.length), res);
|
|
358
|
-
}
|
|
359
|
-
if (pathname.startsWith('/api/specs/')) {
|
|
360
|
-
return await handleSpec(projectRoot, pathname.slice('/api/specs/'.length), res);
|
|
361
|
-
}
|
|
362
|
-
if (pathname === '/api/render') {
|
|
363
|
-
return await handleRender(projectRoot, url.searchParams.get('file'), res);
|
|
241
|
+
const scoped = matchProjectPath(url);
|
|
242
|
+
if (scoped) {
|
|
243
|
+
const answer = await hub.read(scoped.key, scoped.inner);
|
|
244
|
+
return sendJson(res, answer.status, answer.body);
|
|
364
245
|
}
|
|
365
246
|
sendJson(res, 404, { error: 'not found' });
|
|
366
247
|
}
|
|
367
248
|
catch (err) {
|
|
249
|
+
if (err instanceof HubRequestError)
|
|
250
|
+
return sendJson(res, err.status, { error: err.message });
|
|
368
251
|
// A single handler failure must never crash the server.
|
|
369
252
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
370
253
|
}
|
|
371
254
|
}
|
|
372
255
|
/**
|
|
373
|
-
* Starts the
|
|
374
|
-
*
|
|
375
|
-
*
|
|
256
|
+
* Starts the hub on exactly the requested port. Unlike the per-project servers
|
|
257
|
+
* this replaced, it never advances past `EADDRINUSE`: mods find the hub at a
|
|
258
|
+
* fixed address, so a hub that moved would be one no session could reach.
|
|
259
|
+
* Side-effect free (no logging, signal handlers or registry) so tests can drive
|
|
260
|
+
* it directly.
|
|
376
261
|
*/
|
|
377
|
-
export async function startDashboardServer(
|
|
262
|
+
export async function startDashboardServer(opts = {}) {
|
|
378
263
|
const host = opts.host ?? DEFAULT_HOST;
|
|
379
264
|
assertBindableHost(host, 'dashboard', opts.allowRemote, 'Pass --allow-remote to accept that risk.');
|
|
380
265
|
const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
|
|
381
266
|
const clients = new Set();
|
|
267
|
+
const hub = new DashboardHub({
|
|
268
|
+
// Driving a session from the page runs tools in it, so only a hub that
|
|
269
|
+
// nobody else can reach may do so; `--allow-remote` leaves it read-only.
|
|
270
|
+
interactive: LOOPBACK_HOSTS.has(normalizeHost(host)),
|
|
271
|
+
...opts.hub,
|
|
272
|
+
});
|
|
273
|
+
const unsubscribe = hub.subscribe((event) => broadcast(clients, event));
|
|
382
274
|
const server = http.createServer((req, res) => {
|
|
383
|
-
void handleRequest(
|
|
275
|
+
void handleRequest(hub, req, res, clients, host, acceptAnyHost);
|
|
384
276
|
});
|
|
385
|
-
const
|
|
386
|
-
const watcher = watchTospec(projectRoot, () => broadcastChange(clients));
|
|
387
|
-
server.once('close', () => watcher?.close());
|
|
388
|
-
return { server, url: connectableUrl(host, actualPort), port: actualPort };
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* Watches `<root>/tospec` recursively and calls `onChange` (debounced), or null
|
|
392
|
-
* if the directory can't be watched. `fs.watch` uses OS change notifications,
|
|
393
|
-
* so it never locks the watched files — agents and users stay free to edit or
|
|
394
|
-
* delete them (D2). Recursive watch is native on win32/macOS, Node 20+ on Linux.
|
|
395
|
-
*/
|
|
396
|
-
function watchTospec(projectRoot, onChange) {
|
|
397
|
-
const dir = path.join(projectRoot, 'tospec');
|
|
398
|
-
let timer = null;
|
|
277
|
+
const requestedPort = opts.port ?? DEFAULT_PORT;
|
|
399
278
|
try {
|
|
400
|
-
|
|
401
|
-
if (timer)
|
|
402
|
-
clearTimeout(timer);
|
|
403
|
-
timer = setTimeout(onChange, 200);
|
|
404
|
-
});
|
|
405
|
-
watcher.on('close', () => timer && clearTimeout(timer));
|
|
406
|
-
watcher.on('error', () => { }); // transient watch errors must not crash the server
|
|
407
|
-
return watcher;
|
|
279
|
+
await listenOnce(server, requestedPort, host);
|
|
408
280
|
}
|
|
409
|
-
catch {
|
|
410
|
-
|
|
281
|
+
catch (err) {
|
|
282
|
+
if (err.code === 'EADDRINUSE') {
|
|
283
|
+
throw new Error(`Port ${requestedPort} is already in use. Another dashboard may be running: see tospec dashboard status.`);
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
411
286
|
}
|
|
287
|
+
const address = server.address();
|
|
288
|
+
const port = typeof address === 'object' && address ? address.port : requestedPort;
|
|
289
|
+
const sweep = setInterval(() => hub.sweep(), SWEEP_INTERVAL_MS);
|
|
290
|
+
sweep.unref();
|
|
291
|
+
const close = async () => {
|
|
292
|
+
clearInterval(sweep);
|
|
293
|
+
unsubscribe();
|
|
294
|
+
hub.close();
|
|
295
|
+
// Held polls and SSE streams would otherwise keep close() from draining.
|
|
296
|
+
server.closeAllConnections?.();
|
|
297
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
298
|
+
};
|
|
299
|
+
return { server, url: connectableUrl(host, port), port, hub, close };
|
|
412
300
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
export function pidFilePath(projectRoot) {
|
|
418
|
-
const hash = crypto.createHash('sha1').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
|
|
419
|
-
return path.join(os.tmpdir(), `tospec-dashboard-${hash}.json`);
|
|
301
|
+
function registryDir() {
|
|
302
|
+
// Derived from the one config resolver, so relocating the config directory
|
|
303
|
+
// moves the registry with it.
|
|
304
|
+
return path.join(getGlobalConfigDir(), 'dashboards');
|
|
420
305
|
}
|
|
421
306
|
/** True if a process with this PID exists (signal 0 probes without killing). */
|
|
422
307
|
function isProcessAlive(pid) {
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
// terminate the user's own shell.
|
|
308
|
+
// On POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
|
|
309
|
+
// named one, so a corrupt marker would make `stop` terminate the user's own
|
|
310
|
+
// shell.
|
|
427
311
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
428
312
|
return false;
|
|
429
313
|
try {
|
|
@@ -435,67 +319,6 @@ function isProcessAlive(pid) {
|
|
|
435
319
|
return err.code === 'EPERM';
|
|
436
320
|
}
|
|
437
321
|
}
|
|
438
|
-
/**
|
|
439
|
-
* This project's dashboard PID record, or null if absent or corrupt. `pid` is
|
|
440
|
-
* the only required field — a truncated record still names a process to reap.
|
|
441
|
-
*/
|
|
442
|
-
async function readPidRecord(pidFile) {
|
|
443
|
-
try {
|
|
444
|
-
const rec = JSON.parse(await fs.readFile(pidFile, 'utf-8'));
|
|
445
|
-
return typeof rec?.pid === 'number' ? rec : null;
|
|
446
|
-
}
|
|
447
|
-
catch {
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Records this dashboard so `--stop` can find it. Reads tolerate the missing
|
|
453
|
-
* optional fields of pre-0.13 records; writes do not, since the root is what
|
|
454
|
-
* attributes a scanned record to its project.
|
|
455
|
-
*/
|
|
456
|
-
async function writePidRecord(pidFile, rec) {
|
|
457
|
-
await fs.writeFile(pidFile, JSON.stringify(rec), 'utf-8');
|
|
458
|
-
}
|
|
459
|
-
/**
|
|
460
|
-
* Removes a pid record only if it still names `pid`: the path is keyed by
|
|
461
|
-
* project, not process, so two sibling dashboards share this file and either
|
|
462
|
-
* clearer would otherwise delete the other's record.
|
|
463
|
-
*
|
|
464
|
-
* Limitation: one record per project, so only the later writer is tracked by
|
|
465
|
-
* pid file; both stay reachable through their registry markers.
|
|
466
|
-
*
|
|
467
|
-
* Sync so it completes before the `process.exit` its signal-handler caller
|
|
468
|
-
* races — hence not reusing the async `readPidRecord`.
|
|
469
|
-
*/
|
|
470
|
-
export function clearPidRecordOwnedBy(pidFile, pid) {
|
|
471
|
-
try {
|
|
472
|
-
const rec = JSON.parse(readFileSync(pidFile, 'utf-8'));
|
|
473
|
-
if (rec?.pid === pid)
|
|
474
|
-
unlinkSync(pidFile);
|
|
475
|
-
}
|
|
476
|
-
catch {
|
|
477
|
-
// Absent: nothing to do. Corrupt: left alone — it names no process, and a
|
|
478
|
-
// half-written record may belong to one that is starting up.
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
/** Every project's pid file, not just this one's — they all live in the temp dir. */
|
|
482
|
-
async function pidFilePaths() {
|
|
483
|
-
const dir = os.tmpdir();
|
|
484
|
-
try {
|
|
485
|
-
return (await fs.readdir(dir))
|
|
486
|
-
// Exactly the shape pidFilePath() generates — never wider than we write.
|
|
487
|
-
.filter((name) => /^tospec-dashboard-[0-9a-f]{12}\.json$/.test(name))
|
|
488
|
-
.map((name) => path.join(dir, name));
|
|
489
|
-
}
|
|
490
|
-
catch {
|
|
491
|
-
return [];
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
function registryDir() {
|
|
495
|
-
// Derived from the one config resolver, so relocating the config directory
|
|
496
|
-
// moves the registry with it.
|
|
497
|
-
return path.join(getGlobalConfigDir(), 'dashboards');
|
|
498
|
-
}
|
|
499
322
|
/**
|
|
500
323
|
* Lists live dashboards sorted by port, reaping any marker whose process has
|
|
501
324
|
* died. A missing or unreadable dir means no dashboards are running.
|
|
@@ -515,6 +338,7 @@ export async function listDashboards() {
|
|
|
515
338
|
if (!m)
|
|
516
339
|
continue;
|
|
517
340
|
const pid = Number(m[1]);
|
|
341
|
+
const port = Number(m[2]);
|
|
518
342
|
const file = path.join(dir, name);
|
|
519
343
|
if (!isProcessAlive(pid)) {
|
|
520
344
|
// Best-effort: failing to unlink one stale marker must not take down a
|
|
@@ -522,112 +346,27 @@ export async function listDashboards() {
|
|
|
522
346
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
523
347
|
continue;
|
|
524
348
|
}
|
|
525
|
-
let
|
|
349
|
+
let url = '';
|
|
526
350
|
try {
|
|
527
|
-
|
|
351
|
+
url = (await fs.readFile(file, 'utf-8')).trim();
|
|
528
352
|
}
|
|
529
353
|
catch {
|
|
530
|
-
/* marker
|
|
354
|
+
/* unreadable marker → fall back to loopback below */
|
|
531
355
|
}
|
|
532
|
-
|
|
356
|
+
// A marker from before the hub held a project root, not a URL.
|
|
357
|
+
if (!/^https?:\/\//.test(url))
|
|
358
|
+
url = connectableUrl(DEFAULT_HOST, port);
|
|
359
|
+
running.push({ pid, port, url });
|
|
533
360
|
}
|
|
534
361
|
return running.sort((a, b) => a.port - b.port);
|
|
535
362
|
}
|
|
536
|
-
/**
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
*/
|
|
540
|
-
export function formatRunningDashboard(d, sep = '\t') {
|
|
541
|
-
const url = `http://${DEFAULT_HOST}:${d.port}`;
|
|
542
|
-
return `pid ${d.pid}${sep}${url}${sep}${d.projectRoot || '(unknown project)'}`;
|
|
543
|
-
}
|
|
544
|
-
/**
|
|
545
|
-
* `--stop` found dashboards but cannot ask which to stop (no terminal). Carries
|
|
546
|
-
* its own diagnostic so the shared failure sink renders it.
|
|
547
|
-
*/
|
|
548
|
-
export class DashboardStopError extends Error {
|
|
549
|
-
diagnostic;
|
|
550
|
-
constructor(message, fix) {
|
|
551
|
-
super(message);
|
|
552
|
-
this.name = 'DashboardStopError';
|
|
553
|
-
this.diagnostic = { severity: 'error', code: 'dashboard_stop_needs_selection', message, fix };
|
|
554
|
-
}
|
|
363
|
+
/** One line describing a running dashboard, for `start`'s refusal. */
|
|
364
|
+
export function formatRunningDashboard(d) {
|
|
365
|
+
return `pid ${d.pid} ${d.url}`;
|
|
555
366
|
}
|
|
556
|
-
|
|
557
|
-
* The detached child neither reported itself listening nor exited. It may still
|
|
558
|
-
* be coming up, so the message reports what was not observed rather than
|
|
559
|
-
* claiming failure.
|
|
560
|
-
*/
|
|
561
|
-
export class DashboardReadinessTimeoutError extends Error {
|
|
562
|
-
diagnostic;
|
|
563
|
-
constructor(timeoutMs) {
|
|
564
|
-
const message = `The background dashboard process did not report that it is listening within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
565
|
-
'It was started and has not exited, so it may still be coming up.';
|
|
566
|
-
super(message);
|
|
567
|
-
this.name = 'DashboardReadinessTimeoutError';
|
|
568
|
-
this.diagnostic = {
|
|
569
|
-
severity: 'error',
|
|
570
|
-
code: 'dashboard_detach_readiness_timeout',
|
|
571
|
-
message,
|
|
572
|
-
fix: 'Check whether it came up with: tospec dashboard --list (then tospec dashboard --stop to stop it).',
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
/**
|
|
577
|
-
* Every dashboard `--stop` can reach: the registry unioned with every project's
|
|
578
|
-
* pid file, keyed by pid, reaping dead records along the way.
|
|
579
|
-
*
|
|
580
|
-
* The registry alone is not enough — `listDashboards()` reaps any marker whose
|
|
581
|
-
* pid looks dead, so a live server can lose its marker and become unstoppable
|
|
582
|
-
* while its pid file still names it.
|
|
583
|
-
*
|
|
584
|
-
* `ownRootHint` names records written before pid files carried their own root;
|
|
585
|
-
* it never restricts the result.
|
|
586
|
-
*/
|
|
587
|
-
export async function collectStoppableDashboards(ownRootHint) {
|
|
588
|
-
const byPid = new Map();
|
|
589
|
-
for (const d of await listDashboards())
|
|
590
|
-
byPid.set(d.pid, d);
|
|
591
|
-
const ownPidFile = ownRootHint ? pidFilePath(ownRootHint) : null;
|
|
592
|
-
for (const file of await pidFilePaths()) {
|
|
593
|
-
const rec = await readPidRecord(file);
|
|
594
|
-
if (!rec)
|
|
595
|
-
continue;
|
|
596
|
-
// Reap like the marker reaper does: a crashed dashboard's record would
|
|
597
|
-
// otherwise linger until the OS recycled its pid, then read as alive again —
|
|
598
|
-
// a phantom `--stop` would kill an unrelated process for. Unlink is
|
|
599
|
-
// best-effort: the POSIX temp dir is shared with other users.
|
|
600
|
-
if (!isProcessAlive(rec.pid)) {
|
|
601
|
-
await fs.rm(file, { force: true }).catch(() => { });
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
// Live, but we cannot name a port to report or reach it by.
|
|
605
|
-
if (typeof rec.port !== 'number')
|
|
606
|
-
continue;
|
|
607
|
-
const root = rec.projectRoot ?? (file === ownPidFile && ownRootHint ? path.resolve(ownRootHint) : '');
|
|
608
|
-
const known = byPid.get(rec.pid);
|
|
609
|
-
if (!known) {
|
|
610
|
-
byPid.set(rec.pid, { pid: rec.pid, port: rec.port, projectRoot: root });
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
// A content-less registry marker never matches its project; the pid file may
|
|
614
|
-
// still know the root.
|
|
615
|
-
if (!known.projectRoot && root)
|
|
616
|
-
byPid.set(rec.pid, { ...known, projectRoot: root });
|
|
617
|
-
}
|
|
618
|
-
return [...byPid.values()].sort((a, b) => a.port - b.port);
|
|
619
|
-
}
|
|
620
|
-
/**
|
|
621
|
-
* Ports held by live dashboards, reaping dead markers as a side effect. Runs at
|
|
622
|
-
* every startup so a crashed dashboard's port frees up.
|
|
623
|
-
*/
|
|
624
|
-
async function reapAndCollectPorts() {
|
|
625
|
-
return new Set((await listDashboards()).map((d) => d.port));
|
|
626
|
-
}
|
|
627
|
-
/** Creates the `{pid}_{port}` marker for this dashboard, holding its project root. */
|
|
628
|
-
async function registerDashboard(pid, port, projectRoot) {
|
|
367
|
+
async function registerDashboard(pid, port, url) {
|
|
629
368
|
await fs.mkdir(registryDir(), { recursive: true });
|
|
630
|
-
await fs.writeFile(path.join(registryDir(), `${pid}_${port}`),
|
|
369
|
+
await fs.writeFile(path.join(registryDir(), `${pid}_${port}`), url, 'utf-8');
|
|
631
370
|
}
|
|
632
371
|
/** Removes any marker for this pid (port-agnostic, so callers needn't know it). */
|
|
633
372
|
async function unregisterDashboard(pid) {
|
|
@@ -666,42 +405,45 @@ function unregisterDashboardSync(pid) {
|
|
|
666
405
|
}
|
|
667
406
|
}
|
|
668
407
|
/**
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
408
|
+
* The detached child neither reported itself listening nor exited. It may still
|
|
409
|
+
* be coming up, so the message reports what was not observed rather than
|
|
410
|
+
* claiming failure.
|
|
411
|
+
*/
|
|
412
|
+
export class DashboardReadinessTimeoutError extends Error {
|
|
413
|
+
diagnostic;
|
|
414
|
+
constructor(timeoutMs) {
|
|
415
|
+
const message = `The background dashboard process did not report that it is listening within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
416
|
+
'It was started and has not exited, so it may still be coming up.';
|
|
417
|
+
super(message);
|
|
418
|
+
this.name = 'DashboardReadinessTimeoutError';
|
|
419
|
+
this.diagnostic = {
|
|
420
|
+
severity: 'error',
|
|
421
|
+
code: 'dashboard_detach_readiness_timeout',
|
|
422
|
+
message,
|
|
423
|
+
fix: 'Check whether it came up with: tospec dashboard status (then tospec dashboard stop to stop it).',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* `tospec dashboard start`: launches the hub as a detached background process
|
|
429
|
+
* by re-running this CLI as the hidden `dashboard serve` (the child is the
|
|
430
|
+
* actual server), then waits for the child's `LISTENING` report, so the URL
|
|
431
|
+
* printed is one the child actually bound.
|
|
683
432
|
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
433
|
+
* Refuses while any hub is running: one hub serves every project, and a second
|
|
434
|
+
* one could only bind another port that no mod looks at. Limitation: no
|
|
435
|
+
* cross-process lock, so two simultaneous `start` could both pass the guard;
|
|
436
|
+
* the loser then fails on the port.
|
|
686
437
|
*/
|
|
687
|
-
export async function detachDashboard(
|
|
688
|
-
const
|
|
689
|
-
const running = shouldRefuseDetach(await collectStoppableDashboards(projectRoot), projectRoot);
|
|
438
|
+
export async function detachDashboard(cliEntry, opts) {
|
|
439
|
+
const running = (await listDashboards())[0];
|
|
690
440
|
if (running) {
|
|
691
|
-
|
|
692
|
-
console.log(
|
|
693
|
-
console.log(
|
|
694
|
-
console.log('Stop it with: tospec dashboard --stop');
|
|
441
|
+
console.log('Dashboard already running.');
|
|
442
|
+
console.log(` ${formatRunningDashboard(running)}`);
|
|
443
|
+
console.log('Stop it with: tospec dashboard stop');
|
|
695
444
|
return;
|
|
696
445
|
}
|
|
697
|
-
|
|
698
|
-
// child re-checks and binds, so a race for this port just shifts it upward;
|
|
699
|
-
// what gets reported is the URL the child confirms, never this prediction.
|
|
700
|
-
const used = await reapAndCollectPorts();
|
|
701
|
-
let port = opts.port;
|
|
702
|
-
while (used.has(port))
|
|
703
|
-
port++;
|
|
704
|
-
const args = [cliEntry, 'dashboard', opts.targetPath, '--port', String(port), '--host', opts.host];
|
|
446
|
+
const args = [cliEntry, 'dashboard', 'serve', '--port', String(opts.port), '--host', opts.host];
|
|
705
447
|
if (opts.open)
|
|
706
448
|
args.push('--open');
|
|
707
449
|
if (opts.allowRemote)
|
|
@@ -713,15 +455,13 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
|
713
455
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
714
456
|
// On Windows, detached: true otherwise pops a new console window.
|
|
715
457
|
windowsHide: true,
|
|
716
|
-
env: { ...process.env,
|
|
458
|
+
env: { ...process.env, [DETACHED_ENV]: '1' },
|
|
717
459
|
});
|
|
718
|
-
// No pid means the spawn itself failed; reporting success would
|
|
719
|
-
// `--stop` could never
|
|
460
|
+
// No pid means the spawn itself failed; reporting success would point at a
|
|
461
|
+
// process `--stop` could never reach.
|
|
720
462
|
if (child.pid === undefined) {
|
|
721
463
|
throw new Error('Could not start the background dashboard process.');
|
|
722
464
|
}
|
|
723
|
-
// Everything that can go wrong after a successful spawn happens inside the
|
|
724
|
-
// child and is only observable through this handshake.
|
|
725
465
|
let confirmedUrl;
|
|
726
466
|
try {
|
|
727
467
|
confirmedUrl = await waitForListening(child);
|
|
@@ -731,31 +471,14 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
|
731
471
|
// un-unref'd handle would keep this process's event loop alive.
|
|
732
472
|
child.unref();
|
|
733
473
|
}
|
|
734
|
-
// Derived from the URL the child reported, so this record and the child's own
|
|
735
|
-
// agree whichever lands last. Limitation: no cross-process lock, so two
|
|
736
|
-
// simultaneous --detach could both pass the guard.
|
|
737
|
-
await writePidRecord(pidFile, {
|
|
738
|
-
pid: child.pid,
|
|
739
|
-
port: portFromUrl(confirmedUrl) ?? port,
|
|
740
|
-
url: confirmedUrl,
|
|
741
|
-
projectRoot: path.resolve(projectRoot),
|
|
742
|
-
});
|
|
743
474
|
console.log(`Dashboard starting in background at ${confirmedUrl}`);
|
|
744
|
-
console.log('Stop it with: tospec dashboard
|
|
745
|
-
}
|
|
746
|
-
/** `http://host:5621` -> `5621`, or null if the URL carries no parseable port. */
|
|
747
|
-
function portFromUrl(url) {
|
|
748
|
-
const match = /:(\d+)$/.exec(url);
|
|
749
|
-
return match ? Number(match[1]) : null;
|
|
475
|
+
console.log('Stop it with: tospec dashboard stop');
|
|
750
476
|
}
|
|
751
477
|
/**
|
|
752
478
|
* Races the child's outcome: resolves with the confirmed URL on
|
|
753
479
|
* `LISTENING <url>`, rejects with its captured stderr if it exits first, and
|
|
754
480
|
* rejects on timeout if it does neither. Closes the stderr pipe once settled so
|
|
755
481
|
* our end cannot keep this process's event loop alive.
|
|
756
|
-
*
|
|
757
|
-
* The timeout is a real third outcome: a child that binds then wedges emits
|
|
758
|
-
* neither event, and `--detach` would hang with no output.
|
|
759
482
|
*/
|
|
760
483
|
function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
761
484
|
return new Promise((resolve, reject) => {
|
|
@@ -804,44 +527,13 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
|
804
527
|
}, timeoutMs);
|
|
805
528
|
});
|
|
806
529
|
}
|
|
807
|
-
/** Case-correct path equality (the Windows filesystem is case-insensitive). */
|
|
808
|
-
export function samePath(a, b) {
|
|
809
|
-
const na = path.resolve(a);
|
|
810
|
-
const nb = path.resolve(b);
|
|
811
|
-
return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
812
|
-
}
|
|
813
|
-
/**
|
|
814
|
-
* The dashboards `--stop` may terminate without asking. Empty is the caller's
|
|
815
|
-
* cue to offer the pick-from-all list. A candidate with no recorded project
|
|
816
|
-
* never matches: `path.resolve('')` is the cwd, so a blank root would claim it.
|
|
817
|
-
*/
|
|
818
|
-
export function selectStopTargets(candidates, projectRoot) {
|
|
819
|
-
if (!projectRoot)
|
|
820
|
-
return [];
|
|
821
|
-
return candidates.filter((d) => d.projectRoot && samePath(d.projectRoot, projectRoot));
|
|
822
|
-
}
|
|
823
|
-
/**
|
|
824
|
-
* The dashboard already serving this project, or null to go ahead and start one.
|
|
825
|
-
* One dashboard per project, foreground or background — see
|
|
826
|
-
* `tospec/decisions/20260727_232950-one-dashboard-per-project.md`.
|
|
827
|
-
*
|
|
828
|
-
* Deliberately the same question `--stop` asks, over the same candidate set, so
|
|
829
|
-
* the two can never disagree about what is running.
|
|
830
|
-
*/
|
|
831
|
-
export function shouldRefuseDetach(candidates, projectRoot) {
|
|
832
|
-
// First match only: one is enough to refuse and point at.
|
|
833
|
-
return selectStopTargets(candidates, projectRoot)[0] ?? null;
|
|
834
|
-
}
|
|
835
530
|
/**
|
|
836
|
-
* Terminates a running dashboard and clears its registry marker
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
* Returns null on success, or the reason the signal was refused — the POSIX temp
|
|
841
|
-
* dir is shared, so a candidate can belong to another user.
|
|
531
|
+
* Terminates a running dashboard and clears its registry marker. A dead pid
|
|
532
|
+
* counts as already stopped. Returns null on success, or the reason the signal
|
|
533
|
+
* was refused.
|
|
842
534
|
*
|
|
843
535
|
* Limitation: SIGTERM; Windows maps it to TerminateProcess (hard kill), so the
|
|
844
|
-
* target's own cleanup won't run — we clear its
|
|
536
|
+
* target's own cleanup won't run — we clear its marker here.
|
|
845
537
|
*/
|
|
846
538
|
export async function stopRunningDashboard(d) {
|
|
847
539
|
try {
|
|
@@ -849,71 +541,145 @@ export async function stopRunningDashboard(d) {
|
|
|
849
541
|
}
|
|
850
542
|
catch (err) {
|
|
851
543
|
// ESRCH means it exited between listing and now: already stopped, so still
|
|
852
|
-
// clear its
|
|
544
|
+
// clear its marker. Anything else (EPERM) is a real refusal.
|
|
853
545
|
const { code, message } = err;
|
|
854
546
|
if (code !== 'ESRCH')
|
|
855
547
|
return code ?? message;
|
|
856
548
|
}
|
|
857
549
|
await unregisterDashboard(d.pid);
|
|
858
|
-
// Only this dashboard's record: the path is per-project, so stopping one of
|
|
859
|
-
// two siblings must not clear the other's.
|
|
860
|
-
if (d.projectRoot)
|
|
861
|
-
clearPidRecordOwnedBy(pidFilePath(d.projectRoot), d.pid);
|
|
862
550
|
return null;
|
|
863
551
|
}
|
|
864
552
|
/**
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
|
|
868
|
-
|
|
553
|
+
* `tospec dashboard stop`: stops every hub, not a pick. One serves all
|
|
554
|
+
* projects, so there is no "this project's dashboard" left to choose.
|
|
555
|
+
*/
|
|
556
|
+
export async function dashboardStopCommand() {
|
|
557
|
+
const running = await listDashboards();
|
|
558
|
+
if (running.length === 0) {
|
|
559
|
+
console.log('No dashboards are running.');
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
for (const d of running) {
|
|
563
|
+
const refusal = await stopRunningDashboard(d);
|
|
564
|
+
if (refusal === null) {
|
|
565
|
+
console.log(`Stopped dashboard (pid ${d.pid}) at ${d.url}.`);
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
console.error(`Could not stop dashboard (pid ${d.pid}) at ${d.url}: ${refusal}.`);
|
|
569
|
+
process.exitCode = 1;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Asks a running hub which projects it serves. Sessions live only in the hub's
|
|
575
|
+
* memory, so the registry marker can say a hub is running but not whom it
|
|
576
|
+
* serves.
|
|
577
|
+
*/
|
|
578
|
+
export async function fetchHubProjects(d, timeoutMs = STATUS_TIMEOUT_MS) {
|
|
579
|
+
const res = await fetch(`${d.url}/api/projects`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
580
|
+
if (!res.ok)
|
|
581
|
+
throw new Error(`it answered HTTP ${res.status}`);
|
|
582
|
+
const body = (await res.json());
|
|
583
|
+
if (!Array.isArray(body.projects))
|
|
584
|
+
throw new Error('its answer had no project list');
|
|
585
|
+
return body.projects;
|
|
586
|
+
}
|
|
587
|
+
function plural(n, noun) {
|
|
588
|
+
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* The `status` block for one hub. Projects with no live session stay listed as
|
|
592
|
+
* offline: the hub still serves their last snapshot.
|
|
593
|
+
*/
|
|
594
|
+
export function formatDashboardStatus(d, projects) {
|
|
595
|
+
const sessionCount = projects.reduce((sum, p) => sum + p.sessions.length, 0);
|
|
596
|
+
const lines = [`Dashboard running at ${d.url} (pid ${d.pid}).`, `Sessions: ${sessionCount}`];
|
|
597
|
+
if (projects.length === 0) {
|
|
598
|
+
lines.push('Projects: none');
|
|
599
|
+
return lines;
|
|
600
|
+
}
|
|
601
|
+
lines.push(`Projects: ${projects.length}`);
|
|
602
|
+
const counts = projects.map((p) => (p.sessions.length === 0 ? 'offline' : plural(p.sessions.length, 'session')));
|
|
603
|
+
const nameWidth = Math.max(...projects.map((p) => p.name.length));
|
|
604
|
+
const countWidth = Math.max(...counts.map((c) => c.length));
|
|
605
|
+
projects.forEach((p, i) => {
|
|
606
|
+
// Root last: two projects can share a directory name, and the root is what
|
|
607
|
+
// tells them apart.
|
|
608
|
+
lines.push(` ${p.name.padEnd(nameWidth)} ${counts[i].padEnd(countWidth)} ${p.root}`);
|
|
609
|
+
});
|
|
610
|
+
return lines;
|
|
611
|
+
}
|
|
612
|
+
/** `tospec dashboard status`: whether a hub runs, and the sessions it serves. */
|
|
613
|
+
export async function dashboardStatusCommand() {
|
|
614
|
+
const running = await listDashboards();
|
|
615
|
+
if (running.length === 0) {
|
|
616
|
+
console.log('Dashboard is not running.');
|
|
617
|
+
console.log('Start it with: tospec dashboard start');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
for (const [i, d] of running.entries()) {
|
|
621
|
+
if (i > 0)
|
|
622
|
+
console.log('');
|
|
623
|
+
let projects;
|
|
624
|
+
try {
|
|
625
|
+
projects = await fetchHubProjects(d);
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
// The process is alive but not answering: still binding, wedged, or a
|
|
629
|
+
// reused pid that is not a hub at all. Saying "running" would claim the
|
|
630
|
+
// one thing this could not confirm.
|
|
631
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
632
|
+
console.error(`Error: Dashboard process ${d.pid} is registered at ${d.url} but did not answer: ${reason}.`);
|
|
633
|
+
console.error('Fix: Retry in a moment; if it still does not answer, run tospec dashboard stop, then tospec dashboard start.');
|
|
634
|
+
process.exitCode = 1;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
for (const line of formatDashboardStatus(d, projects))
|
|
638
|
+
console.log(line);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* `tospec dashboard serve` (hidden), the process `start` spawns: starts the
|
|
643
|
+
* hub, records it, prints the URL, and stays up until terminated.
|
|
869
644
|
*/
|
|
870
|
-
export async function runDashboard(
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
645
|
+
export async function runDashboard(opts = {}) {
|
|
646
|
+
const started = await startDashboardServer(opts);
|
|
647
|
+
const { url, port } = started;
|
|
648
|
+
// Recorded before the readiness line, so a `status` right after `start`
|
|
649
|
+
// returns already sees it.
|
|
650
|
+
try {
|
|
651
|
+
await registerDashboard(process.pid, port, url);
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
await started.close();
|
|
655
|
+
throw err;
|
|
656
|
+
}
|
|
876
657
|
console.log(`Dashboard running at ${url} (Ctrl+C to stop)`);
|
|
658
|
+
console.log('Claude Code sessions in tospec projects register through the tospec-dashboard mod.');
|
|
877
659
|
// Detached child only: `detachDashboard` waits on stderr for exactly this line
|
|
878
|
-
// before it reports anything. A
|
|
879
|
-
if (process.env
|
|
660
|
+
// before it reports anything. A `serve` run by hand has no parent watching.
|
|
661
|
+
if (process.env[DETACHED_ENV]) {
|
|
880
662
|
// The parent closes its end of this pipe once it reads the line below and
|
|
881
|
-
// then exits, so every later stderr write
|
|
882
|
-
//
|
|
883
|
-
//
|
|
663
|
+
// then exits, so every later stderr write hits EPIPE. Unhandled, that error
|
|
664
|
+
// killed the background dashboard a few writes in; handled, the writes are
|
|
665
|
+
// dropped.
|
|
884
666
|
process.stderr.on('error', () => { });
|
|
885
667
|
console.error(`${LISTENING_PREFIX}${url}`);
|
|
886
668
|
}
|
|
887
|
-
await registerDashboard(process.pid, port, path.resolve(projectRoot));
|
|
888
|
-
// Every dashboard records itself, foreground included: `--detach` can only
|
|
889
|
-
// refuse for one that left a record, and a registry marker alone can be
|
|
890
|
-
// reaped out from under a live server. The child is handed its parent's path
|
|
891
|
-
// so the two cannot disagree about where the file lives.
|
|
892
|
-
const pidFile = process.env.TOSPEC_DASHBOARD_PIDFILE ?? pidFilePath(projectRoot);
|
|
893
|
-
await writePidRecord(pidFile, {
|
|
894
|
-
pid: process.pid,
|
|
895
|
-
port,
|
|
896
|
-
url,
|
|
897
|
-
projectRoot: path.resolve(projectRoot),
|
|
898
|
-
});
|
|
899
669
|
if (opts.open) {
|
|
900
670
|
openBrowser(url);
|
|
901
671
|
}
|
|
902
|
-
// Normal termination removes this dashboard's
|
|
903
|
-
//
|
|
672
|
+
// Normal termination removes this dashboard's marker. Windows hard-kills on
|
|
673
|
+
// `stop`, so there `stopRunningDashboard` clears it instead.
|
|
904
674
|
const cleanup = () => {
|
|
905
|
-
//
|
|
906
|
-
|
|
907
|
-
//
|
|
908
|
-
// readdir + rm could lose the race with process.exit, and a marker
|
|
909
|
-
// outliving its process reads as alive again once the OS reuses the pid —
|
|
910
|
-
// which `--stop` would then kill.
|
|
675
|
+
// Sync: fired and forgotten, the async readdir + rm could lose the race
|
|
676
|
+
// with process.exit, and a marker outliving its process reads as alive
|
|
677
|
+
// again once the OS reuses the pid — which `stop` would then kill.
|
|
911
678
|
unregisterDashboardSync(process.pid);
|
|
912
|
-
|
|
913
|
-
server.close(() => process.exit(0));
|
|
679
|
+
void started.close().then(() => process.exit(0));
|
|
914
680
|
};
|
|
915
681
|
process.on('SIGINT', cleanup);
|
|
916
682
|
process.on('SIGTERM', cleanup);
|
|
917
|
-
return
|
|
683
|
+
return started;
|
|
918
684
|
}
|
|
919
685
|
//# sourceMappingURL=dashboard.js.map
|