@seanmars/tospec 0.19.0-beta.7 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -231
- package/README.md +69 -82
- package/assets/dashboard/app.js +14 -3
- package/assets/dashboard/style.css +7 -0
- package/assets/rules/tospec/decision.md +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +227 -86
- 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 +366 -107
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/dashboard.d.ts +57 -99
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +224 -282
- 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 +385 -79
- package/dist/commands/decision.js.map +1 -1
- package/dist/commands/metrics.d.ts +28 -51
- package/dist/commands/metrics.d.ts.map +1 -1
- package/dist/commands/metrics.js +62 -93
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/shared-output.d.ts +18 -21
- package/dist/commands/shared-output.d.ts.map +1 -1
- package/dist/commands/shared-output.js +49 -23
- 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 +29 -7
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/validate.d.ts +43 -45
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +262 -129
- 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 +81 -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 +378 -235
- package/dist/core/archive.js.map +1 -1
- package/dist/core/artifact-graph/graph.d.ts +25 -42
- package/dist/core/artifact-graph/graph.d.ts.map +1 -1
- package/dist/core/artifact-graph/graph.js +45 -63
- package/dist/core/artifact-graph/graph.js.map +1 -1
- package/dist/core/artifact-graph/index.d.ts +1 -1
- package/dist/core/artifact-graph/index.d.ts.map +1 -1
- package/dist/core/artifact-graph/index.js +1 -1
- package/dist/core/artifact-graph/index.js.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +97 -103
- package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.js +146 -119
- package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
- package/dist/core/artifact-graph/outputs.d.ts +9 -23
- package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
- package/dist/core/artifact-graph/outputs.js +45 -38
- 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 +14 -15
- package/dist/core/codex-residue.d.ts.map +1 -1
- package/dist/core/codex-residue.js +18 -22
- package/dist/core/codex-residue.js.map +1 -1
- 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 +39 -26
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +36 -22
- package/dist/core/config.js.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 +62 -70
- 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 +38 -67
- package/dist/core/global-config.js.map +1 -1
- package/dist/core/init.d.ts +25 -19
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +168 -181
- 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 +41 -83
- package/dist/core/local-server.d.ts.map +1 -1
- package/dist/core/local-server.js +53 -98
- 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 +29 -14
- package/dist/core/migrate.d.ts.map +1 -1
- package/dist/core/migrate.js +184 -122
- 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 +43 -15
- package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
- package/dist/core/parsers/requirement-blocks.js +142 -70
- 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.map +1 -1
- package/dist/core/parsers/spec-structure.js +10 -6
- package/dist/core/parsers/spec-structure.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 +9 -17
- 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 +6 -1
- package/dist/core/rules.d.ts.map +1 -1
- package/dist/core/rules.js +23 -2
- package/dist/core/rules.js.map +1 -1
- 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 +19 -8
- package/dist/core/shared/rules-generation.d.ts.map +1 -1
- package/dist/core/shared/rules-generation.js +117 -50
- package/dist/core/shared/rules-generation.js.map +1 -1
- package/dist/core/shared/skill-generation.d.ts +28 -43
- 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 +39 -61
- 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.js +6 -6
- package/dist/core/spec-presenter.js.map +1 -1
- package/dist/core/specs-apply.d.ts +22 -23
- package/dist/core/specs-apply.d.ts.map +1 -1
- package/dist/core/specs-apply.js +166 -193
- 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/validate.d.ts +13 -0
- package/dist/core/templates/fragments/validate.d.ts.map +1 -0
- package/dist/core/templates/fragments/validate.js +13 -0
- package/dist/core/templates/fragments/validate.js.map +1 -0
- 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 +11 -13
- 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 +19 -6
- 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 -2
- 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 +2 -2
- 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 +4 -3
- 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 +9 -2
- package/dist/core/templates/workflows/update.js.map +1 -1
- package/dist/core/update.d.ts +23 -23
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +139 -118
- 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 +4 -10
- 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 +43 -63
- package/dist/core/validation/validator.d.ts.map +1 -1
- package/dist/core/validation/validator.js +500 -267
- 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 +30 -54
- package/dist/utils/change-utils.d.ts.map +1 -1
- package/dist/utils/change-utils.js +133 -91
- 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 +9 -18
- package/dist/utils/link.d.ts.map +1 -1
- package/dist/utils/link.js +9 -18
- package/dist/utils/link.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 +7 -20
- package/dist/utils/spec-files.d.ts.map +1 -1
- package/dist/utils/spec-files.js +26 -51
- 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 +2 -3
- package/schemas/decision/templates/decision.md +3 -1
- package/schemas/decision/templates/index.md +2 -2
- package/schemas/issue/schema.yaml +8 -1
- package/schemas/issue/templates/spec.md +27 -2
- package/schemas/sdd/schema.yaml +20 -1
- package/schemas/sdd/templates/spec.md +27 -2
- /package/assets/rules/tospec/{single-sourc-of-truth.md → single-source-of-truth.md} +0 -0
|
@@ -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,46 +10,35 @@ 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 { acceptsAnyHostHeader, assertBindableHost, isAllowedHostHeader, listenFrom, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
|
|
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';
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// marks it as a `--detach` child — a normal foreground run never emits it.
|
|
22
|
+
// Readiness signal the detached child writes to stderr once bound; emitted only
|
|
23
|
+
// when TOSPEC_DASHBOARD_PIDFILE marks it as a `--detach` child.
|
|
30
24
|
const LISTENING_PREFIX = 'LISTENING ';
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
34
31
|
const READINESS_TIMEOUT_MS = 10_000;
|
|
35
32
|
/** This command's shipped frontend; `serveAsset` is confined to it. */
|
|
36
33
|
const ASSETS_DIR = packageAssetsDir('dashboard');
|
|
37
|
-
// -----------------------------------------------------------------------------
|
|
38
|
-
// API handlers
|
|
39
|
-
// -----------------------------------------------------------------------------
|
|
40
34
|
/**
|
|
41
35
|
* Decodes one URL path segment and confines it to a single directory name, or
|
|
42
36
|
* answers the request and returns null.
|
|
43
37
|
*
|
|
44
38
|
* WHATWG URL normalizes a literal `..` out of the pathname but does not
|
|
45
|
-
* percent-decode, so `%2e%2e%2f`
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* 200 vs 404 split was an existence oracle over arbitrary directories, and a hit
|
|
49
|
-
* returned that directory read as a change.
|
|
50
|
-
*
|
|
51
|
-
* Malformed escapes (`%zz`) throw `URIError`; that is the client's mistake, so
|
|
52
|
-
* it answers 400 rather than unwinding to the router's outer catch as a 500.
|
|
53
|
-
*
|
|
54
|
-
* Extracted because `handleChangeDetail` and `handleSpec` each carried their own
|
|
55
|
-
* copy and a comment claiming they were "deliberately identical" — by which
|
|
56
|
-
* point only one of them guarded the decode. One implementation is what keeps
|
|
57
|
-
* that claim true; `label` is the only thing that legitimately differs.
|
|
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.
|
|
58
42
|
*/
|
|
59
43
|
function decodePathSegment(raw, label, res) {
|
|
60
44
|
let decoded;
|
|
@@ -99,32 +83,32 @@ async function handleSpec(projectRoot, rawId, res) {
|
|
|
99
83
|
sendJson(res, 200, { id, requirements: spec.requirements });
|
|
100
84
|
}
|
|
101
85
|
/**
|
|
102
|
-
* Git activity + health signals (ADR 001). `change`
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* `days` widens the window: a day count (capped at 3650 to bound the
|
|
106
|
-
* 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.
|
|
107
89
|
*/
|
|
108
90
|
async function handleActivity(projectRoot, changeParam, daysParam, res) {
|
|
109
91
|
const change = changeParam ?? undefined;
|
|
110
92
|
if (change && (change.includes('/') || change.includes('\\') || change.includes('..'))) {
|
|
111
93
|
return sendJson(res, 403, { error: 'forbidden' });
|
|
112
94
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
}
|
|
119
105
|
sendJson(res, 200, await collectActivity(projectRoot, change, window));
|
|
120
106
|
}
|
|
121
107
|
/**
|
|
122
108
|
* Trust boundary (D5): resolves a client-supplied path to an absolute `.md` file
|
|
123
|
-
* under `<root>/<prefix>`, or null when it escapes.
|
|
124
|
-
* originate from our own API payloads, so anything else is an attack or a bug.
|
|
109
|
+
* under `<root>/<prefix>`, or null when it escapes.
|
|
125
110
|
*/
|
|
126
111
|
function resolveTospecFile(projectRoot, file, prefix) {
|
|
127
|
-
// Windows-safe: normalise backslashes before resolving.
|
|
128
112
|
const resolved = path.resolve(projectRoot, file.replace(/\\/g, '/'));
|
|
129
113
|
if (!resolved.startsWith(path.resolve(projectRoot) + path.sep))
|
|
130
114
|
return null;
|
|
@@ -133,16 +117,11 @@ function resolveTospecFile(projectRoot, file, prefix) {
|
|
|
133
117
|
return null;
|
|
134
118
|
return resolved;
|
|
135
119
|
}
|
|
136
|
-
/** The directory holding archived changes, one level below `tospec/changes/`. */
|
|
137
120
|
const ARCHIVE_SEGMENT = 'archive';
|
|
138
121
|
/**
|
|
139
|
-
* True for a path inside `tospec/changes/archive/`.
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* Deliberately not folded into `resolveTospecFile`: `/api/render` shares that
|
|
144
|
-
* helper and legitimately renders archived documents read-only. The rejection
|
|
145
|
-
* belongs on the write path.
|
|
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.
|
|
146
125
|
*/
|
|
147
126
|
function isArchivedChangePath(projectRoot, resolvedAbsPath) {
|
|
148
127
|
const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
|
|
@@ -150,13 +129,9 @@ function isArchivedChangePath(projectRoot, resolvedAbsPath) {
|
|
|
150
129
|
return parts[0] === 'tospec' && parts[1] === 'changes' && parts[2] === ARCHIVE_SEGMENT;
|
|
151
130
|
}
|
|
152
131
|
/**
|
|
153
|
-
* Maps a resolved task-file path back to its
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* An archived change is one level deeper (`changes/archive/<stamp>-<name>/`), so
|
|
158
|
-
* `parts[2]` there is the literal string `archive` — a directory, not a change.
|
|
159
|
-
* Returning null keeps any future caller from re-deriving that as a change name.
|
|
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.
|
|
160
135
|
*/
|
|
161
136
|
function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
|
|
162
137
|
const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
|
|
@@ -167,7 +142,11 @@ function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
|
|
|
167
142
|
return null;
|
|
168
143
|
return parts[2] || null;
|
|
169
144
|
}
|
|
170
|
-
/**
|
|
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
|
+
*/
|
|
171
150
|
async function handleRender(projectRoot, fileParam, res) {
|
|
172
151
|
if (!fileParam) {
|
|
173
152
|
return sendJson(res, 400, { error: 'missing file parameter' });
|
|
@@ -183,7 +162,7 @@ async function handleRender(projectRoot, fileParam, res) {
|
|
|
183
162
|
catch {
|
|
184
163
|
return sendJson(res, 404, { error: 'not found' });
|
|
185
164
|
}
|
|
186
|
-
const html = await
|
|
165
|
+
const html = await renderMarkdownSafe(content);
|
|
187
166
|
sendJson(res, 200, { html });
|
|
188
167
|
}
|
|
189
168
|
/** Reads a JSON request body, capped so a stray client can't exhaust memory. */
|
|
@@ -202,14 +181,12 @@ async function readJsonBody(req, limit = 8192) {
|
|
|
202
181
|
}
|
|
203
182
|
}
|
|
204
183
|
/**
|
|
205
|
-
* Ticks a task checkbox
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* 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.
|
|
209
187
|
*
|
|
210
|
-
* Requiring `content-type: application/json` is load-bearing
|
|
211
|
-
*
|
|
212
|
-
* 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.
|
|
213
190
|
*/
|
|
214
191
|
async function handleTaskUpdate(projectRoot, req, res) {
|
|
215
192
|
if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
|
|
@@ -233,28 +210,58 @@ async function handleTaskUpdate(projectRoot, req, res) {
|
|
|
233
210
|
if (!resolved) {
|
|
234
211
|
return sendJson(res, 403, { error: 'forbidden' });
|
|
235
212
|
}
|
|
236
|
-
//
|
|
237
|
-
//
|
|
213
|
+
// Before the sync gate: that gate asks whether a change is *becoming*
|
|
214
|
+
// immutable; an archived one already is.
|
|
238
215
|
if (isArchivedChangePath(projectRoot, resolved)) {
|
|
239
216
|
return sendJson(res, 403, { error: 'change is archived; tasks are immutable' });
|
|
240
217
|
}
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
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.
|
|
244
240
|
const changeName = changeNameFromTaskFile(projectRoot, resolved);
|
|
245
241
|
if (changeName) {
|
|
246
242
|
const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
|
|
247
243
|
if ((await readSyncConclusion(changeDir)) === 'PASS') {
|
|
248
244
|
return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
|
|
249
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
|
+
}
|
|
250
258
|
}
|
|
251
259
|
try {
|
|
252
260
|
await setTaskDone(resolved, line, done);
|
|
253
261
|
}
|
|
254
262
|
catch (err) {
|
|
255
263
|
const message = err instanceof Error ? err.message : String(err);
|
|
256
|
-
//
|
|
257
|
-
// 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.
|
|
258
265
|
if (/not a task/.test(message))
|
|
259
266
|
return sendJson(res, 409, { error: message });
|
|
260
267
|
if (/ENOENT/.test(message))
|
|
@@ -263,10 +270,6 @@ async function handleTaskUpdate(projectRoot, req, res) {
|
|
|
263
270
|
}
|
|
264
271
|
sendJson(res, 200, { file, line, done });
|
|
265
272
|
}
|
|
266
|
-
// -----------------------------------------------------------------------------
|
|
267
|
-
// Live updates (SSE)
|
|
268
|
-
// -----------------------------------------------------------------------------
|
|
269
|
-
/** Registers an SSE client and streams `change` events until it disconnects. */
|
|
270
273
|
function handleEvents(req, res, clients) {
|
|
271
274
|
res.writeHead(200, {
|
|
272
275
|
'content-type': 'text/event-stream',
|
|
@@ -277,21 +280,39 @@ function handleEvents(req, res, clients) {
|
|
|
277
280
|
clients.add(res);
|
|
278
281
|
req.on('close', () => clients.delete(res));
|
|
279
282
|
}
|
|
280
|
-
/** Pushes a `change` event to every connected SSE client. */
|
|
281
283
|
function broadcastChange(clients) {
|
|
282
284
|
for (const client of clients) {
|
|
283
285
|
client.write('event: change\ndata: {}\n\n');
|
|
284
286
|
}
|
|
285
287
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
}
|
|
289
309
|
async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAnyHost) {
|
|
290
310
|
try {
|
|
291
311
|
// Host allowlist runs before any route (DNS-rebinding guard).
|
|
292
312
|
if (!isAllowedHostHeader(req.headers.host, boundHost, acceptAnyHost)) {
|
|
293
313
|
return sendJson(res, 403, { error: 'forbidden host' });
|
|
294
314
|
}
|
|
315
|
+
applyContentSecurityPolicy(res);
|
|
295
316
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
296
317
|
const pathname = url.pathname;
|
|
297
318
|
if (req.method === 'POST' && pathname === '/api/task') {
|
|
@@ -302,8 +323,10 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
302
323
|
}
|
|
303
324
|
if (pathname === '/api/events')
|
|
304
325
|
return handleEvents(req, res, clients);
|
|
305
|
-
|
|
326
|
+
// `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
|
|
327
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
306
328
|
return await serveAsset(res, ASSETS_DIR, 'index.html');
|
|
329
|
+
}
|
|
307
330
|
if (pathname.startsWith('/assets/')) {
|
|
308
331
|
return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
|
|
309
332
|
}
|
|
@@ -335,21 +358,14 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
335
358
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
336
359
|
}
|
|
337
360
|
}
|
|
338
|
-
// -----------------------------------------------------------------------------
|
|
339
|
-
// Lifecycle
|
|
340
|
-
// -----------------------------------------------------------------------------
|
|
341
361
|
/**
|
|
342
|
-
* Starts the
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* Returns the server, its URL, and the actual port. Side-effect free (no
|
|
346
|
-
* 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.
|
|
347
365
|
*/
|
|
348
366
|
export async function startDashboardServer(projectRoot, opts = {}) {
|
|
349
367
|
const host = opts.host ?? DEFAULT_HOST;
|
|
350
368
|
assertBindableHost(host, 'dashboard', opts.allowRemote, 'Pass --allow-remote to accept that risk.');
|
|
351
|
-
// Resolved once here so the Host-header policy has a single home, rather than
|
|
352
|
-
// each request re-deriving it from two separate inputs.
|
|
353
369
|
const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
|
|
354
370
|
const clients = new Set();
|
|
355
371
|
const server = http.createServer((req, res) => {
|
|
@@ -358,14 +374,13 @@ export async function startDashboardServer(projectRoot, opts = {}) {
|
|
|
358
374
|
const actualPort = await listenFrom(server, opts.port ?? DEFAULT_PORT, host);
|
|
359
375
|
const watcher = watchTospec(projectRoot, () => broadcastChange(clients));
|
|
360
376
|
server.once('close', () => watcher?.close());
|
|
361
|
-
return { server, url:
|
|
377
|
+
return { server, url: connectableUrl(host, actualPort), port: actualPort };
|
|
362
378
|
}
|
|
363
379
|
/**
|
|
364
|
-
* Watches `<root>/tospec` recursively and calls `onChange` (debounced)
|
|
365
|
-
*
|
|
366
|
-
* watched files — agents and users stay free to edit or
|
|
367
|
-
*
|
|
368
|
-
* 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.
|
|
369
384
|
*/
|
|
370
385
|
function watchTospec(projectRoot, onChange) {
|
|
371
386
|
const dir = path.join(projectRoot, 'tospec');
|
|
@@ -385,22 +400,19 @@ function watchTospec(projectRoot, onChange) {
|
|
|
385
400
|
}
|
|
386
401
|
}
|
|
387
402
|
/**
|
|
388
|
-
* PID-file location
|
|
389
|
-
*
|
|
390
|
-
* 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.
|
|
391
405
|
*/
|
|
392
406
|
export function pidFilePath(projectRoot) {
|
|
393
407
|
const hash = crypto.createHash('sha1').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
|
|
394
408
|
return path.join(os.tmpdir(), `tospec-dashboard-${hash}.json`);
|
|
395
409
|
}
|
|
396
|
-
/** True if a process with this PID
|
|
410
|
+
/** True if a process with this PID exists (signal 0 probes without killing). */
|
|
397
411
|
function isProcessAlive(pid) {
|
|
398
|
-
//
|
|
399
|
-
// `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
|
|
400
|
-
// one, so a record
|
|
401
|
-
//
|
|
402
|
-
// make `--stop` terminate the user's own shell. Treated as not alive, so such
|
|
403
|
-
// 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.
|
|
404
416
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
405
417
|
return false;
|
|
406
418
|
try {
|
|
@@ -413,11 +425,8 @@ function isProcessAlive(pid) {
|
|
|
413
425
|
}
|
|
414
426
|
}
|
|
415
427
|
/**
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* a truncated record still names a process to reap. Callers that need to reach
|
|
419
|
-
* the dashboard check `port` themselves — see `shouldRefuseDetach` for why that
|
|
420
|
-
* 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.
|
|
421
430
|
*/
|
|
422
431
|
async function readPidRecord(pidFile) {
|
|
423
432
|
try {
|
|
@@ -429,32 +438,23 @@ async function readPidRecord(pidFile) {
|
|
|
429
438
|
}
|
|
430
439
|
}
|
|
431
440
|
/**
|
|
432
|
-
* Records this dashboard so `--stop` can find it. Reads
|
|
433
|
-
* optional fields
|
|
434
|
-
*
|
|
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.
|
|
435
444
|
*/
|
|
436
445
|
async function writePidRecord(pidFile, rec) {
|
|
437
446
|
await fs.writeFile(pidFile, JSON.stringify(rec), 'utf-8');
|
|
438
447
|
}
|
|
439
448
|
/**
|
|
440
|
-
* Removes a pid record if it still names `pid
|
|
441
|
-
*
|
|
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.
|
|
442
452
|
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
* places that clear it (a dashboard's own exit, and `--stop` once it has killed
|
|
446
|
-
* one) would otherwise delete whichever record happened to be there.
|
|
447
|
-
*
|
|
448
|
-
* ponytail: one record per project, so of two sibling dashboards only the later
|
|
449
|
-
* writer is tracked by pid file at all. This keeps the tracked one from being
|
|
450
|
-
* deleted by the other's exit; it cannot hand the earlier dashboard a record
|
|
451
|
-
* back. Both stay reachable through their registry markers, which is what the
|
|
452
|
-
* guard reads. Per-process pid files only if a second foreground start ever
|
|
453
|
-
* becomes something we support. The read and the delete are also not one atomic
|
|
454
|
-
* 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.
|
|
455
455
|
*
|
|
456
456
|
* Sync so it completes before the `process.exit` its signal-handler caller
|
|
457
|
-
* races —
|
|
457
|
+
* races — hence not reusing the async `readPidRecord`.
|
|
458
458
|
*/
|
|
459
459
|
export function clearPidRecordOwnedBy(pidFile, pid) {
|
|
460
460
|
try {
|
|
@@ -463,9 +463,8 @@ export function clearPidRecordOwnedBy(pidFile, pid) {
|
|
|
463
463
|
unlinkSync(pidFile);
|
|
464
464
|
}
|
|
465
465
|
catch {
|
|
466
|
-
// Absent
|
|
467
|
-
//
|
|
468
|
-
// 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.
|
|
469
468
|
}
|
|
470
469
|
}
|
|
471
470
|
/** Every project's pid file, not just this one's — they all live in the temp dir. */
|
|
@@ -473,7 +472,7 @@ async function pidFilePaths() {
|
|
|
473
472
|
const dir = os.tmpdir();
|
|
474
473
|
try {
|
|
475
474
|
return (await fs.readdir(dir))
|
|
476
|
-
// Exactly the shape pidFilePath() generates — never
|
|
475
|
+
// Exactly the shape pidFilePath() generates — never wider than we write.
|
|
477
476
|
.filter((name) => /^tospec-dashboard-[0-9a-f]{12}\.json$/.test(name))
|
|
478
477
|
.map((name) => path.join(dir, name));
|
|
479
478
|
}
|
|
@@ -482,15 +481,13 @@ async function pidFilePaths() {
|
|
|
482
481
|
}
|
|
483
482
|
}
|
|
484
483
|
function registryDir() {
|
|
485
|
-
// Derived
|
|
486
|
-
//
|
|
487
|
-
// 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.
|
|
488
486
|
return path.join(getGlobalConfigDir(), 'dashboards');
|
|
489
487
|
}
|
|
490
488
|
/**
|
|
491
|
-
* Lists live dashboards, reaping any marker whose process has
|
|
492
|
-
*
|
|
493
|
-
* 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.
|
|
494
491
|
*/
|
|
495
492
|
export async function listDashboards() {
|
|
496
493
|
const dir = registryDir();
|
|
@@ -509,9 +506,8 @@ export async function listDashboards() {
|
|
|
509
506
|
const pid = Number(m[1]);
|
|
510
507
|
const file = path.join(dir, name);
|
|
511
508
|
if (!isProcessAlive(pid)) {
|
|
512
|
-
// Best-effort
|
|
513
|
-
//
|
|
514
|
-
// 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.
|
|
515
511
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
516
512
|
continue;
|
|
517
513
|
}
|
|
@@ -528,8 +524,7 @@ export async function listDashboards() {
|
|
|
528
524
|
}
|
|
529
525
|
/**
|
|
530
526
|
* One line describing a running dashboard, for `--list` and `--stop`'s picker.
|
|
531
|
-
* The URL assumes
|
|
532
|
-
* the host a `--allow-remote` dashboard was bound to.
|
|
527
|
+
* The URL assumes loopback — `RunningDashboard` doesn't record the bound host.
|
|
533
528
|
*/
|
|
534
529
|
export function formatRunningDashboard(d, sep = '\t') {
|
|
535
530
|
const url = `http://${DEFAULT_HOST}:${d.port}`;
|
|
@@ -537,8 +532,7 @@ export function formatRunningDashboard(d, sep = '\t') {
|
|
|
537
532
|
}
|
|
538
533
|
/**
|
|
539
534
|
* `--stop` found dashboards but cannot ask which to stop (no terminal). Carries
|
|
540
|
-
* its own diagnostic so the shared failure sink renders it
|
|
541
|
-
* `RootSelectionError` and `ArchiveBlockedError`.
|
|
535
|
+
* its own diagnostic so the shared failure sink renders it.
|
|
542
536
|
*/
|
|
543
537
|
export class DashboardStopError extends Error {
|
|
544
538
|
diagnostic;
|
|
@@ -549,11 +543,9 @@ export class DashboardStopError extends Error {
|
|
|
549
543
|
}
|
|
550
544
|
}
|
|
551
545
|
/**
|
|
552
|
-
* The detached child neither reported itself listening nor exited.
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
* and points at `--list`, which can still find it via the record the child
|
|
556
|
-
* writes for itself.
|
|
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.
|
|
557
549
|
*/
|
|
558
550
|
export class DashboardReadinessTimeoutError extends Error {
|
|
559
551
|
diagnostic;
|
|
@@ -571,14 +563,12 @@ export class DashboardReadinessTimeoutError extends Error {
|
|
|
571
563
|
}
|
|
572
564
|
}
|
|
573
565
|
/**
|
|
574
|
-
* Every dashboard `--stop` can reach: the registry
|
|
575
|
-
* file, keyed by pid, reaping
|
|
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.
|
|
576
568
|
*
|
|
577
569
|
* The registry alone is not enough — `listDashboards()` reaps any marker whose
|
|
578
570
|
* pid looks dead, so a live server can lose its marker and become unstoppable
|
|
579
|
-
* while its pid file still names it
|
|
580
|
-
* than only this project's is what makes that reachable from any directory,
|
|
581
|
-
* which is the case `--stop`'s pick-from-all list exists for.
|
|
571
|
+
* while its pid file still names it.
|
|
582
572
|
*
|
|
583
573
|
* `ownRootHint` names records written before pid files carried their own root;
|
|
584
574
|
* it never restricts the result.
|
|
@@ -592,14 +582,10 @@ export async function collectStoppableDashboards(ownRootHint) {
|
|
|
592
582
|
const rec = await readPidRecord(file);
|
|
593
583
|
if (!rec)
|
|
594
584
|
continue;
|
|
595
|
-
// Reap like the marker reaper does
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
// Tidying is best-effort: `force` only swallows ENOENT, and the temp dir is
|
|
601
|
-
// shared between users on POSIX, so another user's stale file would fail to
|
|
602
|
-
// 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.
|
|
603
589
|
if (!isProcessAlive(rec.pid)) {
|
|
604
590
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
605
591
|
continue;
|
|
@@ -613,15 +599,15 @@ export async function collectStoppableDashboards(ownRootHint) {
|
|
|
613
599
|
byPid.set(rec.pid, { pid: rec.pid, port: rec.port, projectRoot: root });
|
|
614
600
|
continue;
|
|
615
601
|
}
|
|
616
|
-
// A registry marker
|
|
617
|
-
//
|
|
602
|
+
// A content-less registry marker never matches its project; the pid file may
|
|
603
|
+
// still know the root.
|
|
618
604
|
if (!known.projectRoot && root)
|
|
619
605
|
byPid.set(rec.pid, { ...known, projectRoot: root });
|
|
620
606
|
}
|
|
621
607
|
return [...byPid.values()].sort((a, b) => a.port - b.port);
|
|
622
608
|
}
|
|
623
609
|
/**
|
|
624
|
-
* Ports held by live dashboards
|
|
610
|
+
* Ports held by live dashboards, reaping dead markers as a side effect. Runs at
|
|
625
611
|
* every startup so a crashed dashboard's port frees up.
|
|
626
612
|
*/
|
|
627
613
|
async function reapAndCollectPorts() {
|
|
@@ -644,59 +630,40 @@ async function unregisterDashboard(pid) {
|
|
|
644
630
|
}
|
|
645
631
|
await Promise.all(names
|
|
646
632
|
.filter((n) => n.startsWith(`${pid}_`))
|
|
647
|
-
// Best-effort: a marker we cannot unlink must not abort the stop loop
|
|
648
|
-
// leave the dashboards after this one running.
|
|
633
|
+
// Best-effort: a marker we cannot unlink must not abort the stop loop.
|
|
649
634
|
.map((n) => fs.rm(path.join(dir, n), { force: true }).catch(() => { })));
|
|
650
635
|
}
|
|
651
636
|
/**
|
|
652
|
-
* Launches the dashboard as a detached background process
|
|
653
|
-
*
|
|
654
|
-
* child's
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* spawn → wait for the child's report → write the PID record → print the URL.
|
|
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.
|
|
658
642
|
*
|
|
659
|
-
* Refuses if any live dashboard already serves this project,
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
* either record can be the only one left: a foreground dashboard used to write
|
|
663
|
-
* no pid file at all, and a background one can lose the one it wrote. Both made
|
|
664
|
-
* the old guard see nothing and spawn a second server.
|
|
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).
|
|
665
646
|
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
* itself, foreground included, and is all that remains if this process is
|
|
671
|
-
* killed mid-handshake or gives up on the readiness timeout.
|
|
672
|
-
* - This one is what makes a record exist by the time the call returns. The
|
|
673
|
-
* child writes its own only *after* it has signalled, so without this a
|
|
674
|
-
* rapid second `--detach` could find nothing to refuse against, and a child
|
|
675
|
-
* that wedges after binding would be an untrackable orphan.
|
|
676
|
-
* Both use `fs.writeFile`, so a concurrent truncate-then-write is two processes
|
|
677
|
-
* laying down the same bytes; a reader that catches it mid-write falls back to
|
|
678
|
-
* the registry marker (`readPidRecord` returns null on a parse failure).
|
|
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.
|
|
679
651
|
*
|
|
680
652
|
* This is the only guarded start path: nothing stops a second foreground
|
|
681
|
-
* `tospec dashboard`, so "one dashboard per project"
|
|
682
|
-
* not as a machine-wide invariant.
|
|
653
|
+
* `tospec dashboard`, so "one dashboard per project" is not machine-wide.
|
|
683
654
|
*/
|
|
684
655
|
export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
685
656
|
const pidFile = pidFilePath(projectRoot);
|
|
686
|
-
// Reaps every dead record along the way, including the stale pid file a
|
|
687
|
-
// crashed dashboard of this project left behind.
|
|
688
657
|
const running = shouldRefuseDetach(await collectStoppableDashboards(projectRoot), projectRoot);
|
|
689
658
|
if (running) {
|
|
690
|
-
// Same fields as `--list
|
|
691
|
-
// dashboard named here is recognisably the row those commands show.
|
|
659
|
+
// Same fields and separator as `--list` and `--stop`'s picker.
|
|
692
660
|
console.log('Dashboard already running for this project.');
|
|
693
661
|
console.log(` ${formatRunningDashboard(running, ' ')}`);
|
|
694
662
|
console.log('Stop it with: tospec dashboard --stop');
|
|
695
663
|
return;
|
|
696
664
|
}
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
// 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.
|
|
700
667
|
const used = await reapAndCollectPorts();
|
|
701
668
|
let port = opts.port;
|
|
702
669
|
while (used.has(port))
|
|
@@ -709,41 +676,32 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
|
709
676
|
args.push('--allow-remote');
|
|
710
677
|
const child = spawn(process.execPath, args, {
|
|
711
678
|
detached: true,
|
|
712
|
-
// stderr is piped so the parent can
|
|
713
|
-
//
|
|
714
|
-
// waitForListening. stdout stays ignored; it is human text for a
|
|
715
|
-
// foreground run, not this handshake's concern.
|
|
679
|
+
// stderr is piped so the parent can read the child's LISTENING report or
|
|
680
|
+
// its error text on an early exit (waitForListening).
|
|
716
681
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
717
682
|
// On Windows, detached: true otherwise pops a new console window.
|
|
718
683
|
windowsHide: true,
|
|
719
684
|
env: { ...process.env, TOSPEC_DASHBOARD_PIDFILE: pidFile },
|
|
720
685
|
});
|
|
721
|
-
// No pid means the spawn itself failed;
|
|
722
|
-
//
|
|
686
|
+
// No pid means the spawn itself failed; reporting success would leave a record
|
|
687
|
+
// `--stop` could never use.
|
|
723
688
|
if (child.pid === undefined) {
|
|
724
689
|
throw new Error('Could not start the background dashboard process.');
|
|
725
690
|
}
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
// --allow-remote refusal, EADDRINUSE, a permission error — happens inside
|
|
729
|
-
// the child and was previously unobservable behind stdio: 'ignore'.
|
|
691
|
+
// Everything that can go wrong after a successful spawn happens inside the
|
|
692
|
+
// child and is only observable through this handshake.
|
|
730
693
|
let confirmedUrl;
|
|
731
694
|
try {
|
|
732
695
|
confirmedUrl = await waitForListening(child);
|
|
733
696
|
}
|
|
734
697
|
finally {
|
|
735
698
|
// Unconditional: on the timeout path the child is still running, and an
|
|
736
|
-
// un-unref'd handle would keep this process's event loop alive
|
|
737
|
-
// error has already been reported.
|
|
699
|
+
// un-unref'd handle would keep this process's event loop alive.
|
|
738
700
|
child.unref();
|
|
739
701
|
}
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
// ponytail: no cross-process lock; two --detach in the same instant could
|
|
745
|
-
// still both pass the guard. A human running the command twice is sequential,
|
|
746
|
-
// so this synchronous write is enough — add a lockfile only if that changes.
|
|
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.
|
|
747
705
|
await writePidRecord(pidFile, {
|
|
748
706
|
pid: child.pid,
|
|
749
707
|
port: portFromUrl(confirmedUrl) ?? port,
|
|
@@ -759,16 +717,13 @@ function portFromUrl(url) {
|
|
|
759
717
|
return match ? Number(match[1]) : null;
|
|
760
718
|
}
|
|
761
719
|
/**
|
|
762
|
-
* Races the child's
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
* child cannot keep this process's event loop alive on our end of it.
|
|
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.
|
|
767
724
|
*
|
|
768
|
-
* The timeout is
|
|
769
|
-
*
|
|
770
|
-
* being emitted — and with only those two listeners either case left
|
|
771
|
-
* `tospec dashboard --detach` hanging with no output at all.
|
|
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.
|
|
772
727
|
*/
|
|
773
728
|
function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
774
729
|
return new Promise((resolve, reject) => {
|
|
@@ -796,7 +751,11 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
|
796
751
|
return;
|
|
797
752
|
settled = true;
|
|
798
753
|
cleanup();
|
|
799
|
-
reject(new Error(
|
|
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}).`));
|
|
800
759
|
};
|
|
801
760
|
child.stderr?.on('data', onData);
|
|
802
761
|
child.on('exit', onExit);
|
|
@@ -816,12 +775,9 @@ export function samePath(a, b) {
|
|
|
816
775
|
return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
817
776
|
}
|
|
818
777
|
/**
|
|
819
|
-
* The dashboards `--stop` may terminate without asking. Empty
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
* A candidate with no recorded project never matches: `path.resolve('')` is the
|
|
823
|
-
* cwd, so a blank root would silently claim it. Pure, so this is testable
|
|
824
|
-
* 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.
|
|
825
781
|
*/
|
|
826
782
|
export function selectStopTargets(candidates, projectRoot) {
|
|
827
783
|
if (!projectRoot)
|
|
@@ -834,55 +790,46 @@ export function selectStopTargets(candidates, projectRoot) {
|
|
|
834
790
|
* `tospec/decisions/20260727_232950-one-dashboard-per-project.md`.
|
|
835
791
|
*
|
|
836
792
|
* Deliberately the same question `--stop` asks, over the same candidate set, so
|
|
837
|
-
* the two
|
|
838
|
-
* the record `--stop` cannot reach: a live but truncated record carries no port,
|
|
839
|
-
* so `collectStoppableDashboards` drops it and it never blocks a start it could
|
|
840
|
-
* not tell the user how to unblock.
|
|
793
|
+
* the two can never disagree about what is running.
|
|
841
794
|
*/
|
|
842
795
|
export function shouldRefuseDetach(candidates, projectRoot) {
|
|
843
|
-
//
|
|
844
|
-
// several (a project that already had two before this guard existed). One is
|
|
845
|
-
// 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.
|
|
846
797
|
return selectStopTargets(candidates, projectRoot)[0] ?? null;
|
|
847
798
|
}
|
|
848
799
|
/**
|
|
849
800
|
* Terminates a running dashboard and clears its registry marker, plus the
|
|
850
|
-
* 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
|
|
851
802
|
* already stopped.
|
|
852
803
|
*
|
|
853
|
-
* Returns null on success, or the reason the signal was refused —
|
|
854
|
-
*
|
|
855
|
-
* as stopped would be this ticket's own bug all over again, so we hand the
|
|
856
|
-
* 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.
|
|
857
806
|
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
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.
|
|
860
809
|
*/
|
|
861
810
|
export async function stopRunningDashboard(d) {
|
|
862
811
|
try {
|
|
863
812
|
process.kill(d.pid);
|
|
864
813
|
}
|
|
865
814
|
catch (err) {
|
|
866
|
-
// ESRCH means it exited between listing and now: already stopped, so
|
|
867
|
-
//
|
|
815
|
+
// ESRCH means it exited between listing and now: already stopped, so still
|
|
816
|
+
// clear its records. Anything else (EPERM) is a real refusal.
|
|
868
817
|
const { code, message } = err;
|
|
869
818
|
if (code !== 'ESRCH')
|
|
870
819
|
return code ?? message;
|
|
871
820
|
}
|
|
872
821
|
await unregisterDashboard(d.pid);
|
|
873
822
|
// Only this dashboard's record: the path is per-project, so stopping one of
|
|
874
|
-
// two siblings must not clear the other's.
|
|
823
|
+
// two siblings must not clear the other's.
|
|
875
824
|
if (d.projectRoot)
|
|
876
825
|
clearPidRecordOwnedBy(pidFilePath(d.projectRoot), d.pid);
|
|
877
826
|
return null;
|
|
878
827
|
}
|
|
879
828
|
/**
|
|
880
|
-
* CLI entry: allocates a free port, starts the server, prints the URL,
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
* (so each project lands on its own port off the 5620 base), then bind — which
|
|
885
|
-
* 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.
|
|
886
833
|
*/
|
|
887
834
|
export async function runDashboard(projectRoot, opts = {}) {
|
|
888
835
|
const used = await reapAndCollectPorts();
|
|
@@ -891,19 +838,16 @@ export async function runDashboard(projectRoot, opts = {}) {
|
|
|
891
838
|
start++;
|
|
892
839
|
const { server, url, port } = await startDashboardServer(projectRoot, { ...opts, port: start });
|
|
893
840
|
console.log(`Dashboard running at ${url} (Ctrl+C to stop)`);
|
|
894
|
-
// Detached child only:
|
|
895
|
-
//
|
|
896
|
-
// anything. A direct foreground run has no parent watching, so it stays quiet.
|
|
841
|
+
// Detached child only: `detachDashboard` waits on stderr for exactly this line
|
|
842
|
+
// before it reports anything. A foreground run has no parent watching.
|
|
897
843
|
if (process.env.TOSPEC_DASHBOARD_PIDFILE) {
|
|
898
844
|
console.error(`${LISTENING_PREFIX}${url}`);
|
|
899
845
|
}
|
|
900
846
|
await registerDashboard(process.pid, port, path.resolve(projectRoot));
|
|
901
|
-
// Every dashboard records itself, foreground included
|
|
902
|
-
// refuse for
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
// about the file's location even though the child re-resolves the root for
|
|
906
|
-
// 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.
|
|
907
851
|
const pidFile = process.env.TOSPEC_DASHBOARD_PIDFILE ?? pidFilePath(projectRoot);
|
|
908
852
|
await writePidRecord(pidFile, {
|
|
909
853
|
pid: process.pid,
|
|
@@ -914,10 +858,8 @@ export async function runDashboard(projectRoot, opts = {}) {
|
|
|
914
858
|
if (opts.open) {
|
|
915
859
|
openBrowser(url);
|
|
916
860
|
}
|
|
917
|
-
// Normal termination
|
|
918
|
-
//
|
|
919
|
-
// so this does not run for that path and stopRunningDashboard clears them
|
|
920
|
-
// 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.
|
|
921
863
|
const cleanup = () => {
|
|
922
864
|
// SSE keep-alive sockets would otherwise block close() from ever draining.
|
|
923
865
|
server.closeAllConnections?.();
|