@seanmars/tospec 0.19.0-beta.8 → 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 -310
- package/README.md +69 -82
- package/assets/dashboard/app.js +11 -0
- package/assets/dashboard/style.css +7 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +89 -106
- 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 +293 -145
- 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 +190 -313
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/decision.d.ts +38 -27
- package/dist/commands/decision.d.ts.map +1 -1
- package/dist/commands/decision.js +296 -128
- 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 +12 -27
- package/dist/commands/shared-output.d.ts.map +1 -1
- package/dist/commands/shared-output.js +22 -45
- package/dist/commands/shared-output.js.map +1 -1
- package/dist/commands/show.d.ts +4 -7
- package/dist/commands/show.d.ts.map +1 -1
- package/dist/commands/show.js +23 -11
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/validate.d.ts +34 -58
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +225 -141
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/workflow/index.d.ts +1 -5
- package/dist/commands/workflow/index.d.ts.map +1 -1
- package/dist/commands/workflow/index.js +1 -5
- package/dist/commands/workflow/index.js.map +1 -1
- package/dist/commands/workflow/instructions.d.ts +14 -24
- package/dist/commands/workflow/instructions.d.ts.map +1 -1
- package/dist/commands/workflow/instructions.js +219 -128
- package/dist/commands/workflow/instructions.js.map +1 -1
- package/dist/commands/workflow/new-change.d.ts +2 -5
- package/dist/commands/workflow/new-change.d.ts.map +1 -1
- package/dist/commands/workflow/new-change.js +69 -32
- package/dist/commands/workflow/new-change.js.map +1 -1
- package/dist/commands/workflow/schemas.d.ts +1 -5
- package/dist/commands/workflow/schemas.d.ts.map +1 -1
- package/dist/commands/workflow/schemas.js +6 -17
- package/dist/commands/workflow/schemas.js.map +1 -1
- package/dist/commands/workflow/shared.d.ts +37 -42
- package/dist/commands/workflow/shared.d.ts.map +1 -1
- package/dist/commands/workflow/shared.js +23 -54
- package/dist/commands/workflow/shared.js.map +1 -1
- package/dist/commands/workflow/status.d.ts +7 -17
- package/dist/commands/workflow/status.d.ts.map +1 -1
- package/dist/commands/workflow/status.js +57 -72
- package/dist/commands/workflow/status.js.map +1 -1
- package/dist/commands/workflow/templates.d.ts +8 -8
- package/dist/commands/workflow/templates.d.ts.map +1 -1
- package/dist/commands/workflow/templates.js +32 -46
- package/dist/commands/workflow/templates.js.map +1 -1
- package/dist/core/archive.d.ts +22 -31
- package/dist/core/archive.d.ts.map +1 -1
- package/dist/core/archive.js +296 -283
- 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 +54 -120
- package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.js +129 -111
- 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 +36 -81
- package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
- package/dist/core/artifact-graph/resolver.js +60 -101
- 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 +6 -14
- package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
- package/dist/core/artifact-graph/stub-detection.js +13 -16
- package/dist/core/artifact-graph/stub-detection.js.map +1 -1
- 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 +16 -27
- package/dist/core/change-presenter.d.ts.map +1 -1
- package/dist/core/change-presenter.js +53 -53
- package/dist/core/change-presenter.js.map +1 -1
- package/dist/core/change-status-policy.d.ts +4 -8
- package/dist/core/change-status-policy.d.ts.map +1 -1
- package/dist/core/change-status-policy.js +9 -17
- 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 +7 -41
- package/dist/core/config-schema.d.ts.map +1 -1
- package/dist/core/config-schema.js +35 -74
- package/dist/core/config-schema.js.map +1 -1
- package/dist/core/config.d.ts +25 -49
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +22 -45
- 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 +35 -22
- package/dist/core/dashboard-data.d.ts.map +1 -1
- package/dist/core/dashboard-data.js +57 -72
- 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 +12 -28
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +90 -168
- package/dist/core/init.js.map +1 -1
- package/dist/core/list.d.ts.map +1 -1
- package/dist/core/list.js +80 -38
- 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 +15 -23
- package/dist/core/markdown-render.d.ts.map +1 -1
- package/dist/core/markdown-render.js +25 -34
- package/dist/core/markdown-render.js.map +1 -1
- package/dist/core/migrate.d.ts +19 -16
- package/dist/core/migrate.d.ts.map +1 -1
- package/dist/core/migrate.js +151 -135
- 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 +23 -30
- 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 +8 -14
- package/dist/core/root-selection.d.ts.map +1 -1
- package/dist/core/root-selection.js +3 -6
- package/dist/core/root-selection.js.map +1 -1
- package/dist/core/rules.d.ts.map +1 -1
- package/dist/core/rules.js +2 -3
- 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.map +1 -1
- package/dist/core/schemas/base.schema.js +6 -12
- package/dist/core/schemas/base.schema.js.map +1 -1
- package/dist/core/schemas/change.schema.d.ts +8 -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/shared/index.d.ts +2 -7
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +2 -7
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/shared/rules-generation.d.ts +5 -15
- package/dist/core/shared/rules-generation.d.ts.map +1 -1
- package/dist/core/shared/rules-generation.js +33 -37
- 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 +35 -76
- package/dist/core/shared/tool-detection.d.ts.map +1 -1
- package/dist/core/shared/tool-detection.js +73 -93
- 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 +5 -10
- package/dist/core/spec-presenter.js.map +1 -1
- package/dist/core/specs-apply.d.ts +16 -31
- package/dist/core/specs-apply.d.ts.map +1 -1
- package/dist/core/specs-apply.js +146 -195
- 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 +16 -5
- package/dist/core/templates/workflows/archive.js.map +1 -1
- package/dist/core/templates/workflows/decision.js +3 -3
- 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 +10 -36
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +59 -126
- 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 -25
- 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 +30 -12
- 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 +42 -68
- package/dist/core/validation/validator.d.ts.map +1 -1
- package/dist/core/validation/validator.js +468 -285
- 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 +24 -76
- package/dist/utils/change-utils.d.ts.map +1 -1
- package/dist/utils/change-utils.js +95 -142
- 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 +10 -20
- package/dist/utils/item-discovery.d.ts.map +1 -1
- package/dist/utils/item-discovery.js +31 -55
- 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/issue/schema.yaml +8 -1
- package/schemas/issue/templates/spec.md +20 -3
- package/schemas/sdd/schema.yaml +20 -1
- package/schemas/sdd/templates/spec.md +20 -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';
|
|
@@ -20,50 +15,30 @@ 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';
|
|
18
|
+
import { acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader, listenFrom, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
|
|
24
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
|
-
* Drops one leading `Error: ` from text a child process already formatted for
|
|
33
|
-
* display, so re-wrapping it does not stack a second prefix. Only the first
|
|
34
|
-
* line is considered — a message whose body legitimately mentions "Error:"
|
|
35
|
-
* further in keeps it.
|
|
36
|
-
*/
|
|
25
|
+
/** Drops one leading `Error: ` the child added, so wrapping doesn't stack prefixes. */
|
|
37
26
|
function stripErrorPrefix(text) {
|
|
38
27
|
return text.replace(/^Error:\s*/, '');
|
|
39
28
|
}
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// slower than a cold Node start plus a bind means it is stuck, not busy.
|
|
29
|
+
// The child signals right after binding, so slower than a cold start plus a
|
|
30
|
+
// bind means it is stuck, not busy.
|
|
43
31
|
const READINESS_TIMEOUT_MS = 10_000;
|
|
44
32
|
/** This command's shipped frontend; `serveAsset` is confined to it. */
|
|
45
33
|
const ASSETS_DIR = packageAssetsDir('dashboard');
|
|
46
|
-
// -----------------------------------------------------------------------------
|
|
47
|
-
// API handlers
|
|
48
|
-
// -----------------------------------------------------------------------------
|
|
49
34
|
/**
|
|
50
35
|
* Decodes one URL path segment and confines it to a single directory name, or
|
|
51
36
|
* answers the request and returns null.
|
|
52
37
|
*
|
|
53
38
|
* WHATWG URL normalizes a literal `..` out of the pathname but does not
|
|
54
|
-
* percent-decode, so `%2e%2e%2f`
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* 200 vs 404 split was an existence oracle over arbitrary directories, and a hit
|
|
58
|
-
* returned that directory read as a change.
|
|
59
|
-
*
|
|
60
|
-
* Malformed escapes (`%zz`) throw `URIError`; that is the client's mistake, so
|
|
61
|
-
* it answers 400 rather than unwinding to the router's outer catch as a 500.
|
|
62
|
-
*
|
|
63
|
-
* Extracted because `handleChangeDetail` and `handleSpec` each carried their own
|
|
64
|
-
* copy and a comment claiming they were "deliberately identical" — by which
|
|
65
|
-
* point only one of them guarded the decode. One implementation is what keeps
|
|
66
|
-
* 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.
|
|
67
42
|
*/
|
|
68
43
|
function decodePathSegment(raw, label, res) {
|
|
69
44
|
let decoded;
|
|
@@ -108,32 +83,32 @@ async function handleSpec(projectRoot, rawId, res) {
|
|
|
108
83
|
sendJson(res, 200, { id, requirements: spec.requirements });
|
|
109
84
|
}
|
|
110
85
|
/**
|
|
111
|
-
* Git activity + health signals (ADR 001). `change`
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* `days` widens the window: a day count (capped at 3650 to bound the
|
|
115
|
-
* 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.
|
|
116
89
|
*/
|
|
117
90
|
async function handleActivity(projectRoot, changeParam, daysParam, res) {
|
|
118
91
|
const change = changeParam ?? undefined;
|
|
119
92
|
if (change && (change.includes('/') || change.includes('\\') || change.includes('..'))) {
|
|
120
93
|
return sendJson(res, 403, { error: 'forbidden' });
|
|
121
94
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|
|
128
105
|
sendJson(res, 200, await collectActivity(projectRoot, change, window));
|
|
129
106
|
}
|
|
130
107
|
/**
|
|
131
108
|
* Trust boundary (D5): resolves a client-supplied path to an absolute `.md` file
|
|
132
|
-
* under `<root>/<prefix>`, or null when it escapes.
|
|
133
|
-
* originate from our own API payloads, so anything else is an attack or a bug.
|
|
109
|
+
* under `<root>/<prefix>`, or null when it escapes.
|
|
134
110
|
*/
|
|
135
111
|
function resolveTospecFile(projectRoot, file, prefix) {
|
|
136
|
-
// Windows-safe: normalise backslashes before resolving.
|
|
137
112
|
const resolved = path.resolve(projectRoot, file.replace(/\\/g, '/'));
|
|
138
113
|
if (!resolved.startsWith(path.resolve(projectRoot) + path.sep))
|
|
139
114
|
return null;
|
|
@@ -142,16 +117,11 @@ function resolveTospecFile(projectRoot, file, prefix) {
|
|
|
142
117
|
return null;
|
|
143
118
|
return resolved;
|
|
144
119
|
}
|
|
145
|
-
/** The directory holding archived changes, one level below `tospec/changes/`. */
|
|
146
120
|
const ARCHIVE_SEGMENT = 'archive';
|
|
147
121
|
/**
|
|
148
|
-
* True for a path inside `tospec/changes/archive/`.
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* Deliberately not folded into `resolveTospecFile`: `/api/render` shares that
|
|
153
|
-
* helper and legitimately renders archived documents read-only. The rejection
|
|
154
|
-
* 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.
|
|
155
125
|
*/
|
|
156
126
|
function isArchivedChangePath(projectRoot, resolvedAbsPath) {
|
|
157
127
|
const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
|
|
@@ -159,13 +129,9 @@ function isArchivedChangePath(projectRoot, resolvedAbsPath) {
|
|
|
159
129
|
return parts[0] === 'tospec' && parts[1] === 'changes' && parts[2] === ARCHIVE_SEGMENT;
|
|
160
130
|
}
|
|
161
131
|
/**
|
|
162
|
-
* Maps a resolved task-file path back to its
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* An archived change is one level deeper (`changes/archive/<stamp>-<name>/`), so
|
|
167
|
-
* `parts[2]` there is the literal string `archive` — a directory, not a change.
|
|
168
|
-
* 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.
|
|
169
135
|
*/
|
|
170
136
|
function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
|
|
171
137
|
const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
|
|
@@ -177,12 +143,9 @@ function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
|
|
|
177
143
|
return parts[2] || null;
|
|
178
144
|
}
|
|
179
145
|
/**
|
|
180
|
-
* Renders
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* assigns this with `innerHTML`, so anything the renderer passes through runs
|
|
184
|
-
* in the dashboard's origin — inside the CSRF boundary that `handleTaskUpdate`
|
|
185
|
-
* relies on. See `core/markdown-render.ts`.
|
|
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.
|
|
186
149
|
*/
|
|
187
150
|
async function handleRender(projectRoot, fileParam, res) {
|
|
188
151
|
if (!fileParam) {
|
|
@@ -218,14 +181,12 @@ async function readJsonBody(req, limit = 8192) {
|
|
|
218
181
|
}
|
|
219
182
|
}
|
|
220
183
|
/**
|
|
221
|
-
* Ticks a task checkbox
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* 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.
|
|
225
187
|
*
|
|
226
|
-
* Requiring `content-type: application/json` is load-bearing
|
|
227
|
-
*
|
|
228
|
-
* 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.
|
|
229
190
|
*/
|
|
230
191
|
async function handleTaskUpdate(projectRoot, req, res) {
|
|
231
192
|
if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
|
|
@@ -249,30 +210,47 @@ async function handleTaskUpdate(projectRoot, req, res) {
|
|
|
249
210
|
if (!resolved) {
|
|
250
211
|
return sendJson(res, 403, { error: 'forbidden' });
|
|
251
212
|
}
|
|
252
|
-
//
|
|
253
|
-
//
|
|
213
|
+
// Before the sync gate: that gate asks whether a change is *becoming*
|
|
214
|
+
// immutable; an archived one already is.
|
|
254
215
|
if (isArchivedChangePath(projectRoot, resolved)) {
|
|
255
216
|
return sendJson(res, 403, { error: 'change is archived; tasks are immutable' });
|
|
256
217
|
}
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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.
|
|
260
240
|
const changeName = changeNameFromTaskFile(projectRoot, resolved);
|
|
261
241
|
if (changeName) {
|
|
262
242
|
const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
|
|
263
243
|
if ((await readSyncConclusion(changeDir)) === 'PASS') {
|
|
264
244
|
return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
|
|
265
245
|
}
|
|
266
|
-
// Confined to the schema's tracked task file,
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
// reports progress from `resolveTaskFiles` alone, so a write outside it
|
|
273
|
-
// left no trace anywhere in the UI.
|
|
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.
|
|
274
252
|
const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
|
|
275
|
-
if (!tracked.includes(path.resolve(resolved))) {
|
|
253
|
+
if (!tracked.includes(realFile) && !tracked.includes(path.resolve(resolved))) {
|
|
276
254
|
return sendJson(res, 409, {
|
|
277
255
|
error: `${path.basename(resolved)} is not this change's tracked task file`,
|
|
278
256
|
});
|
|
@@ -283,8 +261,7 @@ async function handleTaskUpdate(projectRoot, req, res) {
|
|
|
283
261
|
}
|
|
284
262
|
catch (err) {
|
|
285
263
|
const message = err instanceof Error ? err.message : String(err);
|
|
286
|
-
//
|
|
287
|
-
// 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.
|
|
288
265
|
if (/not a task/.test(message))
|
|
289
266
|
return sendJson(res, 409, { error: message });
|
|
290
267
|
if (/ENOENT/.test(message))
|
|
@@ -293,10 +270,6 @@ async function handleTaskUpdate(projectRoot, req, res) {
|
|
|
293
270
|
}
|
|
294
271
|
sendJson(res, 200, { file, line, done });
|
|
295
272
|
}
|
|
296
|
-
// -----------------------------------------------------------------------------
|
|
297
|
-
// Live updates (SSE)
|
|
298
|
-
// -----------------------------------------------------------------------------
|
|
299
|
-
/** Registers an SSE client and streams `change` events until it disconnects. */
|
|
300
273
|
function handleEvents(req, res, clients) {
|
|
301
274
|
res.writeHead(200, {
|
|
302
275
|
'content-type': 'text/event-stream',
|
|
@@ -307,27 +280,18 @@ function handleEvents(req, res, clients) {
|
|
|
307
280
|
clients.add(res);
|
|
308
281
|
req.on('close', () => clients.delete(res));
|
|
309
282
|
}
|
|
310
|
-
/** Pushes a `change` event to every connected SSE client. */
|
|
311
283
|
function broadcastChange(clients) {
|
|
312
284
|
for (const client of clients) {
|
|
313
285
|
client.write('event: change\ndata: {}\n\n');
|
|
314
286
|
}
|
|
315
287
|
}
|
|
316
|
-
// -----------------------------------------------------------------------------
|
|
317
|
-
// Router
|
|
318
|
-
// -----------------------------------------------------------------------------
|
|
319
288
|
/**
|
|
320
289
|
* Second layer behind `renderMarkdownSafe`, applied to every response.
|
|
321
290
|
*
|
|
322
|
-
* `script-src 'self'` is the load-bearing directive: it refuses inline
|
|
323
|
-
* handlers and `javascript:` URLs
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* lets through.
|
|
327
|
-
*
|
|
328
|
-
* `style-src` keeps `'unsafe-inline'` deliberately — Chart.js writes inline
|
|
329
|
-
* styles, and a style injection cannot call the write endpoint, so tightening
|
|
330
|
-
* it would trade a working dashboard for no security.
|
|
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.
|
|
331
295
|
*/
|
|
332
296
|
function applyContentSecurityPolicy(res) {
|
|
333
297
|
res.setHeader('content-security-policy', [
|
|
@@ -336,7 +300,6 @@ function applyContentSecurityPolicy(res) {
|
|
|
336
300
|
"style-src 'self' 'unsafe-inline'",
|
|
337
301
|
"img-src 'self' data:",
|
|
338
302
|
"font-src 'self'",
|
|
339
|
-
// EventSource on /api/events and every fetch of /api/*.
|
|
340
303
|
"connect-src 'self'",
|
|
341
304
|
"base-uri 'none'",
|
|
342
305
|
"form-action 'none'",
|
|
@@ -360,8 +323,10 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
360
323
|
}
|
|
361
324
|
if (pathname === '/api/events')
|
|
362
325
|
return handleEvents(req, res, clients);
|
|
363
|
-
|
|
326
|
+
// `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
|
|
327
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
364
328
|
return await serveAsset(res, ASSETS_DIR, 'index.html');
|
|
329
|
+
}
|
|
365
330
|
if (pathname.startsWith('/assets/')) {
|
|
366
331
|
return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
|
|
367
332
|
}
|
|
@@ -393,21 +358,14 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
393
358
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
394
359
|
}
|
|
395
360
|
}
|
|
396
|
-
// -----------------------------------------------------------------------------
|
|
397
|
-
// Lifecycle
|
|
398
|
-
// -----------------------------------------------------------------------------
|
|
399
361
|
/**
|
|
400
|
-
* Starts the
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* Returns the server, its URL, and the actual port. Side-effect free (no
|
|
404
|
-
* 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.
|
|
405
365
|
*/
|
|
406
366
|
export async function startDashboardServer(projectRoot, opts = {}) {
|
|
407
367
|
const host = opts.host ?? DEFAULT_HOST;
|
|
408
368
|
assertBindableHost(host, 'dashboard', opts.allowRemote, 'Pass --allow-remote to accept that risk.');
|
|
409
|
-
// Resolved once here so the Host-header policy has a single home, rather than
|
|
410
|
-
// each request re-deriving it from two separate inputs.
|
|
411
369
|
const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
|
|
412
370
|
const clients = new Set();
|
|
413
371
|
const server = http.createServer((req, res) => {
|
|
@@ -416,14 +374,13 @@ export async function startDashboardServer(projectRoot, opts = {}) {
|
|
|
416
374
|
const actualPort = await listenFrom(server, opts.port ?? DEFAULT_PORT, host);
|
|
417
375
|
const watcher = watchTospec(projectRoot, () => broadcastChange(clients));
|
|
418
376
|
server.once('close', () => watcher?.close());
|
|
419
|
-
return { server, url:
|
|
377
|
+
return { server, url: connectableUrl(host, actualPort), port: actualPort };
|
|
420
378
|
}
|
|
421
379
|
/**
|
|
422
|
-
* Watches `<root>/tospec` recursively and calls `onChange` (debounced)
|
|
423
|
-
*
|
|
424
|
-
* watched files — agents and users stay free to edit or
|
|
425
|
-
*
|
|
426
|
-
* Limitation: 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.
|
|
427
384
|
*/
|
|
428
385
|
function watchTospec(projectRoot, onChange) {
|
|
429
386
|
const dir = path.join(projectRoot, 'tospec');
|
|
@@ -443,22 +400,19 @@ function watchTospec(projectRoot, onChange) {
|
|
|
443
400
|
}
|
|
444
401
|
}
|
|
445
402
|
/**
|
|
446
|
-
* PID-file location
|
|
447
|
-
*
|
|
448
|
-
* 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.
|
|
449
405
|
*/
|
|
450
406
|
export function pidFilePath(projectRoot) {
|
|
451
407
|
const hash = crypto.createHash('sha1').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
|
|
452
408
|
return path.join(os.tmpdir(), `tospec-dashboard-${hash}.json`);
|
|
453
409
|
}
|
|
454
|
-
/** True if a process with this PID
|
|
410
|
+
/** True if a process with this PID exists (signal 0 probes without killing). */
|
|
455
411
|
function isProcessAlive(pid) {
|
|
456
|
-
//
|
|
457
|
-
// `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
|
|
458
|
-
// one, so a record
|
|
459
|
-
//
|
|
460
|
-
// make `--stop` terminate the user's own shell. Treated as not alive, so such
|
|
461
|
-
// 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.
|
|
462
416
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
463
417
|
return false;
|
|
464
418
|
try {
|
|
@@ -471,11 +425,8 @@ function isProcessAlive(pid) {
|
|
|
471
425
|
}
|
|
472
426
|
}
|
|
473
427
|
/**
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
* a truncated record still names a process to reap. Callers that need to reach
|
|
477
|
-
* the dashboard check `port` themselves — see `shouldRefuseDetach` for why that
|
|
478
|
-
* 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.
|
|
479
430
|
*/
|
|
480
431
|
async function readPidRecord(pidFile) {
|
|
481
432
|
try {
|
|
@@ -487,32 +438,23 @@ async function readPidRecord(pidFile) {
|
|
|
487
438
|
}
|
|
488
439
|
}
|
|
489
440
|
/**
|
|
490
|
-
* Records this dashboard so `--stop` can find it. Reads
|
|
491
|
-
* optional fields
|
|
492
|
-
*
|
|
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.
|
|
493
444
|
*/
|
|
494
445
|
async function writePidRecord(pidFile, rec) {
|
|
495
446
|
await fs.writeFile(pidFile, JSON.stringify(rec), 'utf-8');
|
|
496
447
|
}
|
|
497
448
|
/**
|
|
498
|
-
* Removes a pid record if it still names `pid
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
* The path is keyed by project, not by process, and nothing guards a second
|
|
502
|
-
* foreground start — so two dashboards for one project share this file. Both
|
|
503
|
-
* places that clear it (a dashboard's own exit, and `--stop` once it has killed
|
|
504
|
-
* 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.
|
|
505
452
|
*
|
|
506
|
-
* Limitation: one record per project, so
|
|
507
|
-
*
|
|
508
|
-
* deleted by the other's exit; it cannot hand the earlier dashboard a record
|
|
509
|
-
* back. Both stay reachable through their registry markers, which is what the
|
|
510
|
-
* guard reads. Per-process pid files only if a second foreground start ever
|
|
511
|
-
* becomes something we support. The read and the delete are also not one atomic
|
|
512
|
-
* 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.
|
|
513
455
|
*
|
|
514
456
|
* Sync so it completes before the `process.exit` its signal-handler caller
|
|
515
|
-
* races —
|
|
457
|
+
* races — hence not reusing the async `readPidRecord`.
|
|
516
458
|
*/
|
|
517
459
|
export function clearPidRecordOwnedBy(pidFile, pid) {
|
|
518
460
|
try {
|
|
@@ -521,9 +463,8 @@ export function clearPidRecordOwnedBy(pidFile, pid) {
|
|
|
521
463
|
unlinkSync(pidFile);
|
|
522
464
|
}
|
|
523
465
|
catch {
|
|
524
|
-
// Absent
|
|
525
|
-
//
|
|
526
|
-
// 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.
|
|
527
468
|
}
|
|
528
469
|
}
|
|
529
470
|
/** Every project's pid file, not just this one's — they all live in the temp dir. */
|
|
@@ -531,7 +472,7 @@ async function pidFilePaths() {
|
|
|
531
472
|
const dir = os.tmpdir();
|
|
532
473
|
try {
|
|
533
474
|
return (await fs.readdir(dir))
|
|
534
|
-
// Exactly the shape pidFilePath() generates — never
|
|
475
|
+
// Exactly the shape pidFilePath() generates — never wider than we write.
|
|
535
476
|
.filter((name) => /^tospec-dashboard-[0-9a-f]{12}\.json$/.test(name))
|
|
536
477
|
.map((name) => path.join(dir, name));
|
|
537
478
|
}
|
|
@@ -540,15 +481,13 @@ async function pidFilePaths() {
|
|
|
540
481
|
}
|
|
541
482
|
}
|
|
542
483
|
function registryDir() {
|
|
543
|
-
// Derived
|
|
544
|
-
//
|
|
545
|
-
// 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.
|
|
546
486
|
return path.join(getGlobalConfigDir(), 'dashboards');
|
|
547
487
|
}
|
|
548
488
|
/**
|
|
549
|
-
* Lists live dashboards, reaping any marker whose process has
|
|
550
|
-
*
|
|
551
|
-
* 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.
|
|
552
491
|
*/
|
|
553
492
|
export async function listDashboards() {
|
|
554
493
|
const dir = registryDir();
|
|
@@ -567,9 +506,8 @@ export async function listDashboards() {
|
|
|
567
506
|
const pid = Number(m[1]);
|
|
568
507
|
const file = path.join(dir, name);
|
|
569
508
|
if (!isProcessAlive(pid)) {
|
|
570
|
-
// Best-effort
|
|
571
|
-
//
|
|
572
|
-
// 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.
|
|
573
511
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
574
512
|
continue;
|
|
575
513
|
}
|
|
@@ -586,8 +524,7 @@ export async function listDashboards() {
|
|
|
586
524
|
}
|
|
587
525
|
/**
|
|
588
526
|
* One line describing a running dashboard, for `--list` and `--stop`'s picker.
|
|
589
|
-
* The URL assumes
|
|
590
|
-
* the host a `--allow-remote` dashboard was bound to.
|
|
527
|
+
* The URL assumes loopback — `RunningDashboard` doesn't record the bound host.
|
|
591
528
|
*/
|
|
592
529
|
export function formatRunningDashboard(d, sep = '\t') {
|
|
593
530
|
const url = `http://${DEFAULT_HOST}:${d.port}`;
|
|
@@ -595,8 +532,7 @@ export function formatRunningDashboard(d, sep = '\t') {
|
|
|
595
532
|
}
|
|
596
533
|
/**
|
|
597
534
|
* `--stop` found dashboards but cannot ask which to stop (no terminal). Carries
|
|
598
|
-
* its own diagnostic so the shared failure sink renders it
|
|
599
|
-
* `RootSelectionError` and `ArchiveBlockedError`.
|
|
535
|
+
* its own diagnostic so the shared failure sink renders it.
|
|
600
536
|
*/
|
|
601
537
|
export class DashboardStopError extends Error {
|
|
602
538
|
diagnostic;
|
|
@@ -607,11 +543,9 @@ export class DashboardStopError extends Error {
|
|
|
607
543
|
}
|
|
608
544
|
}
|
|
609
545
|
/**
|
|
610
|
-
* The detached child neither reported itself listening nor exited.
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
* and points at `--list`, which can still find it via the record the child
|
|
614
|
-
* 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.
|
|
615
549
|
*/
|
|
616
550
|
export class DashboardReadinessTimeoutError extends Error {
|
|
617
551
|
diagnostic;
|
|
@@ -629,14 +563,12 @@ export class DashboardReadinessTimeoutError extends Error {
|
|
|
629
563
|
}
|
|
630
564
|
}
|
|
631
565
|
/**
|
|
632
|
-
* Every dashboard `--stop` can reach: the registry
|
|
633
|
-
* 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.
|
|
634
568
|
*
|
|
635
569
|
* The registry alone is not enough — `listDashboards()` reaps any marker whose
|
|
636
570
|
* pid looks dead, so a live server can lose its marker and become unstoppable
|
|
637
|
-
* while its pid file still names it
|
|
638
|
-
* than only this project's is what makes that reachable from any directory,
|
|
639
|
-
* which is the case `--stop`'s pick-from-all list exists for.
|
|
571
|
+
* while its pid file still names it.
|
|
640
572
|
*
|
|
641
573
|
* `ownRootHint` names records written before pid files carried their own root;
|
|
642
574
|
* it never restricts the result.
|
|
@@ -650,14 +582,10 @@ export async function collectStoppableDashboards(ownRootHint) {
|
|
|
650
582
|
const rec = await readPidRecord(file);
|
|
651
583
|
if (!rec)
|
|
652
584
|
continue;
|
|
653
|
-
// Reap like the marker reaper does
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
// Tidying is best-effort: `force` only swallows ENOENT, and the temp dir is
|
|
659
|
-
// shared between users on POSIX, so another user's stale file would fail to
|
|
660
|
-
// 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.
|
|
661
589
|
if (!isProcessAlive(rec.pid)) {
|
|
662
590
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
663
591
|
continue;
|
|
@@ -671,15 +599,15 @@ export async function collectStoppableDashboards(ownRootHint) {
|
|
|
671
599
|
byPid.set(rec.pid, { pid: rec.pid, port: rec.port, projectRoot: root });
|
|
672
600
|
continue;
|
|
673
601
|
}
|
|
674
|
-
// A registry marker
|
|
675
|
-
//
|
|
602
|
+
// A content-less registry marker never matches its project; the pid file may
|
|
603
|
+
// still know the root.
|
|
676
604
|
if (!known.projectRoot && root)
|
|
677
605
|
byPid.set(rec.pid, { ...known, projectRoot: root });
|
|
678
606
|
}
|
|
679
607
|
return [...byPid.values()].sort((a, b) => a.port - b.port);
|
|
680
608
|
}
|
|
681
609
|
/**
|
|
682
|
-
* Ports held by live dashboards
|
|
610
|
+
* Ports held by live dashboards, reaping dead markers as a side effect. Runs at
|
|
683
611
|
* every startup so a crashed dashboard's port frees up.
|
|
684
612
|
*/
|
|
685
613
|
async function reapAndCollectPorts() {
|
|
@@ -702,59 +630,40 @@ async function unregisterDashboard(pid) {
|
|
|
702
630
|
}
|
|
703
631
|
await Promise.all(names
|
|
704
632
|
.filter((n) => n.startsWith(`${pid}_`))
|
|
705
|
-
// Best-effort: a marker we cannot unlink must not abort the stop loop
|
|
706
|
-
// leave the dashboards after this one running.
|
|
633
|
+
// Best-effort: a marker we cannot unlink must not abort the stop loop.
|
|
707
634
|
.map((n) => fs.rm(path.join(dir, n), { force: true }).catch(() => { })));
|
|
708
635
|
}
|
|
709
636
|
/**
|
|
710
|
-
* Launches the dashboard as a detached background process
|
|
711
|
-
*
|
|
712
|
-
* child's
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
* 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.
|
|
716
642
|
*
|
|
717
|
-
* Refuses if any live dashboard already serves this project,
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
* either record can be the only one left: a foreground dashboard used to write
|
|
721
|
-
* no pid file at all, and a background one can lose the one it wrote. Both made
|
|
722
|
-
* 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).
|
|
723
646
|
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
* itself, foreground included, and is all that remains if this process is
|
|
729
|
-
* killed mid-handshake or gives up on the readiness timeout.
|
|
730
|
-
* - This one is what makes a record exist by the time the call returns. The
|
|
731
|
-
* child writes its own only *after* it has signalled, so without this a
|
|
732
|
-
* rapid second `--detach` could find nothing to refuse against, and a child
|
|
733
|
-
* that wedges after binding would be an untrackable orphan.
|
|
734
|
-
* Both use `fs.writeFile`, so a concurrent truncate-then-write is two processes
|
|
735
|
-
* laying down the same bytes; a reader that catches it mid-write falls back to
|
|
736
|
-
* 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.
|
|
737
651
|
*
|
|
738
652
|
* This is the only guarded start path: nothing stops a second foreground
|
|
739
|
-
* `tospec dashboard`, so "one dashboard per project"
|
|
740
|
-
* not as a machine-wide invariant.
|
|
653
|
+
* `tospec dashboard`, so "one dashboard per project" is not machine-wide.
|
|
741
654
|
*/
|
|
742
655
|
export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
743
656
|
const pidFile = pidFilePath(projectRoot);
|
|
744
|
-
// Reaps every dead record along the way, including the stale pid file a
|
|
745
|
-
// crashed dashboard of this project left behind.
|
|
746
657
|
const running = shouldRefuseDetach(await collectStoppableDashboards(projectRoot), projectRoot);
|
|
747
658
|
if (running) {
|
|
748
|
-
// Same fields as `--list
|
|
749
|
-
// dashboard named here is recognisably the row those commands show.
|
|
659
|
+
// Same fields and separator as `--list` and `--stop`'s picker.
|
|
750
660
|
console.log('Dashboard already running for this project.');
|
|
751
661
|
console.log(` ${formatRunningDashboard(running, ' ')}`);
|
|
752
662
|
console.log('Stop it with: tospec dashboard --stop');
|
|
753
663
|
return;
|
|
754
664
|
}
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
// 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.
|
|
758
667
|
const used = await reapAndCollectPorts();
|
|
759
668
|
let port = opts.port;
|
|
760
669
|
while (used.has(port))
|
|
@@ -767,41 +676,32 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
|
767
676
|
args.push('--allow-remote');
|
|
768
677
|
const child = spawn(process.execPath, args, {
|
|
769
678
|
detached: true,
|
|
770
|
-
// stderr is piped so the parent can
|
|
771
|
-
//
|
|
772
|
-
// waitForListening. stdout stays ignored; it is human text for a
|
|
773
|
-
// 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).
|
|
774
681
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
775
682
|
// On Windows, detached: true otherwise pops a new console window.
|
|
776
683
|
windowsHide: true,
|
|
777
684
|
env: { ...process.env, TOSPEC_DASHBOARD_PIDFILE: pidFile },
|
|
778
685
|
});
|
|
779
|
-
// No pid means the spawn itself failed;
|
|
780
|
-
//
|
|
686
|
+
// No pid means the spawn itself failed; reporting success would leave a record
|
|
687
|
+
// `--stop` could never use.
|
|
781
688
|
if (child.pid === undefined) {
|
|
782
689
|
throw new Error('Could not start the background dashboard process.');
|
|
783
690
|
}
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
// --allow-remote refusal, EADDRINUSE, a permission error — happens inside
|
|
787
|
-
// 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.
|
|
788
693
|
let confirmedUrl;
|
|
789
694
|
try {
|
|
790
695
|
confirmedUrl = await waitForListening(child);
|
|
791
696
|
}
|
|
792
697
|
finally {
|
|
793
698
|
// Unconditional: on the timeout path the child is still running, and an
|
|
794
|
-
// un-unref'd handle would keep this process's event loop alive
|
|
795
|
-
// error has already been reported.
|
|
699
|
+
// un-unref'd handle would keep this process's event loop alive.
|
|
796
700
|
child.unref();
|
|
797
701
|
}
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
// Limitation: no cross-process lock; two --detach in the same instant could
|
|
803
|
-
// still both pass the guard. A human running the command twice is sequential,
|
|
804
|
-
// 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.
|
|
805
705
|
await writePidRecord(pidFile, {
|
|
806
706
|
pid: child.pid,
|
|
807
707
|
port: portFromUrl(confirmedUrl) ?? port,
|
|
@@ -817,16 +717,13 @@ function portFromUrl(url) {
|
|
|
817
717
|
return match ? Number(match[1]) : null;
|
|
818
718
|
}
|
|
819
719
|
/**
|
|
820
|
-
* Races the child's
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
* 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.
|
|
825
724
|
*
|
|
826
|
-
* The timeout is
|
|
827
|
-
*
|
|
828
|
-
* being emitted — and with only those two listeners either case left
|
|
829
|
-
* `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.
|
|
830
727
|
*/
|
|
831
728
|
function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
832
729
|
return new Promise((resolve, reject) => {
|
|
@@ -855,11 +752,8 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
|
855
752
|
settled = true;
|
|
856
753
|
cleanup();
|
|
857
754
|
reject(new Error(
|
|
858
|
-
// The child already formatted its failure as `Error: <message
|
|
859
|
-
//
|
|
860
|
-
// `Error: ` formatter print it twice, so the same refusal read as
|
|
861
|
-
// "Error: Error: Refusing to bind..." under --detach and correctly in
|
|
862
|
-
// the foreground. Strip the child's prefix, not the parent's.
|
|
755
|
+
// The child already formatted its failure as `Error: <message>`;
|
|
756
|
+
// carrying that through made the parent print "Error: Error: ...".
|
|
863
757
|
stripErrorPrefix(stderr.trim()) ||
|
|
864
758
|
`The background dashboard process exited before it started listening (code ${code}).`));
|
|
865
759
|
};
|
|
@@ -881,12 +775,9 @@ export function samePath(a, b) {
|
|
|
881
775
|
return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
882
776
|
}
|
|
883
777
|
/**
|
|
884
|
-
* The dashboards `--stop` may terminate without asking. Empty
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
* A candidate with no recorded project never matches: `path.resolve('')` is the
|
|
888
|
-
* cwd, so a blank root would silently claim it. Pure, so this is testable
|
|
889
|
-
* 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.
|
|
890
781
|
*/
|
|
891
782
|
export function selectStopTargets(candidates, projectRoot) {
|
|
892
783
|
if (!projectRoot)
|
|
@@ -899,55 +790,46 @@ export function selectStopTargets(candidates, projectRoot) {
|
|
|
899
790
|
* `tospec/decisions/20260727_232950-one-dashboard-per-project.md`.
|
|
900
791
|
*
|
|
901
792
|
* Deliberately the same question `--stop` asks, over the same candidate set, so
|
|
902
|
-
* the two
|
|
903
|
-
* the record `--stop` cannot reach: a live but truncated record carries no port,
|
|
904
|
-
* so `collectStoppableDashboards` drops it and it never blocks a start it could
|
|
905
|
-
* not tell the user how to unblock.
|
|
793
|
+
* the two can never disagree about what is running.
|
|
906
794
|
*/
|
|
907
795
|
export function shouldRefuseDetach(candidates, projectRoot) {
|
|
908
|
-
//
|
|
909
|
-
// several (a project that already had two before this guard existed). One is
|
|
910
|
-
// 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.
|
|
911
797
|
return selectStopTargets(candidates, projectRoot)[0] ?? null;
|
|
912
798
|
}
|
|
913
799
|
/**
|
|
914
800
|
* Terminates a running dashboard and clears its registry marker, plus the
|
|
915
|
-
* 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
|
|
916
802
|
* already stopped.
|
|
917
803
|
*
|
|
918
|
-
* Returns null on success, or the reason the signal was refused —
|
|
919
|
-
*
|
|
920
|
-
* as stopped would be this ticket's own bug all over again, so we hand the
|
|
921
|
-
* 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.
|
|
922
806
|
*
|
|
923
|
-
* Limitation: SIGTERM;
|
|
924
|
-
*
|
|
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.
|
|
925
809
|
*/
|
|
926
810
|
export async function stopRunningDashboard(d) {
|
|
927
811
|
try {
|
|
928
812
|
process.kill(d.pid);
|
|
929
813
|
}
|
|
930
814
|
catch (err) {
|
|
931
|
-
// ESRCH means it exited between listing and now: already stopped, so
|
|
932
|
-
//
|
|
815
|
+
// ESRCH means it exited between listing and now: already stopped, so still
|
|
816
|
+
// clear its records. Anything else (EPERM) is a real refusal.
|
|
933
817
|
const { code, message } = err;
|
|
934
818
|
if (code !== 'ESRCH')
|
|
935
819
|
return code ?? message;
|
|
936
820
|
}
|
|
937
821
|
await unregisterDashboard(d.pid);
|
|
938
822
|
// Only this dashboard's record: the path is per-project, so stopping one of
|
|
939
|
-
// two siblings must not clear the other's.
|
|
823
|
+
// two siblings must not clear the other's.
|
|
940
824
|
if (d.projectRoot)
|
|
941
825
|
clearPidRecordOwnedBy(pidFilePath(d.projectRoot), d.pid);
|
|
942
826
|
return null;
|
|
943
827
|
}
|
|
944
828
|
/**
|
|
945
|
-
* CLI entry: allocates a free port, starts the server, prints the URL,
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
* (so each project lands on its own port off the 5620 base), then bind — which
|
|
950
|
-
* 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.
|
|
951
833
|
*/
|
|
952
834
|
export async function runDashboard(projectRoot, opts = {}) {
|
|
953
835
|
const used = await reapAndCollectPorts();
|
|
@@ -956,19 +838,16 @@ export async function runDashboard(projectRoot, opts = {}) {
|
|
|
956
838
|
start++;
|
|
957
839
|
const { server, url, port } = await startDashboardServer(projectRoot, { ...opts, port: start });
|
|
958
840
|
console.log(`Dashboard running at ${url} (Ctrl+C to stop)`);
|
|
959
|
-
// Detached child only:
|
|
960
|
-
//
|
|
961
|
-
// 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.
|
|
962
843
|
if (process.env.TOSPEC_DASHBOARD_PIDFILE) {
|
|
963
844
|
console.error(`${LISTENING_PREFIX}${url}`);
|
|
964
845
|
}
|
|
965
846
|
await registerDashboard(process.pid, port, path.resolve(projectRoot));
|
|
966
|
-
// Every dashboard records itself, foreground included
|
|
967
|
-
// refuse for
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
// about the file's location even though the child re-resolves the root for
|
|
971
|
-
// 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.
|
|
972
851
|
const pidFile = process.env.TOSPEC_DASHBOARD_PIDFILE ?? pidFilePath(projectRoot);
|
|
973
852
|
await writePidRecord(pidFile, {
|
|
974
853
|
pid: process.pid,
|
|
@@ -979,10 +858,8 @@ export async function runDashboard(projectRoot, opts = {}) {
|
|
|
979
858
|
if (opts.open) {
|
|
980
859
|
openBrowser(url);
|
|
981
860
|
}
|
|
982
|
-
// Normal termination
|
|
983
|
-
//
|
|
984
|
-
// so this does not run for that path and stopRunningDashboard clears them
|
|
985
|
-
// 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.
|
|
986
863
|
const cleanup = () => {
|
|
987
864
|
// SSE keep-alive sockets would otherwise block close() from ever draining.
|
|
988
865
|
server.closeAllConnections?.();
|