@seanmars/tospec 0.19.0-beta.8 → 0.21.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 +89 -296
- package/README.md +69 -82
- package/assets/dashboard/app.js +11 -0
- package/assets/dashboard/style.css +7 -0
- package/bin/tospec.js +1 -1
- package/dist/cli/index.d.ts +6 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +120 -112
- 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 +300 -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 +252 -330
- 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 +301 -131
- 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 +228 -142
- 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 +230 -132
- 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 +75 -34
- 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 +33 -31
- package/dist/core/archive.d.ts.map +1 -1
- package/dist/core/archive.js +322 -284
- 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 +46 -11
- 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 +34 -25
- 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 +93 -169
- package/dist/core/init.js.map +1 -1
- package/dist/core/list.d.ts.map +1 -1
- package/dist/core/list.js +95 -41
- 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 +162 -136
- 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 +30 -23
- 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 +474 -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 +41 -45
- 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,16 +1,11 @@
|
|
|
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
|
-
import { promises as fs, readFileSync, unlinkSync, watch } from 'node:fs';
|
|
8
|
+
import { promises as fs, readdirSync, readFileSync, rmSync, unlinkSync, watch, } from 'node:fs';
|
|
14
9
|
import * as path from 'node:path';
|
|
15
10
|
import * as os from 'node:os';
|
|
16
11
|
import * as crypto from 'node:crypto';
|
|
@@ -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) {
|
|
@@ -204,12 +167,19 @@ async function handleRender(projectRoot, fileParam, res) {
|
|
|
204
167
|
}
|
|
205
168
|
/** Reads a JSON request body, capped so a stray client can't exhaust memory. */
|
|
206
169
|
async function readJsonBody(req, limit = 8192) {
|
|
207
|
-
|
|
170
|
+
// Bytes first, decoded once: `string += chunk` decodes each chunk alone, so a
|
|
171
|
+
// multi-byte character split across two chunks (a CJK filename) came out as
|
|
172
|
+
// U+FFFD and the request addressed a file that does not exist.
|
|
173
|
+
const chunks = [];
|
|
174
|
+
let size = 0;
|
|
208
175
|
for await (const chunk of req) {
|
|
209
|
-
|
|
210
|
-
|
|
176
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
177
|
+
size += buffer.length;
|
|
178
|
+
if (size > limit)
|
|
211
179
|
throw new Error('body too large');
|
|
180
|
+
chunks.push(buffer);
|
|
212
181
|
}
|
|
182
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
213
183
|
try {
|
|
214
184
|
return JSON.parse(raw);
|
|
215
185
|
}
|
|
@@ -218,14 +188,12 @@ async function readJsonBody(req, limit = 8192) {
|
|
|
218
188
|
}
|
|
219
189
|
}
|
|
220
190
|
/**
|
|
221
|
-
* Ticks a task checkbox
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* never edit specs or anything outside the tospec tree.
|
|
191
|
+
* Ticks a task checkbox: `{ file, line, done }`, `file` root-relative and `line`
|
|
192
|
+
* 1-based. Writes are confined to `<root>/tospec/changes/`, so the dashboard can
|
|
193
|
+
* never edit specs.
|
|
225
194
|
*
|
|
226
|
-
* Requiring `content-type: application/json` is load-bearing
|
|
227
|
-
*
|
|
228
|
-
* answer, so a random web page cannot drive the loopback dashboard.
|
|
195
|
+
* Requiring `content-type: application/json` is load-bearing: it pushes any
|
|
196
|
+
* cross-origin POST into a CORS preflight this server does not answer.
|
|
229
197
|
*/
|
|
230
198
|
async function handleTaskUpdate(projectRoot, req, res) {
|
|
231
199
|
if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
|
|
@@ -249,42 +217,62 @@ async function handleTaskUpdate(projectRoot, req, res) {
|
|
|
249
217
|
if (!resolved) {
|
|
250
218
|
return sendJson(res, 403, { error: 'forbidden' });
|
|
251
219
|
}
|
|
252
|
-
//
|
|
253
|
-
//
|
|
220
|
+
// Before the sync gate: that gate asks whether a change is *becoming*
|
|
221
|
+
// immutable; an archived one already is.
|
|
254
222
|
if (isArchivedChangePath(projectRoot, resolved)) {
|
|
255
223
|
return sendJson(res, 403, { error: 'change is archived; tasks are immutable' });
|
|
256
224
|
}
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// addressed line was writable too — including proposal.md and design.md,
|
|
270
|
-
// the records this whole workflow exists to keep. The dashboard never
|
|
271
|
-
// offered those edits and never showed them afterwards either: `/api/task`
|
|
272
|
-
// reports progress from `resolveTaskFiles` alone, so a write outside it
|
|
273
|
-
// left no trace anywhere in the UI.
|
|
274
|
-
const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
|
|
275
|
-
if (!tracked.includes(path.resolve(resolved))) {
|
|
276
|
-
return sendJson(res, 409, {
|
|
277
|
-
error: `${path.basename(resolved)} is not this change's tracked task file`,
|
|
278
|
-
});
|
|
225
|
+
// The checks above are lexical. A task file that is itself a symlink out of
|
|
226
|
+
// the project passes both, and the write would land outside the root, so the
|
|
227
|
+
// fence is re-checked on the real path. Before the tracked-file check below:
|
|
228
|
+
// that check canonicalizes its own side, so the symlink used to fail there
|
|
229
|
+
// as "not the tracked file" — refused, but for the wrong reason. A file that
|
|
230
|
+
// is gone by now is the 404 the write would have reported.
|
|
231
|
+
let realFile;
|
|
232
|
+
try {
|
|
233
|
+
const realRoot = await fs.realpath(projectRoot);
|
|
234
|
+
realFile = await fs.realpath(resolved);
|
|
235
|
+
if (!realFile.startsWith(realRoot + path.sep)) {
|
|
236
|
+
return sendJson(res, 403, { error: 'forbidden' });
|
|
279
237
|
}
|
|
280
238
|
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
if (err.code === 'ENOENT')
|
|
241
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
// A file directly under tospec/changes/ belongs to no change, so neither the
|
|
245
|
+
// sync gate nor the tracked-file check below can apply to it. Skipping both
|
|
246
|
+
// left any markdown there writable by a raw POST.
|
|
247
|
+
const changeName = changeNameFromTaskFile(projectRoot, resolved);
|
|
248
|
+
if (!changeName) {
|
|
249
|
+
return sendJson(res, 403, { error: 'forbidden' });
|
|
250
|
+
}
|
|
251
|
+
// Freeze task edits once a change is sync certified. The frontend disable is
|
|
252
|
+
// only UX; this gate is what protects certified content from a raw POST. A
|
|
253
|
+
// read error reads as not certified.
|
|
254
|
+
const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
|
|
255
|
+
if ((await readSyncConclusion(changeDir)) === 'PASS') {
|
|
256
|
+
return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
|
|
257
|
+
}
|
|
258
|
+
// Confined to the schema's tracked task file, via the resolver the read
|
|
259
|
+
// side uses. The path check above only proves the file sits inside a change
|
|
260
|
+
// directory, so any other markdown there with a checkbox on the addressed
|
|
261
|
+
// line was writable too, invisibly. The resolver canonicalizes the files it
|
|
262
|
+
// finds and leaves its `tasks.md` fallback as joined, so both spellings of
|
|
263
|
+
// the request path are compared.
|
|
264
|
+
const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
|
|
265
|
+
if (!tracked.includes(realFile) && !tracked.includes(path.resolve(resolved))) {
|
|
266
|
+
return sendJson(res, 409, {
|
|
267
|
+
error: `${path.basename(resolved)} is not this change's tracked task file`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
281
270
|
try {
|
|
282
271
|
await setTaskDone(resolved, line, done);
|
|
283
272
|
}
|
|
284
273
|
catch (err) {
|
|
285
274
|
const message = err instanceof Error ? err.message : String(err);
|
|
286
|
-
//
|
|
287
|
-
// is gone. Neither is a server fault, so don't 500.
|
|
275
|
+
// Stale client view or a deleted file — neither is a server fault, so no 500.
|
|
288
276
|
if (/not a task/.test(message))
|
|
289
277
|
return sendJson(res, 409, { error: message });
|
|
290
278
|
if (/ENOENT/.test(message))
|
|
@@ -293,10 +281,6 @@ async function handleTaskUpdate(projectRoot, req, res) {
|
|
|
293
281
|
}
|
|
294
282
|
sendJson(res, 200, { file, line, done });
|
|
295
283
|
}
|
|
296
|
-
// -----------------------------------------------------------------------------
|
|
297
|
-
// Live updates (SSE)
|
|
298
|
-
// -----------------------------------------------------------------------------
|
|
299
|
-
/** Registers an SSE client and streams `change` events until it disconnects. */
|
|
300
284
|
function handleEvents(req, res, clients) {
|
|
301
285
|
res.writeHead(200, {
|
|
302
286
|
'content-type': 'text/event-stream',
|
|
@@ -307,27 +291,18 @@ function handleEvents(req, res, clients) {
|
|
|
307
291
|
clients.add(res);
|
|
308
292
|
req.on('close', () => clients.delete(res));
|
|
309
293
|
}
|
|
310
|
-
/** Pushes a `change` event to every connected SSE client. */
|
|
311
294
|
function broadcastChange(clients) {
|
|
312
295
|
for (const client of clients) {
|
|
313
296
|
client.write('event: change\ndata: {}\n\n');
|
|
314
297
|
}
|
|
315
298
|
}
|
|
316
|
-
// -----------------------------------------------------------------------------
|
|
317
|
-
// Router
|
|
318
|
-
// -----------------------------------------------------------------------------
|
|
319
299
|
/**
|
|
320
300
|
* Second layer behind `renderMarkdownSafe`, applied to every response.
|
|
321
301
|
*
|
|
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.
|
|
302
|
+
* `script-src 'self'` is the load-bearing directive: it refuses the inline
|
|
303
|
+
* handlers and `javascript:` URLs a markdown file would need to reach
|
|
304
|
+
* `POST /api/task` from inside this origin. `style-src` keeps `'unsafe-inline'`
|
|
305
|
+
* for Chart.js — a style injection cannot call the write endpoint.
|
|
331
306
|
*/
|
|
332
307
|
function applyContentSecurityPolicy(res) {
|
|
333
308
|
res.setHeader('content-security-policy', [
|
|
@@ -336,7 +311,6 @@ function applyContentSecurityPolicy(res) {
|
|
|
336
311
|
"style-src 'self' 'unsafe-inline'",
|
|
337
312
|
"img-src 'self' data:",
|
|
338
313
|
"font-src 'self'",
|
|
339
|
-
// EventSource on /api/events and every fetch of /api/*.
|
|
340
314
|
"connect-src 'self'",
|
|
341
315
|
"base-uri 'none'",
|
|
342
316
|
"form-action 'none'",
|
|
@@ -360,8 +334,10 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
360
334
|
}
|
|
361
335
|
if (pathname === '/api/events')
|
|
362
336
|
return handleEvents(req, res, clients);
|
|
363
|
-
|
|
337
|
+
// `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
|
|
338
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
364
339
|
return await serveAsset(res, ASSETS_DIR, 'index.html');
|
|
340
|
+
}
|
|
365
341
|
if (pathname.startsWith('/assets/')) {
|
|
366
342
|
return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
|
|
367
343
|
}
|
|
@@ -393,21 +369,14 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
|
|
|
393
369
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
394
370
|
}
|
|
395
371
|
}
|
|
396
|
-
// -----------------------------------------------------------------------------
|
|
397
|
-
// Lifecycle
|
|
398
|
-
// -----------------------------------------------------------------------------
|
|
399
372
|
/**
|
|
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.
|
|
373
|
+
* Starts the server, advancing past `EADDRINUSE` until a port is free (D6) so
|
|
374
|
+
* dashboards for different projects coexist. Side-effect free (no logging,
|
|
375
|
+
* signal handlers or registry) so tests can drive it directly.
|
|
405
376
|
*/
|
|
406
377
|
export async function startDashboardServer(projectRoot, opts = {}) {
|
|
407
378
|
const host = opts.host ?? DEFAULT_HOST;
|
|
408
379
|
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
380
|
const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
|
|
412
381
|
const clients = new Set();
|
|
413
382
|
const server = http.createServer((req, res) => {
|
|
@@ -416,14 +385,13 @@ export async function startDashboardServer(projectRoot, opts = {}) {
|
|
|
416
385
|
const actualPort = await listenFrom(server, opts.port ?? DEFAULT_PORT, host);
|
|
417
386
|
const watcher = watchTospec(projectRoot, () => broadcastChange(clients));
|
|
418
387
|
server.once('close', () => watcher?.close());
|
|
419
|
-
return { server, url:
|
|
388
|
+
return { server, url: connectableUrl(host, actualPort), port: actualPort };
|
|
420
389
|
}
|
|
421
390
|
/**
|
|
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.
|
|
391
|
+
* Watches `<root>/tospec` recursively and calls `onChange` (debounced), or null
|
|
392
|
+
* if the directory can't be watched. `fs.watch` uses OS change notifications,
|
|
393
|
+
* so it never locks the watched files — agents and users stay free to edit or
|
|
394
|
+
* delete them (D2). Recursive watch is native on win32/macOS, Node 20+ on Linux.
|
|
427
395
|
*/
|
|
428
396
|
function watchTospec(projectRoot, onChange) {
|
|
429
397
|
const dir = path.join(projectRoot, 'tospec');
|
|
@@ -443,22 +411,19 @@ function watchTospec(projectRoot, onChange) {
|
|
|
443
411
|
}
|
|
444
412
|
}
|
|
445
413
|
/**
|
|
446
|
-
* PID-file location
|
|
447
|
-
*
|
|
448
|
-
* it never gets committed; `--detach` and `--stop` both derive the same path.
|
|
414
|
+
* PID-file location, keyed by project root so `--detach` and `--stop` derive the
|
|
415
|
+
* same path. In the OS temp dir, not the repo, so it never gets committed.
|
|
449
416
|
*/
|
|
450
417
|
export function pidFilePath(projectRoot) {
|
|
451
418
|
const hash = crypto.createHash('sha1').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
|
|
452
419
|
return path.join(os.tmpdir(), `tospec-dashboard-${hash}.json`);
|
|
453
420
|
}
|
|
454
|
-
/** True if a process with this PID
|
|
421
|
+
/** True if a process with this PID exists (signal 0 probes without killing). */
|
|
455
422
|
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`.
|
|
423
|
+
// Every record's pid arrives here, so the sign check guards the module: on
|
|
424
|
+
// POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
|
|
425
|
+
// named one, so a corrupt record in the shared temp dir would make `--stop`
|
|
426
|
+
// terminate the user's own shell.
|
|
462
427
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
463
428
|
return false;
|
|
464
429
|
try {
|
|
@@ -471,11 +436,8 @@ function isProcessAlive(pid) {
|
|
|
471
436
|
}
|
|
472
437
|
}
|
|
473
438
|
/**
|
|
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`.
|
|
439
|
+
* This project's dashboard PID record, or null if absent or corrupt. `pid` is
|
|
440
|
+
* the only required field — a truncated record still names a process to reap.
|
|
479
441
|
*/
|
|
480
442
|
async function readPidRecord(pidFile) {
|
|
481
443
|
try {
|
|
@@ -487,32 +449,23 @@ async function readPidRecord(pidFile) {
|
|
|
487
449
|
}
|
|
488
450
|
}
|
|
489
451
|
/**
|
|
490
|
-
* Records this dashboard so `--stop` can find it. Reads
|
|
491
|
-
* optional fields
|
|
492
|
-
*
|
|
452
|
+
* Records this dashboard so `--stop` can find it. Reads tolerate the missing
|
|
453
|
+
* optional fields of pre-0.13 records; writes do not, since the root is what
|
|
454
|
+
* attributes a scanned record to its project.
|
|
493
455
|
*/
|
|
494
456
|
async function writePidRecord(pidFile, rec) {
|
|
495
457
|
await fs.writeFile(pidFile, JSON.stringify(rec), 'utf-8');
|
|
496
458
|
}
|
|
497
459
|
/**
|
|
498
|
-
* Removes a pid record if it still names `pid
|
|
499
|
-
*
|
|
460
|
+
* Removes a pid record only if it still names `pid`: the path is keyed by
|
|
461
|
+
* project, not process, so two sibling dashboards share this file and either
|
|
462
|
+
* clearer would otherwise delete the other's record.
|
|
500
463
|
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
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.
|
|
505
|
-
*
|
|
506
|
-
* Limitation: one record per project, so of two sibling dashboards only the later
|
|
507
|
-
* writer is tracked by pid file at all. This keeps the tracked one from being
|
|
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.
|
|
464
|
+
* Limitation: one record per project, so only the later writer is tracked by
|
|
465
|
+
* pid file; both stay reachable through their registry markers.
|
|
513
466
|
*
|
|
514
467
|
* Sync so it completes before the `process.exit` its signal-handler caller
|
|
515
|
-
* races —
|
|
468
|
+
* races — hence not reusing the async `readPidRecord`.
|
|
516
469
|
*/
|
|
517
470
|
export function clearPidRecordOwnedBy(pidFile, pid) {
|
|
518
471
|
try {
|
|
@@ -521,9 +474,8 @@ export function clearPidRecordOwnedBy(pidFile, pid) {
|
|
|
521
474
|
unlinkSync(pidFile);
|
|
522
475
|
}
|
|
523
476
|
catch {
|
|
524
|
-
// Absent
|
|
525
|
-
//
|
|
526
|
-
// half-written record could still belong to one that is starting up.
|
|
477
|
+
// Absent: nothing to do. Corrupt: left alone — it names no process, and a
|
|
478
|
+
// half-written record may belong to one that is starting up.
|
|
527
479
|
}
|
|
528
480
|
}
|
|
529
481
|
/** Every project's pid file, not just this one's — they all live in the temp dir. */
|
|
@@ -531,7 +483,7 @@ async function pidFilePaths() {
|
|
|
531
483
|
const dir = os.tmpdir();
|
|
532
484
|
try {
|
|
533
485
|
return (await fs.readdir(dir))
|
|
534
|
-
// Exactly the shape pidFilePath() generates — never
|
|
486
|
+
// Exactly the shape pidFilePath() generates — never wider than we write.
|
|
535
487
|
.filter((name) => /^tospec-dashboard-[0-9a-f]{12}\.json$/.test(name))
|
|
536
488
|
.map((name) => path.join(dir, name));
|
|
537
489
|
}
|
|
@@ -540,15 +492,13 @@ async function pidFilePaths() {
|
|
|
540
492
|
}
|
|
541
493
|
}
|
|
542
494
|
function registryDir() {
|
|
543
|
-
// Derived
|
|
544
|
-
//
|
|
545
|
-
// with it instead of leaving the two in different trees.
|
|
495
|
+
// Derived from the one config resolver, so relocating the config directory
|
|
496
|
+
// moves the registry with it.
|
|
546
497
|
return path.join(getGlobalConfigDir(), 'dashboards');
|
|
547
498
|
}
|
|
548
499
|
/**
|
|
549
|
-
* Lists live dashboards, reaping any marker whose process has
|
|
550
|
-
*
|
|
551
|
-
* content. A missing/unreadable dir → no dashboards running. Sorted by port.
|
|
500
|
+
* Lists live dashboards sorted by port, reaping any marker whose process has
|
|
501
|
+
* died. A missing or unreadable dir means no dashboards are running.
|
|
552
502
|
*/
|
|
553
503
|
export async function listDashboards() {
|
|
554
504
|
const dir = registryDir();
|
|
@@ -567,9 +517,8 @@ export async function listDashboards() {
|
|
|
567
517
|
const pid = Number(m[1]);
|
|
568
518
|
const file = path.join(dir, name);
|
|
569
519
|
if (!isProcessAlive(pid)) {
|
|
570
|
-
// Best-effort
|
|
571
|
-
//
|
|
572
|
-
// command that merely wanted to list what is running.
|
|
520
|
+
// Best-effort: failing to unlink one stale marker must not take down a
|
|
521
|
+
// command that only wanted to list.
|
|
573
522
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
574
523
|
continue;
|
|
575
524
|
}
|
|
@@ -586,8 +535,7 @@ export async function listDashboards() {
|
|
|
586
535
|
}
|
|
587
536
|
/**
|
|
588
537
|
* 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.
|
|
538
|
+
* The URL assumes loopback — `RunningDashboard` doesn't record the bound host.
|
|
591
539
|
*/
|
|
592
540
|
export function formatRunningDashboard(d, sep = '\t') {
|
|
593
541
|
const url = `http://${DEFAULT_HOST}:${d.port}`;
|
|
@@ -595,8 +543,7 @@ export function formatRunningDashboard(d, sep = '\t') {
|
|
|
595
543
|
}
|
|
596
544
|
/**
|
|
597
545
|
* `--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`.
|
|
546
|
+
* its own diagnostic so the shared failure sink renders it.
|
|
600
547
|
*/
|
|
601
548
|
export class DashboardStopError extends Error {
|
|
602
549
|
diagnostic;
|
|
@@ -607,11 +554,9 @@ export class DashboardStopError extends Error {
|
|
|
607
554
|
}
|
|
608
555
|
}
|
|
609
556
|
/**
|
|
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.
|
|
557
|
+
* The detached child neither reported itself listening nor exited. It may still
|
|
558
|
+
* be coming up, so the message reports what was not observed rather than
|
|
559
|
+
* claiming failure.
|
|
615
560
|
*/
|
|
616
561
|
export class DashboardReadinessTimeoutError extends Error {
|
|
617
562
|
diagnostic;
|
|
@@ -629,14 +574,12 @@ export class DashboardReadinessTimeoutError extends Error {
|
|
|
629
574
|
}
|
|
630
575
|
}
|
|
631
576
|
/**
|
|
632
|
-
* Every dashboard `--stop` can reach: the registry
|
|
633
|
-
* file, keyed by pid, reaping
|
|
577
|
+
* Every dashboard `--stop` can reach: the registry unioned with every project's
|
|
578
|
+
* pid file, keyed by pid, reaping dead records along the way.
|
|
634
579
|
*
|
|
635
580
|
* The registry alone is not enough — `listDashboards()` reaps any marker whose
|
|
636
581
|
* 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.
|
|
582
|
+
* while its pid file still names it.
|
|
640
583
|
*
|
|
641
584
|
* `ownRootHint` names records written before pid files carried their own root;
|
|
642
585
|
* it never restricts the result.
|
|
@@ -650,14 +593,10 @@ export async function collectStoppableDashboards(ownRootHint) {
|
|
|
650
593
|
const rec = await readPidRecord(file);
|
|
651
594
|
if (!rec)
|
|
652
595
|
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.
|
|
596
|
+
// Reap like the marker reaper does: a crashed dashboard's record would
|
|
597
|
+
// otherwise linger until the OS recycled its pid, then read as alive again —
|
|
598
|
+
// a phantom `--stop` would kill an unrelated process for. Unlink is
|
|
599
|
+
// best-effort: the POSIX temp dir is shared with other users.
|
|
661
600
|
if (!isProcessAlive(rec.pid)) {
|
|
662
601
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
663
602
|
continue;
|
|
@@ -671,15 +610,15 @@ export async function collectStoppableDashboards(ownRootHint) {
|
|
|
671
610
|
byPid.set(rec.pid, { pid: rec.pid, port: rec.port, projectRoot: root });
|
|
672
611
|
continue;
|
|
673
612
|
}
|
|
674
|
-
// A registry marker
|
|
675
|
-
//
|
|
613
|
+
// A content-less registry marker never matches its project; the pid file may
|
|
614
|
+
// still know the root.
|
|
676
615
|
if (!known.projectRoot && root)
|
|
677
616
|
byPid.set(rec.pid, { ...known, projectRoot: root });
|
|
678
617
|
}
|
|
679
618
|
return [...byPid.values()].sort((a, b) => a.port - b.port);
|
|
680
619
|
}
|
|
681
620
|
/**
|
|
682
|
-
* Ports held by live dashboards
|
|
621
|
+
* Ports held by live dashboards, reaping dead markers as a side effect. Runs at
|
|
683
622
|
* every startup so a crashed dashboard's port frees up.
|
|
684
623
|
*/
|
|
685
624
|
async function reapAndCollectPorts() {
|
|
@@ -702,64 +641,66 @@ async function unregisterDashboard(pid) {
|
|
|
702
641
|
}
|
|
703
642
|
await Promise.all(names
|
|
704
643
|
.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.
|
|
644
|
+
// Best-effort: a marker we cannot unlink must not abort the stop loop.
|
|
707
645
|
.map((n) => fs.rm(path.join(dir, n), { force: true }).catch(() => { })));
|
|
708
646
|
}
|
|
647
|
+
/** `unregisterDashboard` for the signal handler, which must finish before exit. */
|
|
648
|
+
function unregisterDashboardSync(pid) {
|
|
649
|
+
const dir = registryDir();
|
|
650
|
+
let names;
|
|
651
|
+
try {
|
|
652
|
+
names = readdirSync(dir);
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
for (const name of names) {
|
|
658
|
+
if (!name.startsWith(`${pid}_`))
|
|
659
|
+
continue;
|
|
660
|
+
try {
|
|
661
|
+
rmSync(path.join(dir, name), { force: true });
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// Best-effort, as in the async variant.
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
709
668
|
/**
|
|
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.
|
|
669
|
+
* Launches the dashboard as a detached background process: re-runs this CLI
|
|
670
|
+
* without `--detach` (the child is the actual server), then waits for the
|
|
671
|
+
* child's `LISTENING` report, so the URL printed is one the child actually
|
|
672
|
+
* bound — including after an EADDRINUSE fallback moved it off the predicted
|
|
673
|
+
* port. Order: spawn → wait for the report → write the PID record → print.
|
|
716
674
|
*
|
|
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.
|
|
675
|
+
* Refuses if any live dashboard already serves this project, asking the shared
|
|
676
|
+
* candidate set because either record can be the only one left (a foreground
|
|
677
|
+
* dashboard writes no pid file; a background one can lose its registry marker).
|
|
723
678
|
*
|
|
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).
|
|
679
|
+
* This process and the child both write the PID record, with identical fields.
|
|
680
|
+
* The child's is all that remains if this process dies mid-handshake; this one
|
|
681
|
+
* makes a record exist by the time the call returns, since the child writes only
|
|
682
|
+
* after signalling.
|
|
737
683
|
*
|
|
738
684
|
* 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.
|
|
685
|
+
* `tospec dashboard`, so "one dashboard per project" is not machine-wide.
|
|
741
686
|
*/
|
|
742
687
|
export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
743
688
|
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
689
|
const running = shouldRefuseDetach(await collectStoppableDashboards(projectRoot), projectRoot);
|
|
747
690
|
if (running) {
|
|
748
|
-
// Same fields as `--list
|
|
749
|
-
// dashboard named here is recognisably the row those commands show.
|
|
691
|
+
// Same fields and separator as `--list` and `--stop`'s picker.
|
|
750
692
|
console.log('Dashboard already running for this project.');
|
|
751
693
|
console.log(` ${formatRunningDashboard(running, ' ')}`);
|
|
752
694
|
console.log('Stop it with: tospec dashboard --stop');
|
|
753
695
|
return;
|
|
754
696
|
}
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
697
|
+
// Predict the port to hand the child, skipping ones live dashboards hold. The
|
|
698
|
+
// child re-checks and binds, so a race for this port just shifts it upward;
|
|
699
|
+
// what gets reported is the URL the child confirms, never this prediction.
|
|
758
700
|
const used = await reapAndCollectPorts();
|
|
759
701
|
let port = opts.port;
|
|
760
702
|
while (used.has(port))
|
|
761
703
|
port++;
|
|
762
|
-
const url = `http://${opts.host}:${port}`;
|
|
763
704
|
const args = [cliEntry, 'dashboard', opts.targetPath, '--port', String(port), '--host', opts.host];
|
|
764
705
|
if (opts.open)
|
|
765
706
|
args.push('--open');
|
|
@@ -767,41 +708,32 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
|
|
|
767
708
|
args.push('--allow-remote');
|
|
768
709
|
const child = spawn(process.execPath, args, {
|
|
769
710
|
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.
|
|
711
|
+
// stderr is piped so the parent can read the child's LISTENING report or
|
|
712
|
+
// its error text on an early exit (waitForListening).
|
|
774
713
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
775
714
|
// On Windows, detached: true otherwise pops a new console window.
|
|
776
715
|
windowsHide: true,
|
|
777
716
|
env: { ...process.env, TOSPEC_DASHBOARD_PIDFILE: pidFile },
|
|
778
717
|
});
|
|
779
|
-
// No pid means the spawn itself failed;
|
|
780
|
-
//
|
|
718
|
+
// No pid means the spawn itself failed; reporting success would leave a record
|
|
719
|
+
// `--stop` could never use.
|
|
781
720
|
if (child.pid === undefined) {
|
|
782
721
|
throw new Error('Could not start the background dashboard process.');
|
|
783
722
|
}
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
// --allow-remote refusal, EADDRINUSE, a permission error — happens inside
|
|
787
|
-
// the child and was previously unobservable behind stdio: 'ignore'.
|
|
723
|
+
// Everything that can go wrong after a successful spawn happens inside the
|
|
724
|
+
// child and is only observable through this handshake.
|
|
788
725
|
let confirmedUrl;
|
|
789
726
|
try {
|
|
790
727
|
confirmedUrl = await waitForListening(child);
|
|
791
728
|
}
|
|
792
729
|
finally {
|
|
793
730
|
// 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.
|
|
731
|
+
// un-unref'd handle would keep this process's event loop alive.
|
|
796
732
|
child.unref();
|
|
797
733
|
}
|
|
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.
|
|
734
|
+
// Derived from the URL the child reported, so this record and the child's own
|
|
735
|
+
// agree whichever lands last. Limitation: no cross-process lock, so two
|
|
736
|
+
// simultaneous --detach could both pass the guard.
|
|
805
737
|
await writePidRecord(pidFile, {
|
|
806
738
|
pid: child.pid,
|
|
807
739
|
port: portFromUrl(confirmedUrl) ?? port,
|
|
@@ -817,16 +749,13 @@ function portFromUrl(url) {
|
|
|
817
749
|
return match ? Number(match[1]) : null;
|
|
818
750
|
}
|
|
819
751
|
/**
|
|
820
|
-
* Races the child's
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
* child cannot keep this process's event loop alive on our end of it.
|
|
752
|
+
* Races the child's outcome: resolves with the confirmed URL on
|
|
753
|
+
* `LISTENING <url>`, rejects with its captured stderr if it exits first, and
|
|
754
|
+
* rejects on timeout if it does neither. Closes the stderr pipe once settled so
|
|
755
|
+
* our end cannot keep this process's event loop alive.
|
|
825
756
|
*
|
|
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.
|
|
757
|
+
* The timeout is a real third outcome: a child that binds then wedges emits
|
|
758
|
+
* neither event, and `--detach` would hang with no output.
|
|
830
759
|
*/
|
|
831
760
|
function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
832
761
|
return new Promise((resolve, reject) => {
|
|
@@ -842,7 +771,11 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
|
842
771
|
};
|
|
843
772
|
const onData = (chunk) => {
|
|
844
773
|
stderr += chunk.toString();
|
|
845
|
-
|
|
774
|
+
// Complete lines only: a pipe read can end mid-line, and matching the
|
|
775
|
+
// unterminated tail resolved with a truncated URL (`http://127.0.0.1:56`).
|
|
776
|
+
const lines = stderr.split('\n');
|
|
777
|
+
lines.pop();
|
|
778
|
+
const line = lines.find((l) => l.startsWith(LISTENING_PREFIX));
|
|
846
779
|
if (line && !settled) {
|
|
847
780
|
settled = true;
|
|
848
781
|
cleanup();
|
|
@@ -855,11 +788,8 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
|
|
|
855
788
|
settled = true;
|
|
856
789
|
cleanup();
|
|
857
790
|
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.
|
|
791
|
+
// The child already formatted its failure as `Error: <message>`;
|
|
792
|
+
// carrying that through made the parent print "Error: Error: ...".
|
|
863
793
|
stripErrorPrefix(stderr.trim()) ||
|
|
864
794
|
`The background dashboard process exited before it started listening (code ${code}).`));
|
|
865
795
|
};
|
|
@@ -881,12 +811,9 @@ export function samePath(a, b) {
|
|
|
881
811
|
return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
882
812
|
}
|
|
883
813
|
/**
|
|
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.
|
|
814
|
+
* The dashboards `--stop` may terminate without asking. Empty is the caller's
|
|
815
|
+
* cue to offer the pick-from-all list. A candidate with no recorded project
|
|
816
|
+
* never matches: `path.resolve('')` is the cwd, so a blank root would claim it.
|
|
890
817
|
*/
|
|
891
818
|
export function selectStopTargets(candidates, projectRoot) {
|
|
892
819
|
if (!projectRoot)
|
|
@@ -899,55 +826,46 @@ export function selectStopTargets(candidates, projectRoot) {
|
|
|
899
826
|
* `tospec/decisions/20260727_232950-one-dashboard-per-project.md`.
|
|
900
827
|
*
|
|
901
828
|
* 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.
|
|
829
|
+
* the two can never disagree about what is running.
|
|
906
830
|
*/
|
|
907
831
|
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.
|
|
832
|
+
// First match only: one is enough to refuse and point at.
|
|
911
833
|
return selectStopTargets(candidates, projectRoot)[0] ?? null;
|
|
912
834
|
}
|
|
913
835
|
/**
|
|
914
836
|
* 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
|
|
837
|
+
* per-project pid file if that still names this pid. A dead pid counts as
|
|
916
838
|
* already stopped.
|
|
917
839
|
*
|
|
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.
|
|
840
|
+
* Returns null on success, or the reason the signal was refused — the POSIX temp
|
|
841
|
+
* dir is shared, so a candidate can belong to another user.
|
|
922
842
|
*
|
|
923
|
-
* Limitation: SIGTERM;
|
|
924
|
-
*
|
|
843
|
+
* Limitation: SIGTERM; Windows maps it to TerminateProcess (hard kill), so the
|
|
844
|
+
* target's own cleanup won't run — we clear its files here.
|
|
925
845
|
*/
|
|
926
846
|
export async function stopRunningDashboard(d) {
|
|
927
847
|
try {
|
|
928
848
|
process.kill(d.pid);
|
|
929
849
|
}
|
|
930
850
|
catch (err) {
|
|
931
|
-
// ESRCH means it exited between listing and now: already stopped, so
|
|
932
|
-
//
|
|
851
|
+
// ESRCH means it exited between listing and now: already stopped, so still
|
|
852
|
+
// clear its records. Anything else (EPERM) is a real refusal.
|
|
933
853
|
const { code, message } = err;
|
|
934
854
|
if (code !== 'ESRCH')
|
|
935
855
|
return code ?? message;
|
|
936
856
|
}
|
|
937
857
|
await unregisterDashboard(d.pid);
|
|
938
858
|
// Only this dashboard's record: the path is per-project, so stopping one of
|
|
939
|
-
// two siblings must not clear the other's.
|
|
859
|
+
// two siblings must not clear the other's.
|
|
940
860
|
if (d.projectRoot)
|
|
941
861
|
clearPidRecordOwnedBy(pidFilePath(d.projectRoot), d.pid);
|
|
942
862
|
return null;
|
|
943
863
|
}
|
|
944
864
|
/**
|
|
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.
|
|
865
|
+
* CLI entry: allocates a free port, starts the server, prints the URL, and stays
|
|
866
|
+
* up until terminated. Port allocation reaps dead registry markers and skips
|
|
867
|
+
* ports live dashboards hold; the bind then advances past any the registry
|
|
868
|
+
* didn't know about.
|
|
951
869
|
*/
|
|
952
870
|
export async function runDashboard(projectRoot, opts = {}) {
|
|
953
871
|
const used = await reapAndCollectPorts();
|
|
@@ -956,19 +874,21 @@ export async function runDashboard(projectRoot, opts = {}) {
|
|
|
956
874
|
start++;
|
|
957
875
|
const { server, url, port } = await startDashboardServer(projectRoot, { ...opts, port: start });
|
|
958
876
|
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.
|
|
877
|
+
// Detached child only: `detachDashboard` waits on stderr for exactly this line
|
|
878
|
+
// before it reports anything. A foreground run has no parent watching.
|
|
962
879
|
if (process.env.TOSPEC_DASHBOARD_PIDFILE) {
|
|
880
|
+
// The parent closes its end of this pipe once it reads the line below and
|
|
881
|
+
// then exits, so every later stderr write (a config warning on
|
|
882
|
+
// /api/activity, a failed --open) hits EPIPE. Unhandled, that error killed
|
|
883
|
+
// the background dashboard a few writes in; handled, the writes are dropped.
|
|
884
|
+
process.stderr.on('error', () => { });
|
|
963
885
|
console.error(`${LISTENING_PREFIX}${url}`);
|
|
964
886
|
}
|
|
965
887
|
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.
|
|
888
|
+
// Every dashboard records itself, foreground included: `--detach` can only
|
|
889
|
+
// refuse for one that left a record, and a registry marker alone can be
|
|
890
|
+
// reaped out from under a live server. The child is handed its parent's path
|
|
891
|
+
// so the two cannot disagree about where the file lives.
|
|
972
892
|
const pidFile = process.env.TOSPEC_DASHBOARD_PIDFILE ?? pidFilePath(projectRoot);
|
|
973
893
|
await writePidRecord(pidFile, {
|
|
974
894
|
pid: process.pid,
|
|
@@ -979,14 +899,16 @@ export async function runDashboard(projectRoot, opts = {}) {
|
|
|
979
899
|
if (opts.open) {
|
|
980
900
|
openBrowser(url);
|
|
981
901
|
}
|
|
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.
|
|
902
|
+
// Normal termination removes this dashboard's own records. Windows hard-kills
|
|
903
|
+
// on `--stop`, so there `stopRunningDashboard` clears them instead.
|
|
986
904
|
const cleanup = () => {
|
|
987
905
|
// SSE keep-alive sockets would otherwise block close() from ever draining.
|
|
988
906
|
server.closeAllConnections?.();
|
|
989
|
-
|
|
907
|
+
// Sync, like clearPidRecordOwnedBy: fired and forgotten, the async
|
|
908
|
+
// readdir + rm could lose the race with process.exit, and a marker
|
|
909
|
+
// outliving its process reads as alive again once the OS reuses the pid —
|
|
910
|
+
// which `--stop` would then kill.
|
|
911
|
+
unregisterDashboardSync(process.pid);
|
|
990
912
|
clearPidRecordOwnedBy(pidFile, process.pid);
|
|
991
913
|
server.close(() => process.exit(0));
|
|
992
914
|
};
|