@niujiajie/freespec 0.0.0-stage → 1.15.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/LICENSE +22 -0
- package/README.md +58 -2
- package/bin/freespec.js +5 -0
- package/dist/cli/index.d.ts +25 -0
- package/dist/cli/index.js +359 -0
- package/dist/commands/shared-output.d.ts +18 -0
- package/dist/commands/shared-output.js +61 -0
- package/dist/commands/workflow/default-schema.d.ts +2 -0
- package/dist/commands/workflow/default-schema.js +4 -0
- package/dist/commands/workflow/index.d.ts +13 -0
- package/dist/commands/workflow/index.js +10 -0
- package/dist/commands/workflow/instructions.d.ts +39 -0
- package/dist/commands/workflow/instructions.js +565 -0
- package/dist/commands/workflow/new-change.d.ts +19 -0
- package/dist/commands/workflow/new-change.js +135 -0
- package/dist/commands/workflow/shared.d.ts +94 -0
- package/dist/commands/workflow/shared.js +169 -0
- package/dist/commands/workflow/status.d.ts +18 -0
- package/dist/commands/workflow/status.js +201 -0
- package/dist/core/artifact-graph/graph.d.ts +72 -0
- package/dist/core/artifact-graph/graph.js +165 -0
- package/dist/core/artifact-graph/index.d.ts +9 -0
- package/dist/core/artifact-graph/index.js +14 -0
- package/dist/core/artifact-graph/instruction-loader.d.ts +208 -0
- package/dist/core/artifact-graph/instruction-loader.js +268 -0
- package/dist/core/artifact-graph/outputs.d.ts +19 -0
- package/dist/core/artifact-graph/outputs.js +351 -0
- package/dist/core/artifact-graph/resolver.d.ts +89 -0
- package/dist/core/artifact-graph/resolver.js +342 -0
- package/dist/core/artifact-graph/schema.d.ts +27 -0
- package/dist/core/artifact-graph/schema.js +160 -0
- package/dist/core/artifact-graph/state.d.ts +12 -0
- package/dist/core/artifact-graph/state.js +31 -0
- package/dist/core/artifact-graph/types.d.ts +40 -0
- package/dist/core/artifact-graph/types.js +59 -0
- package/dist/core/available-tools.d.ts +18 -0
- package/dist/core/available-tools.js +49 -0
- package/dist/core/change-layout.d.ts +17 -0
- package/dist/core/change-layout.js +30 -0
- package/dist/core/change-metadata/index.d.ts +2 -0
- package/dist/core/change-metadata/index.js +2 -0
- package/dist/core/change-metadata/schema.d.ts +23 -0
- package/dist/core/change-metadata/schema.js +46 -0
- package/dist/core/change-status-policy.d.ts +49 -0
- package/dist/core/change-status-policy.js +50 -0
- package/dist/core/command-generation/adapters/amazon-q.d.ts +17 -0
- package/dist/core/command-generation/adapters/amazon-q.js +32 -0
- package/dist/core/command-generation/adapters/antigravity.d.ts +13 -0
- package/dist/core/command-generation/adapters/antigravity.js +27 -0
- package/dist/core/command-generation/adapters/atomcode.d.ts +24 -0
- package/dist/core/command-generation/adapters/atomcode.js +46 -0
- package/dist/core/command-generation/adapters/auggie.d.ts +13 -0
- package/dist/core/command-generation/adapters/auggie.js +28 -0
- package/dist/core/command-generation/adapters/bob.d.ts +18 -0
- package/dist/core/command-generation/adapters/bob.js +33 -0
- package/dist/core/command-generation/adapters/claude.d.ts +13 -0
- package/dist/core/command-generation/adapters/claude.js +32 -0
- package/dist/core/command-generation/adapters/cline.d.ts +14 -0
- package/dist/core/command-generation/adapters/cline.js +27 -0
- package/dist/core/command-generation/adapters/codeassistant.d.ts +15 -0
- package/dist/core/command-generation/adapters/codeassistant.js +29 -0
- package/dist/core/command-generation/adapters/codebuddy.d.ts +13 -0
- package/dist/core/command-generation/adapters/codebuddy.js +29 -0
- package/dist/core/command-generation/adapters/codestudio.d.ts +13 -0
- package/dist/core/command-generation/adapters/codestudio.js +27 -0
- package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
- package/dist/core/command-generation/adapters/command-code.js +34 -0
- package/dist/core/command-generation/adapters/continue.d.ts +13 -0
- package/dist/core/command-generation/adapters/continue.js +33 -0
- package/dist/core/command-generation/adapters/costrict.d.ts +13 -0
- package/dist/core/command-generation/adapters/costrict.js +28 -0
- package/dist/core/command-generation/adapters/crush.d.ts +13 -0
- package/dist/core/command-generation/adapters/crush.js +30 -0
- package/dist/core/command-generation/adapters/cursor.d.ts +14 -0
- package/dist/core/command-generation/adapters/cursor.js +31 -0
- 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/easycode.d.ts +17 -0
- package/dist/core/command-generation/adapters/easycode.js +28 -0
- package/dist/core/command-generation/adapters/factory.d.ts +13 -0
- package/dist/core/command-generation/adapters/factory.js +28 -0
- package/dist/core/command-generation/adapters/gemini.d.ts +13 -0
- package/dist/core/command-generation/adapters/gemini.js +27 -0
- package/dist/core/command-generation/adapters/gigacode.d.ts +17 -0
- package/dist/core/command-generation/adapters/gigacode.js +31 -0
- package/dist/core/command-generation/adapters/github-copilot.d.ts +13 -0
- package/dist/core/command-generation/adapters/github-copilot.js +27 -0
- package/dist/core/command-generation/adapters/iflow.d.ts +13 -0
- package/dist/core/command-generation/adapters/iflow.js +30 -0
- package/dist/core/command-generation/adapters/index.d.ts +40 -0
- package/dist/core/command-generation/adapters/index.js +40 -0
- package/dist/core/command-generation/adapters/junie.d.ts +13 -0
- package/dist/core/command-generation/adapters/junie.js +27 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +14 -0
- package/dist/core/command-generation/adapters/kilocode.js +23 -0
- package/dist/core/command-generation/adapters/kiro.d.ts +13 -0
- package/dist/core/command-generation/adapters/kiro.js +27 -0
- package/dist/core/command-generation/adapters/lingma.d.ts +13 -0
- package/dist/core/command-generation/adapters/lingma.js +30 -0
- 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.d.ts +14 -0
- package/dist/core/command-generation/adapters/opencode.js +38 -0
- package/dist/core/command-generation/adapters/pi.d.ts +18 -0
- package/dist/core/command-generation/adapters/pi.js +39 -0
- package/dist/core/command-generation/adapters/qoder.d.ts +13 -0
- package/dist/core/command-generation/adapters/qoder.js +30 -0
- package/dist/core/command-generation/adapters/qwen.d.ts +17 -0
- package/dist/core/command-generation/adapters/qwen.js +31 -0
- package/dist/core/command-generation/adapters/roocode.d.ts +14 -0
- package/dist/core/command-generation/adapters/roocode.js +27 -0
- 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 +29 -0
- package/dist/core/command-generation/generator.js +43 -0
- package/dist/core/command-generation/index.d.ts +22 -0
- package/dist/core/command-generation/index.js +24 -0
- 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.d.ts +36 -0
- package/dist/core/command-generation/registry.js +114 -0
- package/dist/core/command-generation/toml.d.ts +19 -0
- package/dist/core/command-generation/toml.js +39 -0
- package/dist/core/command-generation/types.d.ts +63 -0
- package/dist/core/command-generation/types.js +8 -0
- package/dist/core/command-generation/yaml.d.ts +36 -0
- package/dist/core/command-generation/yaml.js +48 -0
- package/dist/core/command-surface.d.ts +15 -0
- package/dist/core/command-surface.js +33 -0
- package/dist/core/config-prompts.d.ts +9 -0
- package/dist/core/config-prompts.js +56 -0
- package/dist/core/config-schema.d.ts +98 -0
- package/dist/core/config-schema.js +308 -0
- package/dist/core/config.d.ts +50 -0
- package/dist/core/config.js +122 -0
- package/dist/core/file-state.d.ts +36 -0
- package/dist/core/file-state.js +144 -0
- package/dist/core/file-view.d.ts +13 -0
- package/dist/core/file-view.js +14 -0
- package/dist/core/global-config.d.ts +101 -0
- package/dist/core/global-config.js +182 -0
- package/dist/core/id.d.ts +17 -0
- package/dist/core/id.js +30 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +7 -0
- package/dist/core/init.d.ts +57 -0
- package/dist/core/init.js +836 -0
- package/dist/core/list.d.ts +11 -0
- package/dist/core/list.js +197 -0
- package/dist/core/migration.d.ts +121 -0
- package/dist/core/migration.js +540 -0
- package/dist/core/onboarding-commands.d.ts +30 -0
- package/dist/core/onboarding-commands.js +38 -0
- package/dist/core/openspec-root.d.ts +47 -0
- package/dist/core/openspec-root.js +196 -0
- package/dist/core/planning-home.d.ts +16 -0
- package/dist/core/planning-home.js +67 -0
- package/dist/core/profile-sync-drift.d.ts +31 -0
- package/dist/core/profile-sync-drift.js +201 -0
- package/dist/core/profiles.d.ts +19 -0
- package/dist/core/profiles.js +33 -0
- package/dist/core/project-config.d.ts +132 -0
- package/dist/core/project-config.js +519 -0
- package/dist/core/references.d.ts +10 -0
- package/dist/core/references.js +46 -0
- package/dist/core/root-selection.d.ts +59 -0
- package/dist/core/root-selection.js +132 -0
- package/dist/core/shared/allowed-tools.d.ts +12 -0
- package/dist/core/shared/allowed-tools.js +12 -0
- package/dist/core/shared/ide-restart.d.ts +26 -0
- package/dist/core/shared/ide-restart.js +42 -0
- package/dist/core/shared/index.d.ts +11 -0
- package/dist/core/shared/index.js +11 -0
- package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
- package/dist/core/shared/skill-content-equivalence.js +45 -0
- package/dist/core/shared/skill-files.d.ts +7 -0
- package/dist/core/shared/skill-files.js +43 -0
- package/dist/core/shared/skill-generation.d.ts +49 -0
- package/dist/core/shared/skill-generation.js +125 -0
- 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 +99 -0
- package/dist/core/shared/tool-detection.js +318 -0
- package/dist/core/shared-skill-target.d.ts +51 -0
- package/dist/core/shared-skill-target.js +239 -0
- package/dist/core/store/errors.d.ts +20 -0
- package/dist/core/store/errors.js +22 -0
- package/dist/core/store/index.d.ts +2 -0
- package/dist/core/store/index.js +2 -0
- package/dist/core/styles/palette.d.ts +7 -0
- package/dist/core/styles/palette.js +8 -0
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/types.d.ts +19 -0
- package/dist/core/templates/types.js +5 -0
- package/dist/core/templates/workflows/personal.d.ts +22 -0
- package/dist/core/templates/workflows/personal.js +225 -0
- package/dist/core/update-plan.d.ts +127 -0
- package/dist/core/update-plan.js +390 -0
- package/dist/core/update.d.ts +154 -0
- package/dist/core/update.js +723 -0
- package/dist/core/version-check.d.ts +188 -0
- package/dist/core/version-check.js +906 -0
- package/dist/core/zod-issues.d.ts +4 -0
- package/dist/core/zod-issues.js +10 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/prompts/searchable-multi-select.d.ts +35 -0
- package/dist/prompts/searchable-multi-select.js +188 -0
- package/dist/telemetry/config.d.ts +37 -0
- package/dist/telemetry/config.js +148 -0
- package/dist/telemetry/index.d.ts +39 -0
- package/dist/telemetry/index.js +217 -0
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/ui/ascii-patterns.d.ts +16 -0
- package/dist/ui/ascii-patterns.js +133 -0
- package/dist/ui/welcome-screen.d.ts +18 -0
- package/dist/ui/welcome-screen.js +199 -0
- package/dist/utils/change-metadata.d.ts +116 -0
- package/dist/utils/change-metadata.js +324 -0
- package/dist/utils/change-utils.d.ts +79 -0
- package/dist/utils/change-utils.js +121 -0
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +16 -0
- package/dist/utils/command-references.d.ts +104 -0
- package/dist/utils/command-references.js +190 -0
- package/dist/utils/date.d.ts +8 -0
- package/dist/utils/date.js +13 -0
- package/dist/utils/executable-path.d.ts +5 -0
- package/dist/utils/executable-path.js +44 -0
- package/dist/utils/file-system.d.ts +58 -0
- package/dist/utils/file-system.js +432 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/index.js +9 -0
- package/dist/utils/interactive.d.ts +85 -0
- package/dist/utils/interactive.js +174 -0
- package/dist/utils/item-discovery.d.ts +15 -0
- package/dist/utils/item-discovery.js +33 -0
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -0
- package/dist/utils/match.d.ts +3 -0
- package/dist/utils/match.js +22 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +185 -0
- package/dist/utils/shell-detection.d.ts +20 -0
- package/dist/utils/shell-detection.js +94 -0
- package/dist/utils/task-progress.d.ts +67 -0
- package/dist/utils/task-progress.js +184 -0
- package/package.json +74 -4
- package/schemas//351/273/230/350/256/244/schema.yaml +68 -0
- package/schemas//351/273/230/350/256/244/templates//344/273/273/345/212/241/346/270/205/345/215/225.md +25 -0
- package/schemas//351/273/230/350/256/244/templates//346/265/213/350/257/225/347/224/250/344/276/213.md +34 -0
- package/schemas//351/273/230/350/256/244/templates//350/256/276/350/256/241/346/226/207/346/241/243.md +172 -0
- package/schemas//351/273/230/350/256/244/templates//351/234/200/346/261/202/346/226/207/346/241/243.md +19 -0
- package/skill-files//344/273/273/345/212/241/references//346/212/244/346/240/217.md +43 -0
- package/skill-files//344/273/273/345/212/241/references//347/262/222/345/272/246.md +23 -0
- package/skill-files//344/273/273/345/212/241/references//347/272/246/346/235/237.md +8 -0
- package/skill-files//344/273/273/345/212/241/scripts/check-tasks.mjs +83 -0
- package/skill-files//345/206/231Java/references/Java/350/247/204/350/214/203.md +25 -0
- package/skill-files//345/206/231Java/references//345/215/261/351/231/251/345/206/231/346/263/225.md +27 -0
- package/skill-files//345/206/231Java/references//345/256/214/346/210/220/345/257/271/347/205/247.md +15 -0
- package/skill-files//345/206/231Java/references//346/212/244/346/240/217.md +35 -0
- package/skill-files//345/206/231Java/references//347/272/246/346/235/237.md +8 -0
- package/skill-files//345/206/231Java/references//350/270/251/345/235/221.md +17 -0
- package/skill-files//345/256/241Java/references//346/212/244/346/240/217.md +19 -0
- package/skill-files//345/256/241Java/references//347/244/272/344/276/213.md +23 -0
- package/skill-files//345/256/241Java/references//347/272/246/346/235/237.md +7 -0
- package/skill-files//345/256/241Java/references//350/247/204/345/210/231.md +23 -0
- package/skill-files//345/256/241Java/references//351/242/206/345/237/237/346/243/200/351/241/271.md +58 -0
- package/skill-files//345/256/241/346/226/207/346/241/243/references//346/212/244/346/240/217.md +18 -0
- package/skill-files//345/256/241/346/226/207/346/241/243/references//347/272/246/346/235/237.md +8 -0
- package/skill-files//345/256/241/346/226/207/346/241/243/references//347/273/264/345/272/246.md +17 -0
- package/skill-files//345/256/241/346/226/207/346/241/243/references//350/270/251/345/235/221.md +11 -0
- package/skill-files//346/226/260/345/273/272/references//344/272/244/346/216/245.md +11 -0
- package/skill-files//346/226/260/345/273/272/references//346/212/244/346/240/217.md +14 -0
- package/skill-files//346/226/260/345/273/272/references//347/272/246/346/235/237.md +7 -0
- package/skill-files//346/267/261/346/200/235/config/prompts.yaml +114 -0
- package/skill-files//346/267/261/346/200/235/references//344/270/211/346/226/271/346/240/270/345/257/271.md +72 -0
- package/skill-files//346/267/261/346/200/235/references//344/276/213/345/255/220.md +39 -0
- package/skill-files//346/267/261/346/200/235/references//346/200/216/344/271/210/346/203/263.md +61 -0
- package/skill-files//346/267/261/346/200/235/references//346/212/244/346/240/217.md +26 -0
- package/skill-files//346/267/261/346/200/235/references//347/202/271/351/200/211/346/217/220/351/227/256.md +66 -0
- package/skill-files//346/267/261/346/200/235/references//347/231/273/350/256/260.md +45 -0
- package/skill-files//346/267/261/346/200/235/references//347/272/246/346/235/237.md +9 -0
- package/skill-files//347/224/250/344/276/213/references//346/200/216/344/271/210/345/206/231.md +13 -0
- package/skill-files//347/224/250/344/276/213/references//346/212/244/346/240/217.md +42 -0
- package/skill-files//347/224/250/344/276/213/references//347/272/246/346/235/237.md +5 -0
- package/skill-files//347/224/250/344/276/213/scripts/check-cases.mjs +70 -0
- package/skill-files//350/256/276/350/256/241/references//345/207/206/345/244/207.md +25 -0
- package/skill-files//350/256/276/350/256/241/references//346/212/244/346/240/217.md +134 -0
- package/skill-files//350/256/276/350/256/241/references//346/226/207/351/243/216.md +17 -0
- package/skill-files//350/256/276/350/256/241/references//347/225/214/351/235/242.md +18 -0
- package/skill-files//350/256/276/350/256/241/references//347/261/273/345/236/213.md +19 -0
- package/skill-files//350/256/276/350/256/241/references//347/272/246/346/235/237.md +25 -0
- package/skill-files//350/256/276/350/256/241/scripts/_smoke.mjs +72 -0
- package/skill-files//350/256/276/350/256/241/scripts/check-design.mjs +331 -0
- package/skill-files//351/234/200/346/261/202/references//346/212/244/346/240/217.md +26 -0
- package/skill-files//351/234/200/346/261/202/references//346/226/207/351/243/216.md +23 -0
- package/skill-files//351/234/200/346/261/202/references//346/267/261/346/200/235/346/221/204/345/205/245.md +21 -0
- package/skill-files//351/234/200/346/261/202/references//347/272/246/346/235/237.md +8 -0
- package/skill-files//351/234/200/346/261/202/references//351/234/200/346/261/202/345/276/205/345/256/232.md +13 -0
- package/skill-files//351/234/200/346/261/202/scripts/check-requirements.mjs +60 -0
- package/skill-files//351/252/214/346/224/266/references//346/212/244/346/240/217.md +27 -0
- package/skill-files//351/252/214/346/224/266/references//346/240/270/345/257/271.md +17 -0
- package/skill-files//351/252/214/346/224/266/references//347/272/246/346/235/237.md +7 -0
|
@@ -0,0 +1,906 @@
|
|
|
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 { fileURLToPath } from 'url';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { isCiEnvironment } from '../utils/ci.js';
|
|
9
|
+
import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
|
|
10
|
+
import { FileSystemUtils } from '../utils/file-system.js';
|
|
11
|
+
import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
|
|
12
|
+
import { findCopiesOnPath, samePath } from '../utils/executable-path.js';
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
|
|
15
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
16
|
+
const REQUEST_TIMEOUT_MS = 1500;
|
|
17
|
+
const MAX_RESPONSE_BYTES = 256 * 1024;
|
|
18
|
+
const VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
19
|
+
const MAX_REDIRECTS = 3;
|
|
20
|
+
/**
|
|
21
|
+
* A version we are willing to print. The registry only ever serves SemVer here,
|
|
22
|
+
* so anything else is either a broken mirror or a hostile response — and since
|
|
23
|
+
* this string lands in the terminal next to an install command, an unvalidated
|
|
24
|
+
* one could smuggle ANSI cursor controls and repaint the lines around it.
|
|
25
|
+
*/
|
|
26
|
+
const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
|
|
27
|
+
let standaloneBinaryPath = null;
|
|
28
|
+
/**
|
|
29
|
+
* Called by the standalone binary's entry point. That copy has no package
|
|
30
|
+
* manager behind it, so a package-manager command is the wrong upgrade advice.
|
|
31
|
+
*/
|
|
32
|
+
export function markStandaloneBinary(executablePath) {
|
|
33
|
+
standaloneBinaryPath = executablePath;
|
|
34
|
+
}
|
|
35
|
+
/** The standalone binary's own path, or null when running from a package. */
|
|
36
|
+
export function getStandaloneBinaryPath() {
|
|
37
|
+
return standaloneBinaryPath;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `freespec install` reinstalls the standalone binary where it already is,
|
|
41
|
+
* under its own name, so the new copy cannot end up shadowed by the old one.
|
|
42
|
+
* Spelled with the full path when `freespec` on PATH is some other copy.
|
|
43
|
+
* Null when this is not the binary.
|
|
44
|
+
*/
|
|
45
|
+
function standaloneUpgradeCommand() {
|
|
46
|
+
if (!standaloneBinaryPath)
|
|
47
|
+
return null;
|
|
48
|
+
const first = findCopiesOnPath()[0];
|
|
49
|
+
if (first && samePath(first, standaloneBinaryPath))
|
|
50
|
+
return 'freespec install';
|
|
51
|
+
return process.platform === 'win32'
|
|
52
|
+
? `& '${standaloneBinaryPath.replace(/'/g, "''")}' install`
|
|
53
|
+
: `'${standaloneBinaryPath.replace(/'/g, `'\\''`)}' install`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The check is opt-out and must never get in the way: no network in CI or
|
|
57
|
+
* tests, an explicit escape hatch for anyone offline or air-gapped, and the
|
|
58
|
+
* same privacy signals telemetry already honors — a user who set DO_NOT_TRACK
|
|
59
|
+
* or telemetry.enabled false did not agree to a different outbound request.
|
|
60
|
+
*/
|
|
61
|
+
function isCheckEnabled() {
|
|
62
|
+
if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined)
|
|
63
|
+
return false;
|
|
64
|
+
if (isTelemetryOptedOutByEnv())
|
|
65
|
+
return false;
|
|
66
|
+
if (isCiEnvironment())
|
|
67
|
+
return false;
|
|
68
|
+
if (process.env.NODE_ENV === 'test')
|
|
69
|
+
return false;
|
|
70
|
+
// Same config opt-out as telemetry (env remains the hard override above).
|
|
71
|
+
if (getGlobalConfig().telemetry?.enabled === false)
|
|
72
|
+
return false;
|
|
73
|
+
// A config that cannot be parsed may be hiding that opt-out.
|
|
74
|
+
if (isGlobalConfigUnreadable())
|
|
75
|
+
return false;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The configured registry, if npm exported one. Not trustworthy: npm exports
|
|
80
|
+
* every config source it read, including a `registry=` line in a
|
|
81
|
+
* repository-local .npmrc that travels with a clone.
|
|
82
|
+
*/
|
|
83
|
+
function configuredRegistry() {
|
|
84
|
+
const configured = process.env.npm_config_registry?.trim();
|
|
85
|
+
return configured ? configured : undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The registry to ask. A configured registry is honored only over TLS: a
|
|
89
|
+
* cleartext hop would aim the request at a link-local or internal address and
|
|
90
|
+
* let anyone on the path choose the "newer version" answer.
|
|
91
|
+
*
|
|
92
|
+
* A rejected registry disables the check rather than falling back to public
|
|
93
|
+
* npm. Falling back would send a request an organization on an internal mirror
|
|
94
|
+
* deliberately avoided, and would then report a version resolved against a
|
|
95
|
+
* registry the eventual `npm install -g` does not use.
|
|
96
|
+
*/
|
|
97
|
+
export function registryUrl() {
|
|
98
|
+
const configured = configuredRegistry();
|
|
99
|
+
if (configured && !/^https:\/\//i.test(configured))
|
|
100
|
+
return null;
|
|
101
|
+
const base = configured ?? DEFAULT_REGISTRY;
|
|
102
|
+
return `${base.replace(/\/+$/, '')}/${PACKAGE_NAME}/latest`;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* True when the check is talking to the public npm registry. Read from the raw
|
|
106
|
+
* env var, not from registryUrl(), so a registry that was rejected above still
|
|
107
|
+
* disqualifies a self-upgrade instead of looking like the default.
|
|
108
|
+
*/
|
|
109
|
+
function isDefaultRegistry() {
|
|
110
|
+
const configured = configuredRegistry();
|
|
111
|
+
if (!configured)
|
|
112
|
+
return true;
|
|
113
|
+
try {
|
|
114
|
+
return new URL(configured).origin === new URL(DEFAULT_REGISTRY).origin;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Compares two prerelease tags per SemVer: dot-separated identifiers compared
|
|
122
|
+
* one by one, numeric identifiers numerically (so beta.10 > beta.2), numeric
|
|
123
|
+
* ranking below alphanumeric, and a longer identifier list winning ties.
|
|
124
|
+
*/
|
|
125
|
+
function comparePrerelease(a, b) {
|
|
126
|
+
if (a === b)
|
|
127
|
+
return 0;
|
|
128
|
+
if (a === '')
|
|
129
|
+
return 1;
|
|
130
|
+
if (b === '')
|
|
131
|
+
return -1;
|
|
132
|
+
const left = a.split('.');
|
|
133
|
+
const right = b.split('.');
|
|
134
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
135
|
+
const l = left[i];
|
|
136
|
+
const r = right[i];
|
|
137
|
+
if (l === undefined)
|
|
138
|
+
return -1;
|
|
139
|
+
if (r === undefined)
|
|
140
|
+
return 1;
|
|
141
|
+
const lNumeric = /^\d+$/.test(l);
|
|
142
|
+
const rNumeric = /^\d+$/.test(r);
|
|
143
|
+
if (lNumeric && rNumeric) {
|
|
144
|
+
const diff = Number.parseInt(l, 10) - Number.parseInt(r, 10);
|
|
145
|
+
if (diff !== 0)
|
|
146
|
+
return diff > 0 ? 1 : -1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (lNumeric !== rNumeric)
|
|
150
|
+
return lNumeric ? -1 : 1;
|
|
151
|
+
if (l !== r)
|
|
152
|
+
return l > r ? 1 : -1;
|
|
153
|
+
}
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Compares two semver-ish versions. Returns 1 when a > b, -1 when a < b, 0
|
|
158
|
+
* otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
|
|
159
|
+
*/
|
|
160
|
+
export function compareVersions(a, b) {
|
|
161
|
+
const parse = (version) => {
|
|
162
|
+
const withoutBuild = version.trim().replace(/^v/, '').split('+', 1)[0] ?? '';
|
|
163
|
+
const separator = withoutBuild.indexOf('-');
|
|
164
|
+
const core = separator === -1 ? withoutBuild : withoutBuild.slice(0, separator);
|
|
165
|
+
const prerelease = separator === -1 ? '' : withoutBuild.slice(separator + 1);
|
|
166
|
+
const parts = core.split('.').map((n) => Number.parseInt(n, 10));
|
|
167
|
+
return {
|
|
168
|
+
numbers: [parts[0] || 0, parts[1] || 0, parts[2] || 0],
|
|
169
|
+
prerelease,
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
const left = parse(a);
|
|
173
|
+
const right = parse(b);
|
|
174
|
+
for (let i = 0; i < 3; i++) {
|
|
175
|
+
if (left.numbers[i] > right.numbers[i])
|
|
176
|
+
return 1;
|
|
177
|
+
if (left.numbers[i] < right.numbers[i])
|
|
178
|
+
return -1;
|
|
179
|
+
}
|
|
180
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Reads the `latest` dist-tag. Sends no custom Accept header: the registry
|
|
184
|
+
* answers `/<pkg>/latest` with 406 for npm's abbreviated-metadata type, which
|
|
185
|
+
* it only serves on the full packument.
|
|
186
|
+
*
|
|
187
|
+
* Uses node:http(s) rather than fetch so the timeout can destroy the socket.
|
|
188
|
+
* Aborting a fetch that is still completing its TCP handshake — a firewall
|
|
189
|
+
* dropping packets, a captive portal — leaves the connect handle open and the
|
|
190
|
+
* CLI cannot exit until the OS gives up, long after the hint has printed.
|
|
191
|
+
*/
|
|
192
|
+
function fetchLatestVersion() {
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
let settled = false;
|
|
195
|
+
let timer;
|
|
196
|
+
const finish = (version) => {
|
|
197
|
+
if (settled)
|
|
198
|
+
return;
|
|
199
|
+
settled = true;
|
|
200
|
+
if (timer)
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
resolve(version);
|
|
203
|
+
};
|
|
204
|
+
let url;
|
|
205
|
+
try {
|
|
206
|
+
const resolved = registryUrl();
|
|
207
|
+
if (resolved === null) {
|
|
208
|
+
resolve(null);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
url = new URL(resolved);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
resolve(null);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Mirrors and corporate front-ends redirect; without following one the
|
|
218
|
+
// check would be permanently and silently dead for them.
|
|
219
|
+
let redirectsLeft = MAX_REDIRECTS;
|
|
220
|
+
// The budget timer must tear down whichever request is open when it
|
|
221
|
+
// fires. Closing over the first hop's request would leave a redirected
|
|
222
|
+
// socket alive: a target that trickles bytes keeps resetting its idle
|
|
223
|
+
// timeout, and only the body-size cap would end it.
|
|
224
|
+
let activeRequest;
|
|
225
|
+
const send = (target) => {
|
|
226
|
+
const request = (target.protocol === 'http:' ? http : https).get(target, { timeout: REQUEST_TIMEOUT_MS }, (response) => {
|
|
227
|
+
const status = response.statusCode ?? 0;
|
|
228
|
+
const location = response.headers.location;
|
|
229
|
+
if (status >= 300 && status < 400 && location) {
|
|
230
|
+
response.resume();
|
|
231
|
+
request.destroy();
|
|
232
|
+
if (redirectsLeft <= 0) {
|
|
233
|
+
finish(null);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
redirectsLeft -= 1;
|
|
237
|
+
try {
|
|
238
|
+
const next = new URL(location, target);
|
|
239
|
+
// Cross-host is allowed - that is the whole point of following a
|
|
240
|
+
// redirect for a mirror or corporate front-end - but never off
|
|
241
|
+
// TLS: a MITM on a cleartext hop would control the "newer
|
|
242
|
+
// version" answer, and the reply cannot install anything on its
|
|
243
|
+
// own (canSelfUpgrade refuses a non-default registry, and the
|
|
244
|
+
// version is re-validated against a strict pattern).
|
|
245
|
+
if (next.protocol === 'https:') {
|
|
246
|
+
send(next);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Unparseable Location.
|
|
252
|
+
}
|
|
253
|
+
finish(null);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (status !== 200) {
|
|
257
|
+
response.resume();
|
|
258
|
+
request.destroy();
|
|
259
|
+
finish(null);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
let body = '';
|
|
263
|
+
response.setEncoding('utf-8');
|
|
264
|
+
response.on('data', (chunk) => {
|
|
265
|
+
body += chunk;
|
|
266
|
+
// The dist-tag document is small; refuse to buffer a firehose.
|
|
267
|
+
if (body.length > MAX_RESPONSE_BYTES) {
|
|
268
|
+
request.destroy();
|
|
269
|
+
finish(null);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
response.on('end', () => {
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(body);
|
|
275
|
+
const version = parsed.version;
|
|
276
|
+
finish(typeof version === 'string' && SAFE_VERSION.test(version) ? version : null);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
finish(null);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
response.on('error', () => finish(null));
|
|
283
|
+
});
|
|
284
|
+
activeRequest = request;
|
|
285
|
+
request.on('timeout', () => {
|
|
286
|
+
request.destroy();
|
|
287
|
+
finish(null);
|
|
288
|
+
});
|
|
289
|
+
request.on('error', () => finish(null));
|
|
290
|
+
// One budget for the whole exchange, redirects included.
|
|
291
|
+
if (!timer) {
|
|
292
|
+
timer = setTimeout(() => {
|
|
293
|
+
activeRequest?.destroy();
|
|
294
|
+
finish(null);
|
|
295
|
+
}, REQUEST_TIMEOUT_MS);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
send(url);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Checks the registry while preserving enough detail for callers to explain
|
|
303
|
+
* why no newer version was reported. Never throws.
|
|
304
|
+
*/
|
|
305
|
+
export async function checkForCliUpdate() {
|
|
306
|
+
if (!isCheckEnabled() || registryUrl() === null) {
|
|
307
|
+
return { status: 'disabled', latest: null };
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const latest = await fetchLatestVersion();
|
|
311
|
+
if (!latest)
|
|
312
|
+
return { status: 'offline', latest: null };
|
|
313
|
+
return {
|
|
314
|
+
status: compareVersions(latest, OPENSPEC_VERSION) > 0 ? 'available' : 'current',
|
|
315
|
+
latest,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return { status: 'offline', latest: null };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Returns the published version when the installed CLI is behind it, otherwise
|
|
324
|
+
* null. Kept as the compatibility surface used by `freespec update`.
|
|
325
|
+
*/
|
|
326
|
+
export async function getAvailableCliUpdate() {
|
|
327
|
+
const result = await checkForCliUpdate();
|
|
328
|
+
return result.status === 'available' ? result.latest : null;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Directory the running CLI was loaded from, or null when it cannot be
|
|
332
|
+
* resolved. Shown in the upgrade hint so anyone who upgraded but still runs an
|
|
333
|
+
* old binary — a stale pnpm/volta/npx shim, or two installs on PATH — can see
|
|
334
|
+
* which copy is actually answering.
|
|
335
|
+
*/
|
|
336
|
+
export function getInstallDir() {
|
|
337
|
+
// The binary is not a package install, so no package-manager logic applies.
|
|
338
|
+
if (standaloneBinaryPath)
|
|
339
|
+
return null;
|
|
340
|
+
// Resolved at runtime from this module's own (symlink-resolved) location.
|
|
341
|
+
// require.resolve would be resolved by the binary's bundler at build time,
|
|
342
|
+
// baking the build machine's path into it.
|
|
343
|
+
const packageJson = fileURLToPath(new URL('../../package.json', import.meta.url));
|
|
344
|
+
return fs.existsSync(packageJson) ? path.dirname(packageJson) : null;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* True when the running CLI resolves from a `node_modules` belonging to the
|
|
348
|
+
* project being updated or any ancestor of it — the hoisted-root layout npm and
|
|
349
|
+
* pnpm workspaces produce. Anchored on the target path rather than the working
|
|
350
|
+
* directory, since `freespec update <path>` and running from a sub-package are
|
|
351
|
+
* both normal. Never throws: process.cwd() fails when the directory has been
|
|
352
|
+
* deleted, and a wrong upgrade hint must not take down a successful update.
|
|
353
|
+
*/
|
|
354
|
+
export function isProjectLocalInstall(installDir, projectPath = '.') {
|
|
355
|
+
if (!installDir)
|
|
356
|
+
return false;
|
|
357
|
+
// Windows paths differ in case and drive-letter casing between sources.
|
|
358
|
+
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
359
|
+
try {
|
|
360
|
+
let dir = FileSystemUtils.canonicalizeExistingPath(projectPath);
|
|
361
|
+
const target = normalize(FileSystemUtils.canonicalizeExistingPath(installDir));
|
|
362
|
+
for (;;) {
|
|
363
|
+
if (target.startsWith(normalize(path.join(dir, 'node_modules') + path.sep))) {
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
const parent = path.dirname(dir);
|
|
367
|
+
if (parent === dir)
|
|
368
|
+
return false;
|
|
369
|
+
dir = parent;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* True for the throwaway caches npx/pnpm dlx/bunx unpack into. Telling those
|
|
378
|
+
* users to install globally would create the second copy on PATH they were
|
|
379
|
+
* deliberately avoiding.
|
|
380
|
+
*/
|
|
381
|
+
export function isEphemeralRunnerInstall(installDir) {
|
|
382
|
+
if (!installDir)
|
|
383
|
+
return false;
|
|
384
|
+
const segments = installDir.split(/[\\/]/).map((segment) => segment.toLowerCase());
|
|
385
|
+
return segments.some((segment, i) => segment === '_npx' ||
|
|
386
|
+
segment === '_bunx' ||
|
|
387
|
+
// Only a package manager's own cache, never a user directory that
|
|
388
|
+
// happens to be called "dlx". Windows uses pnpm-cache for the same job.
|
|
389
|
+
(segment === 'dlx' &&
|
|
390
|
+
['pnpm', 'bun', '.pnpm', 'pnpm-cache', 'bun-cache'].includes(segments[i - 1] ?? '')));
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Directories npm installs global packages into. Derived from the running node
|
|
394
|
+
* rather than by shelling out to `npm prefix -g`, which would cost more than
|
|
395
|
+
* the version check itself. Only a hint: `process.execPath` is realpath'd, so
|
|
396
|
+
* on Homebrew it lands in the Cellar rather than the brew prefix — which is
|
|
397
|
+
* why the install's own layout is the primary signal below.
|
|
398
|
+
*/
|
|
399
|
+
export function npmGlobalRoots() {
|
|
400
|
+
const roots = [];
|
|
401
|
+
const nodeDir = path.dirname(process.execPath);
|
|
402
|
+
if (process.platform === 'win32') {
|
|
403
|
+
roots.push(path.join(nodeDir, 'node_modules'));
|
|
404
|
+
if (process.env.APPDATA) {
|
|
405
|
+
roots.push(path.join(process.env.APPDATA, 'npm', 'node_modules'));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
roots.push(path.resolve(nodeDir, '..', 'lib', 'node_modules'));
|
|
410
|
+
}
|
|
411
|
+
const prefix = process.env.npm_config_prefix;
|
|
412
|
+
if (prefix) {
|
|
413
|
+
roots.push(process.platform === 'win32'
|
|
414
|
+
? path.join(prefix, 'node_modules')
|
|
415
|
+
: path.join(prefix, 'lib', 'node_modules'));
|
|
416
|
+
}
|
|
417
|
+
return roots;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The prefix of an npm global install, read from the install's own shape:
|
|
421
|
+
* `<prefix>/lib/node_modules/<pkg>` on POSIX, `<prefix>/node_modules/<pkg>` on
|
|
422
|
+
* Windows. Self-describing, so it holds for Homebrew, nvm, Debian and anywhere
|
|
423
|
+
* else npm's prefix is not derivable from the node binary. Null when the
|
|
424
|
+
* layout does not match.
|
|
425
|
+
*/
|
|
426
|
+
export function npmPrefixFromInstallDir(installDir) {
|
|
427
|
+
if (!installDir)
|
|
428
|
+
return null;
|
|
429
|
+
let dir = installDir;
|
|
430
|
+
for (;;) {
|
|
431
|
+
const parent = path.dirname(dir);
|
|
432
|
+
if (parent === dir)
|
|
433
|
+
return null;
|
|
434
|
+
if (path.basename(dir).toLowerCase() === 'node_modules')
|
|
435
|
+
break;
|
|
436
|
+
dir = parent;
|
|
437
|
+
}
|
|
438
|
+
const container = path.dirname(dir);
|
|
439
|
+
if (process.platform === 'win32')
|
|
440
|
+
return container;
|
|
441
|
+
// POSIX npm always nests the root under lib/.
|
|
442
|
+
return path.basename(container).toLowerCase() === 'lib' ? path.dirname(container) : null;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* True only when npm itself owns this copy. Everything else — a pnpm, bun,
|
|
446
|
+
* yarn or volta global — would be made worse by `npm install -g`, which adds a
|
|
447
|
+
* second copy that may not even be the one on PATH.
|
|
448
|
+
*/
|
|
449
|
+
export function isNpmGlobalInstall(installDir, roots = npmGlobalRoots()) {
|
|
450
|
+
if (!installDir)
|
|
451
|
+
return false;
|
|
452
|
+
// Another manager's layout can still look like npm's (volta nests a whole
|
|
453
|
+
// node install), so who owns it is decided before where it sits.
|
|
454
|
+
if (detectPackageManager(installDir) !== 'npm')
|
|
455
|
+
return false;
|
|
456
|
+
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
457
|
+
const target = normalize(installDir);
|
|
458
|
+
if (roots.some((root) => target.startsWith(normalize(root + path.sep))))
|
|
459
|
+
return true;
|
|
460
|
+
// The derived roots miss any prefix that is not beside the node binary, so
|
|
461
|
+
// fall back to the install's own shape plus the bin directory npm would
|
|
462
|
+
// have written the shim into.
|
|
463
|
+
const prefix = npmPrefixFromInstallDir(installDir);
|
|
464
|
+
if (!prefix)
|
|
465
|
+
return false;
|
|
466
|
+
try {
|
|
467
|
+
// Corroborate with something npm itself wrote: the bin dir on POSIX, the
|
|
468
|
+
// .cmd shim on Windows. The prefix alone proves nothing — it is just the
|
|
469
|
+
// parent of the node_modules dir the CLI resolved from, so a hand-copied
|
|
470
|
+
// portable tree would pass and be offered an npm upgrade it never had.
|
|
471
|
+
return fs.existsSync(process.platform === 'win32' ? path.join(prefix, 'freespec.cmd') : path.join(prefix, 'bin'));
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* True when the CLI is running from a clone rather than an install. Upgrade
|
|
479
|
+
* advice is meaningless there: the version is whatever the branch says.
|
|
480
|
+
*/
|
|
481
|
+
export function isSourceCheckout(installDir) {
|
|
482
|
+
if (!installDir)
|
|
483
|
+
return false;
|
|
484
|
+
try {
|
|
485
|
+
return fs.existsSync(path.join(installDir, '.git'));
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function detectKnownPackageManager(installDir) {
|
|
492
|
+
const segments = (installDir ?? '').split(/[\\/]/).map((segment) => segment.toLowerCase());
|
|
493
|
+
const has = (...names) => names.some((name) => segments.includes(name));
|
|
494
|
+
if (has('.volta') || (has('volta') && has('tools') && has('image')))
|
|
495
|
+
return 'volta';
|
|
496
|
+
if (has('.bun', '_bunx', 'bun-cache'))
|
|
497
|
+
return 'bun';
|
|
498
|
+
if (has('_npx'))
|
|
499
|
+
return 'npm';
|
|
500
|
+
if (has('.pnpm', '.pnpm-global', 'pnpm-cache'))
|
|
501
|
+
return 'pnpm';
|
|
502
|
+
if (has('pnpm') && has('global', 'dlx', 'store'))
|
|
503
|
+
return 'pnpm';
|
|
504
|
+
if (has('.yarn') || (has('yarn') && has('global')))
|
|
505
|
+
return 'yarn';
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The package manager that owns this copy, so the printed command is one the
|
|
510
|
+
* user's setup will actually honor.
|
|
511
|
+
*/
|
|
512
|
+
export function detectPackageManager(installDir) {
|
|
513
|
+
return detectKnownPackageManager(installDir) ?? 'npm';
|
|
514
|
+
}
|
|
515
|
+
const GLOBAL_UPGRADE_COMMANDS = {
|
|
516
|
+
npm: `npm install -g ${PACKAGE_NAME}@latest`,
|
|
517
|
+
pnpm: `pnpm add -g ${PACKAGE_NAME}@latest`,
|
|
518
|
+
bun: `bun add -g ${PACKAGE_NAME}@latest`,
|
|
519
|
+
yarn: `yarn global add ${PACKAGE_NAME}@latest`,
|
|
520
|
+
volta: `volta install ${PACKAGE_NAME}@latest`,
|
|
521
|
+
};
|
|
522
|
+
function detectGlobalPackageManager(installDir) {
|
|
523
|
+
if (isNpmGlobalInstall(installDir))
|
|
524
|
+
return 'npm';
|
|
525
|
+
if (!installDir)
|
|
526
|
+
return null;
|
|
527
|
+
const segments = installDir.split(/[\\/]/).map((segment) => segment.toLowerCase());
|
|
528
|
+
const has = (...names) => names.some((name) => segments.includes(name));
|
|
529
|
+
const hasSequence = (...names) => segments.some((_, index) => names.every((name, offset) => segments[index + offset] === name));
|
|
530
|
+
if (hasSequence('.volta', 'tools', 'image') || hasSequence('volta', 'tools', 'image')) {
|
|
531
|
+
return 'volta';
|
|
532
|
+
}
|
|
533
|
+
if (has('.pnpm-global') || hasSequence('pnpm', 'global'))
|
|
534
|
+
return 'pnpm';
|
|
535
|
+
if (hasSequence('yarn', 'global') || hasSequence('yarn', 'data', 'global'))
|
|
536
|
+
return 'yarn';
|
|
537
|
+
if (hasSequence('.bun', 'install', 'global'))
|
|
538
|
+
return 'bun';
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
/** Describes the running copy without guessing when its owner is ambiguous. */
|
|
542
|
+
export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.') {
|
|
543
|
+
if (standaloneBinaryPath) {
|
|
544
|
+
return { location: path.dirname(standaloneBinaryPath), packageManager: null, scope: 'standalone' };
|
|
545
|
+
}
|
|
546
|
+
if (!installDir) {
|
|
547
|
+
return { location: null, packageManager: null, scope: null };
|
|
548
|
+
}
|
|
549
|
+
if (isSourceCheckout(installDir)) {
|
|
550
|
+
return { location: installDir, packageManager: null, scope: 'source' };
|
|
551
|
+
}
|
|
552
|
+
if (isEphemeralRunnerInstall(installDir)) {
|
|
553
|
+
return {
|
|
554
|
+
location: installDir,
|
|
555
|
+
packageManager: detectKnownPackageManager(installDir),
|
|
556
|
+
scope: 'temporary',
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
if (isProjectLocalInstall(installDir, projectPath)) {
|
|
560
|
+
return {
|
|
561
|
+
location: installDir,
|
|
562
|
+
packageManager: detectKnownPackageManager(installDir),
|
|
563
|
+
scope: 'project',
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const packageManager = detectGlobalPackageManager(installDir);
|
|
567
|
+
return {
|
|
568
|
+
location: installDir,
|
|
569
|
+
packageManager,
|
|
570
|
+
scope: packageManager ? 'global' : null,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/** Returns a safe update command only for an install with known global ownership. */
|
|
574
|
+
export function getCliUpdateCommand(install) {
|
|
575
|
+
if (install.scope === 'standalone')
|
|
576
|
+
return standaloneUpgradeCommand();
|
|
577
|
+
if (install.scope !== 'global' || install.packageManager === null)
|
|
578
|
+
return null;
|
|
579
|
+
return GLOBAL_UPGRADE_COMMANDS[install.packageManager];
|
|
580
|
+
}
|
|
581
|
+
/** Builds the concise human-readable form of the version report. */
|
|
582
|
+
export function buildVersionReportLines(version, install, update, command = null) {
|
|
583
|
+
const details = [install.packageManager, install.scope].filter(Boolean).join(', ');
|
|
584
|
+
const lines = [`FreeSpec ${version}${details ? ` (${details})` : ''}`];
|
|
585
|
+
if (!update)
|
|
586
|
+
return lines;
|
|
587
|
+
if (update.status === 'available') {
|
|
588
|
+
lines.push(`Update available: ${update.latest}`);
|
|
589
|
+
if (command)
|
|
590
|
+
lines.push(` ${command}`);
|
|
591
|
+
}
|
|
592
|
+
else if (update.status === 'current') {
|
|
593
|
+
lines.push('FreeSpec is up to date.');
|
|
594
|
+
}
|
|
595
|
+
else if (update.status === 'disabled') {
|
|
596
|
+
lines.push('Update check disabled.');
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
lines.push('Could not check for updates.');
|
|
600
|
+
}
|
|
601
|
+
return lines;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Builds the hint, with the upgrade command chosen for how this copy of the CLI
|
|
605
|
+
* was installed. Pure so every branch is assertable.
|
|
606
|
+
*/
|
|
607
|
+
export function buildCliUpdateLines(latestVersion, installDir, projectPath, options = {}) {
|
|
608
|
+
const lines = [`A newer FreeSpec CLI is available (v${OPENSPEC_VERSION} → v${latestVersion}).`];
|
|
609
|
+
// Omitted when we are about to offer to run it — printing a command and then
|
|
610
|
+
// asking to run that same command reads like the user has to do both.
|
|
611
|
+
if (options.withCommand !== false) {
|
|
612
|
+
lines.push(...buildUpgradeCommandLines(installDir, projectPath));
|
|
613
|
+
}
|
|
614
|
+
if (installDir) {
|
|
615
|
+
lines.push(` Running from: ${installDir}`);
|
|
616
|
+
}
|
|
617
|
+
return lines;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* The upgrade command for however this copy was installed, plus the reminder
|
|
621
|
+
* that instruction files come from the CLI and so need a second pass.
|
|
622
|
+
*/
|
|
623
|
+
export function buildUpgradeCommandLines(installDir, projectPath) {
|
|
624
|
+
const lines = [];
|
|
625
|
+
const standalone = standaloneUpgradeCommand();
|
|
626
|
+
if (standalone) {
|
|
627
|
+
lines.push(` ${standalone}`);
|
|
628
|
+
lines.push(' Then run "freespec update" again to pick up new workflows.');
|
|
629
|
+
return lines;
|
|
630
|
+
}
|
|
631
|
+
if (isEphemeralRunnerInstall(installDir)) {
|
|
632
|
+
// That command *is* the update, so there is nothing to run afterwards.
|
|
633
|
+
lines.push(` npx ${PACKAGE_NAME}@latest update`);
|
|
634
|
+
return lines;
|
|
635
|
+
}
|
|
636
|
+
if (isProjectLocalInstall(installDir, projectPath)) {
|
|
637
|
+
// Its package manager owns the lockfile; naming npm could be wrong.
|
|
638
|
+
lines.push(` Update the ${PACKAGE_NAME} dependency in this project.`);
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
lines.push(` ${GLOBAL_UPGRADE_COMMANDS[detectPackageManager(installDir)]}`);
|
|
642
|
+
}
|
|
643
|
+
lines.push(' Then run "freespec update" again to pick up new workflows.');
|
|
644
|
+
return lines;
|
|
645
|
+
}
|
|
646
|
+
// cross-spawn resolves npm's shim on Windows, where spawning "npm" directly
|
|
647
|
+
// fails. Loaded lazily so ordinary runs skip its module graph.
|
|
648
|
+
let cachedSpawn;
|
|
649
|
+
function loadSpawn() {
|
|
650
|
+
if (cachedSpawn === undefined) {
|
|
651
|
+
cachedSpawn = require('cross-spawn');
|
|
652
|
+
}
|
|
653
|
+
return cachedSpawn;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Whether we can run the upgrade for the user instead of only printing it.
|
|
657
|
+
*
|
|
658
|
+
* Only an npm-owned global install qualifies, because `npm install -g` is the
|
|
659
|
+
* only command we run: a pnpm/bun/yarn/volta global would get a second copy
|
|
660
|
+
* that may not be the one on PATH, a project dependency belongs to that
|
|
661
|
+
* project's package manager, an npx/dlx cache has nothing to upgrade, and a
|
|
662
|
+
* source checkout is not an install at all.
|
|
663
|
+
*
|
|
664
|
+
* A non-default registry is also disqualifying. `npm install -g` resolves
|
|
665
|
+
* against whatever `npm_config_registry` says, which npm may have read from a
|
|
666
|
+
* cloned repository's .npmrc — so such a registry may still inform the check,
|
|
667
|
+
* but must never be the thing that offers to install a global package.
|
|
668
|
+
*/
|
|
669
|
+
export function canSelfUpgrade(installDir, projectPath) {
|
|
670
|
+
if (!installDir)
|
|
671
|
+
return false;
|
|
672
|
+
if (!isDefaultRegistry())
|
|
673
|
+
return false;
|
|
674
|
+
if (isEphemeralRunnerInstall(installDir))
|
|
675
|
+
return false;
|
|
676
|
+
// Both anchors matter: `freespec update ../other` from a project that owns
|
|
677
|
+
// the CLI as a dependency is still a project-local install.
|
|
678
|
+
if (isProjectLocalInstall(installDir, projectPath))
|
|
679
|
+
return false;
|
|
680
|
+
if (isProjectLocalInstall(installDir))
|
|
681
|
+
return false;
|
|
682
|
+
if (isSourceCheckout(installDir))
|
|
683
|
+
return false;
|
|
684
|
+
return isNpmGlobalInstall(installDir);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Whether to offer the upgrade rather than just print the command. Kept here,
|
|
688
|
+
* as a pure function of the environment, because the interesting mistakes live
|
|
689
|
+
* in this decision: offering where `npm install -g` cannot help, or asking a
|
|
690
|
+
* question no one can answer.
|
|
691
|
+
*/
|
|
692
|
+
export function shouldOfferUpgrade(params) {
|
|
693
|
+
// A prompt written to a redirected stdout is a question the user never sees
|
|
694
|
+
// and the command waits on forever.
|
|
695
|
+
if (!params.interactive || !params.stdoutIsTty)
|
|
696
|
+
return false;
|
|
697
|
+
return canSelfUpgrade(params.installDir, params.projectPath);
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Runs `npm install -g <pkg>@latest`, inheriting stdio so npm's own output —
|
|
701
|
+
* including any auth or permission prompt — reaches the user directly.
|
|
702
|
+
* Resolves true only on a clean exit.
|
|
703
|
+
*/
|
|
704
|
+
async function runGlobalUpgrade() {
|
|
705
|
+
const spawn = loadSpawn();
|
|
706
|
+
return new Promise((resolve) => {
|
|
707
|
+
const child = spawn('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
|
|
708
|
+
stdio: 'inherit',
|
|
709
|
+
});
|
|
710
|
+
child.on('error', () => resolve(false));
|
|
711
|
+
child.on('close', (code) => resolve(code === 0));
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* The `freespec` npm installs alongside its global package, so the upgrade can
|
|
716
|
+
* be handed to the copy npm just wrote rather than to whatever PATH resolves.
|
|
717
|
+
* Null when it cannot be found, in which case PATH is the only option left.
|
|
718
|
+
*/
|
|
719
|
+
export function upgradedBinPath(roots = npmGlobalRoots(), installDir = getInstallDir()) {
|
|
720
|
+
// The copy npm just replaced tells us exactly which prefix it wrote to;
|
|
721
|
+
// a root derived from the node binary can point at an unrelated install.
|
|
722
|
+
const ownPrefix = npmPrefixFromInstallDir(installDir);
|
|
723
|
+
const ordered = ownPrefix
|
|
724
|
+
? [
|
|
725
|
+
process.platform === 'win32'
|
|
726
|
+
? path.join(ownPrefix, 'node_modules')
|
|
727
|
+
: path.join(ownPrefix, 'lib', 'node_modules'),
|
|
728
|
+
...roots,
|
|
729
|
+
]
|
|
730
|
+
: roots;
|
|
731
|
+
for (const root of ordered) {
|
|
732
|
+
// npm writes the shim beside the global root on Windows
|
|
733
|
+
// (%APPDATA%\\npm\\freespec.cmd) and in <prefix>/bin on POSIX.
|
|
734
|
+
const candidates = process.platform === 'win32'
|
|
735
|
+
? [path.join(path.dirname(root), 'freespec.cmd')]
|
|
736
|
+
: [path.resolve(root, '..', '..', 'bin', 'freespec')];
|
|
737
|
+
for (const candidate of candidates) {
|
|
738
|
+
try {
|
|
739
|
+
if (fs.existsSync(candidate))
|
|
740
|
+
return candidate;
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
// Unreadable candidate; try the next one.
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Asks a CLI binary its version. Used to confirm an upgrade actually landed:
|
|
751
|
+
* `npm install -g` exits 0 even when it installed nothing, so its exit code
|
|
752
|
+
* alone cannot justify telling the user they are on a new version.
|
|
753
|
+
*/
|
|
754
|
+
export function readCliVersion(binPath) {
|
|
755
|
+
const spawn = loadSpawn();
|
|
756
|
+
return new Promise((resolve) => {
|
|
757
|
+
let output = '';
|
|
758
|
+
let child;
|
|
759
|
+
try {
|
|
760
|
+
child = spawn(binPath, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
resolve(null);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
// Never let a probe hold the CLI open: a wrapper that traps SIGTERM would
|
|
767
|
+
// otherwise keep the process alive for as long as it runs.
|
|
768
|
+
child.unref();
|
|
769
|
+
const timer = setTimeout(() => {
|
|
770
|
+
child.kill('SIGKILL');
|
|
771
|
+
resolve(null);
|
|
772
|
+
}, VERSION_PROBE_TIMEOUT_MS);
|
|
773
|
+
child.stdout?.on('data', (chunk) => {
|
|
774
|
+
output += chunk.toString();
|
|
775
|
+
});
|
|
776
|
+
child.on('error', () => {
|
|
777
|
+
clearTimeout(timer);
|
|
778
|
+
resolve(null);
|
|
779
|
+
});
|
|
780
|
+
child.on('close', () => {
|
|
781
|
+
clearTimeout(timer);
|
|
782
|
+
// A line that is only a version, not the first version-shaped token
|
|
783
|
+
// anywhere: a wrapper banner ("Node.js v25.8.1 | FreeSpec") would
|
|
784
|
+
// otherwise be read as the answer.
|
|
785
|
+
const version = output
|
|
786
|
+
.split(/\r?\n/)
|
|
787
|
+
.map((line) => line.trim())
|
|
788
|
+
.filter((line) => SAFE_VERSION.test(line.replace(/^v/, '')))
|
|
789
|
+
.pop();
|
|
790
|
+
resolve(version ? version.replace(/^v/, '') : null);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
function isPromptCancellation(error) {
|
|
795
|
+
const name = error?.name;
|
|
796
|
+
return name === 'ExitPromptError' || name === 'AbortPromptError';
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Offers to run the upgrade and reports what actually happened. The version is
|
|
800
|
+
* read back from the installed binary rather than assumed, so "upgraded" is a
|
|
801
|
+
* fact and a PATH that still answers with the old copy is caught here instead
|
|
802
|
+
* of silently doing nothing.
|
|
803
|
+
*/
|
|
804
|
+
export async function offerCliUpgrade(latestVersion) {
|
|
805
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
806
|
+
let accepted = false;
|
|
807
|
+
try {
|
|
808
|
+
accepted = await confirm({
|
|
809
|
+
message: `Upgrade to v${latestVersion} now?`,
|
|
810
|
+
default: true,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
// Ctrl-C means stop, not "no thanks, carry on with everything else".
|
|
815
|
+
return isPromptCancellation(error) ? 'cancelled' : 'declined';
|
|
816
|
+
}
|
|
817
|
+
if (!accepted)
|
|
818
|
+
return 'declined';
|
|
819
|
+
console.log();
|
|
820
|
+
const installed = await runGlobalUpgrade();
|
|
821
|
+
console.log();
|
|
822
|
+
if (!installed) {
|
|
823
|
+
console.log(chalk.yellow('The upgrade did not complete. A global install may need'));
|
|
824
|
+
console.log(chalk.yellow('elevated permissions, or a different package manager.'));
|
|
825
|
+
return 'failed';
|
|
826
|
+
}
|
|
827
|
+
const binPath = upgradedBinPath();
|
|
828
|
+
const version = await readCliVersion(binPath ?? 'freespec');
|
|
829
|
+
if (!version) {
|
|
830
|
+
console.log(chalk.yellow('Upgrade finished, but no "freespec" could be run to confirm it.'));
|
|
831
|
+
return 'not-on-path';
|
|
832
|
+
}
|
|
833
|
+
if (compareVersions(version, OPENSPEC_VERSION) <= 0) {
|
|
834
|
+
console.log(chalk.yellow(`Upgrade finished, but "freespec" still reports v${version}.`));
|
|
835
|
+
console.log(chalk.dim(binPath
|
|
836
|
+
? // We asked the installed copy directly, so PATH is not the story.
|
|
837
|
+
` npm reported success, but ${binPath} did not change.`
|
|
838
|
+
: ' Another install earlier on your PATH is answering first.'));
|
|
839
|
+
return 'not-on-path';
|
|
840
|
+
}
|
|
841
|
+
console.log(chalk.green(`✓ Upgraded to v${version}.`));
|
|
842
|
+
return 'upgraded';
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Runs `freespec update` again with the CLI that was just installed — this
|
|
846
|
+
* process is still the old code, so it cannot write the new workflows itself.
|
|
847
|
+
* Resolves the exit code to pass along; when no `freespec` is on PATH the
|
|
848
|
+
* upgrade still landed but nothing was regenerated, so it says so and
|
|
849
|
+
* resolves 0 rather than reporting a failure the upgrade did not have.
|
|
850
|
+
*/
|
|
851
|
+
export async function rerunUpdateWithUpgradedCli(projectPath, options = {}) {
|
|
852
|
+
const spawn = loadSpawn();
|
|
853
|
+
const binPath = options.binPath ?? upgradedBinPath() ?? 'freespec';
|
|
854
|
+
// The re-run stands in for the command the user typed, so it has to carry
|
|
855
|
+
// the flags they typed with it.
|
|
856
|
+
const args = ['update'];
|
|
857
|
+
if (options.force)
|
|
858
|
+
args.push('--force');
|
|
859
|
+
// `--` so a path that looks like a flag stays a path.
|
|
860
|
+
args.push('--', projectPath);
|
|
861
|
+
return new Promise((resolve) => {
|
|
862
|
+
const child = spawn(binPath, args, {
|
|
863
|
+
stdio: 'inherit',
|
|
864
|
+
env: {
|
|
865
|
+
...process.env,
|
|
866
|
+
// The child must not offer the upgrade again: if PATH still resolves
|
|
867
|
+
// to the old binary, prompting would loop forever.
|
|
868
|
+
OPENSPEC_NO_UPDATE_CHECK: '1',
|
|
869
|
+
// This is a continuation of the command the user already ran, and the
|
|
870
|
+
// parent recorded it; counting it twice would overstate usage.
|
|
871
|
+
OPENSPEC_TELEMETRY: '0',
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
child.on('error', () => {
|
|
875
|
+
// Nothing to hand off to: the upgrade landed but the instruction files
|
|
876
|
+
// are still the old ones, so this run did not do what was asked.
|
|
877
|
+
console.log(chalk.yellow('Instruction files were not regenerated.'));
|
|
878
|
+
console.log(chalk.dim(' Run "freespec update" to pick up the new workflows.'));
|
|
879
|
+
resolve(1);
|
|
880
|
+
});
|
|
881
|
+
// A child killed by a signal reports no code; that is not success.
|
|
882
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Prints the upgrade hint. Instruction files are generated by the installed
|
|
887
|
+
* CLI, so "up to date" only ever means "matches this CLI" — without this note
|
|
888
|
+
* a stale install looks like a successful update.
|
|
889
|
+
*/
|
|
890
|
+
export function displayCliUpdateNote(latestVersion, projectPath = '.', options = {}) {
|
|
891
|
+
const [headline, ...rest] = buildCliUpdateLines(latestVersion, getInstallDir(), projectPath, options);
|
|
892
|
+
console.log();
|
|
893
|
+
console.log(chalk.yellow(headline));
|
|
894
|
+
for (const line of rest) {
|
|
895
|
+
console.log(chalk.dim(line));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Prints just the manual command, for when the offer was declined or failed.
|
|
900
|
+
*/
|
|
901
|
+
export function displayUpgradeCommand(projectPath = '.') {
|
|
902
|
+
for (const line of buildUpgradeCommandLines(getInstallDir(), projectPath)) {
|
|
903
|
+
console.log(chalk.dim(line));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
//# sourceMappingURL=version-check.js.map
|