@codewalla_india/openspec 1.3.1 → 1.3.2
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/LICENSE +22 -22
- package/README.md +272 -191
- package/bin/openspec.js +5 -5
- package/dist/cli/index.js +99 -62
- package/dist/commands/change.d.ts +0 -2
- package/dist/commands/change.js +107 -86
- package/dist/commands/config.js +14 -16
- package/dist/commands/doctor.js +9 -4
- package/dist/commands/feedback.js +79 -30
- package/dist/commands/modify.d.ts +26 -0
- package/dist/commands/modify.js +147 -0
- package/dist/commands/schema.js +170 -91
- package/dist/commands/show.js +5 -2
- package/dist/commands/spec.js +49 -29
- package/dist/commands/store.js +15 -21
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +43 -20
- package/dist/commands/workflow/index.d.ts +2 -2
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/instructions.d.ts +6 -26
- package/dist/commands/workflow/instructions.js +183 -376
- package/dist/commands/workflow/new-change.d.ts +0 -4
- package/dist/commands/workflow/new-change.js +10 -28
- package/dist/commands/workflow/shared.d.ts +34 -19
- package/dist/commands/workflow/shared.js +34 -4
- package/dist/commands/workflow/status.js +10 -32
- package/dist/commands/workflow/templates.js +15 -5
- package/dist/commands/workset.d.ts +2 -2
- package/dist/commands/workset.js +19 -22
- package/dist/comprehension-quiz/fingerprint.d.ts +39 -0
- package/dist/comprehension-quiz/fingerprint.js +84 -0
- package/dist/comprehension-quiz/index.d.ts +42 -0
- package/dist/comprehension-quiz/index.js +47 -0
- package/dist/comprehension-quiz/pass-record.d.ts +44 -0
- package/dist/comprehension-quiz/pass-record.js +98 -0
- package/dist/comprehension-quiz/providers/anthropic.d.ts +34 -0
- package/dist/comprehension-quiz/providers/anthropic.js +53 -0
- package/dist/comprehension-quiz/providers/factory.d.ts +44 -0
- package/dist/comprehension-quiz/providers/factory.js +174 -0
- package/dist/comprehension-quiz/providers/interface.d.ts +59 -0
- package/dist/comprehension-quiz/providers/interface.js +63 -0
- package/dist/comprehension-quiz/providers/local.d.ts +34 -0
- package/dist/comprehension-quiz/providers/local.js +53 -0
- package/dist/comprehension-quiz/providers/ollama.d.ts +34 -0
- package/dist/comprehension-quiz/providers/ollama.js +53 -0
- package/dist/comprehension-quiz/providers/openai.d.ts +34 -0
- package/dist/comprehension-quiz/providers/openai.js +53 -0
- package/dist/comprehension-quiz/question-generator.d.ts +69 -0
- package/dist/comprehension-quiz/question-generator.js +141 -0
- package/dist/comprehension-quiz/quiz-executor.d.ts +70 -0
- package/dist/comprehension-quiz/quiz-executor.js +165 -0
- package/dist/comprehension-quiz/types.d.ts +115 -0
- package/dist/comprehension-quiz/types.js +11 -0
- package/dist/core/archive.d.ts +14 -1
- package/dist/core/archive.js +1350 -190
- package/dist/core/artifact-graph/graph.d.ts +28 -9
- package/dist/core/artifact-graph/graph.js +94 -43
- package/dist/core/artifact-graph/index.d.ts +1 -1
- package/dist/core/artifact-graph/index.js +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +30 -3
- package/dist/core/artifact-graph/instruction-loader.js +70 -12
- package/dist/core/artifact-graph/outputs.d.ts +1 -0
- package/dist/core/artifact-graph/outputs.js +67 -5
- package/dist/core/artifact-graph/resolver.d.ts +14 -0
- package/dist/core/artifact-graph/resolver.js +69 -15
- package/dist/core/artifact-graph/types.js +21 -3
- package/dist/core/available-tools.d.ts +2 -2
- package/dist/core/available-tools.js +14 -3
- package/dist/core/change-metadata/schema.d.ts +2 -0
- package/dist/core/change-metadata/schema.js +13 -0
- package/dist/core/change-status-policy.d.ts +10 -1
- package/dist/core/change-status-policy.js +30 -2
- package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
- package/dist/core/command-generation/adapters/amazon-q.js +11 -5
- package/dist/core/command-generation/adapters/antigravity.js +6 -5
- package/dist/core/command-generation/adapters/auggie.js +7 -6
- package/dist/core/command-generation/adapters/bob.d.ts +5 -1
- package/dist/core/command-generation/adapters/bob.js +11 -10
- package/dist/core/command-generation/adapters/claude.d.ts +1 -1
- package/dist/core/command-generation/adapters/claude.js +12 -17
- package/dist/core/command-generation/adapters/cline.js +5 -5
- package/dist/core/command-generation/adapters/codebuddy.js +8 -7
- package/dist/core/command-generation/adapters/continue.js +8 -7
- package/dist/core/command-generation/adapters/costrict.js +7 -6
- package/dist/core/command-generation/adapters/crush.js +9 -9
- package/dist/core/command-generation/adapters/cursor.js +8 -8
- package/dist/core/command-generation/adapters/devin.d.ts +19 -0
- package/dist/core/command-generation/adapters/devin.js +36 -0
- package/dist/core/command-generation/adapters/factory.js +7 -6
- package/dist/core/command-generation/adapters/gemini.js +40 -5
- package/dist/core/command-generation/adapters/github-copilot.js +6 -5
- package/dist/core/command-generation/adapters/iflow.js +9 -8
- package/dist/core/command-generation/adapters/index.d.ts +4 -2
- package/dist/core/command-generation/adapters/index.js +4 -2
- package/dist/core/command-generation/adapters/junie.js +6 -5
- package/dist/core/command-generation/adapters/kilocode.js +1 -1
- package/dist/core/command-generation/adapters/kiro.js +6 -5
- package/dist/core/command-generation/adapters/lingma.js +9 -9
- package/dist/core/command-generation/adapters/oh-my-pi.d.ts +21 -0
- package/dist/core/command-generation/adapters/oh-my-pi.js +42 -0
- package/dist/core/command-generation/adapters/opencode.js +6 -8
- package/dist/core/command-generation/adapters/pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/pi.js +7 -10
- package/dist/core/command-generation/adapters/qoder.js +9 -9
- package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
- package/dist/core/command-generation/adapters/qwen.js +14 -9
- package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
- package/dist/core/command-generation/adapters/roocode.js +9 -9
- package/dist/core/command-generation/adapters/trae.d.ts +13 -0
- package/dist/core/command-generation/adapters/trae.js +28 -0
- package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
- package/dist/core/command-generation/adapters/zcode.js +33 -0
- package/dist/core/command-generation/generator.d.ts +8 -0
- package/dist/core/command-generation/generator.js +15 -1
- package/dist/core/command-generation/index.d.ts +1 -1
- package/dist/core/command-generation/index.js +1 -1
- package/dist/core/command-generation/invocation.d.ts +73 -0
- package/dist/core/command-generation/invocation.js +77 -0
- package/dist/core/command-generation/registry.js +8 -4
- package/dist/core/command-generation/types.d.ts +9 -2
- package/dist/core/command-generation/yaml.d.ts +20 -6
- package/dist/core/command-generation/yaml.js +31 -21
- package/dist/core/command-surface.d.ts +15 -0
- package/dist/core/command-surface.js +33 -0
- package/dist/core/completions/command-registry.js +16 -61
- package/dist/core/completions/generators/bash-generator.js +41 -41
- package/dist/core/completions/generators/fish-generator.js +7 -7
- package/dist/core/completions/generators/powershell-generator.js +44 -37
- package/dist/core/completions/generators/zsh-generator.js +33 -33
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
- package/dist/core/completions/installers/zsh-installer.js +20 -4
- package/dist/core/completions/templates/bash-templates.js +24 -24
- package/dist/core/completions/templates/fish-templates.js +38 -38
- package/dist/core/completions/templates/powershell-templates.js +28 -28
- package/dist/core/completions/templates/zsh-templates.js +39 -39
- package/dist/core/config-prompts.d.ts +1 -1
- package/dist/core/config-prompts.js +14 -1
- package/dist/core/config-schema.d.ts +10 -1
- package/dist/core/config-schema.js +67 -1
- package/dist/core/config.d.ts +15 -0
- package/dist/core/config.js +46 -5
- package/dist/core/file-state.js +56 -24
- package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
- package/dist/core/github-copilot/cloud-agent.js +497 -0
- package/dist/core/global-config.d.ts +16 -0
- package/dist/core/init.d.ts +58 -0
- package/dist/core/init.js +474 -73
- package/dist/core/legacy-cleanup.d.ts +59 -0
- package/dist/core/legacy-cleanup.js +253 -24
- package/dist/core/list.js +23 -16
- package/dist/core/migration.d.ts +73 -1
- package/dist/core/migration.js +360 -11
- package/dist/core/modify/conflict-detector.d.ts +26 -0
- package/dist/core/modify/conflict-detector.js +62 -0
- package/dist/core/modify/dependency-propagator.d.ts +38 -0
- package/dist/core/modify/dependency-propagator.js +103 -0
- package/dist/core/modify/history-tracker.d.ts +34 -0
- package/dist/core/modify/history-tracker.js +114 -0
- package/dist/core/modify/index.d.ts +7 -0
- package/dist/core/modify/index.js +7 -0
- package/dist/core/onboarding-commands.d.ts +30 -0
- package/dist/core/onboarding-commands.js +38 -0
- package/dist/core/openspec-root.js +18 -15
- package/dist/core/parsers/change-parser.d.ts +17 -2
- package/dist/core/parsers/change-parser.js +35 -21
- package/dist/core/parsers/code-fence.d.ts +15 -0
- package/dist/core/parsers/code-fence.js +49 -0
- package/dist/core/parsers/markdown-parser.d.ts +0 -3
- package/dist/core/parsers/markdown-parser.js +7 -60
- package/dist/core/parsers/requirement-blocks.d.ts +29 -0
- package/dist/core/parsers/requirement-blocks.js +150 -31
- package/dist/core/parsers/requirement-text.d.ts +48 -0
- package/dist/core/parsers/requirement-text.js +98 -0
- package/dist/core/parsers/spec-structure.d.ts +1 -1
- package/dist/core/parsers/spec-structure.js +20 -30
- package/dist/core/profile-sync-drift.d.ts +0 -8
- package/dist/core/profile-sync-drift.js +49 -50
- package/dist/core/profiles.d.ts +2 -2
- package/dist/core/profiles.js +2 -2
- package/dist/core/project-config.d.ts +27 -18
- package/dist/core/project-config.js +101 -34
- package/dist/core/references.js +42 -4
- package/dist/core/relationship-health.d.ts +8 -0
- package/dist/core/relationship-health.js +13 -0
- package/dist/core/root-selection.d.ts +9 -6
- package/dist/core/root-selection.js +42 -5
- package/dist/core/schemas/base.schema.js +7 -2
- package/dist/core/shared/allowed-tools.d.ts +12 -0
- package/dist/core/shared/allowed-tools.js +12 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
- package/dist/core/shared/skill-content-equivalence.js +41 -0
- package/dist/core/shared/skill-generation.js +17 -13
- package/dist/core/shared/skill-paths.d.ts +13 -0
- package/dist/core/shared/skill-paths.js +23 -0
- package/dist/core/shared/tool-detection.d.ts +27 -7
- package/dist/core/shared/tool-detection.js +167 -37
- package/dist/core/shared-skill-target.d.ts +16 -0
- package/dist/core/shared-skill-target.js +156 -0
- package/dist/core/specs-apply.d.ts +80 -33
- package/dist/core/specs-apply.js +667 -145
- package/dist/core/store/git.d.ts +13 -0
- package/dist/core/store/git.js +24 -0
- package/dist/core/store/operations.js +20 -0
- package/dist/core/templates/skill-templates.d.ts +3 -2
- package/dist/core/templates/skill-templates.js +3 -2
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +186 -224
- package/dist/core/templates/workflows/archive-change.js +387 -260
- package/dist/core/templates/workflows/bulk-archive-change.js +655 -475
- package/dist/core/templates/workflows/continue-change.js +210 -219
- package/dist/core/templates/workflows/explore.js +479 -443
- package/dist/core/templates/workflows/feedback.js +97 -97
- package/dist/core/templates/workflows/ff-change.js +200 -191
- package/dist/core/templates/workflows/modify-change.d.ts +4 -1
- package/dist/core/templates/workflows/modify-change.js +237 -115
- package/dist/core/templates/workflows/new-change.js +127 -134
- package/dist/core/templates/workflows/onboard.js +548 -542
- package/dist/core/templates/workflows/propose.js +273 -217
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/sync-specs.js +500 -273
- package/dist/core/templates/workflows/update-change.d.ts +10 -0
- package/dist/core/templates/workflows/update-change.js +177 -0
- package/dist/core/templates/workflows/verify-change.js +324 -326
- package/dist/core/update.d.ts +40 -5
- package/dist/core/update.js +411 -96
- package/dist/core/validation/constants.d.ts +4 -1
- package/dist/core/validation/constants.js +4 -1
- package/dist/core/validation/plan-validator.d.ts +8 -0
- package/dist/core/validation/plan-validator.js +45 -0
- package/dist/core/validation/task-numbering.d.ts +16 -0
- package/dist/core/validation/task-numbering.js +57 -0
- package/dist/core/validation/validator.d.ts +27 -3
- package/dist/core/validation/validator.js +337 -48
- package/dist/core/version-check.d.ts +152 -0
- package/dist/core/version-check.js +731 -0
- package/dist/core/view.js +13 -18
- package/dist/prompts/searchable-multi-select.js +1 -1
- package/dist/telemetry/caller-detection.d.ts +27 -0
- package/dist/telemetry/caller-detection.js +85 -0
- package/dist/telemetry/caller-detection.test.d.ts +5 -0
- package/dist/telemetry/config.d.ts +48 -2
- package/dist/telemetry/config.js +113 -11
- package/dist/telemetry/config.test.d.ts +5 -0
- package/dist/telemetry/deduplication.d.ts +35 -0
- package/dist/telemetry/deduplication.js +82 -0
- package/dist/telemetry/deduplication.test.d.ts +5 -0
- package/dist/telemetry/identity.d.ts +62 -22
- package/dist/telemetry/identity.js +147 -102
- package/dist/telemetry/index.d.ts +126 -15
- package/dist/telemetry/index.js +569 -37
- package/dist/telemetry/sanitization.d.ts +40 -0
- package/dist/telemetry/sanitization.js +114 -0
- package/dist/telemetry/sanitization.test.d.ts +5 -0
- package/dist/telemetry/throttling.d.ts +20 -0
- package/dist/telemetry/throttling.js +70 -0
- package/dist/ui/welcome-screen.d.ts +9 -1
- package/dist/ui/welcome-screen.js +91 -38
- package/dist/utils/change-metadata.d.ts +43 -0
- package/dist/utils/change-metadata.js +123 -7
- package/dist/utils/change-utils.d.ts +10 -4
- package/dist/utils/change-utils.js +20 -13
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +16 -0
- package/dist/utils/command-references.d.ts +92 -6
- package/dist/utils/command-references.js +180 -7
- package/dist/utils/date.d.ts +8 -0
- package/dist/utils/date.js +13 -0
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +70 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/interactive.d.ts +23 -0
- package/dist/utils/interactive.js +32 -0
- package/dist/utils/item-discovery.d.ts +19 -0
- package/dist/utils/item-discovery.js +30 -48
- package/dist/utils/shell-detection.d.ts +1 -1
- package/dist/utils/shell-detection.js +64 -11
- package/dist/utils/spec-discovery.d.ts +40 -0
- package/dist/utils/spec-discovery.js +115 -0
- package/dist/utils/task-progress.d.ts +29 -1
- package/dist/utils/task-progress.js +109 -15
- package/package.json +96 -84
- package/schemas/spec-driven/schema.yaml +252 -184
- package/schemas/spec-driven/templates/design.md +19 -19
- package/schemas/spec-driven/templates/plan-template.md +91 -0
- package/schemas/spec-driven/templates/proposal.md +29 -24
- package/schemas/spec-driven/templates/spec.md +11 -8
- package/schemas/spec-driven/templates/tasks.md +9 -9
- package/scripts/postinstall.js +83 -83
- package/dist/core/command-generation/adapters/codex.d.ts +0 -16
- package/dist/core/command-generation/adapters/codex.js +0 -39
- package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
- package/dist/core/command-generation/adapters/windsurf.js +0 -38
- package/dist/core/comprehension/config.d.ts +0 -20
- package/dist/core/comprehension/config.js +0 -23
- package/dist/core/comprehension/fingerprint.d.ts +0 -14
- package/dist/core/comprehension/fingerprint.js +0 -33
- package/dist/core/comprehension/index.d.ts +0 -57
- package/dist/core/comprehension/index.js +0 -87
- package/dist/core/comprehension/pass-record.d.ts +0 -29
- package/dist/core/comprehension/pass-record.js +0 -64
- package/dist/core/comprehension/stats.d.ts +0 -35
- package/dist/core/comprehension/stats.js +0 -133
- package/dist/core/templates/workflows/comprehension-guidance.d.ts +0 -9
- package/dist/core/templates/workflows/comprehension-guidance.js +0 -73
- package/dist/core/templates/workflows/mcp-guidance.d.ts +0 -13
- package/dist/core/templates/workflows/mcp-guidance.js +0 -131
- package/dist/core/templates/workflows/user-prompt-guidance.d.ts +0 -16
- package/dist/core/templates/workflows/user-prompt-guidance.js +0 -48
- package/dist/telemetry/caller.d.ts +0 -5
- package/dist/telemetry/caller.js +0 -29
- package/dist/telemetry/client.d.ts +0 -27
- package/dist/telemetry/client.js +0 -127
- package/dist/telemetry/command-context.d.ts +0 -13
- package/dist/telemetry/command-context.js +0 -59
- package/dist/telemetry/comprehension.d.ts +0 -44
- package/dist/telemetry/comprehension.js +0 -105
- package/dist/telemetry/content.d.ts +0 -10
- package/dist/telemetry/content.js +0 -56
- package/dist/telemetry/git-stats.d.ts +0 -12
- package/dist/telemetry/git-stats.js +0 -69
- package/dist/telemetry/identify-cache.d.ts +0 -7
- package/dist/telemetry/identify-cache.js +0 -47
- package/dist/telemetry/input.d.ts +0 -17
- package/dist/telemetry/input.js +0 -68
- package/dist/telemetry/marker.d.ts +0 -37
- package/dist/telemetry/marker.js +0 -67
- package/dist/telemetry/workflow.d.ts +0 -85
- package/dist/telemetry/workflow.js +0 -318
- package/schemas/spec-driven/templates/plan.md +0 -19
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import https from 'https';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { isCiEnvironment } from '../utils/ci.js';
|
|
8
|
+
import { getGlobalConfig } from './global-config.js';
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
|
|
11
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 1500;
|
|
13
|
+
const MAX_RESPONSE_BYTES = 256 * 1024;
|
|
14
|
+
const VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
15
|
+
const MAX_REDIRECTS = 3;
|
|
16
|
+
/**
|
|
17
|
+
* A version we are willing to print. The registry only ever serves SemVer here,
|
|
18
|
+
* so anything else is either a broken mirror or a hostile response — and since
|
|
19
|
+
* this string lands in the terminal next to an install command, an unvalidated
|
|
20
|
+
* one could smuggle ANSI cursor controls and repaint the lines around it.
|
|
21
|
+
*/
|
|
22
|
+
const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
|
|
23
|
+
/**
|
|
24
|
+
* The check is opt-out and must never get in the way: no network in CI or
|
|
25
|
+
* tests, an explicit escape hatch for anyone offline or air-gapped, and the
|
|
26
|
+
* same privacy signals telemetry already honors — a user who set DO_NOT_TRACK
|
|
27
|
+
* or telemetry.enabled false did not agree to a different outbound request.
|
|
28
|
+
*/
|
|
29
|
+
function isCheckEnabled() {
|
|
30
|
+
if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined)
|
|
31
|
+
return false;
|
|
32
|
+
if (process.env.DO_NOT_TRACK === '1')
|
|
33
|
+
return false;
|
|
34
|
+
if (process.env.OPENSPEC_TELEMETRY === '0')
|
|
35
|
+
return false;
|
|
36
|
+
if (isCiEnvironment())
|
|
37
|
+
return false;
|
|
38
|
+
if (process.env.NODE_ENV === 'test')
|
|
39
|
+
return false;
|
|
40
|
+
// Same config opt-out as telemetry (env remains the hard override above).
|
|
41
|
+
if (getGlobalConfig().telemetry?.enabled === false)
|
|
42
|
+
return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The registry to ask: only the environment variable npm exports (under
|
|
47
|
+
* `npm run`, or an explicit export). Deliberately not a `registry=` line from
|
|
48
|
+
* any .npmrc — letting file contents choose the destination of an outbound
|
|
49
|
+
* request is a flow worth avoiding for a convenience this small, and a project
|
|
50
|
+
* file would travel with a cloned repository. Anyone on a private mirror can
|
|
51
|
+
* export `npm_config_registry`, or turn the check off entirely.
|
|
52
|
+
*/
|
|
53
|
+
export function registryUrl() {
|
|
54
|
+
const configured = process.env.npm_config_registry?.trim();
|
|
55
|
+
const base = configured && /^https?:\/\//i.test(configured) ? configured : DEFAULT_REGISTRY;
|
|
56
|
+
return `${base.replace(/\/+$/, '')}/${PACKAGE_NAME}/latest`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Compares two prerelease tags per SemVer: dot-separated identifiers compared
|
|
60
|
+
* one by one, numeric identifiers numerically (so beta.10 > beta.2), numeric
|
|
61
|
+
* ranking below alphanumeric, and a longer identifier list winning ties.
|
|
62
|
+
*/
|
|
63
|
+
function comparePrerelease(a, b) {
|
|
64
|
+
if (a === b)
|
|
65
|
+
return 0;
|
|
66
|
+
if (a === '')
|
|
67
|
+
return 1;
|
|
68
|
+
if (b === '')
|
|
69
|
+
return -1;
|
|
70
|
+
const left = a.split('.');
|
|
71
|
+
const right = b.split('.');
|
|
72
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
73
|
+
const l = left[i];
|
|
74
|
+
const r = right[i];
|
|
75
|
+
if (l === undefined)
|
|
76
|
+
return -1;
|
|
77
|
+
if (r === undefined)
|
|
78
|
+
return 1;
|
|
79
|
+
const lNumeric = /^\d+$/.test(l);
|
|
80
|
+
const rNumeric = /^\d+$/.test(r);
|
|
81
|
+
if (lNumeric && rNumeric) {
|
|
82
|
+
const diff = Number.parseInt(l, 10) - Number.parseInt(r, 10);
|
|
83
|
+
if (diff !== 0)
|
|
84
|
+
return diff > 0 ? 1 : -1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (lNumeric !== rNumeric)
|
|
88
|
+
return lNumeric ? -1 : 1;
|
|
89
|
+
if (l !== r)
|
|
90
|
+
return l > r ? 1 : -1;
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Compares two semver-ish versions. Returns 1 when a > b, -1 when a < b, 0
|
|
96
|
+
* otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
|
|
97
|
+
*/
|
|
98
|
+
export function compareVersions(a, b) {
|
|
99
|
+
const parse = (version) => {
|
|
100
|
+
const withoutBuild = version.trim().replace(/^v/, '').split('+', 1)[0] ?? '';
|
|
101
|
+
const separator = withoutBuild.indexOf('-');
|
|
102
|
+
const core = separator === -1 ? withoutBuild : withoutBuild.slice(0, separator);
|
|
103
|
+
const prerelease = separator === -1 ? '' : withoutBuild.slice(separator + 1);
|
|
104
|
+
const parts = core.split('.').map((n) => Number.parseInt(n, 10));
|
|
105
|
+
return {
|
|
106
|
+
numbers: [parts[0] || 0, parts[1] || 0, parts[2] || 0],
|
|
107
|
+
prerelease,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
const left = parse(a);
|
|
111
|
+
const right = parse(b);
|
|
112
|
+
for (let i = 0; i < 3; i++) {
|
|
113
|
+
if (left.numbers[i] > right.numbers[i])
|
|
114
|
+
return 1;
|
|
115
|
+
if (left.numbers[i] < right.numbers[i])
|
|
116
|
+
return -1;
|
|
117
|
+
}
|
|
118
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Reads the `latest` dist-tag. Sends no custom Accept header: the registry
|
|
122
|
+
* answers `/<pkg>/latest` with 406 for npm's abbreviated-metadata type, which
|
|
123
|
+
* it only serves on the full packument.
|
|
124
|
+
*
|
|
125
|
+
* Uses node:http(s) rather than fetch so the timeout can destroy the socket.
|
|
126
|
+
* Aborting a fetch that is still completing its TCP handshake — a firewall
|
|
127
|
+
* dropping packets, a captive portal — leaves the connect handle open and the
|
|
128
|
+
* CLI cannot exit until the OS gives up, long after the hint has printed.
|
|
129
|
+
*/
|
|
130
|
+
function fetchLatestVersion() {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
let settled = false;
|
|
133
|
+
let timer;
|
|
134
|
+
const finish = (version) => {
|
|
135
|
+
if (settled)
|
|
136
|
+
return;
|
|
137
|
+
settled = true;
|
|
138
|
+
if (timer)
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
resolve(version);
|
|
141
|
+
};
|
|
142
|
+
let url;
|
|
143
|
+
try {
|
|
144
|
+
url = new URL(registryUrl());
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
resolve(null);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Mirrors and corporate front-ends redirect; without following one the
|
|
151
|
+
// check would be permanently and silently dead for them.
|
|
152
|
+
let redirectsLeft = MAX_REDIRECTS;
|
|
153
|
+
// The budget timer must tear down whichever request is open when it
|
|
154
|
+
// fires. Closing over the first hop's request would leave a redirected
|
|
155
|
+
// socket alive: a target that trickles bytes keeps resetting its idle
|
|
156
|
+
// timeout, and only the body-size cap would end it.
|
|
157
|
+
let activeRequest;
|
|
158
|
+
const send = (target) => {
|
|
159
|
+
const request = (target.protocol === 'http:' ? http : https).get(target, { timeout: REQUEST_TIMEOUT_MS }, (response) => {
|
|
160
|
+
const status = response.statusCode ?? 0;
|
|
161
|
+
const location = response.headers.location;
|
|
162
|
+
if (status >= 300 && status < 400 && location) {
|
|
163
|
+
response.resume();
|
|
164
|
+
request.destroy();
|
|
165
|
+
if (redirectsLeft <= 0) {
|
|
166
|
+
finish(null);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
redirectsLeft -= 1;
|
|
170
|
+
try {
|
|
171
|
+
const next = new URL(location, target);
|
|
172
|
+
// Never follow a downgrade to plain http: a MITM on the reply
|
|
173
|
+
// would control the "newer version" answer.
|
|
174
|
+
const downgrade = target.protocol === 'https:' && next.protocol === 'http:';
|
|
175
|
+
if (!downgrade && (next.protocol === 'http:' || next.protocol === 'https:')) {
|
|
176
|
+
send(next);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Unparseable Location.
|
|
182
|
+
}
|
|
183
|
+
finish(null);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (status !== 200) {
|
|
187
|
+
response.resume();
|
|
188
|
+
request.destroy();
|
|
189
|
+
finish(null);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
let body = '';
|
|
193
|
+
response.setEncoding('utf-8');
|
|
194
|
+
response.on('data', (chunk) => {
|
|
195
|
+
body += chunk;
|
|
196
|
+
// The dist-tag document is small; refuse to buffer a firehose.
|
|
197
|
+
if (body.length > MAX_RESPONSE_BYTES) {
|
|
198
|
+
request.destroy();
|
|
199
|
+
finish(null);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
response.on('end', () => {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(body);
|
|
205
|
+
const version = parsed.version;
|
|
206
|
+
finish(typeof version === 'string' && SAFE_VERSION.test(version) ? version : null);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
finish(null);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
response.on('error', () => finish(null));
|
|
213
|
+
});
|
|
214
|
+
activeRequest = request;
|
|
215
|
+
request.on('timeout', () => {
|
|
216
|
+
request.destroy();
|
|
217
|
+
finish(null);
|
|
218
|
+
});
|
|
219
|
+
request.on('error', () => finish(null));
|
|
220
|
+
// One budget for the whole exchange, redirects included.
|
|
221
|
+
if (!timer) {
|
|
222
|
+
timer = setTimeout(() => {
|
|
223
|
+
activeRequest?.destroy();
|
|
224
|
+
finish(null);
|
|
225
|
+
}, REQUEST_TIMEOUT_MS);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
send(url);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Returns the published version when the installed CLI is behind it, otherwise
|
|
233
|
+
* null. Never throws and never blocks for longer than the request timeout.
|
|
234
|
+
*/
|
|
235
|
+
export async function getAvailableCliUpdate() {
|
|
236
|
+
if (!isCheckEnabled())
|
|
237
|
+
return null;
|
|
238
|
+
try {
|
|
239
|
+
const latest = await fetchLatestVersion();
|
|
240
|
+
if (!latest)
|
|
241
|
+
return null;
|
|
242
|
+
return compareVersions(latest, OPENSPEC_VERSION) > 0 ? latest : null;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Directory the running CLI was loaded from, or null when it cannot be
|
|
250
|
+
* resolved. Shown in the upgrade hint so anyone who upgraded but still runs an
|
|
251
|
+
* old binary — a stale pnpm/volta/npx shim, or two installs on PATH — can see
|
|
252
|
+
* which copy is actually answering.
|
|
253
|
+
*/
|
|
254
|
+
export function getInstallDir() {
|
|
255
|
+
try {
|
|
256
|
+
return path.dirname(require.resolve('../../package.json'));
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* True when the running CLI resolves from a `node_modules` belonging to the
|
|
264
|
+
* project being updated or any ancestor of it — the hoisted-root layout npm and
|
|
265
|
+
* pnpm workspaces produce. Anchored on the target path rather than the working
|
|
266
|
+
* directory, since `openspec update <path>` and running from a sub-package are
|
|
267
|
+
* both normal. Never throws: process.cwd() fails when the directory has been
|
|
268
|
+
* deleted, and a wrong upgrade hint must not take down a successful update.
|
|
269
|
+
*/
|
|
270
|
+
export function isProjectLocalInstall(installDir, projectPath = '.') {
|
|
271
|
+
if (!installDir)
|
|
272
|
+
return false;
|
|
273
|
+
// Windows paths differ in case and drive-letter casing between sources.
|
|
274
|
+
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
275
|
+
try {
|
|
276
|
+
let dir = path.resolve(projectPath);
|
|
277
|
+
const target = normalize(installDir);
|
|
278
|
+
for (;;) {
|
|
279
|
+
if (target.startsWith(normalize(path.join(dir, 'node_modules') + path.sep))) {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
const parent = path.dirname(dir);
|
|
283
|
+
if (parent === dir)
|
|
284
|
+
return false;
|
|
285
|
+
dir = parent;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* True for the throwaway caches npx/pnpm dlx/bunx unpack into. Telling those
|
|
294
|
+
* users to install globally would create the second copy on PATH they were
|
|
295
|
+
* deliberately avoiding.
|
|
296
|
+
*/
|
|
297
|
+
export function isEphemeralRunnerInstall(installDir) {
|
|
298
|
+
if (!installDir)
|
|
299
|
+
return false;
|
|
300
|
+
const segments = installDir.split(/[\\/]/).map((segment) => segment.toLowerCase());
|
|
301
|
+
return segments.some((segment, i) => segment === '_npx' ||
|
|
302
|
+
segment === '_bunx' ||
|
|
303
|
+
// Only a package manager's own cache, never a user directory that
|
|
304
|
+
// happens to be called "dlx". Windows uses pnpm-cache for the same job.
|
|
305
|
+
(segment === 'dlx' &&
|
|
306
|
+
['pnpm', 'bun', '.pnpm', 'pnpm-cache', 'bun-cache'].includes(segments[i - 1] ?? '')));
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Directories npm installs global packages into. Derived from the running node
|
|
310
|
+
* rather than by shelling out to `npm prefix -g`, which would cost more than
|
|
311
|
+
* the version check itself. Only a hint: `process.execPath` is realpath'd, so
|
|
312
|
+
* on Homebrew it lands in the Cellar rather than the brew prefix — which is
|
|
313
|
+
* why the install's own layout is the primary signal below.
|
|
314
|
+
*/
|
|
315
|
+
export function npmGlobalRoots() {
|
|
316
|
+
const roots = [];
|
|
317
|
+
const nodeDir = path.dirname(process.execPath);
|
|
318
|
+
if (process.platform === 'win32') {
|
|
319
|
+
roots.push(path.join(nodeDir, 'node_modules'));
|
|
320
|
+
if (process.env.APPDATA) {
|
|
321
|
+
roots.push(path.join(process.env.APPDATA, 'npm', 'node_modules'));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
roots.push(path.resolve(nodeDir, '..', 'lib', 'node_modules'));
|
|
326
|
+
}
|
|
327
|
+
const prefix = process.env.npm_config_prefix;
|
|
328
|
+
if (prefix) {
|
|
329
|
+
roots.push(process.platform === 'win32'
|
|
330
|
+
? path.join(prefix, 'node_modules')
|
|
331
|
+
: path.join(prefix, 'lib', 'node_modules'));
|
|
332
|
+
}
|
|
333
|
+
return roots;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The prefix of an npm global install, read from the install's own shape:
|
|
337
|
+
* `<prefix>/lib/node_modules/<pkg>` on POSIX, `<prefix>/node_modules/<pkg>` on
|
|
338
|
+
* Windows. Self-describing, so it holds for Homebrew, nvm, Debian and anywhere
|
|
339
|
+
* else npm's prefix is not derivable from the node binary. Null when the
|
|
340
|
+
* layout does not match.
|
|
341
|
+
*/
|
|
342
|
+
export function npmPrefixFromInstallDir(installDir) {
|
|
343
|
+
if (!installDir)
|
|
344
|
+
return null;
|
|
345
|
+
let dir = installDir;
|
|
346
|
+
for (;;) {
|
|
347
|
+
const parent = path.dirname(dir);
|
|
348
|
+
if (parent === dir)
|
|
349
|
+
return null;
|
|
350
|
+
if (path.basename(dir).toLowerCase() === 'node_modules')
|
|
351
|
+
break;
|
|
352
|
+
dir = parent;
|
|
353
|
+
}
|
|
354
|
+
const container = path.dirname(dir);
|
|
355
|
+
if (process.platform === 'win32')
|
|
356
|
+
return container;
|
|
357
|
+
// POSIX npm always nests the root under lib/.
|
|
358
|
+
return path.basename(container).toLowerCase() === 'lib' ? path.dirname(container) : null;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* True only when npm itself owns this copy. Everything else — a pnpm, bun,
|
|
362
|
+
* yarn or volta global — would be made worse by `npm install -g`, which adds a
|
|
363
|
+
* second copy that may not even be the one on PATH.
|
|
364
|
+
*/
|
|
365
|
+
export function isNpmGlobalInstall(installDir, roots = npmGlobalRoots()) {
|
|
366
|
+
if (!installDir)
|
|
367
|
+
return false;
|
|
368
|
+
// Another manager's layout can still look like npm's (volta nests a whole
|
|
369
|
+
// node install), so who owns it is decided before where it sits.
|
|
370
|
+
if (detectPackageManager(installDir) !== 'npm')
|
|
371
|
+
return false;
|
|
372
|
+
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
373
|
+
const target = normalize(installDir);
|
|
374
|
+
if (roots.some((root) => target.startsWith(normalize(root + path.sep))))
|
|
375
|
+
return true;
|
|
376
|
+
// The derived roots miss any prefix that is not beside the node binary, so
|
|
377
|
+
// fall back to the install's own shape plus the bin directory npm would
|
|
378
|
+
// have written the shim into.
|
|
379
|
+
const prefix = npmPrefixFromInstallDir(installDir);
|
|
380
|
+
if (!prefix)
|
|
381
|
+
return false;
|
|
382
|
+
try {
|
|
383
|
+
// Corroborate with something npm itself wrote: the bin dir on POSIX, the
|
|
384
|
+
// .cmd shim on Windows. The prefix alone proves nothing — it is just the
|
|
385
|
+
// parent of the node_modules dir the CLI resolved from, so a hand-copied
|
|
386
|
+
// portable tree would pass and be offered an npm upgrade it never had.
|
|
387
|
+
return fs.existsSync(process.platform === 'win32' ? path.join(prefix, 'openspec.cmd') : path.join(prefix, 'bin'));
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* True when the CLI is running from a clone rather than an install. Upgrade
|
|
395
|
+
* advice is meaningless there: the version is whatever the branch says.
|
|
396
|
+
*/
|
|
397
|
+
export function isSourceCheckout(installDir) {
|
|
398
|
+
if (!installDir)
|
|
399
|
+
return false;
|
|
400
|
+
try {
|
|
401
|
+
return fs.existsSync(path.join(installDir, '.git'));
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The package manager that owns this copy, so the printed command is one the
|
|
409
|
+
* user's setup will actually honor.
|
|
410
|
+
*/
|
|
411
|
+
export function detectPackageManager(installDir) {
|
|
412
|
+
// Lowercased because the Windows directories are capitalized and undotted:
|
|
413
|
+
// %LOCALAPPDATA%\\Volta, \\Yarn\\Data, \\pnpm-cache.
|
|
414
|
+
const segments = (installDir ?? '').split(/[\\/]/).map((segment) => segment.toLowerCase());
|
|
415
|
+
const has = (...names) => names.some((name) => segments.includes(name));
|
|
416
|
+
// The undotted spelling exists for Windows (%LOCALAPPDATA%\Volta), whose
|
|
417
|
+
// layout nests tools\image; require both segments so a user or project
|
|
418
|
+
// directory merely named "volta" (even one with its own "tools" dir) does
|
|
419
|
+
// not steal the install.
|
|
420
|
+
if (has('.volta') || (has('volta') && has('tools') && has('image')))
|
|
421
|
+
return 'volta';
|
|
422
|
+
if (has('.bun'))
|
|
423
|
+
return 'bun';
|
|
424
|
+
// These two need a corroborating segment: a directory merely named "pnpm" or
|
|
425
|
+
// "yarn" (a user's home, a project) is not a global install of one.
|
|
426
|
+
if (has('.pnpm-global', 'pnpm-cache'))
|
|
427
|
+
return 'pnpm';
|
|
428
|
+
if (has('pnpm') && has('global', 'dlx', 'store'))
|
|
429
|
+
return 'pnpm';
|
|
430
|
+
if (has('.yarn') || (has('yarn') && has('global')))
|
|
431
|
+
return 'yarn';
|
|
432
|
+
return 'npm';
|
|
433
|
+
}
|
|
434
|
+
const GLOBAL_UPGRADE_COMMANDS = {
|
|
435
|
+
npm: `npm install -g ${PACKAGE_NAME}@latest`,
|
|
436
|
+
pnpm: `pnpm add -g ${PACKAGE_NAME}@latest`,
|
|
437
|
+
bun: `bun add -g ${PACKAGE_NAME}@latest`,
|
|
438
|
+
yarn: `yarn global add ${PACKAGE_NAME}@latest`,
|
|
439
|
+
volta: `volta install ${PACKAGE_NAME}@latest`,
|
|
440
|
+
};
|
|
441
|
+
/**
|
|
442
|
+
* Builds the hint, with the upgrade command chosen for how this copy of the CLI
|
|
443
|
+
* was installed. Pure so every branch is assertable.
|
|
444
|
+
*/
|
|
445
|
+
export function buildCliUpdateLines(latestVersion, installDir, projectPath, options = {}) {
|
|
446
|
+
const lines = [`A newer OpenSpec CLI is available (v${OPENSPEC_VERSION} → v${latestVersion}).`];
|
|
447
|
+
// Omitted when we are about to offer to run it — printing a command and then
|
|
448
|
+
// asking to run that same command reads like the user has to do both.
|
|
449
|
+
if (options.withCommand !== false) {
|
|
450
|
+
lines.push(...buildUpgradeCommandLines(installDir, projectPath));
|
|
451
|
+
}
|
|
452
|
+
if (installDir) {
|
|
453
|
+
lines.push(` Running from: ${installDir}`);
|
|
454
|
+
}
|
|
455
|
+
return lines;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The upgrade command for however this copy was installed, plus the reminder
|
|
459
|
+
* that instruction files come from the CLI and so need a second pass.
|
|
460
|
+
*/
|
|
461
|
+
export function buildUpgradeCommandLines(installDir, projectPath) {
|
|
462
|
+
const lines = [];
|
|
463
|
+
if (isEphemeralRunnerInstall(installDir)) {
|
|
464
|
+
// That command *is* the update, so there is nothing to run afterwards.
|
|
465
|
+
lines.push(` npx ${PACKAGE_NAME}@latest update`);
|
|
466
|
+
return lines;
|
|
467
|
+
}
|
|
468
|
+
if (isProjectLocalInstall(installDir, projectPath)) {
|
|
469
|
+
// Its package manager owns the lockfile; naming npm could be wrong.
|
|
470
|
+
lines.push(` Update the ${PACKAGE_NAME} dependency in this project.`);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
lines.push(` ${GLOBAL_UPGRADE_COMMANDS[detectPackageManager(installDir)]}`);
|
|
474
|
+
}
|
|
475
|
+
lines.push(' Then run "openspec update" again to pick up new workflows.');
|
|
476
|
+
return lines;
|
|
477
|
+
}
|
|
478
|
+
// cross-spawn resolves npm's shim on Windows, where spawning "npm" directly
|
|
479
|
+
// fails. Loaded lazily so ordinary runs skip its module graph.
|
|
480
|
+
let cachedSpawn;
|
|
481
|
+
function loadSpawn() {
|
|
482
|
+
if (cachedSpawn === undefined) {
|
|
483
|
+
cachedSpawn = require('cross-spawn');
|
|
484
|
+
}
|
|
485
|
+
return cachedSpawn;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Whether we can run the upgrade for the user instead of only printing it.
|
|
489
|
+
*
|
|
490
|
+
* Only an npm-owned global install qualifies, because `npm install -g` is the
|
|
491
|
+
* only command we run: a pnpm/bun/yarn/volta global would get a second copy
|
|
492
|
+
* that may not be the one on PATH, a project dependency belongs to that
|
|
493
|
+
* project's package manager, an npx/dlx cache has nothing to upgrade, and a
|
|
494
|
+
* source checkout is not an install at all.
|
|
495
|
+
*/
|
|
496
|
+
export function canSelfUpgrade(installDir, projectPath) {
|
|
497
|
+
if (!installDir)
|
|
498
|
+
return false;
|
|
499
|
+
if (isEphemeralRunnerInstall(installDir))
|
|
500
|
+
return false;
|
|
501
|
+
// Both anchors matter: `openspec update ../other` from a project that owns
|
|
502
|
+
// the CLI as a dependency is still a project-local install.
|
|
503
|
+
if (isProjectLocalInstall(installDir, projectPath))
|
|
504
|
+
return false;
|
|
505
|
+
if (isProjectLocalInstall(installDir))
|
|
506
|
+
return false;
|
|
507
|
+
if (isSourceCheckout(installDir))
|
|
508
|
+
return false;
|
|
509
|
+
return isNpmGlobalInstall(installDir);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Whether to offer the upgrade rather than just print the command. Kept here,
|
|
513
|
+
* as a pure function of the environment, because the interesting mistakes live
|
|
514
|
+
* in this decision: offering where `npm install -g` cannot help, or asking a
|
|
515
|
+
* question no one can answer.
|
|
516
|
+
*/
|
|
517
|
+
export function shouldOfferUpgrade(params) {
|
|
518
|
+
// A prompt written to a redirected stdout is a question the user never sees
|
|
519
|
+
// and the command waits on forever.
|
|
520
|
+
if (!params.interactive || !params.stdoutIsTty)
|
|
521
|
+
return false;
|
|
522
|
+
return canSelfUpgrade(params.installDir, params.projectPath);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Runs `npm install -g <pkg>@latest`, inheriting stdio so npm's own output —
|
|
526
|
+
* including any auth or permission prompt — reaches the user directly.
|
|
527
|
+
* Resolves true only on a clean exit.
|
|
528
|
+
*/
|
|
529
|
+
async function runGlobalUpgrade() {
|
|
530
|
+
const spawn = loadSpawn();
|
|
531
|
+
return new Promise((resolve) => {
|
|
532
|
+
const child = spawn('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
|
|
533
|
+
stdio: 'inherit',
|
|
534
|
+
});
|
|
535
|
+
child.on('error', () => resolve(false));
|
|
536
|
+
child.on('close', (code) => resolve(code === 0));
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The `openspec` npm installs alongside its global package, so the upgrade can
|
|
541
|
+
* be handed to the copy npm just wrote rather than to whatever PATH resolves.
|
|
542
|
+
* Null when it cannot be found, in which case PATH is the only option left.
|
|
543
|
+
*/
|
|
544
|
+
export function upgradedBinPath(roots = npmGlobalRoots(), installDir = getInstallDir()) {
|
|
545
|
+
// The copy npm just replaced tells us exactly which prefix it wrote to;
|
|
546
|
+
// a root derived from the node binary can point at an unrelated install.
|
|
547
|
+
const ownPrefix = npmPrefixFromInstallDir(installDir);
|
|
548
|
+
const ordered = ownPrefix
|
|
549
|
+
? [
|
|
550
|
+
process.platform === 'win32'
|
|
551
|
+
? path.join(ownPrefix, 'node_modules')
|
|
552
|
+
: path.join(ownPrefix, 'lib', 'node_modules'),
|
|
553
|
+
...roots,
|
|
554
|
+
]
|
|
555
|
+
: roots;
|
|
556
|
+
for (const root of ordered) {
|
|
557
|
+
// npm writes the shim beside the global root on Windows
|
|
558
|
+
// (%APPDATA%\\npm\\openspec.cmd) and in <prefix>/bin on POSIX.
|
|
559
|
+
const candidates = process.platform === 'win32'
|
|
560
|
+
? [path.join(path.dirname(root), 'openspec.cmd')]
|
|
561
|
+
: [path.resolve(root, '..', '..', 'bin', 'openspec')];
|
|
562
|
+
for (const candidate of candidates) {
|
|
563
|
+
try {
|
|
564
|
+
if (fs.existsSync(candidate))
|
|
565
|
+
return candidate;
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
// Unreadable candidate; try the next one.
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Asks a CLI binary its version. Used to confirm an upgrade actually landed:
|
|
576
|
+
* `npm install -g` exits 0 even when it installed nothing, so its exit code
|
|
577
|
+
* alone cannot justify telling the user they are on a new version.
|
|
578
|
+
*/
|
|
579
|
+
export function readCliVersion(binPath) {
|
|
580
|
+
const spawn = loadSpawn();
|
|
581
|
+
return new Promise((resolve) => {
|
|
582
|
+
let output = '';
|
|
583
|
+
let child;
|
|
584
|
+
try {
|
|
585
|
+
child = spawn(binPath, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
resolve(null);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// Never let a probe hold the CLI open: a wrapper that traps SIGTERM would
|
|
592
|
+
// otherwise keep the process alive for as long as it runs.
|
|
593
|
+
child.unref();
|
|
594
|
+
const timer = setTimeout(() => {
|
|
595
|
+
child.kill('SIGKILL');
|
|
596
|
+
resolve(null);
|
|
597
|
+
}, VERSION_PROBE_TIMEOUT_MS);
|
|
598
|
+
child.stdout?.on('data', (chunk) => {
|
|
599
|
+
output += chunk.toString();
|
|
600
|
+
});
|
|
601
|
+
child.on('error', () => {
|
|
602
|
+
clearTimeout(timer);
|
|
603
|
+
resolve(null);
|
|
604
|
+
});
|
|
605
|
+
child.on('close', () => {
|
|
606
|
+
clearTimeout(timer);
|
|
607
|
+
// A line that is only a version, not the first version-shaped token
|
|
608
|
+
// anywhere: a wrapper banner ("Node.js v25.8.1 | OpenSpec") would
|
|
609
|
+
// otherwise be read as the answer.
|
|
610
|
+
const version = output
|
|
611
|
+
.split(/\r?\n/)
|
|
612
|
+
.map((line) => line.trim())
|
|
613
|
+
.filter((line) => SAFE_VERSION.test(line.replace(/^v/, '')))
|
|
614
|
+
.pop();
|
|
615
|
+
resolve(version ? version.replace(/^v/, '') : null);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
function isPromptCancellation(error) {
|
|
620
|
+
const name = error?.name;
|
|
621
|
+
return name === 'ExitPromptError' || name === 'AbortPromptError';
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Offers to run the upgrade and reports what actually happened. The version is
|
|
625
|
+
* read back from the installed binary rather than assumed, so "upgraded" is a
|
|
626
|
+
* fact and a PATH that still answers with the old copy is caught here instead
|
|
627
|
+
* of silently doing nothing.
|
|
628
|
+
*/
|
|
629
|
+
export async function offerCliUpgrade(latestVersion) {
|
|
630
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
631
|
+
let accepted = false;
|
|
632
|
+
try {
|
|
633
|
+
accepted = await confirm({
|
|
634
|
+
message: `Upgrade to v${latestVersion} now?`,
|
|
635
|
+
default: true,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
// Ctrl-C means stop, not "no thanks, carry on with everything else".
|
|
640
|
+
return isPromptCancellation(error) ? 'cancelled' : 'declined';
|
|
641
|
+
}
|
|
642
|
+
if (!accepted)
|
|
643
|
+
return 'declined';
|
|
644
|
+
console.log();
|
|
645
|
+
const installed = await runGlobalUpgrade();
|
|
646
|
+
console.log();
|
|
647
|
+
if (!installed) {
|
|
648
|
+
console.log(chalk.yellow('The upgrade did not complete. A global install may need'));
|
|
649
|
+
console.log(chalk.yellow('elevated permissions, or a different package manager.'));
|
|
650
|
+
return 'failed';
|
|
651
|
+
}
|
|
652
|
+
const binPath = upgradedBinPath();
|
|
653
|
+
const version = await readCliVersion(binPath ?? 'openspec');
|
|
654
|
+
if (!version) {
|
|
655
|
+
console.log(chalk.yellow('Upgrade finished, but no "openspec" could be run to confirm it.'));
|
|
656
|
+
return 'not-on-path';
|
|
657
|
+
}
|
|
658
|
+
if (compareVersions(version, OPENSPEC_VERSION) <= 0) {
|
|
659
|
+
console.log(chalk.yellow(`Upgrade finished, but "openspec" still reports v${version}.`));
|
|
660
|
+
console.log(chalk.dim(binPath
|
|
661
|
+
? // We asked the installed copy directly, so PATH is not the story.
|
|
662
|
+
` npm reported success, but ${binPath} did not change.`
|
|
663
|
+
: ' Another install earlier on your PATH is answering first.'));
|
|
664
|
+
return 'not-on-path';
|
|
665
|
+
}
|
|
666
|
+
console.log(chalk.green(`✓ Upgraded to v${version}.`));
|
|
667
|
+
return 'upgraded';
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Runs `openspec update` again with the CLI that was just installed — this
|
|
671
|
+
* process is still the old code, so it cannot write the new workflows itself.
|
|
672
|
+
* Resolves the exit code to pass along; when no `openspec` is on PATH the
|
|
673
|
+
* upgrade still landed but nothing was regenerated, so it says so and
|
|
674
|
+
* resolves 0 rather than reporting a failure the upgrade did not have.
|
|
675
|
+
*/
|
|
676
|
+
export async function rerunUpdateWithUpgradedCli(projectPath, options = {}) {
|
|
677
|
+
const spawn = loadSpawn();
|
|
678
|
+
const binPath = options.binPath ?? upgradedBinPath() ?? 'openspec';
|
|
679
|
+
// The re-run stands in for the command the user typed, so it has to carry
|
|
680
|
+
// the flags they typed with it.
|
|
681
|
+
const args = ['update'];
|
|
682
|
+
if (options.force)
|
|
683
|
+
args.push('--force');
|
|
684
|
+
// `--` so a path that looks like a flag stays a path.
|
|
685
|
+
args.push('--', projectPath);
|
|
686
|
+
return new Promise((resolve) => {
|
|
687
|
+
const child = spawn(binPath, args, {
|
|
688
|
+
stdio: 'inherit',
|
|
689
|
+
env: {
|
|
690
|
+
...process.env,
|
|
691
|
+
// The child must not offer the upgrade again: if PATH still resolves
|
|
692
|
+
// to the old binary, prompting would loop forever.
|
|
693
|
+
OPENSPEC_NO_UPDATE_CHECK: '1',
|
|
694
|
+
// This is a continuation of the command the user already ran, and the
|
|
695
|
+
// parent recorded it; counting it twice would overstate usage.
|
|
696
|
+
OPENSPEC_TELEMETRY: '0',
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
child.on('error', () => {
|
|
700
|
+
// Nothing to hand off to: the upgrade landed but the instruction files
|
|
701
|
+
// are still the old ones, so this run did not do what was asked.
|
|
702
|
+
console.log(chalk.yellow('Instruction files were not regenerated.'));
|
|
703
|
+
console.log(chalk.dim(' Run "openspec update" to pick up the new workflows.'));
|
|
704
|
+
resolve(1);
|
|
705
|
+
});
|
|
706
|
+
// A child killed by a signal reports no code; that is not success.
|
|
707
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Prints the upgrade hint. Instruction files are generated by the installed
|
|
712
|
+
* CLI, so "up to date" only ever means "matches this CLI" — without this note
|
|
713
|
+
* a stale install looks like a successful update.
|
|
714
|
+
*/
|
|
715
|
+
export function displayCliUpdateNote(latestVersion, projectPath = '.', options = {}) {
|
|
716
|
+
const [headline, ...rest] = buildCliUpdateLines(latestVersion, getInstallDir(), projectPath, options);
|
|
717
|
+
console.log();
|
|
718
|
+
console.log(chalk.yellow(headline));
|
|
719
|
+
for (const line of rest) {
|
|
720
|
+
console.log(chalk.dim(line));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Prints just the manual command, for when the offer was declined or failed.
|
|
725
|
+
*/
|
|
726
|
+
export function displayUpgradeCommand(projectPath = '.') {
|
|
727
|
+
for (const line of buildUpgradeCommandLines(getInstallDir(), projectPath)) {
|
|
728
|
+
console.log(chalk.dim(line));
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
//# sourceMappingURL=version-check.js.map
|