@seanmars/tospec 0.23.0 → 0.26.0-beta
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 +281 -0
- package/README.md +27 -9
- 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 +375 -0
- package/assets/dashboard/lib/dom.js +121 -0
- package/assets/dashboard/lib/routes.js +52 -0
- package/assets/dashboard/lib/store.js +326 -0
- package/assets/dashboard/style.css +3103 -664
- package/assets/dashboard/views/alerts.js +170 -0
- package/assets/dashboard/views/change.js +234 -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 +116 -102
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +213 -100
- package/dist/commands/config.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 +69 -100
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +420 -598
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/decision.d.ts +38 -1
- package/dist/commands/decision.d.ts.map +1 -1
- package/dist/commands/decision.js +303 -27
- package/dist/commands/decision.js.map +1 -1
- package/dist/commands/metrics.d.ts +3 -4
- package/dist/commands/metrics.d.ts.map +1 -1
- package/dist/commands/metrics.js +3 -5
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/shared-output.d.ts +24 -0
- package/dist/commands/shared-output.d.ts.map +1 -1
- package/dist/commands/shared-output.js +21 -2
- package/dist/commands/shared-output.js.map +1 -1
- package/dist/commands/show.d.ts.map +1 -1
- package/dist/commands/show.js +12 -9
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/validate.d.ts +19 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +243 -64
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/workflow/instructions.d.ts +7 -1
- package/dist/commands/workflow/instructions.d.ts.map +1 -1
- package/dist/commands/workflow/instructions.js +299 -81
- package/dist/commands/workflow/instructions.js.map +1 -1
- package/dist/commands/workflow/new-change.d.ts.map +1 -1
- package/dist/commands/workflow/new-change.js +26 -17
- package/dist/commands/workflow/new-change.js.map +1 -1
- package/dist/commands/workflow/prompt-escape.d.ts +25 -0
- package/dist/commands/workflow/prompt-escape.d.ts.map +1 -0
- package/dist/commands/workflow/prompt-escape.js +62 -0
- package/dist/commands/workflow/prompt-escape.js.map +1 -0
- package/dist/commands/workflow/schemas.d.ts.map +1 -1
- package/dist/commands/workflow/schemas.js +34 -16
- package/dist/commands/workflow/schemas.js.map +1 -1
- package/dist/commands/workflow/shared.d.ts +46 -10
- package/dist/commands/workflow/shared.d.ts.map +1 -1
- package/dist/commands/workflow/shared.js +66 -30
- package/dist/commands/workflow/shared.js.map +1 -1
- package/dist/commands/workflow/status.d.ts +5 -2
- package/dist/commands/workflow/status.d.ts.map +1 -1
- package/dist/commands/workflow/status.js +62 -28
- package/dist/commands/workflow/status.js.map +1 -1
- package/dist/core/archive.d.ts +38 -0
- package/dist/core/archive.d.ts.map +1 -1
- package/dist/core/archive.js +492 -75
- package/dist/core/archive.js.map +1 -1
- package/dist/core/artifact-graph/index.d.ts +2 -2
- package/dist/core/artifact-graph/index.d.ts.map +1 -1
- package/dist/core/artifact-graph/index.js +2 -2
- package/dist/core/artifact-graph/index.js.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +8 -0
- package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.js +58 -36
- package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
- package/dist/core/artifact-graph/resolver.d.ts +39 -12
- package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
- package/dist/core/artifact-graph/resolver.js +82 -36
- package/dist/core/artifact-graph/resolver.js.map +1 -1
- package/dist/core/artifact-graph/schema.d.ts +24 -0
- package/dist/core/artifact-graph/schema.d.ts.map +1 -1
- package/dist/core/artifact-graph/schema.js +32 -1
- package/dist/core/artifact-graph/schema.js.map +1 -1
- package/dist/core/artifact-graph/stub-detection.d.ts +10 -2
- package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
- package/dist/core/artifact-graph/stub-detection.js +18 -11
- package/dist/core/artifact-graph/stub-detection.js.map +1 -1
- package/dist/core/artifact-graph/types.d.ts +2 -2
- package/dist/core/artifact-graph/types.d.ts.map +1 -1
- package/dist/core/artifact-graph/types.js +10 -1
- package/dist/core/artifact-graph/types.js.map +1 -1
- package/dist/core/change-metadata/schema.d.ts.map +1 -1
- package/dist/core/change-metadata/schema.js +18 -4
- package/dist/core/change-metadata/schema.js.map +1 -1
- package/dist/core/change-presenter.d.ts.map +1 -1
- package/dist/core/change-presenter.js +13 -8
- package/dist/core/change-presenter.js.map +1 -1
- package/dist/core/change-status-policy.d.ts.map +1 -1
- package/dist/core/change-status-policy.js +3 -1
- package/dist/core/change-status-policy.js.map +1 -1
- package/dist/core/codex-metrics.d.ts +1 -1
- package/dist/core/codex-metrics.js +1 -1
- package/dist/core/config-schema.d.ts +8 -0
- package/dist/core/config-schema.d.ts.map +1 -1
- package/dist/core/config-schema.js +10 -0
- package/dist/core/config-schema.js.map +1 -1
- package/dist/core/dashboard-activity.d.ts +4 -4
- package/dist/core/dashboard-activity.js +4 -4
- package/dist/core/dashboard-data.d.ts +11 -5
- package/dist/core/dashboard-data.d.ts.map +1 -1
- package/dist/core/dashboard-data.js +54 -28
- package/dist/core/dashboard-data.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 +655 -0
- package/dist/core/dashboard-hub.js.map +1 -0
- package/dist/core/dashboard-routes.d.ts +57 -0
- package/dist/core/dashboard-routes.d.ts.map +1 -0
- package/dist/core/dashboard-routes.js +398 -0
- package/dist/core/dashboard-routes.js.map +1 -0
- package/dist/core/global-config.d.ts +71 -2
- package/dist/core/global-config.d.ts.map +1 -1
- package/dist/core/global-config.js +149 -4
- package/dist/core/global-config.js.map +1 -1
- package/dist/core/id.d.ts +9 -0
- package/dist/core/id.d.ts.map +1 -1
- package/dist/core/id.js +37 -0
- package/dist/core/id.js.map +1 -1
- package/dist/core/init.d.ts +9 -2
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +119 -33
- package/dist/core/init.js.map +1 -1
- package/dist/core/list.d.ts.map +1 -1
- package/dist/core/list.js +69 -45
- package/dist/core/list.js.map +1 -1
- package/dist/core/local-server.d.ts +10 -3
- package/dist/core/local-server.d.ts.map +1 -1
- package/dist/core/local-server.js +25 -5
- package/dist/core/local-server.js.map +1 -1
- package/dist/core/markdown-render.d.ts.map +1 -1
- package/dist/core/markdown-render.js +7 -2
- package/dist/core/markdown-render.js.map +1 -1
- package/dist/core/migrate.d.ts +24 -2
- package/dist/core/migrate.d.ts.map +1 -1
- package/dist/core/migrate.js +335 -59
- package/dist/core/migrate.js.map +1 -1
- package/dist/core/parsers/change-parser.d.ts +20 -1
- package/dist/core/parsers/change-parser.d.ts.map +1 -1
- package/dist/core/parsers/change-parser.js +76 -73
- package/dist/core/parsers/change-parser.js.map +1 -1
- package/dist/core/parsers/markdown-parser.d.ts +24 -0
- package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
- package/dist/core/parsers/markdown-parser.js +65 -10
- package/dist/core/parsers/markdown-parser.js.map +1 -1
- package/dist/core/parsers/requirement-blocks.d.ts +127 -6
- package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
- package/dist/core/parsers/requirement-blocks.js +349 -56
- package/dist/core/parsers/requirement-blocks.js.map +1 -1
- package/dist/core/parsers/requirement-text.d.ts +25 -1
- package/dist/core/parsers/requirement-text.d.ts.map +1 -1
- package/dist/core/parsers/requirement-text.js +62 -6
- package/dist/core/parsers/requirement-text.js.map +1 -1
- package/dist/core/parsers/spec-structure.d.ts.map +1 -1
- package/dist/core/parsers/spec-structure.js +36 -24
- package/dist/core/parsers/spec-structure.js.map +1 -1
- package/dist/core/project-config.d.ts +9 -2
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +30 -7
- package/dist/core/project-config.js.map +1 -1
- package/dist/core/project-layout.d.ts +26 -0
- package/dist/core/project-layout.d.ts.map +1 -1
- package/dist/core/project-layout.js +51 -1
- package/dist/core/project-layout.js.map +1 -1
- package/dist/core/schema-names.d.ts +17 -0
- package/dist/core/schema-names.d.ts.map +1 -1
- package/dist/core/schema-names.js +24 -0
- package/dist/core/schema-names.js.map +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 +2 -1
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +2 -1
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/shared/rules-generation.d.ts +12 -0
- package/dist/core/shared/rules-generation.d.ts.map +1 -1
- package/dist/core/shared/rules-generation.js +26 -2
- package/dist/core/shared/rules-generation.js.map +1 -1
- package/dist/core/shared/skill-generation.d.ts +7 -0
- package/dist/core/shared/skill-generation.d.ts.map +1 -1
- package/dist/core/shared/skill-generation.js +19 -0
- package/dist/core/shared/skill-generation.js.map +1 -1
- package/dist/core/shared/tool-detection.d.ts +10 -1
- package/dist/core/shared/tool-detection.d.ts.map +1 -1
- package/dist/core/shared/tool-detection.js +37 -6
- package/dist/core/shared/tool-detection.js.map +1 -1
- package/dist/core/spec-presenter.d.ts.map +1 -1
- package/dist/core/spec-presenter.js +23 -1
- package/dist/core/spec-presenter.js.map +1 -1
- package/dist/core/specs-apply.d.ts +98 -1
- package/dist/core/specs-apply.d.ts.map +1 -1
- package/dist/core/specs-apply.js +426 -63
- package/dist/core/specs-apply.js.map +1 -1
- package/dist/core/templates/fragments/change-diff.d.ts +37 -0
- package/dist/core/templates/fragments/change-diff.d.ts.map +1 -0
- package/dist/core/templates/fragments/change-diff.js +37 -0
- package/dist/core/templates/fragments/change-diff.js.map +1 -0
- package/dist/core/templates/fragments/sync-fail.d.ts +8 -0
- package/dist/core/templates/fragments/sync-fail.d.ts.map +1 -0
- package/dist/core/templates/fragments/sync-fail.js +8 -0
- package/dist/core/templates/fragments/sync-fail.js.map +1 -0
- package/dist/core/templates/fragments/validate.d.ts +14 -1
- package/dist/core/templates/fragments/validate.d.ts.map +1 -1
- package/dist/core/templates/fragments/validate.js +14 -1
- package/dist/core/templates/fragments/validate.js.map +1 -1
- package/dist/core/templates/fragments/verify.d.ts +2 -2
- package/dist/core/templates/fragments/verify.d.ts.map +1 -1
- package/dist/core/templates/fragments/verify.js +8 -5
- package/dist/core/templates/fragments/verify.js.map +1 -1
- package/dist/core/templates/workflows/apply.d.ts +1 -1
- package/dist/core/templates/workflows/apply.d.ts.map +1 -1
- package/dist/core/templates/workflows/apply.js +29 -10
- package/dist/core/templates/workflows/apply.js.map +1 -1
- package/dist/core/templates/workflows/archive.d.ts.map +1 -1
- package/dist/core/templates/workflows/archive.js +27 -9
- package/dist/core/templates/workflows/archive.js.map +1 -1
- package/dist/core/templates/workflows/decision.d.ts.map +1 -1
- package/dist/core/templates/workflows/decision.js +10 -4
- package/dist/core/templates/workflows/decision.js.map +1 -1
- package/dist/core/templates/workflows/explore.d.ts +2 -2
- package/dist/core/templates/workflows/explore.d.ts.map +1 -1
- package/dist/core/templates/workflows/explore.js +7 -3
- package/dist/core/templates/workflows/explore.js.map +1 -1
- package/dist/core/templates/workflows/grill.d.ts +2 -2
- package/dist/core/templates/workflows/grill.d.ts.map +1 -1
- package/dist/core/templates/workflows/grill.js +5 -4
- package/dist/core/templates/workflows/grill.js.map +1 -1
- package/dist/core/templates/workflows/issue.d.ts.map +1 -1
- package/dist/core/templates/workflows/issue.js +9 -3
- package/dist/core/templates/workflows/issue.js.map +1 -1
- package/dist/core/templates/workflows/propose.d.ts.map +1 -1
- package/dist/core/templates/workflows/propose.js +4 -1
- package/dist/core/templates/workflows/propose.js.map +1 -1
- package/dist/core/templates/workflows/sync.d.ts.map +1 -1
- package/dist/core/templates/workflows/sync.js +25 -11
- package/dist/core/templates/workflows/sync.js.map +1 -1
- package/dist/core/templates/workflows/update.d.ts.map +1 -1
- package/dist/core/templates/workflows/update.js +17 -10
- package/dist/core/templates/workflows/update.js.map +1 -1
- package/dist/core/update.d.ts +5 -0
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +69 -29
- package/dist/core/update.js.map +1 -1
- package/dist/core/validation/constants.d.ts +7 -1
- package/dist/core/validation/constants.d.ts.map +1 -1
- package/dist/core/validation/constants.js +16 -2
- package/dist/core/validation/constants.js.map +1 -1
- package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
- package/dist/core/validation/purpose-placeholder.js +35 -11
- package/dist/core/validation/purpose-placeholder.js.map +1 -1
- package/dist/core/validation/section-validator.d.ts +7 -0
- package/dist/core/validation/section-validator.d.ts.map +1 -1
- package/dist/core/validation/section-validator.js +22 -2
- package/dist/core/validation/section-validator.js.map +1 -1
- package/dist/core/validation/sister-changes.d.ts +93 -0
- package/dist/core/validation/sister-changes.d.ts.map +1 -0
- package/dist/core/validation/sister-changes.js +180 -0
- package/dist/core/validation/sister-changes.js.map +1 -0
- package/dist/core/validation/task-markers.d.ts +9 -0
- package/dist/core/validation/task-markers.d.ts.map +1 -0
- package/dist/core/validation/task-markers.js +71 -0
- package/dist/core/validation/task-markers.js.map +1 -0
- package/dist/core/validation/types.d.ts +58 -0
- package/dist/core/validation/types.d.ts.map +1 -1
- package/dist/core/validation/types.js +22 -0
- package/dist/core/validation/types.js.map +1 -1
- package/dist/core/validation/validator.d.ts +79 -2
- package/dist/core/validation/validator.d.ts.map +1 -1
- package/dist/core/validation/validator.js +565 -84
- package/dist/core/validation/validator.js.map +1 -1
- package/dist/utils/change-metadata.d.ts +62 -5
- package/dist/utils/change-metadata.d.ts.map +1 -1
- package/dist/utils/change-metadata.js +193 -31
- package/dist/utils/change-metadata.js.map +1 -1
- package/dist/utils/change-utils.d.ts +21 -0
- package/dist/utils/change-utils.d.ts.map +1 -1
- package/dist/utils/change-utils.js +168 -53
- package/dist/utils/change-utils.js.map +1 -1
- package/dist/utils/command-line.d.ts +11 -0
- package/dist/utils/command-line.d.ts.map +1 -0
- package/dist/utils/command-line.js +43 -0
- package/dist/utils/command-line.js.map +1 -0
- package/dist/utils/display-width.d.ts +10 -0
- package/dist/utils/display-width.d.ts.map +1 -0
- package/dist/utils/display-width.js +15 -0
- package/dist/utils/display-width.js.map +1 -0
- package/dist/utils/file-system.d.ts +16 -0
- package/dist/utils/file-system.d.ts.map +1 -1
- package/dist/utils/file-system.js +49 -2
- package/dist/utils/file-system.js.map +1 -1
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +11 -5
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/interactive.d.ts.map +1 -1
- package/dist/utils/interactive.js +3 -1
- package/dist/utils/interactive.js.map +1 -1
- package/dist/utils/item-discovery.d.ts +3 -2
- package/dist/utils/item-discovery.d.ts.map +1 -1
- package/dist/utils/item-discovery.js +34 -16
- package/dist/utils/item-discovery.js.map +1 -1
- package/dist/utils/link.d.ts +0 -1
- package/dist/utils/link.d.ts.map +1 -1
- package/dist/utils/link.js +0 -1
- package/dist/utils/link.js.map +1 -1
- package/dist/utils/match.d.ts.map +1 -1
- package/dist/utils/match.js +10 -1
- package/dist/utils/match.js.map +1 -1
- package/dist/utils/option-flags.d.ts +9 -0
- package/dist/utils/option-flags.d.ts.map +1 -0
- package/dist/utils/option-flags.js +12 -0
- package/dist/utils/option-flags.js.map +1 -0
- package/dist/utils/sentence.d.ts +9 -0
- package/dist/utils/sentence.d.ts.map +1 -0
- package/dist/utils/sentence.js +12 -0
- package/dist/utils/sentence.js.map +1 -0
- package/dist/utils/spec-files.d.ts +84 -1
- package/dist/utils/spec-files.d.ts.map +1 -1
- package/dist/utils/spec-files.js +192 -3
- package/dist/utils/spec-files.js.map +1 -1
- package/dist/utils/task-progress.d.ts.map +1 -1
- package/dist/utils/task-progress.js +32 -9
- package/dist/utils/task-progress.js.map +1 -1
- package/dist/utils/timestamp.d.ts +27 -0
- package/dist/utils/timestamp.d.ts.map +1 -1
- package/dist/utils/timestamp.js +37 -0
- package/dist/utils/timestamp.js.map +1 -1
- package/package.json +6 -2
- package/schemas/decision/templates/decision.md +2 -2
- package/schemas/issue/schema.yaml +12 -6
- package/schemas/issue/templates/task.md +1 -1
- package/schemas/sdd/schema.yaml +21 -10
- package/schemas/sdd/templates/proposal.md +1 -1
- package/assets/dashboard/chart.umd.js +0 -14
|
@@ -1,172 +1,48 @@
|
|
|
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
|
-
import {
|
|
20
|
-
const DEFAULT_PORT = 5620;
|
|
17
|
+
import { LOOPBACK_HOSTS, acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader, isJsonContentType, listenOnce, normalizeHost, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
|
|
18
|
+
import { maxDisplayWidth, padEndDisplay } from '../utils/display-width.js';
|
|
19
|
+
export const DEFAULT_PORT = 5620;
|
|
21
20
|
const DEFAULT_HOST = '127.0.0.1';
|
|
22
21
|
// Readiness signal the detached child writes to stderr once bound; emitted only
|
|
23
|
-
// when
|
|
22
|
+
// when TOSPEC_DASHBOARD_DETACHED marks it as the child `dashboard start` spawned.
|
|
24
23
|
const LISTENING_PREFIX = 'LISTENING ';
|
|
25
|
-
|
|
26
|
-
function stripErrorPrefix(text) {
|
|
27
|
-
return text.replace(/^Error:\s*/, '');
|
|
28
|
-
}
|
|
24
|
+
const DETACHED_ENV = 'TOSPEC_DASHBOARD_DETACHED';
|
|
29
25
|
// The child signals right after binding, so slower than a cold start plus a
|
|
30
26
|
// bind means it is stuck, not busy.
|
|
31
27
|
const READINESS_TIMEOUT_MS = 10_000;
|
|
28
|
+
// `status` asks a hub on this machine; one that takes longer is not answering.
|
|
29
|
+
const STATUS_TIMEOUT_MS = 3_000;
|
|
30
|
+
// How often online/offline transitions are checked and broadcast.
|
|
31
|
+
const SWEEP_INTERVAL_MS = 5_000;
|
|
32
|
+
// A browser POST carries one task tick or one answer (up to four questions of
|
|
33
|
+
// CJK text); a mod push carries a whole project.
|
|
34
|
+
const BROWSER_BODY_LIMIT = 64 * 1024;
|
|
35
|
+
const SNAPSHOT_BODY_LIMIT = 64 * 1024 * 1024;
|
|
36
|
+
const REPLY_BODY_LIMIT = 16 * 1024 * 1024;
|
|
37
|
+
const ACTIVITY_BODY_LIMIT = 1024 * 1024;
|
|
32
38
|
/** This command's shipped frontend; `serveAsset` is confined to it. */
|
|
33
39
|
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 });
|
|
40
|
+
/** Drops one leading `Error: ` the child added, so wrapping doesn't stack prefixes. */
|
|
41
|
+
function stripErrorPrefix(text) {
|
|
42
|
+
return text.replace(/^Error:\s*/, '');
|
|
167
43
|
}
|
|
168
44
|
/** Reads a JSON request body, capped so a stray client can't exhaust memory. */
|
|
169
|
-
async function readJsonBody(req, limit
|
|
45
|
+
async function readJsonBody(req, limit) {
|
|
170
46
|
// Bytes first, decoded once: `string += chunk` decodes each chunk alone, so a
|
|
171
47
|
// multi-byte character split across two chunks (a CJK filename) came out as
|
|
172
48
|
// U+FFFD and the request addressed a file that does not exist.
|
|
@@ -176,7 +52,7 @@ async function readJsonBody(req, limit = 8192) {
|
|
|
176
52
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
177
53
|
size += buffer.length;
|
|
178
54
|
if (size > limit)
|
|
179
|
-
throw new
|
|
55
|
+
throw new HubRequestError('body too large', 413);
|
|
180
56
|
chunks.push(buffer);
|
|
181
57
|
}
|
|
182
58
|
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
@@ -184,103 +60,9 @@ async function readJsonBody(req, limit = 8192) {
|
|
|
184
60
|
return JSON.parse(raw);
|
|
185
61
|
}
|
|
186
62
|
catch {
|
|
187
|
-
throw new
|
|
63
|
+
throw new HubRequestError('invalid json', 400);
|
|
188
64
|
}
|
|
189
65
|
}
|
|
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
66
|
function handleEvents(req, res, clients) {
|
|
285
67
|
res.writeHead(200, {
|
|
286
68
|
'content-type': 'text/event-stream',
|
|
@@ -291,18 +73,22 @@ function handleEvents(req, res, clients) {
|
|
|
291
73
|
clients.add(res);
|
|
292
74
|
req.on('close', () => clients.delete(res));
|
|
293
75
|
}
|
|
294
|
-
function
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
76
|
+
function broadcast(clients, event) {
|
|
77
|
+
const { type, ...data } = event;
|
|
78
|
+
const line = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
79
|
+
for (const client of clients)
|
|
80
|
+
client.write(line);
|
|
298
81
|
}
|
|
299
82
|
/**
|
|
300
83
|
* Second layer behind `renderMarkdownSafe`, applied to every response.
|
|
301
84
|
*
|
|
302
85
|
* `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
|
|
86
|
+
* handlers and `javascript:` URLs a markdown file would need to reach the task
|
|
87
|
+
* relay from inside this origin. `style-src` still carries `'unsafe-inline'`,
|
|
88
|
+
* added for Chart.js, which the dashboard no longer loads (its charts are SVG
|
|
89
|
+
* styled from the stylesheet, and the app sets styles through the CSSOM).
|
|
90
|
+
* Dropping it is a separate hardening step; a style injection cannot call the
|
|
91
|
+
* write endpoint either way.
|
|
306
92
|
*/
|
|
307
93
|
function applyContentSecurityPolicy(res) {
|
|
308
94
|
res.setHeader('content-security-policy', [
|
|
@@ -317,7 +103,104 @@ function applyContentSecurityPolicy(res) {
|
|
|
317
103
|
"frame-ancestors 'none'",
|
|
318
104
|
].join('; '));
|
|
319
105
|
}
|
|
320
|
-
|
|
106
|
+
/** `/api/p/<key>/<rest>` → the project key and the per-project path `/api/<rest>?…`. */
|
|
107
|
+
function matchProjectPath(url) {
|
|
108
|
+
const match = /^\/api\/p\/([0-9a-f]{12})(\/.*)$/.exec(url.pathname);
|
|
109
|
+
if (!match)
|
|
110
|
+
return null;
|
|
111
|
+
return { key: match[1], inner: `/api${match[2]}${url.search}` };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* `/api/p/<key>/sessions/<id>/<action>`: the hub's own answers about one
|
|
115
|
+
* session, matched before the per-project relay, which would otherwise carry
|
|
116
|
+
* them to the project as a file read.
|
|
117
|
+
*/
|
|
118
|
+
function matchSessionPath(url) {
|
|
119
|
+
const match = /^\/api\/p\/([0-9a-f]{12})\/sessions\/([^/]+)\/(log|answer)$/.exec(url.pathname);
|
|
120
|
+
if (!match)
|
|
121
|
+
return null;
|
|
122
|
+
try {
|
|
123
|
+
return { key: match[1], session: decodeURIComponent(match[2]), action: match[3] };
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function fieldsOf(body) {
|
|
130
|
+
return typeof body === 'object' && body !== null ? body : {};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Every POST must be JSON. That is the CSRF fence for the whole write surface:
|
|
134
|
+
* a cross-origin page can only send `application/json` through a CORS preflight
|
|
135
|
+
* this server never answers, so no web page can push a snapshot, take a queued
|
|
136
|
+
* command, tick a task, or answer for a session.
|
|
137
|
+
*/
|
|
138
|
+
async function handlePost(hub, req, res, url) {
|
|
139
|
+
if (!isJsonContentType(req.headers['content-type'])) {
|
|
140
|
+
return sendJson(res, 415, { error: 'expected application/json' });
|
|
141
|
+
}
|
|
142
|
+
const pathname = url.pathname;
|
|
143
|
+
if (pathname === '/api/mod/snapshot') {
|
|
144
|
+
const body = fieldsOf(await readJsonBody(req, SNAPSHOT_BODY_LIMIT));
|
|
145
|
+
return sendJson(res, 200, { project: hub.acceptSnapshot(body.session, body.snapshot) });
|
|
146
|
+
}
|
|
147
|
+
if (pathname === '/api/mod/poll') {
|
|
148
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
149
|
+
const hangUp = new AbortController();
|
|
150
|
+
res.on('close', () => hangUp.abort());
|
|
151
|
+
const commands = await hub.poll(body.project, body.session, hangUp.signal);
|
|
152
|
+
if (commands === null)
|
|
153
|
+
return sendJson(res, 404, { error: 'unknown project' });
|
|
154
|
+
return sendJson(res, 200, { commands });
|
|
155
|
+
}
|
|
156
|
+
if (pathname === '/api/mod/reply') {
|
|
157
|
+
const body = fieldsOf(await readJsonBody(req, REPLY_BODY_LIMIT));
|
|
158
|
+
const settled = hub.reply(body.id, { status: body.status, body: body.body });
|
|
159
|
+
return sendJson(res, settled ? 200 : 404, settled ? { ok: true } : { error: 'no such request' });
|
|
160
|
+
}
|
|
161
|
+
if (pathname === '/api/mod/leave') {
|
|
162
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
163
|
+
hub.leave(body.project, body.session);
|
|
164
|
+
return sendJson(res, 200, { ok: true });
|
|
165
|
+
}
|
|
166
|
+
if (pathname === '/api/mod/activity') {
|
|
167
|
+
const body = fieldsOf(await readJsonBody(req, ACTIVITY_BODY_LIMIT));
|
|
168
|
+
const known = hub.reportActivity(body.project, body.session, body);
|
|
169
|
+
return sendJson(res, known ? 200 : 404, known ? { ok: true } : { error: 'unknown project' });
|
|
170
|
+
}
|
|
171
|
+
if (pathname === '/api/mod/ask') {
|
|
172
|
+
const body = fieldsOf(await readJsonBody(req, ACTIVITY_BODY_LIMIT));
|
|
173
|
+
return sendJson(res, 200, hub.openAsk(body.project, body.session, body.ask));
|
|
174
|
+
}
|
|
175
|
+
if (pathname === '/api/mod/ask/wait') {
|
|
176
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
177
|
+
const hangUp = new AbortController();
|
|
178
|
+
res.on('close', () => hangUp.abort());
|
|
179
|
+
return sendJson(res, 200, await hub.waitAsk(body.id, hangUp.signal));
|
|
180
|
+
}
|
|
181
|
+
if (pathname === '/api/mod/ask/close') {
|
|
182
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
183
|
+
hub.closeAsk(body.id);
|
|
184
|
+
return sendJson(res, 200, { ok: true });
|
|
185
|
+
}
|
|
186
|
+
const session = matchSessionPath(url);
|
|
187
|
+
if (session && session.action === 'answer') {
|
|
188
|
+
const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
|
|
189
|
+
const answer = hub.answerAsk(session.key, session.session, body.id, body.answer);
|
|
190
|
+
return sendJson(res, answer.status, answer.body);
|
|
191
|
+
}
|
|
192
|
+
const scoped = matchProjectPath(url);
|
|
193
|
+
if (scoped && scoped.inner === '/api/task') {
|
|
194
|
+
const body = await readJsonBody(req, BROWSER_BODY_LIMIT);
|
|
195
|
+
const answer = await hub.write(scoped.key, { method: 'POST', path: '/api/task', body });
|
|
196
|
+
return sendJson(res, answer.status, answer.body);
|
|
197
|
+
}
|
|
198
|
+
if (scoped && scoped.inner === '/api/refresh') {
|
|
199
|
+
return sendJson(res, 202, { queued: hub.requestRefresh(scoped.key) });
|
|
200
|
+
}
|
|
201
|
+
sendJson(res, 404, { error: 'not found' });
|
|
202
|
+
}
|
|
203
|
+
async function handleRequest(hub, req, res, clients, boundHost, acceptAnyHost) {
|
|
321
204
|
try {
|
|
322
205
|
// Host allowlist runs before any route (DNS-rebinding guard).
|
|
323
206
|
if (!isAllowedHostHeader(req.headers.host, boundHost, acceptAnyHost)) {
|
|
@@ -326,14 +209,32 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
326
209
|
applyContentSecurityPolicy(res);
|
|
327
210
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
328
211
|
const pathname = url.pathname;
|
|
329
|
-
if (req.method === 'POST'
|
|
330
|
-
return await
|
|
212
|
+
if (req.method === 'POST') {
|
|
213
|
+
return await handlePost(hub, req, res, url);
|
|
331
214
|
}
|
|
332
215
|
if (req.method !== 'GET') {
|
|
333
216
|
return sendJson(res, 405, { error: 'method not allowed' });
|
|
334
217
|
}
|
|
335
218
|
if (pathname === '/api/events')
|
|
336
219
|
return handleEvents(req, res, clients);
|
|
220
|
+
// The mod's cheap reachability probe, so a session with no hub to talk to
|
|
221
|
+
// does not run the tospec CLI just to find that out. The pid lets `stop`
|
|
222
|
+
// confirm a registry marker still names this hub (`answersAsHub`).
|
|
223
|
+
if (pathname === '/api/health') {
|
|
224
|
+
return sendJson(res, 200, { protocol: DASHBOARD_PROTOCOL, pid: process.pid });
|
|
225
|
+
}
|
|
226
|
+
// `interactive` tells the page whether to offer the answer controls and
|
|
227
|
+
// the task checkboxes.
|
|
228
|
+
if (pathname === '/api/projects') {
|
|
229
|
+
return sendJson(res, 200, { projects: hub.listProjects(), interactive: hub.interactive });
|
|
230
|
+
}
|
|
231
|
+
const session = matchSessionPath(url);
|
|
232
|
+
if (session) {
|
|
233
|
+
if (session.action !== 'log')
|
|
234
|
+
return sendJson(res, 405, { error: 'method not allowed' });
|
|
235
|
+
const log = hub.sessionLog(session.key, session.session);
|
|
236
|
+
return log ? sendJson(res, 200, log) : sendJson(res, 404, { error: 'That session is not connected to this dashboard.' });
|
|
237
|
+
}
|
|
337
238
|
// `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
|
|
338
239
|
if (pathname === '/' || pathname === '/index.html') {
|
|
339
240
|
return await serveAsset(res, ASSETS_DIR, 'index.html');
|
|
@@ -341,89 +242,77 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
341
242
|
if (pathname.startsWith('/assets/')) {
|
|
342
243
|
return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
|
|
343
244
|
}
|
|
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);
|
|
245
|
+
const scoped = matchProjectPath(url);
|
|
246
|
+
if (scoped) {
|
|
247
|
+
const answer = await hub.read(scoped.key, scoped.inner);
|
|
248
|
+
return sendJson(res, answer.status, answer.body);
|
|
364
249
|
}
|
|
365
250
|
sendJson(res, 404, { error: 'not found' });
|
|
366
251
|
}
|
|
367
252
|
catch (err) {
|
|
253
|
+
if (err instanceof HubRequestError)
|
|
254
|
+
return sendJson(res, err.status, { error: err.message });
|
|
368
255
|
// A single handler failure must never crash the server.
|
|
369
256
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
370
257
|
}
|
|
371
258
|
}
|
|
372
259
|
/**
|
|
373
|
-
* Starts the
|
|
374
|
-
*
|
|
375
|
-
*
|
|
260
|
+
* Starts the hub on exactly the requested port. Unlike the per-project servers
|
|
261
|
+
* this replaced, it never advances past `EADDRINUSE`: mods find the hub at a
|
|
262
|
+
* fixed address, so a hub that moved would be one no session could reach.
|
|
263
|
+
* Side-effect free (no logging, signal handlers or registry) so tests can drive
|
|
264
|
+
* it directly.
|
|
376
265
|
*/
|
|
377
|
-
export async function startDashboardServer(
|
|
266
|
+
export async function startDashboardServer(opts = {}) {
|
|
378
267
|
const host = opts.host ?? DEFAULT_HOST;
|
|
379
268
|
assertBindableHost(host, 'dashboard', opts.allowRemote, 'Pass --allow-remote to accept that risk.');
|
|
380
269
|
const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
|
|
381
270
|
const clients = new Set();
|
|
271
|
+
const hub = new DashboardHub({
|
|
272
|
+
// Driving a session from the page runs tools in it, and a task tick writes
|
|
273
|
+
// project files, so only a hub that nobody else can reach may do either;
|
|
274
|
+
// `--allow-remote` leaves it read-only.
|
|
275
|
+
interactive: LOOPBACK_HOSTS.has(normalizeHost(host)),
|
|
276
|
+
...opts.hub,
|
|
277
|
+
});
|
|
278
|
+
const unsubscribe = hub.subscribe((event) => broadcast(clients, event));
|
|
382
279
|
const server = http.createServer((req, res) => {
|
|
383
|
-
void handleRequest(
|
|
280
|
+
void handleRequest(hub, req, res, clients, host, acceptAnyHost);
|
|
384
281
|
});
|
|
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;
|
|
282
|
+
const requestedPort = opts.port ?? DEFAULT_PORT;
|
|
399
283
|
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;
|
|
284
|
+
await listenOnce(server, requestedPort, host);
|
|
408
285
|
}
|
|
409
|
-
catch {
|
|
410
|
-
|
|
286
|
+
catch (err) {
|
|
287
|
+
if (err.code === 'EADDRINUSE') {
|
|
288
|
+
throw new Error(`Port ${requestedPort} is already in use. Another dashboard may be running: see tospec dashboard status.`);
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
411
291
|
}
|
|
292
|
+
const address = server.address();
|
|
293
|
+
const port = typeof address === 'object' && address ? address.port : requestedPort;
|
|
294
|
+
const sweep = setInterval(() => hub.sweep(), SWEEP_INTERVAL_MS);
|
|
295
|
+
sweep.unref();
|
|
296
|
+
const close = async () => {
|
|
297
|
+
clearInterval(sweep);
|
|
298
|
+
unsubscribe();
|
|
299
|
+
hub.close();
|
|
300
|
+
// Held polls and SSE streams would otherwise keep close() from draining.
|
|
301
|
+
server.closeAllConnections?.();
|
|
302
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
303
|
+
};
|
|
304
|
+
return { server, url: connectableUrl(host, port), port, hub, close };
|
|
412
305
|
}
|
|
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`);
|
|
306
|
+
function registryDir() {
|
|
307
|
+
// Derived from the one config resolver, so relocating the config directory
|
|
308
|
+
// moves the registry with it.
|
|
309
|
+
return path.join(getGlobalConfigDir(), 'dashboards');
|
|
420
310
|
}
|
|
421
311
|
/** True if a process with this PID exists (signal 0 probes without killing). */
|
|
422
312
|
function isProcessAlive(pid) {
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
// terminate the user's own shell.
|
|
313
|
+
// On POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
|
|
314
|
+
// named one, so a corrupt marker would make `stop` terminate the user's own
|
|
315
|
+
// shell.
|
|
427
316
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
428
317
|
return false;
|
|
429
318
|
try {
|
|
@@ -435,67 +324,6 @@ function isProcessAlive(pid) {
|
|
|
435
324
|
return err.code === 'EPERM';
|
|
436
325
|
}
|
|
437
326
|
}
|
|
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
327
|
/**
|
|
500
328
|
* Lists live dashboards sorted by port, reaping any marker whose process has
|
|
501
329
|
* died. A missing or unreadable dir means no dashboards are running.
|
|
@@ -515,6 +343,7 @@ export async function listDashboards() {
|
|
|
515
343
|
if (!m)
|
|
516
344
|
continue;
|
|
517
345
|
const pid = Number(m[1]);
|
|
346
|
+
const port = Number(m[2]);
|
|
518
347
|
const file = path.join(dir, name);
|
|
519
348
|
if (!isProcessAlive(pid)) {
|
|
520
349
|
// Best-effort: failing to unlink one stale marker must not take down a
|
|
@@ -522,112 +351,71 @@ export async function listDashboards() {
|
|
|
522
351
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
523
352
|
continue;
|
|
524
353
|
}
|
|
525
|
-
let
|
|
354
|
+
let url = '';
|
|
526
355
|
try {
|
|
527
|
-
|
|
356
|
+
url = (await fs.readFile(file, 'utf-8')).trim();
|
|
528
357
|
}
|
|
529
358
|
catch {
|
|
530
|
-
/* marker
|
|
359
|
+
/* unreadable marker → fall back to loopback below */
|
|
531
360
|
}
|
|
532
|
-
|
|
361
|
+
// A marker from before the hub held a project root, not a URL.
|
|
362
|
+
if (!/^https?:\/\//.test(url))
|
|
363
|
+
url = connectableUrl(DEFAULT_HOST, port);
|
|
364
|
+
running.push({ pid, port, url });
|
|
533
365
|
}
|
|
534
366
|
return running.sort((a, b) => a.port - b.port);
|
|
535
367
|
}
|
|
536
368
|
/**
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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.
|
|
369
|
+
* True only when the marker's URL answers `/api/health` with the marker's own
|
|
370
|
+
* pid. A live pid alone proves nothing: Windows hard-kills on `stop`, and a
|
|
371
|
+
* reboot or Task Manager skips cleanup, so a marker can outlive its hub until
|
|
372
|
+
* the OS hands the pid to an unrelated process — which `stop` then killed.
|
|
373
|
+
* Matching the pid, not just "something answered", also rejects a different
|
|
374
|
+
* hub that later bound the same port.
|
|
547
375
|
*/
|
|
548
|
-
export
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
376
|
+
export async function answersAsHub(d, timeoutMs = STATUS_TIMEOUT_MS) {
|
|
377
|
+
try {
|
|
378
|
+
const res = await fetch(`${d.url}/api/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
379
|
+
if (!res.ok)
|
|
380
|
+
return false;
|
|
381
|
+
const body = (await res.json());
|
|
382
|
+
return body.pid === d.pid;
|
|
554
383
|
}
|
|
555
|
-
|
|
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
|
-
};
|
|
384
|
+
catch {
|
|
385
|
+
return false;
|
|
574
386
|
}
|
|
575
387
|
}
|
|
576
388
|
/**
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
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.
|
|
389
|
+
* Drops markers whose pid no longer runs the hub they name, returning the rest.
|
|
390
|
+
* Stale here means "does not answer as that hub", so a hub wedged past the
|
|
391
|
+
* timeout is dropped too: then `start` fails on the port it still holds, which
|
|
392
|
+
* is recoverable, whereas killing a reused pid is not.
|
|
586
393
|
*/
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
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;
|
|
394
|
+
async function reapUnansweringDashboards(running) {
|
|
395
|
+
const verdicts = await Promise.all(running.map((d) => answersAsHub(d)));
|
|
396
|
+
const live = [];
|
|
397
|
+
const reaped = [];
|
|
398
|
+
for (const [i, d] of running.entries()) {
|
|
399
|
+
if (verdicts[i]) {
|
|
400
|
+
live.push(d);
|
|
603
401
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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;
|
|
402
|
+
else {
|
|
403
|
+
reaped.push(d);
|
|
404
|
+
await unregisterDashboard(d.pid);
|
|
612
405
|
}
|
|
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
406
|
}
|
|
618
|
-
return
|
|
407
|
+
return { live, reaped };
|
|
619
408
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
*/
|
|
624
|
-
|
|
625
|
-
return
|
|
409
|
+
function formatReapedMarker(d) {
|
|
410
|
+
return `Removed a stale dashboard marker: pid ${d.pid} does not answer as the hub at ${d.url}.`;
|
|
411
|
+
}
|
|
412
|
+
/** One line describing a running dashboard, for `start`'s refusal. */
|
|
413
|
+
export function formatRunningDashboard(d) {
|
|
414
|
+
return `pid ${d.pid} ${d.url}`;
|
|
626
415
|
}
|
|
627
|
-
|
|
628
|
-
async function registerDashboard(pid, port, projectRoot) {
|
|
416
|
+
async function registerDashboard(pid, port, url) {
|
|
629
417
|
await fs.mkdir(registryDir(), { recursive: true });
|
|
630
|
-
await fs.writeFile(path.join(registryDir(), `${pid}_${port}`),
|
|
418
|
+
await fs.writeFile(path.join(registryDir(), `${pid}_${port}`), url, 'utf-8');
|
|
631
419
|
}
|
|
632
420
|
/** Removes any marker for this pid (port-agnostic, so callers needn't know it). */
|
|
633
421
|
async function unregisterDashboard(pid) {
|
|
@@ -666,42 +454,48 @@ function unregisterDashboardSync(pid) {
|
|
|
666
454
|
}
|
|
667
455
|
}
|
|
668
456
|
/**
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
457
|
+
* The detached child neither reported itself listening nor exited. It may still
|
|
458
|
+
* be coming up, so the message reports what was not observed rather than
|
|
459
|
+
* claiming failure.
|
|
460
|
+
*/
|
|
461
|
+
export class DashboardReadinessTimeoutError extends Error {
|
|
462
|
+
diagnostic;
|
|
463
|
+
constructor(timeoutMs) {
|
|
464
|
+
const message = `The background dashboard process did not report that it is listening within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
465
|
+
'It was started and has not exited, so it may still be coming up.';
|
|
466
|
+
super(message);
|
|
467
|
+
this.name = 'DashboardReadinessTimeoutError';
|
|
468
|
+
this.diagnostic = {
|
|
469
|
+
severity: 'error',
|
|
470
|
+
code: 'dashboard_detach_readiness_timeout',
|
|
471
|
+
message,
|
|
472
|
+
fix: 'Check whether it came up with: tospec dashboard status (then tospec dashboard stop to stop it).',
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* `tospec dashboard start`: launches the hub as a detached background process
|
|
478
|
+
* by re-running this CLI as the hidden `dashboard serve` (the child is the
|
|
479
|
+
* actual server), then waits for the child's `LISTENING` report, so the URL
|
|
480
|
+
* printed is one the child actually bound.
|
|
683
481
|
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
482
|
+
* Refuses while any hub is running: one hub serves every project, and a second
|
|
483
|
+
* one could only bind another port that no mod looks at. Limitation: no
|
|
484
|
+
* cross-process lock, so two simultaneous `start` could both pass the guard;
|
|
485
|
+
* the loser then fails on the port.
|
|
686
486
|
*/
|
|
687
|
-
export async function detachDashboard(
|
|
688
|
-
const
|
|
689
|
-
const
|
|
487
|
+
export async function detachDashboard(cliEntry, opts) {
|
|
488
|
+
const { live, reaped } = await reapUnansweringDashboards(await listDashboards());
|
|
489
|
+
for (const d of reaped)
|
|
490
|
+
console.log(formatReapedMarker(d));
|
|
491
|
+
const running = live[0];
|
|
690
492
|
if (running) {
|
|
691
|
-
|
|
692
|
-
console.log(
|
|
693
|
-
console.log(
|
|
694
|
-
console.log('Stop it with: tospec dashboard --stop');
|
|
493
|
+
console.log('Dashboard already running.');
|
|
494
|
+
console.log(` ${formatRunningDashboard(running)}`);
|
|
495
|
+
console.log('Stop it with: tospec dashboard stop');
|
|
695
496
|
return;
|
|
696
497
|
}
|
|
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];
|
|
498
|
+
const args = [cliEntry, 'dashboard', 'serve', '--port', String(opts.port), '--host', opts.host];
|
|
705
499
|
if (opts.open)
|
|
706
500
|
args.push('--open');
|
|
707
501
|
if (opts.allowRemote)
|
|
@@ -713,15 +507,13 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
|
713
507
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
714
508
|
// On Windows, detached: true otherwise pops a new console window.
|
|
715
509
|
windowsHide: true,
|
|
716
|
-
env: { ...process.env,
|
|
510
|
+
env: { ...process.env, [DETACHED_ENV]: '1' },
|
|
717
511
|
});
|
|
718
|
-
// No pid means the spawn itself failed; reporting success would
|
|
719
|
-
// `--stop` could never
|
|
512
|
+
// No pid means the spawn itself failed; reporting success would point at a
|
|
513
|
+
// process `--stop` could never reach.
|
|
720
514
|
if (child.pid === undefined) {
|
|
721
515
|
throw new Error('Could not start the background dashboard process.');
|
|
722
516
|
}
|
|
723
|
-
// Everything that can go wrong after a successful spawn happens inside the
|
|
724
|
-
// child and is only observable through this handshake.
|
|
725
517
|
let confirmedUrl;
|
|
726
518
|
try {
|
|
727
519
|
confirmedUrl = await waitForListening(child);
|
|
@@ -731,31 +523,14 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
|
731
523
|
// un-unref'd handle would keep this process's event loop alive.
|
|
732
524
|
child.unref();
|
|
733
525
|
}
|
|
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
526
|
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;
|
|
527
|
+
console.log('Stop it with: tospec dashboard stop');
|
|
750
528
|
}
|
|
751
529
|
/**
|
|
752
530
|
* Races the child's outcome: resolves with the confirmed URL on
|
|
753
531
|
* `LISTENING <url>`, rejects with its captured stderr if it exits first, and
|
|
754
532
|
* rejects on timeout if it does neither. Closes the stderr pipe once settled so
|
|
755
533
|
* 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
534
|
*/
|
|
760
535
|
function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
761
536
|
return new Promise((resolve, reject) => {
|
|
@@ -804,44 +579,13 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
|
804
579
|
}, timeoutMs);
|
|
805
580
|
});
|
|
806
581
|
}
|
|
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
582
|
/**
|
|
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.
|
|
583
|
+
* Terminates a running dashboard and clears its registry marker. A dead pid
|
|
584
|
+
* counts as already stopped. Returns null on success, or the reason the signal
|
|
585
|
+
* was refused.
|
|
842
586
|
*
|
|
843
587
|
* Limitation: SIGTERM; Windows maps it to TerminateProcess (hard kill), so the
|
|
844
|
-
* target's own cleanup won't run — we clear its
|
|
588
|
+
* target's own cleanup won't run — we clear its marker here.
|
|
845
589
|
*/
|
|
846
590
|
export async function stopRunningDashboard(d) {
|
|
847
591
|
try {
|
|
@@ -849,71 +593,149 @@ export async function stopRunningDashboard(d) {
|
|
|
849
593
|
}
|
|
850
594
|
catch (err) {
|
|
851
595
|
// ESRCH means it exited between listing and now: already stopped, so still
|
|
852
|
-
// clear its
|
|
596
|
+
// clear its marker. Anything else (EPERM) is a real refusal.
|
|
853
597
|
const { code, message } = err;
|
|
854
598
|
if (code !== 'ESRCH')
|
|
855
599
|
return code ?? message;
|
|
856
600
|
}
|
|
857
601
|
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
602
|
return null;
|
|
863
603
|
}
|
|
864
604
|
/**
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
|
|
868
|
-
|
|
605
|
+
* `tospec dashboard stop`: stops every hub, not a pick. One serves all
|
|
606
|
+
* projects, so there is no "this project's dashboard" left to choose.
|
|
607
|
+
*/
|
|
608
|
+
export async function dashboardStopCommand() {
|
|
609
|
+
const { live, reaped } = await reapUnansweringDashboards(await listDashboards());
|
|
610
|
+
for (const d of reaped)
|
|
611
|
+
console.log(formatReapedMarker(d));
|
|
612
|
+
if (live.length === 0) {
|
|
613
|
+
console.log('No dashboards are running.');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
for (const d of live) {
|
|
617
|
+
const refusal = await stopRunningDashboard(d);
|
|
618
|
+
if (refusal === null) {
|
|
619
|
+
console.log(`Stopped dashboard (pid ${d.pid}) at ${d.url}.`);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
console.error(`Error: Could not stop dashboard (pid ${d.pid}) at ${d.url}: ${refusal}.`);
|
|
623
|
+
process.exitCode = 1;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Asks a running hub which projects it serves. Sessions live only in the hub's
|
|
629
|
+
* memory, so the registry marker can say a hub is running but not whom it
|
|
630
|
+
* serves.
|
|
631
|
+
*/
|
|
632
|
+
export async function fetchHubProjects(d, timeoutMs = STATUS_TIMEOUT_MS) {
|
|
633
|
+
const res = await fetch(`${d.url}/api/projects`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
634
|
+
if (!res.ok)
|
|
635
|
+
throw new Error(`it answered HTTP ${res.status}`);
|
|
636
|
+
const body = (await res.json());
|
|
637
|
+
if (!Array.isArray(body.projects))
|
|
638
|
+
throw new Error('its answer had no project list');
|
|
639
|
+
return body.projects;
|
|
640
|
+
}
|
|
641
|
+
function plural(n, noun) {
|
|
642
|
+
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* The `status` block for one hub. Projects with no live session stay listed as
|
|
646
|
+
* offline: the hub still serves their last snapshot.
|
|
869
647
|
*/
|
|
870
|
-
export
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
648
|
+
export function formatDashboardStatus(d, projects) {
|
|
649
|
+
const sessionCount = projects.reduce((sum, p) => sum + p.sessions.length, 0);
|
|
650
|
+
const lines = [`Dashboard running at ${d.url} (pid ${d.pid}).`, `Sessions: ${sessionCount}`];
|
|
651
|
+
if (projects.length === 0) {
|
|
652
|
+
lines.push('Projects: none');
|
|
653
|
+
return lines;
|
|
654
|
+
}
|
|
655
|
+
lines.push(`Projects: ${projects.length}`);
|
|
656
|
+
const counts = projects.map((p) => (p.sessions.length === 0 ? 'offline' : plural(p.sessions.length, 'session')));
|
|
657
|
+
const nameWidth = maxDisplayWidth(projects.map((p) => p.name));
|
|
658
|
+
const countWidth = Math.max(...counts.map((c) => c.length));
|
|
659
|
+
projects.forEach((p, i) => {
|
|
660
|
+
// Root last: two projects can share a directory name, and the root is what
|
|
661
|
+
// tells them apart.
|
|
662
|
+
lines.push(` ${padEndDisplay(p.name, nameWidth)} ${counts[i].padEnd(countWidth)} ${p.root}`);
|
|
663
|
+
});
|
|
664
|
+
return lines;
|
|
665
|
+
}
|
|
666
|
+
/** `tospec dashboard status`: whether a hub runs, and the sessions it serves. */
|
|
667
|
+
export async function dashboardStatusCommand() {
|
|
668
|
+
const { live, reaped } = await reapUnansweringDashboards(await listDashboards());
|
|
669
|
+
for (const d of reaped)
|
|
670
|
+
console.log(formatReapedMarker(d));
|
|
671
|
+
if (live.length === 0) {
|
|
672
|
+
console.log('Dashboard is not running.');
|
|
673
|
+
console.log('Start it with: tospec dashboard start');
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
for (const [i, d] of live.entries()) {
|
|
677
|
+
if (i > 0)
|
|
678
|
+
console.log('');
|
|
679
|
+
let projects;
|
|
680
|
+
try {
|
|
681
|
+
projects = await fetchHubProjects(d);
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
// It answered /api/health as this hub a moment ago, so it is a hub, just
|
|
685
|
+
// not serving its project list. Saying "running" would claim the one
|
|
686
|
+
// thing this could not confirm.
|
|
687
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
688
|
+
console.error(`Error: Dashboard process ${d.pid} is registered at ${d.url} but did not answer: ${reason}.`);
|
|
689
|
+
console.error('Fix: Retry in a moment; if it still does not answer, run tospec dashboard stop, then tospec dashboard start.');
|
|
690
|
+
process.exitCode = 1;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
for (const line of formatDashboardStatus(d, projects))
|
|
694
|
+
console.log(line);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* `tospec dashboard serve` (hidden), the process `start` spawns: starts the
|
|
699
|
+
* hub, records it, prints the URL, and stays up until terminated.
|
|
700
|
+
*/
|
|
701
|
+
export async function runDashboard(opts = {}) {
|
|
702
|
+
const started = await startDashboardServer(opts);
|
|
703
|
+
const { url, port } = started;
|
|
704
|
+
// Recorded before the readiness line, so a `status` right after `start`
|
|
705
|
+
// returns already sees it.
|
|
706
|
+
try {
|
|
707
|
+
await registerDashboard(process.pid, port, url);
|
|
708
|
+
}
|
|
709
|
+
catch (err) {
|
|
710
|
+
await started.close();
|
|
711
|
+
throw err;
|
|
712
|
+
}
|
|
876
713
|
console.log(`Dashboard running at ${url} (Ctrl+C to stop)`);
|
|
714
|
+
console.log('Claude Code sessions in tospec projects register through the tospec-dashboard mod.');
|
|
877
715
|
// Detached child only: `detachDashboard` waits on stderr for exactly this line
|
|
878
|
-
// before it reports anything. A
|
|
879
|
-
if (process.env
|
|
716
|
+
// before it reports anything. A `serve` run by hand has no parent watching.
|
|
717
|
+
if (process.env[DETACHED_ENV]) {
|
|
880
718
|
// 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
|
-
//
|
|
719
|
+
// then exits, so every later stderr write hits EPIPE. Unhandled, that error
|
|
720
|
+
// killed the background dashboard a few writes in; handled, the writes are
|
|
721
|
+
// dropped.
|
|
884
722
|
process.stderr.on('error', () => { });
|
|
885
723
|
console.error(`${LISTENING_PREFIX}${url}`);
|
|
886
724
|
}
|
|
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
725
|
if (opts.open) {
|
|
900
726
|
openBrowser(url);
|
|
901
727
|
}
|
|
902
|
-
// Normal termination removes this dashboard's
|
|
903
|
-
//
|
|
728
|
+
// Normal termination removes this dashboard's marker. Windows hard-kills on
|
|
729
|
+
// `stop`, so there `stopRunningDashboard` clears it instead.
|
|
904
730
|
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.
|
|
731
|
+
// Sync: fired and forgotten, the async readdir + rm could lose the race
|
|
732
|
+
// with process.exit, and a marker outliving its process reads as alive
|
|
733
|
+
// again once the OS reuses the pid — which `stop` would then kill.
|
|
911
734
|
unregisterDashboardSync(process.pid);
|
|
912
|
-
|
|
913
|
-
server.close(() => process.exit(0));
|
|
735
|
+
void started.close().then(() => process.exit(0));
|
|
914
736
|
};
|
|
915
737
|
process.on('SIGINT', cleanup);
|
|
916
738
|
process.on('SIGTERM', cleanup);
|
|
917
|
-
return
|
|
739
|
+
return started;
|
|
918
740
|
}
|
|
919
741
|
//# sourceMappingURL=dashboard.js.map
|