@seanmars/tospec 0.19.0-beta.0 → 0.19.0-beta.13
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 +578 -0
- package/README.md +71 -77
- package/assets/dashboard/app.js +14 -3
- package/assets/dashboard/style.css +7 -0
- package/assets/metrics/app.js +22 -0
- package/assets/metrics/style.css +8 -0
- package/assets/rules/tospec/decision.md +3 -0
- package/assets/rules/tospec/single-source-of-truth.md +19 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +266 -82
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/config.d.ts +9 -17
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +374 -95
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/dashboard.d.ts +64 -81
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +350 -218
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/decision.d.ts +41 -19
- package/dist/commands/decision.d.ts.map +1 -1
- package/dist/commands/decision.js +400 -70
- package/dist/commands/decision.js.map +1 -1
- package/dist/commands/metrics.d.ts +34 -48
- package/dist/commands/metrics.d.ts.map +1 -1
- package/dist/commands/metrics.js +74 -84
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/shared-output.d.ts +24 -10
- package/dist/commands/shared-output.d.ts.map +1 -1
- package/dist/commands/shared-output.js +62 -11
- package/dist/commands/shared-output.js.map +1 -1
- package/dist/commands/show.d.ts +7 -0
- package/dist/commands/show.d.ts.map +1 -1
- package/dist/commands/show.js +39 -8
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/validate.d.ts +47 -30
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +282 -108
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/workflow/index.d.ts +6 -10
- package/dist/commands/workflow/index.d.ts.map +1 -1
- package/dist/commands/workflow/index.js +6 -10
- package/dist/commands/workflow/index.js.map +1 -1
- package/dist/commands/workflow/instructions.d.ts +21 -8
- package/dist/commands/workflow/instructions.d.ts.map +1 -1
- package/dist/commands/workflow/instructions.js +254 -95
- package/dist/commands/workflow/instructions.js.map +1 -1
- package/dist/commands/workflow/new-change.d.ts +4 -5
- package/dist/commands/workflow/new-change.d.ts.map +1 -1
- package/dist/commands/workflow/new-change.js +90 -25
- package/dist/commands/workflow/new-change.js.map +1 -1
- package/dist/commands/workflow/schemas.d.ts +3 -5
- package/dist/commands/workflow/schemas.d.ts.map +1 -1
- package/dist/commands/workflow/schemas.js +37 -11
- package/dist/commands/workflow/schemas.js.map +1 -1
- package/dist/commands/workflow/shared.d.ts +48 -21
- package/dist/commands/workflow/shared.d.ts.map +1 -1
- package/dist/commands/workflow/shared.js +36 -33
- package/dist/commands/workflow/shared.js.map +1 -1
- package/dist/commands/workflow/status.d.ts +10 -6
- package/dist/commands/workflow/status.d.ts.map +1 -1
- package/dist/commands/workflow/status.js +84 -42
- package/dist/commands/workflow/status.js.map +1 -1
- package/dist/commands/workflow/templates.d.ts +10 -3
- package/dist/commands/workflow/templates.d.ts.map +1 -1
- package/dist/commands/workflow/templates.js +39 -40
- package/dist/commands/workflow/templates.js.map +1 -1
- package/dist/core/archive.d.ts +26 -21
- package/dist/core/archive.d.ts.map +1 -1
- package/dist/core/archive.js +415 -201
- package/dist/core/archive.js.map +1 -1
- package/dist/core/artifact-graph/graph.d.ts +29 -36
- package/dist/core/artifact-graph/graph.d.ts.map +1 -1
- package/dist/core/artifact-graph/graph.js +50 -58
- package/dist/core/artifact-graph/graph.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 +105 -100
- package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.js +176 -110
- package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
- package/dist/core/artifact-graph/outputs.d.ts +13 -6
- package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
- package/dist/core/artifact-graph/outputs.js +146 -8
- package/dist/core/artifact-graph/outputs.js.map +1 -1
- package/dist/core/artifact-graph/resolver.d.ts +44 -63
- package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
- package/dist/core/artifact-graph/resolver.js +85 -86
- package/dist/core/artifact-graph/resolver.js.map +1 -1
- package/dist/core/artifact-graph/schema.d.ts +0 -6
- package/dist/core/artifact-graph/schema.d.ts.map +1 -1
- package/dist/core/artifact-graph/schema.js +7 -32
- package/dist/core/artifact-graph/schema.js.map +1 -1
- package/dist/core/artifact-graph/state.d.ts +1 -8
- package/dist/core/artifact-graph/state.d.ts.map +1 -1
- package/dist/core/artifact-graph/state.js +2 -17
- package/dist/core/artifact-graph/state.js.map +1 -1
- package/dist/core/artifact-graph/stub-detection.d.ts +12 -0
- package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -0
- package/dist/core/artifact-graph/stub-detection.js +39 -0
- package/dist/core/artifact-graph/stub-detection.js.map +1 -0
- package/dist/core/artifact-graph/types.d.ts +4 -0
- package/dist/core/artifact-graph/types.d.ts.map +1 -1
- package/dist/core/artifact-graph/types.js +30 -10
- package/dist/core/artifact-graph/types.js.map +1 -1
- package/dist/core/available-tools.d.ts +3 -12
- package/dist/core/available-tools.d.ts.map +1 -1
- package/dist/core/available-tools.js +4 -13
- package/dist/core/available-tools.js.map +1 -1
- package/dist/core/change-metadata/schema.d.ts +1 -1
- package/dist/core/change-metadata/schema.d.ts.map +1 -1
- package/dist/core/change-metadata/schema.js +10 -7
- package/dist/core/change-metadata/schema.js.map +1 -1
- package/dist/core/change-presenter.d.ts +23 -18
- package/dist/core/change-presenter.d.ts.map +1 -1
- package/dist/core/change-presenter.js +102 -43
- package/dist/core/change-presenter.js.map +1 -1
- package/dist/core/change-status-policy.d.ts +8 -1
- package/dist/core/change-status-policy.d.ts.map +1 -1
- package/dist/core/change-status-policy.js +25 -1
- package/dist/core/change-status-policy.js.map +1 -1
- package/dist/core/codex-metrics.d.ts +25 -45
- package/dist/core/codex-metrics.d.ts.map +1 -1
- package/dist/core/codex-metrics.js +44 -88
- package/dist/core/codex-metrics.js.map +1 -1
- package/dist/core/codex-residue.d.ts +22 -0
- package/dist/core/codex-residue.d.ts.map +1 -0
- package/dist/core/codex-residue.js +61 -0
- package/dist/core/codex-residue.js.map +1 -0
- package/dist/core/command-generation/adapters/claude.d.ts +2 -9
- package/dist/core/command-generation/adapters/claude.d.ts.map +1 -1
- package/dist/core/command-generation/adapters/claude.js +2 -12
- package/dist/core/command-generation/adapters/claude.js.map +1 -1
- package/dist/core/command-generation/adapters/index.d.ts +1 -9
- package/dist/core/command-generation/adapters/index.d.ts.map +1 -1
- package/dist/core/command-generation/adapters/index.js +1 -9
- package/dist/core/command-generation/adapters/index.js.map +1 -1
- package/dist/core/command-generation/generator.d.ts +0 -17
- package/dist/core/command-generation/generator.d.ts.map +1 -1
- package/dist/core/command-generation/generator.js +0 -17
- package/dist/core/command-generation/generator.js.map +1 -1
- package/dist/core/command-generation/index.d.ts +2 -5
- package/dist/core/command-generation/index.d.ts.map +1 -1
- package/dist/core/command-generation/index.js +0 -9
- package/dist/core/command-generation/index.js.map +1 -1
- package/dist/core/command-generation/types.d.ts +10 -36
- package/dist/core/command-generation/types.d.ts.map +1 -1
- package/dist/core/command-generation/types.js +0 -6
- package/dist/core/command-generation/types.js.map +1 -1
- package/dist/core/command-generation/yaml.d.ts +3 -18
- package/dist/core/command-generation/yaml.d.ts.map +1 -1
- package/dist/core/command-generation/yaml.js +5 -23
- package/dist/core/command-generation/yaml.js.map +1 -1
- package/dist/core/config-prompts.d.ts +2 -4
- package/dist/core/config-prompts.d.ts.map +1 -1
- package/dist/core/config-prompts.js +2 -7
- package/dist/core/config-prompts.js.map +1 -1
- package/dist/core/config-schema.d.ts +8 -53
- package/dist/core/config-schema.d.ts.map +1 -1
- package/dist/core/config-schema.js +49 -62
- package/dist/core/config-schema.js.map +1 -1
- package/dist/core/config.d.ts +56 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +73 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/converters/json-converter.d.ts.map +1 -1
- package/dist/core/dashboard-activity.d.ts +7 -9
- package/dist/core/dashboard-activity.d.ts.map +1 -1
- package/dist/core/dashboard-activity.js +26 -24
- package/dist/core/dashboard-activity.js.map +1 -1
- package/dist/core/dashboard-data.d.ts +40 -22
- package/dist/core/dashboard-data.d.ts.map +1 -1
- package/dist/core/dashboard-data.js +84 -68
- package/dist/core/dashboard-data.js.map +1 -1
- package/dist/core/global-config.d.ts +24 -53
- package/dist/core/global-config.d.ts.map +1 -1
- package/dist/core/global-config.js +43 -62
- package/dist/core/global-config.js.map +1 -1
- package/dist/core/init.d.ts +29 -11
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +232 -164
- package/dist/core/init.js.map +1 -1
- package/dist/core/list.d.ts +1 -1
- package/dist/core/list.d.ts.map +1 -1
- package/dist/core/list.js +121 -28
- package/dist/core/list.js.map +1 -1
- package/dist/core/local-server.d.ts +63 -39
- package/dist/core/local-server.d.ts.map +1 -1
- package/dist/core/local-server.js +99 -53
- package/dist/core/local-server.js.map +1 -1
- package/dist/core/markdown-render.d.ts +25 -0
- package/dist/core/markdown-render.d.ts.map +1 -0
- package/dist/core/markdown-render.js +94 -0
- package/dist/core/markdown-render.js.map +1 -0
- package/dist/core/migrate.d.ts +32 -15
- package/dist/core/migrate.d.ts.map +1 -1
- package/dist/core/migrate.js +220 -108
- package/dist/core/migrate.js.map +1 -1
- package/dist/core/parsers/change-parser.d.ts +7 -10
- package/dist/core/parsers/change-parser.d.ts.map +1 -1
- package/dist/core/parsers/change-parser.js +48 -56
- package/dist/core/parsers/change-parser.js.map +1 -1
- package/dist/core/parsers/markdown-parser.d.ts +8 -9
- package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
- package/dist/core/parsers/markdown-parser.js +31 -22
- package/dist/core/parsers/markdown-parser.js.map +1 -1
- package/dist/core/parsers/requirement-blocks.d.ts +53 -11
- package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
- package/dist/core/parsers/requirement-blocks.js +200 -60
- package/dist/core/parsers/requirement-blocks.js.map +1 -1
- package/dist/core/parsers/requirement-text.d.ts +73 -79
- package/dist/core/parsers/requirement-text.d.ts.map +1 -1
- package/dist/core/parsers/requirement-text.js +137 -79
- package/dist/core/parsers/requirement-text.js.map +1 -1
- package/dist/core/parsers/spec-structure.d.ts +1 -1
- package/dist/core/parsers/spec-structure.d.ts.map +1 -1
- package/dist/core/parsers/spec-structure.js +30 -3
- package/dist/core/parsers/spec-structure.js.map +1 -1
- package/dist/core/planning-home.js.map +1 -1
- package/dist/core/profiles.d.ts +3 -10
- package/dist/core/profiles.d.ts.map +1 -1
- package/dist/core/profiles.js +5 -12
- package/dist/core/profiles.js.map +1 -1
- package/dist/core/project-config.d.ts +43 -44
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +107 -82
- package/dist/core/project-config.js.map +1 -1
- package/dist/core/project-layout.d.ts +10 -18
- package/dist/core/project-layout.d.ts.map +1 -1
- package/dist/core/project-layout.js +16 -26
- package/dist/core/project-layout.js.map +1 -1
- package/dist/core/root-selection.d.ts +11 -7
- package/dist/core/root-selection.d.ts.map +1 -1
- package/dist/core/root-selection.js +7 -8
- package/dist/core/root-selection.js.map +1 -1
- package/dist/core/rules.d.ts +10 -0
- package/dist/core/rules.d.ts.map +1 -0
- package/dist/core/rules.js +43 -0
- package/dist/core/rules.js.map +1 -0
- package/dist/core/schema-names.d.ts +16 -0
- package/dist/core/schema-names.d.ts.map +1 -0
- package/dist/core/schema-names.js +16 -0
- package/dist/core/schema-names.js.map +1 -0
- package/dist/core/schemas/base.schema.d.ts +3 -0
- package/dist/core/schemas/base.schema.d.ts.map +1 -1
- package/dist/core/schemas/base.schema.js +22 -6
- package/dist/core/schemas/base.schema.js.map +1 -1
- package/dist/core/schemas/change.schema.d.ts +16 -0
- package/dist/core/schemas/change.schema.d.ts.map +1 -1
- package/dist/core/schemas/change.schema.js +41 -10
- package/dist/core/schemas/change.schema.js.map +1 -1
- package/dist/core/schemas/spec.schema.d.ts +2 -0
- package/dist/core/schemas/spec.schema.d.ts.map +1 -1
- package/dist/core/shared/index.d.ts +3 -8
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +3 -8
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/shared/rules-generation.d.ts +27 -8
- package/dist/core/shared/rules-generation.d.ts.map +1 -1
- package/dist/core/shared/rules-generation.js +151 -16
- package/dist/core/shared/rules-generation.js.map +1 -1
- package/dist/core/shared/skill-generation.d.ts +38 -53
- package/dist/core/shared/skill-generation.d.ts.map +1 -1
- package/dist/core/shared/skill-generation.js +82 -51
- package/dist/core/shared/skill-generation.js.map +1 -1
- package/dist/core/shared/tool-detection.d.ts +40 -62
- package/dist/core/shared/tool-detection.d.ts.map +1 -1
- package/dist/core/shared/tool-detection.js +88 -80
- package/dist/core/shared/tool-detection.js.map +1 -1
- package/dist/core/skill-metrics.d.ts +36 -63
- package/dist/core/skill-metrics.d.ts.map +1 -1
- package/dist/core/skill-metrics.js +34 -73
- package/dist/core/skill-metrics.js.map +1 -1
- package/dist/core/spec-presenter.d.ts.map +1 -1
- package/dist/core/spec-presenter.js +6 -6
- package/dist/core/spec-presenter.js.map +1 -1
- package/dist/core/specs-apply.d.ts +24 -23
- package/dist/core/specs-apply.d.ts.map +1 -1
- package/dist/core/specs-apply.js +188 -173
- package/dist/core/specs-apply.js.map +1 -1
- package/dist/core/templates/fragments/interview.d.ts +2 -6
- package/dist/core/templates/fragments/interview.d.ts.map +1 -1
- package/dist/core/templates/fragments/interview.js +2 -6
- package/dist/core/templates/fragments/interview.js.map +1 -1
- package/dist/core/templates/fragments/next-step.d.ts +4 -8
- package/dist/core/templates/fragments/next-step.d.ts.map +1 -1
- package/dist/core/templates/fragments/next-step.js +4 -8
- package/dist/core/templates/fragments/next-step.js.map +1 -1
- package/dist/core/templates/fragments/verify.d.ts +9 -12
- package/dist/core/templates/fragments/verify.d.ts.map +1 -1
- package/dist/core/templates/fragments/verify.js +9 -12
- package/dist/core/templates/fragments/verify.js.map +1 -1
- package/dist/core/templates/index.d.ts +0 -6
- package/dist/core/templates/index.d.ts.map +1 -1
- package/dist/core/templates/index.js +0 -7
- package/dist/core/templates/index.js.map +1 -1
- package/dist/core/templates/skill-templates.d.ts +1 -5
- package/dist/core/templates/skill-templates.d.ts.map +1 -1
- package/dist/core/templates/skill-templates.js +0 -5
- package/dist/core/templates/skill-templates.js.map +1 -1
- package/dist/core/templates/types.d.ts +3 -7
- package/dist/core/templates/types.d.ts.map +1 -1
- package/dist/core/templates/types.js +0 -3
- package/dist/core/templates/types.js.map +1 -1
- package/dist/core/templates/workflows/apply.d.ts +3 -9
- package/dist/core/templates/workflows/apply.d.ts.map +1 -1
- package/dist/core/templates/workflows/apply.js +9 -12
- package/dist/core/templates/workflows/apply.js.map +1 -1
- package/dist/core/templates/workflows/archive.d.ts +0 -6
- package/dist/core/templates/workflows/archive.d.ts.map +1 -1
- package/dist/core/templates/workflows/archive.js +7 -5
- package/dist/core/templates/workflows/archive.js.map +1 -1
- package/dist/core/templates/workflows/decision.js +4 -4
- package/dist/core/templates/workflows/decision.js.map +1 -1
- package/dist/core/templates/workflows/explore.js +1 -1
- package/dist/core/templates/workflows/grill.d.ts.map +1 -1
- package/dist/core/templates/workflows/grill.js +0 -2
- package/dist/core/templates/workflows/grill.js.map +1 -1
- package/dist/core/templates/workflows/issue.d.ts +0 -6
- package/dist/core/templates/workflows/issue.d.ts.map +1 -1
- package/dist/core/templates/workflows/issue.js +3 -0
- package/dist/core/templates/workflows/issue.js.map +1 -1
- package/dist/core/templates/workflows/propose.d.ts +0 -6
- package/dist/core/templates/workflows/propose.d.ts.map +1 -1
- package/dist/core/templates/workflows/propose.js +0 -1
- package/dist/core/templates/workflows/propose.js.map +1 -1
- package/dist/core/templates/workflows/sync.d.ts +2 -8
- package/dist/core/templates/workflows/sync.d.ts.map +1 -1
- package/dist/core/templates/workflows/sync.js +2 -2
- package/dist/core/templates/workflows/sync.js.map +1 -1
- package/dist/core/templates/workflows/update.d.ts +0 -6
- package/dist/core/templates/workflows/update.d.ts.map +1 -1
- package/dist/core/templates/workflows/update.js.map +1 -1
- package/dist/core/update.d.ts +26 -21
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +165 -116
- package/dist/core/update.js.map +1 -1
- package/dist/core/user-state-migration.d.ts +13 -15
- package/dist/core/user-state-migration.d.ts.map +1 -1
- package/dist/core/user-state-migration.js +16 -20
- package/dist/core/user-state-migration.js.map +1 -1
- package/dist/core/validation/constants.d.ts +19 -25
- package/dist/core/validation/constants.d.ts.map +1 -1
- package/dist/core/validation/constants.js +25 -20
- package/dist/core/validation/constants.js.map +1 -1
- package/dist/core/validation/prose-length.d.ts +15 -0
- package/dist/core/validation/prose-length.d.ts.map +1 -0
- package/dist/core/validation/prose-length.js +29 -0
- package/dist/core/validation/prose-length.js.map +1 -0
- package/dist/core/validation/purpose-placeholder.d.ts +9 -16
- package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
- package/dist/core/validation/purpose-placeholder.js +30 -44
- package/dist/core/validation/purpose-placeholder.js.map +1 -1
- package/dist/core/validation/section-validator.d.ts +4 -4
- package/dist/core/validation/section-validator.d.ts.map +1 -1
- package/dist/core/validation/section-validator.js +43 -7
- package/dist/core/validation/section-validator.js.map +1 -1
- package/dist/core/validation/task-numbering.d.ts +6 -3
- package/dist/core/validation/task-numbering.d.ts.map +1 -1
- package/dist/core/validation/task-numbering.js +23 -11
- package/dist/core/validation/task-numbering.js.map +1 -1
- package/dist/core/validation/types.d.ts +18 -0
- package/dist/core/validation/types.d.ts.map +1 -1
- package/dist/core/validation/types.js +12 -1
- package/dist/core/validation/types.js.map +1 -1
- package/dist/core/validation/validator.d.ts +50 -51
- package/dist/core/validation/validator.d.ts.map +1 -1
- package/dist/core/validation/validator.js +486 -263
- package/dist/core/validation/validator.js.map +1 -1
- package/dist/prompts/searchable-multi-select.d.ts +3 -8
- package/dist/prompts/searchable-multi-select.d.ts.map +1 -1
- package/dist/prompts/searchable-multi-select.js +16 -39
- package/dist/prompts/searchable-multi-select.js.map +1 -1
- package/dist/utils/change-metadata.d.ts +11 -50
- package/dist/utils/change-metadata.d.ts.map +1 -1
- package/dist/utils/change-metadata.js +48 -67
- package/dist/utils/change-metadata.js.map +1 -1
- package/dist/utils/change-utils.d.ts +31 -54
- package/dist/utils/change-utils.d.ts.map +1 -1
- package/dist/utils/change-utils.js +143 -100
- package/dist/utils/change-utils.js.map +1 -1
- package/dist/utils/file-lock.d.ts +39 -0
- package/dist/utils/file-lock.d.ts.map +1 -0
- package/dist/utils/file-lock.js +149 -0
- package/dist/utils/file-lock.js.map +1 -0
- package/dist/utils/file-system.d.ts +12 -32
- package/dist/utils/file-system.d.ts.map +1 -1
- package/dist/utils/file-system.js +16 -40
- package/dist/utils/file-system.js.map +1 -1
- package/dist/utils/frontmatter.d.ts +7 -11
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +11 -11
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/interactive.d.ts +4 -9
- package/dist/utils/interactive.d.ts.map +1 -1
- package/dist/utils/interactive.js +2 -4
- package/dist/utils/interactive.js.map +1 -1
- package/dist/utils/item-discovery.d.ts +15 -10
- package/dist/utils/item-discovery.d.ts.map +1 -1
- package/dist/utils/item-discovery.js +42 -47
- package/dist/utils/item-discovery.js.map +1 -1
- package/dist/utils/link.d.ts +13 -4
- package/dist/utils/link.d.ts.map +1 -1
- package/dist/utils/link.js +13 -4
- package/dist/utils/link.js.map +1 -1
- package/dist/utils/match.js.map +1 -1
- package/dist/utils/requirement-diff.d.ts +13 -23
- package/dist/utils/requirement-diff.d.ts.map +1 -1
- package/dist/utils/requirement-diff.js +13 -23
- package/dist/utils/requirement-diff.js.map +1 -1
- package/dist/utils/spec-files.d.ts +10 -11
- package/dist/utils/spec-files.d.ts.map +1 -1
- package/dist/utils/spec-files.js +31 -22
- package/dist/utils/spec-files.js.map +1 -1
- package/dist/utils/task-progress.d.ts +11 -9
- package/dist/utils/task-progress.d.ts.map +1 -1
- package/dist/utils/task-progress.js +53 -32
- package/dist/utils/task-progress.js.map +1 -1
- package/dist/utils/timestamp.d.ts +5 -8
- package/dist/utils/timestamp.d.ts.map +1 -1
- package/dist/utils/timestamp.js +5 -8
- package/dist/utils/timestamp.js.map +1 -1
- package/package.json +9 -10
- package/schemas/decision/templates/decision.md +3 -1
- package/schemas/decision/templates/index.md +2 -2
- package/schemas/issue/schema.yaml +11 -2
- package/schemas/issue/templates/spec.md +37 -3
- package/schemas/sdd/schema.yaml +24 -1
- package/schemas/sdd/templates/spec.md +37 -3
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `tospec dashboard` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* functions in `core/dashboard-data.ts`. Reads are GET-only; the sole write is
|
|
7
|
-
* `POST /api/task`, which ticks a task checkbox back into its markdown file.
|
|
8
|
-
* Both file-addressing endpoints (`/api/render`, `/api/task`) are trust
|
|
9
|
-
* boundaries: the requested path must resolve under the tospec root (design
|
|
10
|
-
* decision D5).
|
|
2
|
+
* `tospec dashboard` — a `node:http` server over the aggregators in
|
|
3
|
+
* `core/dashboard-data.ts`. Reads are GET-only; the sole write is
|
|
4
|
+
* `POST /api/task`. `/api/render` and `/api/task` are trust boundaries: the
|
|
5
|
+
* path must resolve under the tospec root (D5).
|
|
11
6
|
*/
|
|
12
7
|
import * as http from 'node:http';
|
|
13
8
|
import { promises as fs, readFileSync, unlinkSync, watch } from 'node:fs';
|
|
@@ -15,22 +10,55 @@ import * as path from 'node:path';
|
|
|
15
10
|
import * as os from 'node:os';
|
|
16
11
|
import * as crypto from 'node:crypto';
|
|
17
12
|
import { spawn } from 'node:child_process';
|
|
18
|
-
import { marked } from 'marked';
|
|
19
13
|
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
|
|
14
|
+
import { renderMarkdownSafe } from '../core/markdown-render.js';
|
|
20
15
|
import { collectOverview, collectChangeDetail, collectArchive, collectDecisions, readSyncConclusion, } from '../core/dashboard-data.js';
|
|
21
16
|
import { collectActivity } from '../core/dashboard-activity.js';
|
|
22
17
|
import { getGlobalConfigDir } from '../core/global-config.js';
|
|
23
|
-
import {
|
|
24
|
-
import { setTaskDone } from '../utils/task-progress.js';
|
|
18
|
+
import { acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader, listenFrom, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
|
|
19
|
+
import { resolveTaskFiles, setTaskDone } from '../utils/task-progress.js';
|
|
25
20
|
const DEFAULT_PORT = 5620;
|
|
26
21
|
const DEFAULT_HOST = '127.0.0.1';
|
|
22
|
+
// Readiness signal the detached child writes to stderr once bound; emitted only
|
|
23
|
+
// when TOSPEC_DASHBOARD_PIDFILE marks it as a `--detach` child.
|
|
24
|
+
const LISTENING_PREFIX = 'LISTENING ';
|
|
25
|
+
/** Drops one leading `Error: ` the child added, so wrapping doesn't stack prefixes. */
|
|
26
|
+
function stripErrorPrefix(text) {
|
|
27
|
+
return text.replace(/^Error:\s*/, '');
|
|
28
|
+
}
|
|
29
|
+
// The child signals right after binding, so slower than a cold start plus a
|
|
30
|
+
// bind means it is stuck, not busy.
|
|
31
|
+
const READINESS_TIMEOUT_MS = 10_000;
|
|
27
32
|
/** This command's shipped frontend; `serveAsset` is confined to it. */
|
|
28
33
|
const ASSETS_DIR = packageAssetsDir('dashboard');
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Decodes one URL path segment and confines it to a single directory name, or
|
|
36
|
+
* answers the request and returns null.
|
|
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
|
+
}
|
|
32
58
|
async function handleChangeDetail(projectRoot, rawName, res) {
|
|
33
|
-
const name =
|
|
59
|
+
const name = decodePathSegment(rawName, 'change name', res);
|
|
60
|
+
if (name === null)
|
|
61
|
+
return;
|
|
34
62
|
try {
|
|
35
63
|
sendJson(res, 200, await collectChangeDetail(projectRoot, name));
|
|
36
64
|
}
|
|
@@ -40,10 +68,9 @@ async function handleChangeDetail(projectRoot, rawName, res) {
|
|
|
40
68
|
}
|
|
41
69
|
}
|
|
42
70
|
async function handleSpec(projectRoot, rawId, res) {
|
|
43
|
-
const id =
|
|
44
|
-
if (
|
|
45
|
-
return
|
|
46
|
-
}
|
|
71
|
+
const id = decodePathSegment(rawId, 'spec id', res);
|
|
72
|
+
if (id === null)
|
|
73
|
+
return;
|
|
47
74
|
const specPath = path.join(projectRoot, 'tospec', 'specs', id, 'spec.md');
|
|
48
75
|
let content;
|
|
49
76
|
try {
|
|
@@ -56,32 +83,32 @@ async function handleSpec(projectRoot, rawId, res) {
|
|
|
56
83
|
sendJson(res, 200, { id, requirements: spec.requirements });
|
|
57
84
|
}
|
|
58
85
|
/**
|
|
59
|
-
* Git activity + health signals (ADR 001). `change`
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* `days` widens the window: a day count (capped at 3650 to bound the
|
|
63
|
-
* zero-filled series) or 'all' for the full history.
|
|
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.
|
|
64
89
|
*/
|
|
65
90
|
async function handleActivity(projectRoot, changeParam, daysParam, res) {
|
|
66
91
|
const change = changeParam ?? undefined;
|
|
67
92
|
if (change && (change.includes('/') || change.includes('\\') || change.includes('..'))) {
|
|
68
93
|
return sendJson(res, 403, { error: 'forbidden' });
|
|
69
94
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|
|
76
105
|
sendJson(res, 200, await collectActivity(projectRoot, change, window));
|
|
77
106
|
}
|
|
78
107
|
/**
|
|
79
108
|
* Trust boundary (D5): resolves a client-supplied path to an absolute `.md` file
|
|
80
|
-
* under `<root>/<prefix>`, or null when it escapes.
|
|
81
|
-
* originate from our own API payloads, so anything else is an attack or a bug.
|
|
109
|
+
* under `<root>/<prefix>`, or null when it escapes.
|
|
82
110
|
*/
|
|
83
111
|
function resolveTospecFile(projectRoot, file, prefix) {
|
|
84
|
-
// Windows-safe: normalise backslashes before resolving.
|
|
85
112
|
const resolved = path.resolve(projectRoot, file.replace(/\\/g, '/'));
|
|
86
113
|
if (!resolved.startsWith(path.resolve(projectRoot) + path.sep))
|
|
87
114
|
return null;
|
|
@@ -90,19 +117,36 @@ function resolveTospecFile(projectRoot, file, prefix) {
|
|
|
90
117
|
return null;
|
|
91
118
|
return resolved;
|
|
92
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
|
+
}
|
|
93
131
|
/**
|
|
94
|
-
* Maps a resolved task-file path back to its
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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.
|
|
97
135
|
*/
|
|
98
136
|
function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
|
|
99
137
|
const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
|
|
100
138
|
const parts = posixRel.split('/');
|
|
101
139
|
if (parts.length < 4 || parts[0] !== 'tospec' || parts[1] !== 'changes')
|
|
102
140
|
return null;
|
|
141
|
+
if (parts[2] === ARCHIVE_SEGMENT)
|
|
142
|
+
return null;
|
|
103
143
|
return parts[2] || null;
|
|
104
144
|
}
|
|
105
|
-
/**
|
|
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
|
+
*/
|
|
106
150
|
async function handleRender(projectRoot, fileParam, res) {
|
|
107
151
|
if (!fileParam) {
|
|
108
152
|
return sendJson(res, 400, { error: 'missing file parameter' });
|
|
@@ -118,7 +162,7 @@ async function handleRender(projectRoot, fileParam, res) {
|
|
|
118
162
|
catch {
|
|
119
163
|
return sendJson(res, 404, { error: 'not found' });
|
|
120
164
|
}
|
|
121
|
-
const html = await
|
|
165
|
+
const html = await renderMarkdownSafe(content);
|
|
122
166
|
sendJson(res, 200, { html });
|
|
123
167
|
}
|
|
124
168
|
/** Reads a JSON request body, capped so a stray client can't exhaust memory. */
|
|
@@ -137,14 +181,12 @@ async function readJsonBody(req, limit = 8192) {
|
|
|
137
181
|
}
|
|
138
182
|
}
|
|
139
183
|
/**
|
|
140
|
-
* Ticks a task checkbox
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* never edit specs or anything outside the tospec tree.
|
|
184
|
+
* Ticks a task checkbox: `{ file, line, done }`, `file` root-relative and `line`
|
|
185
|
+
* 1-based. Writes are confined to `<root>/tospec/changes/`, so the dashboard can
|
|
186
|
+
* never edit specs.
|
|
144
187
|
*
|
|
145
|
-
* Requiring `content-type: application/json` is load-bearing
|
|
146
|
-
*
|
|
147
|
-
* answer, so a random web page cannot drive the loopback dashboard.
|
|
188
|
+
* Requiring `content-type: application/json` is load-bearing: it pushes any
|
|
189
|
+
* cross-origin POST into a CORS preflight this server does not answer.
|
|
148
190
|
*/
|
|
149
191
|
async function handleTaskUpdate(projectRoot, req, res) {
|
|
150
192
|
if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
|
|
@@ -168,23 +210,58 @@ async function handleTaskUpdate(projectRoot, req, res) {
|
|
|
168
210
|
if (!resolved) {
|
|
169
211
|
return sendJson(res, 403, { error: 'forbidden' });
|
|
170
212
|
}
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
213
|
+
// Before the sync gate: that gate asks whether a change is *becoming*
|
|
214
|
+
// immutable; an archived one already is.
|
|
215
|
+
if (isArchivedChangePath(projectRoot, resolved)) {
|
|
216
|
+
return sendJson(res, 403, { error: 'change is archived; tasks are immutable' });
|
|
217
|
+
}
|
|
218
|
+
// The checks above are lexical. A task file that is itself a symlink out of
|
|
219
|
+
// the project passes both, and the write would land outside the root, so the
|
|
220
|
+
// fence is re-checked on the real path. Before the tracked-file check below:
|
|
221
|
+
// that check canonicalizes its own side, so the symlink used to fail there
|
|
222
|
+
// as "not the tracked file" — refused, but for the wrong reason. A file that
|
|
223
|
+
// is gone by now is the 404 the write would have reported.
|
|
224
|
+
let realFile;
|
|
225
|
+
try {
|
|
226
|
+
const realRoot = await fs.realpath(projectRoot);
|
|
227
|
+
realFile = await fs.realpath(resolved);
|
|
228
|
+
if (!realFile.startsWith(realRoot + path.sep)) {
|
|
229
|
+
return sendJson(res, 403, { error: 'forbidden' });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
if (err.code === 'ENOENT')
|
|
234
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
// Freeze task edits once a change is sync certified. The frontend disable is
|
|
238
|
+
// only UX; this gate is what protects certified content from a raw POST. A
|
|
239
|
+
// read error reads as not certified.
|
|
174
240
|
const changeName = changeNameFromTaskFile(projectRoot, resolved);
|
|
175
241
|
if (changeName) {
|
|
176
242
|
const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
|
|
177
243
|
if ((await readSyncConclusion(changeDir)) === 'PASS') {
|
|
178
244
|
return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
|
|
179
245
|
}
|
|
246
|
+
// Confined to the schema's tracked task file, via the resolver the read
|
|
247
|
+
// side uses. The path check above only proves the file sits inside a change
|
|
248
|
+
// directory, so any other markdown there with a checkbox on the addressed
|
|
249
|
+
// line was writable too, invisibly. The resolver canonicalizes the files it
|
|
250
|
+
// finds and leaves its `tasks.md` fallback as joined, so both spellings of
|
|
251
|
+
// the request path are compared.
|
|
252
|
+
const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
|
|
253
|
+
if (!tracked.includes(realFile) && !tracked.includes(path.resolve(resolved))) {
|
|
254
|
+
return sendJson(res, 409, {
|
|
255
|
+
error: `${path.basename(resolved)} is not this change's tracked task file`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
180
258
|
}
|
|
181
259
|
try {
|
|
182
260
|
await setTaskDone(resolved, line, done);
|
|
183
261
|
}
|
|
184
262
|
catch (err) {
|
|
185
263
|
const message = err instanceof Error ? err.message : String(err);
|
|
186
|
-
//
|
|
187
|
-
// is gone. Neither is a server fault, so don't 500.
|
|
264
|
+
// Stale client view or a deleted file — neither is a server fault, so no 500.
|
|
188
265
|
if (/not a task/.test(message))
|
|
189
266
|
return sendJson(res, 409, { error: message });
|
|
190
267
|
if (/ENOENT/.test(message))
|
|
@@ -193,10 +270,6 @@ async function handleTaskUpdate(projectRoot, req, res) {
|
|
|
193
270
|
}
|
|
194
271
|
sendJson(res, 200, { file, line, done });
|
|
195
272
|
}
|
|
196
|
-
// -----------------------------------------------------------------------------
|
|
197
|
-
// Live updates (SSE)
|
|
198
|
-
// -----------------------------------------------------------------------------
|
|
199
|
-
/** Registers an SSE client and streams `change` events until it disconnects. */
|
|
200
273
|
function handleEvents(req, res, clients) {
|
|
201
274
|
res.writeHead(200, {
|
|
202
275
|
'content-type': 'text/event-stream',
|
|
@@ -207,21 +280,39 @@ function handleEvents(req, res, clients) {
|
|
|
207
280
|
clients.add(res);
|
|
208
281
|
req.on('close', () => clients.delete(res));
|
|
209
282
|
}
|
|
210
|
-
/** Pushes a `change` event to every connected SSE client. */
|
|
211
283
|
function broadcastChange(clients) {
|
|
212
284
|
for (const client of clients) {
|
|
213
285
|
client.write('event: change\ndata: {}\n\n');
|
|
214
286
|
}
|
|
215
287
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Second layer behind `renderMarkdownSafe`, applied to every response.
|
|
290
|
+
*
|
|
291
|
+
* `script-src 'self'` is the load-bearing directive: it refuses the inline
|
|
292
|
+
* handlers and `javascript:` URLs a markdown file would need to reach
|
|
293
|
+
* `POST /api/task` from inside this origin. `style-src` keeps `'unsafe-inline'`
|
|
294
|
+
* for Chart.js — a style injection cannot call the write endpoint.
|
|
295
|
+
*/
|
|
296
|
+
function applyContentSecurityPolicy(res) {
|
|
297
|
+
res.setHeader('content-security-policy', [
|
|
298
|
+
"default-src 'none'",
|
|
299
|
+
"script-src 'self'",
|
|
300
|
+
"style-src 'self' 'unsafe-inline'",
|
|
301
|
+
"img-src 'self' data:",
|
|
302
|
+
"font-src 'self'",
|
|
303
|
+
"connect-src 'self'",
|
|
304
|
+
"base-uri 'none'",
|
|
305
|
+
"form-action 'none'",
|
|
306
|
+
"frame-ancestors 'none'",
|
|
307
|
+
].join('; '));
|
|
308
|
+
}
|
|
309
|
+
async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAnyHost) {
|
|
220
310
|
try {
|
|
221
311
|
// Host allowlist runs before any route (DNS-rebinding guard).
|
|
222
|
-
if (!isAllowedHostHeader(req.headers.host, boundHost)) {
|
|
312
|
+
if (!isAllowedHostHeader(req.headers.host, boundHost, acceptAnyHost)) {
|
|
223
313
|
return sendJson(res, 403, { error: 'forbidden host' });
|
|
224
314
|
}
|
|
315
|
+
applyContentSecurityPolicy(res);
|
|
225
316
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
226
317
|
const pathname = url.pathname;
|
|
227
318
|
if (req.method === 'POST' && pathname === '/api/task') {
|
|
@@ -232,8 +323,10 @@ async function handleRequest(projectRoot, req, res, clients, boundHost) {
|
|
|
232
323
|
}
|
|
233
324
|
if (pathname === '/api/events')
|
|
234
325
|
return handleEvents(req, res, clients);
|
|
235
|
-
|
|
326
|
+
// `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
|
|
327
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
236
328
|
return await serveAsset(res, ASSETS_DIR, 'index.html');
|
|
329
|
+
}
|
|
237
330
|
if (pathname.startsWith('/assets/')) {
|
|
238
331
|
return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
|
|
239
332
|
}
|
|
@@ -265,36 +358,29 @@ async function handleRequest(projectRoot, req, res, clients, boundHost) {
|
|
|
265
358
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
266
359
|
}
|
|
267
360
|
}
|
|
268
|
-
// -----------------------------------------------------------------------------
|
|
269
|
-
// Lifecycle
|
|
270
|
-
// -----------------------------------------------------------------------------
|
|
271
361
|
/**
|
|
272
|
-
* Starts the
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* Returns the server, its URL, and the actual port. Side-effect free (no
|
|
276
|
-
* logging, no signal handlers, no registry) so tests can drive it directly.
|
|
362
|
+
* Starts the server, advancing past `EADDRINUSE` until a port is free (D6) so
|
|
363
|
+
* dashboards for different projects coexist. Side-effect free (no logging,
|
|
364
|
+
* signal handlers or registry) so tests can drive it directly.
|
|
277
365
|
*/
|
|
278
366
|
export async function startDashboardServer(projectRoot, opts = {}) {
|
|
279
367
|
const host = opts.host ?? DEFAULT_HOST;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
368
|
+
assertBindableHost(host, 'dashboard', opts.allowRemote, 'Pass --allow-remote to accept that risk.');
|
|
369
|
+
const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
|
|
283
370
|
const clients = new Set();
|
|
284
371
|
const server = http.createServer((req, res) => {
|
|
285
|
-
void handleRequest(projectRoot, req, res, clients, host);
|
|
372
|
+
void handleRequest(projectRoot, req, res, clients, host, acceptAnyHost);
|
|
286
373
|
});
|
|
287
374
|
const actualPort = await listenFrom(server, opts.port ?? DEFAULT_PORT, host);
|
|
288
375
|
const watcher = watchTospec(projectRoot, () => broadcastChange(clients));
|
|
289
376
|
server.once('close', () => watcher?.close());
|
|
290
|
-
return { server, url:
|
|
377
|
+
return { server, url: connectableUrl(host, actualPort), port: actualPort };
|
|
291
378
|
}
|
|
292
379
|
/**
|
|
293
|
-
* Watches `<root>/tospec` recursively and calls `onChange` (debounced)
|
|
294
|
-
*
|
|
295
|
-
* watched files — agents and users stay free to edit or
|
|
296
|
-
*
|
|
297
|
-
* ponytail: recursive watch; native on win32/macOS, Node 20+ on Linux.
|
|
380
|
+
* Watches `<root>/tospec` recursively and calls `onChange` (debounced), or null
|
|
381
|
+
* if the directory can't be watched. `fs.watch` uses OS change notifications,
|
|
382
|
+
* so it never locks the watched files — agents and users stay free to edit or
|
|
383
|
+
* delete them (D2). Recursive watch is native on win32/macOS, Node 20+ on Linux.
|
|
298
384
|
*/
|
|
299
385
|
function watchTospec(projectRoot, onChange) {
|
|
300
386
|
const dir = path.join(projectRoot, 'tospec');
|
|
@@ -314,22 +400,19 @@ function watchTospec(projectRoot, onChange) {
|
|
|
314
400
|
}
|
|
315
401
|
}
|
|
316
402
|
/**
|
|
317
|
-
* PID-file location
|
|
318
|
-
*
|
|
319
|
-
* it never gets committed; `--detach` and `--stop` both derive the same path.
|
|
403
|
+
* PID-file location, keyed by project root so `--detach` and `--stop` derive the
|
|
404
|
+
* same path. In the OS temp dir, not the repo, so it never gets committed.
|
|
320
405
|
*/
|
|
321
406
|
export function pidFilePath(projectRoot) {
|
|
322
407
|
const hash = crypto.createHash('sha1').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
|
|
323
408
|
return path.join(os.tmpdir(), `tospec-dashboard-${hash}.json`);
|
|
324
409
|
}
|
|
325
|
-
/** True if a process with this PID
|
|
410
|
+
/** True if a process with this PID exists (signal 0 probes without killing). */
|
|
326
411
|
function isProcessAlive(pid) {
|
|
327
|
-
//
|
|
328
|
-
// `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
|
|
329
|
-
// one, so a record
|
|
330
|
-
//
|
|
331
|
-
// make `--stop` terminate the user's own shell. Treated as not alive, so such
|
|
332
|
-
// a record is reaped instead of ever reaching `process.kill`.
|
|
412
|
+
// Every record's pid arrives here, so the sign check guards the module: on
|
|
413
|
+
// POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
|
|
414
|
+
// named one, so a corrupt record in the shared temp dir would make `--stop`
|
|
415
|
+
// terminate the user's own shell.
|
|
333
416
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
334
417
|
return false;
|
|
335
418
|
try {
|
|
@@ -342,11 +425,8 @@ function isProcessAlive(pid) {
|
|
|
342
425
|
}
|
|
343
426
|
}
|
|
344
427
|
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* a truncated record still names a process to reap. Callers that need to reach
|
|
348
|
-
* the dashboard check `port` themselves — see `shouldRefuseDetach` for why that
|
|
349
|
-
* matters to `--detach`.
|
|
428
|
+
* This project's dashboard PID record, or null if absent or corrupt. `pid` is
|
|
429
|
+
* the only required field — a truncated record still names a process to reap.
|
|
350
430
|
*/
|
|
351
431
|
async function readPidRecord(pidFile) {
|
|
352
432
|
try {
|
|
@@ -358,32 +438,23 @@ async function readPidRecord(pidFile) {
|
|
|
358
438
|
}
|
|
359
439
|
}
|
|
360
440
|
/**
|
|
361
|
-
* Records this dashboard so `--stop` can find it. Reads
|
|
362
|
-
* optional fields
|
|
363
|
-
*
|
|
441
|
+
* Records this dashboard so `--stop` can find it. Reads tolerate the missing
|
|
442
|
+
* optional fields of pre-0.13 records; writes do not, since the root is what
|
|
443
|
+
* attributes a scanned record to its project.
|
|
364
444
|
*/
|
|
365
445
|
async function writePidRecord(pidFile, rec) {
|
|
366
446
|
await fs.writeFile(pidFile, JSON.stringify(rec), 'utf-8');
|
|
367
447
|
}
|
|
368
448
|
/**
|
|
369
|
-
* Removes a pid record if it still names `pid
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* The path is keyed by project, not by process, and nothing guards a second
|
|
373
|
-
* foreground start — so two dashboards for one project share this file. Both
|
|
374
|
-
* places that clear it (a dashboard's own exit, and `--stop` once it has killed
|
|
375
|
-
* one) would otherwise delete whichever record happened to be there.
|
|
449
|
+
* Removes a pid record only if it still names `pid`: the path is keyed by
|
|
450
|
+
* project, not process, so two sibling dashboards share this file and either
|
|
451
|
+
* clearer would otherwise delete the other's record.
|
|
376
452
|
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* deleted by the other's exit; it cannot hand the earlier dashboard a record
|
|
380
|
-
* back. Both stay reachable through their registry markers, which is what the
|
|
381
|
-
* guard reads. Per-process pid files only if a second foreground start ever
|
|
382
|
-
* becomes something we support. The read and the delete are also not one atomic
|
|
383
|
-
* step, so this is best-effort like every other tidy-up here.
|
|
453
|
+
* Limitation: one record per project, so only the later writer is tracked by
|
|
454
|
+
* pid file; both stay reachable through their registry markers.
|
|
384
455
|
*
|
|
385
456
|
* Sync so it completes before the `process.exit` its signal-handler caller
|
|
386
|
-
* races —
|
|
457
|
+
* races — hence not reusing the async `readPidRecord`.
|
|
387
458
|
*/
|
|
388
459
|
export function clearPidRecordOwnedBy(pidFile, pid) {
|
|
389
460
|
try {
|
|
@@ -392,9 +463,8 @@ export function clearPidRecordOwnedBy(pidFile, pid) {
|
|
|
392
463
|
unlinkSync(pidFile);
|
|
393
464
|
}
|
|
394
465
|
catch {
|
|
395
|
-
// Absent
|
|
396
|
-
//
|
|
397
|
-
// half-written record could still belong to one that is starting up.
|
|
466
|
+
// Absent: nothing to do. Corrupt: left alone — it names no process, and a
|
|
467
|
+
// half-written record may belong to one that is starting up.
|
|
398
468
|
}
|
|
399
469
|
}
|
|
400
470
|
/** Every project's pid file, not just this one's — they all live in the temp dir. */
|
|
@@ -402,7 +472,7 @@ async function pidFilePaths() {
|
|
|
402
472
|
const dir = os.tmpdir();
|
|
403
473
|
try {
|
|
404
474
|
return (await fs.readdir(dir))
|
|
405
|
-
// Exactly the shape pidFilePath() generates — never
|
|
475
|
+
// Exactly the shape pidFilePath() generates — never wider than we write.
|
|
406
476
|
.filter((name) => /^tospec-dashboard-[0-9a-f]{12}\.json$/.test(name))
|
|
407
477
|
.map((name) => path.join(dir, name));
|
|
408
478
|
}
|
|
@@ -411,15 +481,13 @@ async function pidFilePaths() {
|
|
|
411
481
|
}
|
|
412
482
|
}
|
|
413
483
|
function registryDir() {
|
|
414
|
-
// Derived
|
|
415
|
-
//
|
|
416
|
-
// with it instead of leaving the two in different trees.
|
|
484
|
+
// Derived from the one config resolver, so relocating the config directory
|
|
485
|
+
// moves the registry with it.
|
|
417
486
|
return path.join(getGlobalConfigDir(), 'dashboards');
|
|
418
487
|
}
|
|
419
488
|
/**
|
|
420
|
-
* Lists live dashboards, reaping any marker whose process has
|
|
421
|
-
*
|
|
422
|
-
* content. A missing/unreadable dir → no dashboards running. Sorted by port.
|
|
489
|
+
* Lists live dashboards sorted by port, reaping any marker whose process has
|
|
490
|
+
* died. A missing or unreadable dir means no dashboards are running.
|
|
423
491
|
*/
|
|
424
492
|
export async function listDashboards() {
|
|
425
493
|
const dir = registryDir();
|
|
@@ -438,9 +506,8 @@ export async function listDashboards() {
|
|
|
438
506
|
const pid = Number(m[1]);
|
|
439
507
|
const file = path.join(dir, name);
|
|
440
508
|
if (!isProcessAlive(pid)) {
|
|
441
|
-
// Best-effort
|
|
442
|
-
//
|
|
443
|
-
// command that merely wanted to list what is running.
|
|
509
|
+
// Best-effort: failing to unlink one stale marker must not take down a
|
|
510
|
+
// command that only wanted to list.
|
|
444
511
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
445
512
|
continue;
|
|
446
513
|
}
|
|
@@ -457,8 +524,7 @@ export async function listDashboards() {
|
|
|
457
524
|
}
|
|
458
525
|
/**
|
|
459
526
|
* One line describing a running dashboard, for `--list` and `--stop`'s picker.
|
|
460
|
-
* The URL assumes
|
|
461
|
-
* the host a `--allow-remote` dashboard was bound to.
|
|
527
|
+
* The URL assumes loopback — `RunningDashboard` doesn't record the bound host.
|
|
462
528
|
*/
|
|
463
529
|
export function formatRunningDashboard(d, sep = '\t') {
|
|
464
530
|
const url = `http://${DEFAULT_HOST}:${d.port}`;
|
|
@@ -466,8 +532,7 @@ export function formatRunningDashboard(d, sep = '\t') {
|
|
|
466
532
|
}
|
|
467
533
|
/**
|
|
468
534
|
* `--stop` found dashboards but cannot ask which to stop (no terminal). Carries
|
|
469
|
-
* its own diagnostic so the shared failure sink renders it
|
|
470
|
-
* `RootSelectionError` and `ArchiveBlockedError`.
|
|
535
|
+
* its own diagnostic so the shared failure sink renders it.
|
|
471
536
|
*/
|
|
472
537
|
export class DashboardStopError extends Error {
|
|
473
538
|
diagnostic;
|
|
@@ -478,14 +543,32 @@ export class DashboardStopError extends Error {
|
|
|
478
543
|
}
|
|
479
544
|
}
|
|
480
545
|
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
546
|
+
* The detached child neither reported itself listening nor exited. It may still
|
|
547
|
+
* be coming up, so the message reports what was not observed rather than
|
|
548
|
+
* claiming failure.
|
|
549
|
+
*/
|
|
550
|
+
export class DashboardReadinessTimeoutError extends Error {
|
|
551
|
+
diagnostic;
|
|
552
|
+
constructor(timeoutMs) {
|
|
553
|
+
const message = `The background dashboard process did not report that it is listening within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
554
|
+
'It was started and has not exited, so it may still be coming up.';
|
|
555
|
+
super(message);
|
|
556
|
+
this.name = 'DashboardReadinessTimeoutError';
|
|
557
|
+
this.diagnostic = {
|
|
558
|
+
severity: 'error',
|
|
559
|
+
code: 'dashboard_detach_readiness_timeout',
|
|
560
|
+
message,
|
|
561
|
+
fix: 'Check whether it came up with: tospec dashboard --list (then tospec dashboard --stop to stop it).',
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Every dashboard `--stop` can reach: the registry unioned with every project's
|
|
567
|
+
* pid file, keyed by pid, reaping dead records along the way.
|
|
483
568
|
*
|
|
484
569
|
* The registry alone is not enough — `listDashboards()` reaps any marker whose
|
|
485
570
|
* pid looks dead, so a live server can lose its marker and become unstoppable
|
|
486
|
-
* while its pid file still names it
|
|
487
|
-
* than only this project's is what makes that reachable from any directory,
|
|
488
|
-
* which is the case `--stop`'s pick-from-all list exists for.
|
|
571
|
+
* while its pid file still names it.
|
|
489
572
|
*
|
|
490
573
|
* `ownRootHint` names records written before pid files carried their own root;
|
|
491
574
|
* it never restricts the result.
|
|
@@ -499,14 +582,10 @@ export async function collectStoppableDashboards(ownRootHint) {
|
|
|
499
582
|
const rec = await readPidRecord(file);
|
|
500
583
|
if (!rec)
|
|
501
584
|
continue;
|
|
502
|
-
// Reap like the marker reaper does
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
// Tidying is best-effort: `force` only swallows ENOENT, and the temp dir is
|
|
508
|
-
// shared between users on POSIX, so another user's stale file would fail to
|
|
509
|
-
// unlink. Losing that race must not take the whole command down with it.
|
|
585
|
+
// Reap like the marker reaper does: a crashed dashboard's record would
|
|
586
|
+
// otherwise linger until the OS recycled its pid, then read as alive again —
|
|
587
|
+
// a phantom `--stop` would kill an unrelated process for. Unlink is
|
|
588
|
+
// best-effort: the POSIX temp dir is shared with other users.
|
|
510
589
|
if (!isProcessAlive(rec.pid)) {
|
|
511
590
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
512
591
|
continue;
|
|
@@ -520,15 +599,15 @@ export async function collectStoppableDashboards(ownRootHint) {
|
|
|
520
599
|
byPid.set(rec.pid, { pid: rec.pid, port: rec.port, projectRoot: root });
|
|
521
600
|
continue;
|
|
522
601
|
}
|
|
523
|
-
// A registry marker
|
|
524
|
-
//
|
|
602
|
+
// A content-less registry marker never matches its project; the pid file may
|
|
603
|
+
// still know the root.
|
|
525
604
|
if (!known.projectRoot && root)
|
|
526
605
|
byPid.set(rec.pid, { ...known, projectRoot: root });
|
|
527
606
|
}
|
|
528
607
|
return [...byPid.values()].sort((a, b) => a.port - b.port);
|
|
529
608
|
}
|
|
530
609
|
/**
|
|
531
|
-
* Ports held by live dashboards
|
|
610
|
+
* Ports held by live dashboards, reaping dead markers as a side effect. Runs at
|
|
532
611
|
* every startup so a crashed dashboard's port frees up.
|
|
533
612
|
*/
|
|
534
613
|
async function reapAndCollectPorts() {
|
|
@@ -551,45 +630,40 @@ async function unregisterDashboard(pid) {
|
|
|
551
630
|
}
|
|
552
631
|
await Promise.all(names
|
|
553
632
|
.filter((n) => n.startsWith(`${pid}_`))
|
|
554
|
-
// Best-effort: a marker we cannot unlink must not abort the stop loop
|
|
555
|
-
// leave the dashboards after this one running.
|
|
633
|
+
// Best-effort: a marker we cannot unlink must not abort the stop loop.
|
|
556
634
|
.map((n) => fs.rm(path.join(dir, n), { force: true }).catch(() => { })));
|
|
557
635
|
}
|
|
558
636
|
/**
|
|
559
|
-
* Launches the dashboard as a detached background process
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
637
|
+
* Launches the dashboard as a detached background process: re-runs this CLI
|
|
638
|
+
* without `--detach` (the child is the actual server), then waits for the
|
|
639
|
+
* child's `LISTENING` report, so the URL printed is one the child actually
|
|
640
|
+
* bound — including after an EADDRINUSE fallback moved it off the predicted
|
|
641
|
+
* port. Order: spawn → wait for the report → write the PID record → print.
|
|
563
642
|
*
|
|
564
|
-
* Refuses if any live dashboard already serves this project,
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
643
|
+
* Refuses if any live dashboard already serves this project, asking the shared
|
|
644
|
+
* candidate set because either record can be the only one left (a foreground
|
|
645
|
+
* dashboard writes no pid file; a background one can lose its registry marker).
|
|
646
|
+
*
|
|
647
|
+
* This process and the child both write the PID record, with identical fields.
|
|
648
|
+
* The child's is all that remains if this process dies mid-handshake; this one
|
|
649
|
+
* makes a record exist by the time the call returns, since the child writes only
|
|
650
|
+
* after signalling.
|
|
572
651
|
*
|
|
573
652
|
* This is the only guarded start path: nothing stops a second foreground
|
|
574
|
-
* `tospec dashboard`, so "one dashboard per project"
|
|
575
|
-
* not as a machine-wide invariant.
|
|
653
|
+
* `tospec dashboard`, so "one dashboard per project" is not machine-wide.
|
|
576
654
|
*/
|
|
577
655
|
export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
578
656
|
const pidFile = pidFilePath(projectRoot);
|
|
579
|
-
// Reaps every dead record along the way, including the stale pid file a
|
|
580
|
-
// crashed dashboard of this project left behind.
|
|
581
657
|
const running = shouldRefuseDetach(await collectStoppableDashboards(projectRoot), projectRoot);
|
|
582
658
|
if (running) {
|
|
583
|
-
// Same fields as `--list
|
|
584
|
-
// dashboard named here is recognisably the row those commands show.
|
|
659
|
+
// Same fields and separator as `--list` and `--stop`'s picker.
|
|
585
660
|
console.log('Dashboard already running for this project.');
|
|
586
661
|
console.log(` ${formatRunningDashboard(running, ' ')}`);
|
|
587
662
|
console.log('Stop it with: tospec dashboard --stop');
|
|
588
663
|
return;
|
|
589
664
|
}
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
// that grabs this port between now and then just shifts the child upward.
|
|
665
|
+
// Predict the port so the message and provisional record are accurate. The
|
|
666
|
+
// child re-checks and binds, so a race for this port just shifts it upward.
|
|
593
667
|
const used = await reapAndCollectPorts();
|
|
594
668
|
let port = opts.port;
|
|
595
669
|
while (used.has(port))
|
|
@@ -602,29 +676,98 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
|
602
676
|
args.push('--allow-remote');
|
|
603
677
|
const child = spawn(process.execPath, args, {
|
|
604
678
|
detached: true,
|
|
605
|
-
|
|
679
|
+
// stderr is piped so the parent can read the child's LISTENING report or
|
|
680
|
+
// its error text on an early exit (waitForListening).
|
|
681
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
606
682
|
// On Windows, detached: true otherwise pops a new console window.
|
|
607
683
|
windowsHide: true,
|
|
608
684
|
env: { ...process.env, TOSPEC_DASHBOARD_PIDFILE: pidFile },
|
|
609
685
|
});
|
|
610
|
-
|
|
611
|
-
//
|
|
612
|
-
// leave behind a record `--stop` could never use.
|
|
686
|
+
// No pid means the spawn itself failed; reporting success would leave a record
|
|
687
|
+
// `--stop` could never use.
|
|
613
688
|
if (child.pid === undefined) {
|
|
614
689
|
throw new Error('Could not start the background dashboard process.');
|
|
615
690
|
}
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
691
|
+
// Everything that can go wrong after a successful spawn happens inside the
|
|
692
|
+
// child and is only observable through this handshake.
|
|
693
|
+
let confirmedUrl;
|
|
694
|
+
try {
|
|
695
|
+
confirmedUrl = await waitForListening(child);
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
// Unconditional: on the timeout path the child is still running, and an
|
|
699
|
+
// un-unref'd handle would keep this process's event loop alive.
|
|
700
|
+
child.unref();
|
|
701
|
+
}
|
|
702
|
+
// Derived from the URL the child reported, so this record and the child's own
|
|
703
|
+
// agree whichever lands last. Limitation: no cross-process lock, so two
|
|
704
|
+
// simultaneous --detach could both pass the guard.
|
|
619
705
|
await writePidRecord(pidFile, {
|
|
620
706
|
pid: child.pid,
|
|
621
|
-
port,
|
|
622
|
-
url,
|
|
707
|
+
port: portFromUrl(confirmedUrl) ?? port,
|
|
708
|
+
url: confirmedUrl,
|
|
623
709
|
projectRoot: path.resolve(projectRoot),
|
|
624
710
|
});
|
|
625
|
-
console.log(`Dashboard starting in background at ${
|
|
711
|
+
console.log(`Dashboard starting in background at ${confirmedUrl}`);
|
|
626
712
|
console.log('Stop it with: tospec dashboard --stop');
|
|
627
713
|
}
|
|
714
|
+
/** `http://host:5621` -> `5621`, or null if the URL carries no parseable port. */
|
|
715
|
+
function portFromUrl(url) {
|
|
716
|
+
const match = /:(\d+)$/.exec(url);
|
|
717
|
+
return match ? Number(match[1]) : null;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Races the child's outcome: resolves with the confirmed URL on
|
|
721
|
+
* `LISTENING <url>`, rejects with its captured stderr if it exits first, and
|
|
722
|
+
* rejects on timeout if it does neither. Closes the stderr pipe once settled so
|
|
723
|
+
* our end cannot keep this process's event loop alive.
|
|
724
|
+
*
|
|
725
|
+
* The timeout is a real third outcome: a child that binds then wedges emits
|
|
726
|
+
* neither event, and `--detach` would hang with no output.
|
|
727
|
+
*/
|
|
728
|
+
function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
729
|
+
return new Promise((resolve, reject) => {
|
|
730
|
+
let stderr = '';
|
|
731
|
+
let settled = false;
|
|
732
|
+
let timer;
|
|
733
|
+
const cleanup = () => {
|
|
734
|
+
if (timer)
|
|
735
|
+
clearTimeout(timer);
|
|
736
|
+
child.stderr?.removeListener('data', onData);
|
|
737
|
+
child.removeListener('exit', onExit);
|
|
738
|
+
child.stderr?.destroy();
|
|
739
|
+
};
|
|
740
|
+
const onData = (chunk) => {
|
|
741
|
+
stderr += chunk.toString();
|
|
742
|
+
const line = stderr.split('\n').find((l) => l.startsWith(LISTENING_PREFIX));
|
|
743
|
+
if (line && !settled) {
|
|
744
|
+
settled = true;
|
|
745
|
+
cleanup();
|
|
746
|
+
resolve(line.slice(LISTENING_PREFIX.length).trim());
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
const onExit = (code) => {
|
|
750
|
+
if (settled)
|
|
751
|
+
return;
|
|
752
|
+
settled = true;
|
|
753
|
+
cleanup();
|
|
754
|
+
reject(new Error(
|
|
755
|
+
// The child already formatted its failure as `Error: <message>`;
|
|
756
|
+
// carrying that through made the parent print "Error: Error: ...".
|
|
757
|
+
stripErrorPrefix(stderr.trim()) ||
|
|
758
|
+
`The background dashboard process exited before it started listening (code ${code}).`));
|
|
759
|
+
};
|
|
760
|
+
child.stderr?.on('data', onData);
|
|
761
|
+
child.on('exit', onExit);
|
|
762
|
+
timer = setTimeout(() => {
|
|
763
|
+
if (settled)
|
|
764
|
+
return;
|
|
765
|
+
settled = true;
|
|
766
|
+
cleanup();
|
|
767
|
+
reject(new DashboardReadinessTimeoutError(timeoutMs));
|
|
768
|
+
}, timeoutMs);
|
|
769
|
+
});
|
|
770
|
+
}
|
|
628
771
|
/** Case-correct path equality (the Windows filesystem is case-insensitive). */
|
|
629
772
|
export function samePath(a, b) {
|
|
630
773
|
const na = path.resolve(a);
|
|
@@ -632,12 +775,9 @@ export function samePath(a, b) {
|
|
|
632
775
|
return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
633
776
|
}
|
|
634
777
|
/**
|
|
635
|
-
* The dashboards `--stop` may terminate without asking. Empty
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
* A candidate with no recorded project never matches: `path.resolve('')` is the
|
|
639
|
-
* cwd, so a blank root would silently claim it. Pure, so this is testable
|
|
640
|
-
* without touching the registry.
|
|
778
|
+
* The dashboards `--stop` may terminate without asking. Empty is the caller's
|
|
779
|
+
* cue to offer the pick-from-all list. A candidate with no recorded project
|
|
780
|
+
* never matches: `path.resolve('')` is the cwd, so a blank root would claim it.
|
|
641
781
|
*/
|
|
642
782
|
export function selectStopTargets(candidates, projectRoot) {
|
|
643
783
|
if (!projectRoot)
|
|
@@ -650,55 +790,46 @@ export function selectStopTargets(candidates, projectRoot) {
|
|
|
650
790
|
* `tospec/decisions/20260727_232950-one-dashboard-per-project.md`.
|
|
651
791
|
*
|
|
652
792
|
* Deliberately the same question `--stop` asks, over the same candidate set, so
|
|
653
|
-
* the two
|
|
654
|
-
* the record `--stop` cannot reach: a live but truncated record carries no port,
|
|
655
|
-
* so `collectStoppableDashboards` drops it and it never blocks a start it could
|
|
656
|
-
* not tell the user how to unblock.
|
|
793
|
+
* the two can never disagree about what is running.
|
|
657
794
|
*/
|
|
658
795
|
export function shouldRefuseDetach(candidates, projectRoot) {
|
|
659
|
-
//
|
|
660
|
-
// several (a project that already had two before this guard existed). One is
|
|
661
|
-
// enough to refuse and to point at; `--stop` still clears all of them.
|
|
796
|
+
// First match only: one is enough to refuse and point at.
|
|
662
797
|
return selectStopTargets(candidates, projectRoot)[0] ?? null;
|
|
663
798
|
}
|
|
664
799
|
/**
|
|
665
800
|
* Terminates a running dashboard and clears its registry marker, plus the
|
|
666
|
-
* per-project pid file if that still names this pid. A dead pid
|
|
801
|
+
* per-project pid file if that still names this pid. A dead pid counts as
|
|
667
802
|
* already stopped.
|
|
668
803
|
*
|
|
669
|
-
* Returns null on success, or the reason the signal was refused —
|
|
670
|
-
*
|
|
671
|
-
* as stopped would be this ticket's own bug all over again, so we hand the
|
|
672
|
-
* caller what actually happened and leave that dashboard's records alone.
|
|
804
|
+
* Returns null on success, or the reason the signal was refused — the POSIX temp
|
|
805
|
+
* dir is shared, so a candidate can belong to another user.
|
|
673
806
|
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
807
|
+
* Limitation: SIGTERM; Windows maps it to TerminateProcess (hard kill), so the
|
|
808
|
+
* target's own cleanup won't run — we clear its files here.
|
|
676
809
|
*/
|
|
677
810
|
export async function stopRunningDashboard(d) {
|
|
678
811
|
try {
|
|
679
812
|
process.kill(d.pid);
|
|
680
813
|
}
|
|
681
814
|
catch (err) {
|
|
682
|
-
// ESRCH means it exited between listing and now: already stopped, so
|
|
683
|
-
//
|
|
815
|
+
// ESRCH means it exited between listing and now: already stopped, so still
|
|
816
|
+
// clear its records. Anything else (EPERM) is a real refusal.
|
|
684
817
|
const { code, message } = err;
|
|
685
818
|
if (code !== 'ESRCH')
|
|
686
819
|
return code ?? message;
|
|
687
820
|
}
|
|
688
821
|
await unregisterDashboard(d.pid);
|
|
689
822
|
// Only this dashboard's record: the path is per-project, so stopping one of
|
|
690
|
-
// two siblings must not clear the other's.
|
|
823
|
+
// two siblings must not clear the other's.
|
|
691
824
|
if (d.projectRoot)
|
|
692
825
|
clearPidRecordOwnedBy(pidFilePath(d.projectRoot), d.pid);
|
|
693
826
|
return null;
|
|
694
827
|
}
|
|
695
828
|
/**
|
|
696
|
-
* CLI entry: allocates a free port, starts the server, prints the URL,
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
* (so each project lands on its own port off the 5620 base), then bind — which
|
|
701
|
-
* confirms the port and advances past any the registry didn't know about.
|
|
829
|
+
* CLI entry: allocates a free port, starts the server, prints the URL, and stays
|
|
830
|
+
* up until terminated. Port allocation reaps dead registry markers and skips
|
|
831
|
+
* ports live dashboards hold; the bind then advances past any the registry
|
|
832
|
+
* didn't know about.
|
|
702
833
|
*/
|
|
703
834
|
export async function runDashboard(projectRoot, opts = {}) {
|
|
704
835
|
const used = await reapAndCollectPorts();
|
|
@@ -707,13 +838,16 @@ export async function runDashboard(projectRoot, opts = {}) {
|
|
|
707
838
|
start++;
|
|
708
839
|
const { server, url, port } = await startDashboardServer(projectRoot, { ...opts, port: start });
|
|
709
840
|
console.log(`Dashboard running at ${url} (Ctrl+C to stop)`);
|
|
841
|
+
// Detached child only: `detachDashboard` waits on stderr for exactly this line
|
|
842
|
+
// before it reports anything. A foreground run has no parent watching.
|
|
843
|
+
if (process.env.TOSPEC_DASHBOARD_PIDFILE) {
|
|
844
|
+
console.error(`${LISTENING_PREFIX}${url}`);
|
|
845
|
+
}
|
|
710
846
|
await registerDashboard(process.pid, port, path.resolve(projectRoot));
|
|
711
|
-
// Every dashboard records itself, foreground included
|
|
712
|
-
// refuse for
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
// about the file's location even though the child re-resolves the root for
|
|
716
|
-
// the record's own contents.
|
|
847
|
+
// Every dashboard records itself, foreground included: `--detach` can only
|
|
848
|
+
// refuse for one that left a record, and a registry marker alone can be
|
|
849
|
+
// reaped out from under a live server. The child is handed its parent's path
|
|
850
|
+
// so the two cannot disagree about where the file lives.
|
|
717
851
|
const pidFile = process.env.TOSPEC_DASHBOARD_PIDFILE ?? pidFilePath(projectRoot);
|
|
718
852
|
await writePidRecord(pidFile, {
|
|
719
853
|
pid: process.pid,
|
|
@@ -724,10 +858,8 @@ export async function runDashboard(projectRoot, opts = {}) {
|
|
|
724
858
|
if (opts.open) {
|
|
725
859
|
openBrowser(url);
|
|
726
860
|
}
|
|
727
|
-
// Normal termination
|
|
728
|
-
//
|
|
729
|
-
// so this does not run for that path and stopRunningDashboard clears them
|
|
730
|
-
// instead; a dead one is reaped on next start either way.
|
|
861
|
+
// Normal termination removes this dashboard's own records. Windows hard-kills
|
|
862
|
+
// on `--stop`, so there `stopRunningDashboard` clears them instead.
|
|
731
863
|
const cleanup = () => {
|
|
732
864
|
// SSE keep-alive sockets would otherwise block close() from ever draining.
|
|
733
865
|
server.closeAllConnections?.();
|