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