@fission-ai/openspec 1.16.0 → 1.16.1

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/dist/cli/index.js CHANGED
@@ -123,7 +123,7 @@ program
123
123
  .action(async (options) => {
124
124
  const { getCliInstallInfo, checkForCliUpdate, getCliUpdateCommand, canSelfUpgrade, buildVersionReportLines, } = await import('../core/version-check.js');
125
125
  const install = getCliInstallInfo();
126
- const update = options.check ? await checkForCliUpdate() : undefined;
126
+ const update = options.check ? await checkForCliUpdate({ requested: true }) : undefined;
127
127
  const command = update?.status === 'available' ? getCliUpdateCommand(install) : null;
128
128
  const output = {
129
129
  schemaVersion: 1,
@@ -1,6 +1,6 @@
1
1
  import { MarkdownParser } from './markdown-parser.js';
2
2
  import { buildCodeFenceMask } from './requirement-text.js';
3
- import { parseDeltaSpec } from './requirement-blocks.js';
3
+ import { parseDeltaSpec, stripClosingSequence } from './requirement-blocks.js';
4
4
  import path from 'path';
5
5
  import { promises as fs } from 'fs';
6
6
  import { discoverSpecFiles } from '../../utils/spec-discovery.js';
@@ -171,7 +171,8 @@ export class ChangeParser extends MarkdownParser {
171
171
  const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
172
172
  if (headerMatch) {
173
173
  const level = headerMatch[1].length;
174
- const title = headerMatch[2].trim();
174
+ // `## Purpose ##` is the same heading as `## Purpose`.
175
+ const title = stripClosingSequence(headerMatch[2]);
175
176
  const contentLines = this.getContentUntilNextHeaderFromLines(lines, codeFenceLineMask, i + 1, level);
176
177
  const section = {
177
178
  level,
@@ -1,5 +1,5 @@
1
1
  import { buildCodeFenceMask, extractRequirementText, hasScenarioBody } from './requirement-text.js';
2
- import { normalizeRequirementName, scenarioNameFromHeaderText } from './requirement-blocks.js';
2
+ import { normalizeRequirementName, scenarioNameFromHeaderText, stripClosingSequence } from './requirement-blocks.js';
3
3
  export class MarkdownParser {
4
4
  lines;
5
5
  codeFenceLineMask;
@@ -68,7 +68,8 @@ export class MarkdownParser {
68
68
  const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
69
69
  if (headerMatch) {
70
70
  const level = headerMatch[1].length;
71
- const title = headerMatch[2].trim();
71
+ // `## Purpose ##` is the same heading as `## Purpose`.
72
+ const title = stripClosingSequence(headerMatch[2]);
72
73
  const content = this.getContentUntilNextHeader(i + 1, level);
73
74
  const section = {
74
75
  level,
@@ -10,6 +10,15 @@ export interface RequirementsSectionParts {
10
10
  bodyBlocks: RequirementBlock[];
11
11
  after: string;
12
12
  }
13
+ /**
14
+ * Drop an ATX heading's closing run of `#`s: `## Purpose ##` renders as
15
+ * `Purpose`, so the run is not part of the title. As for scenario names, only a
16
+ * run preceded by a space or tab closes the heading, so `C#` keeps its `#`, and
17
+ * `[ \t]` rather than `\s` keeps an NBSP-separated run in the title.
18
+ */
19
+ export declare function stripClosingSequence(title: string): string;
20
+ /** The main spec's `## Requirements` header, closing run allowed (`## Requirements ##`). */
21
+ export declare const REQUIREMENTS_SECTION_HEADER_REGEX: RegExp;
13
22
  export declare function normalizeRequirementName(name: string): string;
14
23
  /**
15
24
  * Case- and whitespace-insensitive fold of a requirement name. Requirement
@@ -105,6 +114,15 @@ export interface DeltaPlan {
105
114
  * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
106
115
  */
107
116
  export declare function parseDeltaSpec(content: string): DeltaPlan;
117
+ /**
118
+ * True when an orphaned requirement sits under a `## ... Requirements` header
119
+ * that is not one of the four delta sections - `## DEPRECATED Requirements`,
120
+ * `## Add Requirements`, `## Requirements`. Such a header reads as a delta
121
+ * operation, so its requirements were meant to be applied: validate reports
122
+ * them as an ERROR and archive refuses (#2195). Orphans under prose sections
123
+ * (`## Notes`, `## Behavior`) or above the first `## ` stay warnings.
124
+ */
125
+ export declare function isUnrecognizedDeltaSection(section: string | null): boolean;
108
126
  export declare function describeUnappliedContent(content: UnappliedContent, sentenceStart?: boolean): string;
109
127
  /**
110
128
  * One `#### ` child of a requirement block: its rendered name and its lines,
@@ -1,10 +1,18 @@
1
1
  import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
2
+ /**
3
+ * Drop an ATX heading's closing run of `#`s: `## Purpose ##` renders as
4
+ * `Purpose`, so the run is not part of the title. As for scenario names, only a
5
+ * run preceded by a space or tab closes the heading, so `C#` keeps its `#`, and
6
+ * `[ \t]` rather than `\s` keeps an NBSP-separated run in the title.
7
+ */
8
+ export function stripClosingSequence(title) {
9
+ return title.replace(/[ \t]+#+[ \t]*$/, '').trim();
10
+ }
11
+ /** The main spec's `## Requirements` header, closing run allowed (`## Requirements ##`). */
12
+ export const REQUIREMENTS_SECTION_HEADER_REGEX = /^##\s+Requirements(?:[ \t]+#+)?\s*$/i;
2
13
  export function normalizeRequirementName(name) {
3
- // An ATX heading may end in a closing run of `#`s: `### Requirement: Foo ###`
4
- // renders as `Foo`, so the run is not part of the name. As for scenario names,
5
- // only a run preceded by a space or tab closes the heading, so `C#` keeps its
6
- // `#`, and `[ \t]` rather than `\s` keeps an NBSP-separated run in the name.
7
- return name.replace(/[ \t]+#+[ \t]*$/, '').trim();
14
+ // `### Requirement: Foo ###` names the requirement `Foo`.
15
+ return stripClosingSequence(name);
8
16
  }
9
17
  /**
10
18
  * Case- and whitespace-insensitive fold of a requirement name. Requirement
@@ -26,7 +34,7 @@ export function extractRequirementsSection(content) {
26
34
  const normalized = normalizeLineEndings(content);
27
35
  const lines = normalized.split('\n');
28
36
  const fenceMask = buildCodeFenceMask(lines);
29
- const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] && /^##\s+Requirements\s*$/i.test(l));
37
+ const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] && REQUIREMENTS_SECTION_HEADER_REGEX.test(l));
30
38
  if (reqHeaderIndex === -1) {
31
39
  // No requirements section; create an empty one at the end
32
40
  const before = content.trimEnd();
@@ -154,6 +162,19 @@ export function parseDeltaSpec(content) {
154
162
  * under it would be dropped without this warning.
155
163
  */
156
164
  const DELTA_SECTION_TITLES = new Set(['ADDED Requirements', 'MODIFIED Requirements', 'REMOVED Requirements', 'RENAMED Requirements'].map((title) => title.toLowerCase()));
165
+ /**
166
+ * True when an orphaned requirement sits under a `## ... Requirements` header
167
+ * that is not one of the four delta sections - `## DEPRECATED Requirements`,
168
+ * `## Add Requirements`, `## Requirements`. Such a header reads as a delta
169
+ * operation, so its requirements were meant to be applied: validate reports
170
+ * them as an ERROR and archive refuses (#2195). Orphans under prose sections
171
+ * (`## Notes`, `## Behavior`) or above the first `## ` stay warnings.
172
+ */
173
+ export function isUnrecognizedDeltaSection(section) {
174
+ return (section !== null &&
175
+ /\brequirements?$/i.test(section) &&
176
+ !DELTA_SECTION_TITLES.has(section.toLowerCase()));
177
+ }
157
178
  /**
158
179
  * Every canonical `### Requirement:` header that is not inside a delta section,
159
180
  * in document order.
@@ -161,10 +182,12 @@ const DELTA_SECTION_TITLES = new Set(['ADDED Requirements', 'MODIFIED Requiremen
161
182
  * Walks the whole file rather than the parsed sections so a requirement written
162
183
  * ABOVE the first `## ` header is reported too - it is dropped just as silently
163
184
  * as one under `## Notes`. Fenced lines are skipped, so a requirement shown
164
- * inside a markdown example is not mistaken for an authored one.
185
+ * inside a markdown example is not mistaken for an authored one, and so are
186
+ * HTML comments, so a commented-out draft is not reported as dropped.
165
187
  */
166
188
  function findOrphanedRequirements(lines, fenceMask) {
167
189
  const orphans = [];
190
+ const masked = maskHtmlComments(lines, fenceMask);
168
191
  let section = null;
169
192
  for (let i = 0; i < lines.length; i++) {
170
193
  if (fenceMask[i])
@@ -172,12 +195,12 @@ function findOrphanedRequirements(lines, fenceMask) {
172
195
  // The same `## ` test splitTopLevelSections uses, so both agree on sections.
173
196
  const sectionMatch = lines[i].match(/^(##)\s+(.+)$/);
174
197
  if (sectionMatch) {
175
- section = sectionMatch[2].trim();
198
+ section = stripClosingSequence(sectionMatch[2]);
176
199
  continue;
177
200
  }
178
201
  if (section !== null && DELTA_SECTION_TITLES.has(section.toLowerCase()))
179
202
  continue;
180
- const header = lines[i].match(REQUIREMENT_HEADER_REGEX);
203
+ const header = masked[i].match(REQUIREMENT_HEADER_REGEX);
181
204
  if (header) {
182
205
  orphans.push({
183
206
  name: normalizeRequirementName(header[1]),
@@ -201,18 +224,24 @@ export function describeUnappliedContent(content, sentenceStart = false) {
201
224
  * (warned about separately), REMOVED/RENAMED bodies, and stray `###` headers
202
225
  * before the first requirement (INFO since #498) are not reported.
203
226
  */
204
- function findUnappliedContent(lines, fenceMask) {
205
- // Blank out comments outside fences, keeping line count; an unterminated one
206
- // runs to EOF. Comments only decide whether a line holds text: sections come
207
- // from the same split the merge reads, where a `## ` line inside a comment is
208
- // still a boundary. Fenced lines keep their text, so a `<!--` in a code sample
209
- // never hides what follows the fence.
227
+ /**
228
+ * Blank out comments outside fences, keeping line count; an unterminated one
229
+ * runs to EOF. Fenced lines keep their text, so a `<!--` in a code sample never
230
+ * hides what follows the fence.
231
+ */
232
+ function maskHtmlComments(lines, fenceMask) {
210
233
  const comments = lines
211
234
  .map((line, i) => (fenceMask[i] ? '' : line))
212
235
  .join('\n')
213
236
  .replace(/<!--[\s\S]*?(?:--!?>|$)/g, (m) => m.replace(/[^\n]/g, ' '))
214
237
  .split('\n');
215
- const masked = lines.map((line, i) => (fenceMask[i] ? line : comments[i]));
238
+ return lines.map((line, i) => (fenceMask[i] ? line : comments[i]));
239
+ }
240
+ function findUnappliedContent(lines, fenceMask) {
241
+ // Comments only decide whether a line holds text: sections come from the
242
+ // same split the merge reads, where a `## ` line inside a comment is still a
243
+ // boundary.
244
+ const masked = maskHtmlComments(lines, fenceMask);
216
245
  const sections = splitTopLevelSections(lines, fenceMask);
217
246
  const firstText = (from, to, skip) => {
218
247
  for (let i = from; i < to; i++)
@@ -260,7 +289,8 @@ function splitTopLevelSections(lines, fenceMask) {
260
289
  continue;
261
290
  const m = lines[i].match(/^(##)\s+(.+)$/);
262
291
  if (m) {
263
- indices.push({ title: m[2].trim(), index: i });
292
+ // `## ADDED Requirements ##` is the same section as `## ADDED Requirements`.
293
+ indices.push({ title: stripClosingSequence(m[2]), index: i });
264
294
  }
265
295
  }
266
296
  for (let i = 0; i < indices.length; i++) {
@@ -1,8 +1,7 @@
1
1
  import { buildCodeFenceMask } from './code-fence.js';
2
- import { normalizeRequirementName } from './requirement-blocks.js';
3
- const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
2
+ import { normalizeRequirementName, REQUIREMENTS_SECTION_HEADER_REGEX as REQUIREMENTS_SECTION_HEADER, } from './requirement-blocks.js';
4
3
  const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
5
- const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
4
+ const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements(?:[ \t]+#+)?\s*$/i;
6
5
  const REQUIREMENT_HEADER = /^###\s*Requirement:\s*(.+)\s*$/i;
7
6
  export function findMainSpecStructureIssues(content) {
8
7
  const normalized = content.replace(/\r\n?/g, '\n');
@@ -8,7 +8,7 @@ import { promises as fs } from 'fs';
8
8
  import { randomUUID } from 'crypto';
9
9
  import path from 'path';
10
10
  import chalk from 'chalk';
11
- import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
+ import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, foldRequirementName, isUnrecognizedDeltaSection, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
12
12
  import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
13
13
  import { buildCodeFenceMask } from './parsers/code-fence.js';
14
14
  import { MarkdownParser } from './parsers/markdown-parser.js';
@@ -137,7 +137,15 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
137
137
  // A well-formed requirement written outside every delta section is not
138
138
  // applied. Say so here as well as in validate: archive is the last point at
139
139
  // which the author can still notice, and the block reads exactly like one
140
- // that would have applied.
140
+ // that would have applied. Under an unrecognized `## ... Requirements`
141
+ // header it was meant as a delta operation, so refuse rather than drop it -
142
+ // the same verdict validate gives (#2195).
143
+ const misplaced = plan.orphanedRequirements.find((o) => isUnrecognizedDeltaSection(o.section));
144
+ if (misplaced) {
145
+ throw new Error(`${specName} validation failed - requirement "${misplaced.name}" (line ${misplaced.line}) is under ` +
146
+ `"## ${misplaced.section}", which is not a delta section, so it would not be applied. ` +
147
+ `Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
148
+ }
141
149
  for (const orphan of plan.orphanedRequirements) {
142
150
  const where = orphan.section
143
151
  ? `under "## ${orphan.section}"`
@@ -1141,7 +1149,7 @@ function extractPurposeSection(content) {
1141
1149
  const masked = maskHtmlComments(normalized).split('\n');
1142
1150
  const fenceMask = buildCodeFenceMask(masked);
1143
1151
  const isStructural = (i) => !fenceMask[i];
1144
- const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose\s*$/i.test(line));
1152
+ const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose(?:[ \t]+#+)?\s*$/i.test(line));
1145
1153
  if (start === -1)
1146
1154
  return undefined;
1147
1155
  let end = masked.length;
@@ -25,7 +25,7 @@ const MARKER_PUNCTUATION = '(?=[ \\t]*(?:$|\\n|[:\\-\u2013\u2014.,;()\\[\\]{}]))
25
25
  const LEADING_MARKER_SHOUTED = new RegExp(`^(?:TBD|TODO)${WORD_END}`, 'u');
26
26
  /** Any other case: a marker only when punctuation or the line end says so. */
27
27
  const LEADING_MARKER_PUNCTUATED = new RegExp(`^(?:TBD|TODO)${WORD_END}${MARKER_PUNCTUATION}`, 'iu');
28
- const PURPOSE_HEADER = /^ {0,3}##(?!#)[ \t]+Purpose[ \t]*$/i;
28
+ const PURPOSE_HEADER = /^ {0,3}##(?!#)[ \t]+Purpose(?:[ \t]+#+)?[ \t]*$/i;
29
29
  const TOP_LEVEL_HEADER = /^ {0,3}#{1,2}(?!#)[ \t]+/;
30
30
  /**
31
31
  * The lines of `text` that sit outside a fenced code block, with line endings
@@ -5,7 +5,7 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
5
5
  import { ChangeParser } from '../parsers/change-parser.js';
6
6
  import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
7
7
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
8
- import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, parseScenarioBlocks, isScenarioLabeledHeader, describeUnappliedContent, } from '../parsers/requirement-blocks.js';
8
+ import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, parseScenarioBlocks, isScenarioLabeledHeader, describeUnappliedContent, isUnrecognizedDeltaSection, } from '../parsers/requirement-blocks.js';
9
9
  import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, hasScenarioBody, scenarioStepCoverage, } from '../parsers/requirement-text.js';
10
10
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
@@ -195,12 +195,15 @@ export class Validator {
195
195
  // ignored - reported as a WARNING rather than an ERROR because a
196
196
  // handful of pre-format archived changes still carry this shape, and
197
197
  // the fix is to move the block, not to reject the change outright.
198
+ // Under an unrecognized `## ... Requirements` header it is an ERROR:
199
+ // that header reads as a delta operation, so the requirement was meant
200
+ // to be applied and archive refuses it too (#2195).
198
201
  for (const orphan of plan.orphanedRequirements) {
199
202
  const where = orphan.section
200
203
  ? `under "## ${orphan.section}"`
201
204
  : 'above the first "## " section';
202
205
  issues.push({
203
- level: 'WARNING',
206
+ level: isUnrecognizedDeltaSection(orphan.section) ? 'ERROR' : 'WARNING',
204
207
  path: entryPath,
205
208
  line: orphan.line,
206
209
  message: `Requirement "${orphan.name}" is ${where}, which is not a delta section, so it is ignored. Move it under "## ADDED Requirements", "## MODIFIED Requirements", "## REMOVED Requirements", or "## RENAMED Requirements".`,
@@ -28,9 +28,12 @@ export interface CliUpdateCheck {
28
28
  }
29
29
  /**
30
30
  * Checks the registry while preserving enough detail for callers to explain
31
- * why no newer version was reported. Never throws.
31
+ * why no newer version was reported. Never throws. Pass `requested` only when
32
+ * the user explicitly asked for the check.
32
33
  */
33
- export declare function checkForCliUpdate(): Promise<CliUpdateCheck>;
34
+ export declare function checkForCliUpdate(options?: {
35
+ requested?: boolean;
36
+ }): Promise<CliUpdateCheck>;
34
37
  /**
35
38
  * Returns the published version when the installed CLI is behind it, otherwise
36
39
  * null. Kept as the compatibility surface used by `openspec update`.
@@ -57,15 +57,21 @@ function standaloneUpgradeCommand() {
57
57
  * tests, an explicit escape hatch for anyone offline or air-gapped, and the
58
58
  * same privacy signals telemetry already honors — a user who set DO_NOT_TRACK
59
59
  * or telemetry.enabled false did not agree to a different outbound request.
60
+ *
61
+ * `requested` is an explicit `openspec version --check`: the user asked for
62
+ * this one request, so neither the telemetry signals nor CI detection apply
63
+ * to it. Only the update-check opt-out and the test guard still stop it.
60
64
  */
61
- function isCheckEnabled() {
65
+ function isCheckEnabled(requested) {
62
66
  if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined)
63
67
  return false;
64
- if (isTelemetryOptedOutByEnv())
68
+ if (process.env.NODE_ENV === 'test')
65
69
  return false;
70
+ if (requested)
71
+ return true;
66
72
  if (isCiEnvironment())
67
73
  return false;
68
- if (process.env.NODE_ENV === 'test')
74
+ if (isTelemetryOptedOutByEnv())
69
75
  return false;
70
76
  // Same config opt-out as telemetry (env remains the hard override above).
71
77
  if (getGlobalConfig().telemetry?.enabled === false)
@@ -180,40 +186,28 @@ export function compareVersions(a, b) {
180
186
  return comparePrerelease(left.prerelease, right.prerelease);
181
187
  }
182
188
  /**
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.
189
+ * One GET, finished by `deadline`. Uses node:http(s) rather than fetch so the
190
+ * timeout can destroy the socket. Aborting a fetch that is still completing
191
+ * its TCP handshake — a firewall dropping packets, a captive portal — leaves
192
+ * the connect handle open and the CLI cannot exit until the OS gives up, long
193
+ * after the hint has printed.
186
194
  *
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.
195
+ * Resolves the final status (with the body only for a 200), or null on any
196
+ * network failure, an oversized body, or a redirect that cannot be followed.
197
+ * With `followRedirects: false` a redirect is returned as-is, unread.
191
198
  */
192
- function fetchLatestVersion() {
199
+ function httpsGet(url, options) {
193
200
  return new Promise((resolve) => {
194
201
  let settled = false;
195
202
  let timer;
196
- const finish = (version) => {
203
+ const finish = (result) => {
197
204
  if (settled)
198
205
  return;
199
206
  settled = true;
200
207
  if (timer)
201
208
  clearTimeout(timer);
202
- resolve(version);
209
+ resolve(result);
203
210
  };
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
211
  // Mirrors and corporate front-ends redirect; without following one the
218
212
  // check would be permanently and silently dead for them.
219
213
  let redirectsLeft = MAX_REDIRECTS;
@@ -229,6 +223,10 @@ function fetchLatestVersion() {
229
223
  if (status >= 300 && status < 400 && location) {
230
224
  response.resume();
231
225
  request.destroy();
226
+ if (!options.followRedirects) {
227
+ finish({ status, body: '' });
228
+ return;
229
+ }
232
230
  if (redirectsLeft <= 0) {
233
231
  finish(null);
234
232
  return;
@@ -256,29 +254,20 @@ function fetchLatestVersion() {
256
254
  if (status !== 200) {
257
255
  response.resume();
258
256
  request.destroy();
259
- finish(null);
257
+ finish({ status, body: '' });
260
258
  return;
261
259
  }
262
260
  let body = '';
263
261
  response.setEncoding('utf-8');
264
262
  response.on('data', (chunk) => {
265
263
  body += chunk;
266
- // The dist-tag document is small; refuse to buffer a firehose.
264
+ // Version documents are small; refuse to buffer a firehose.
267
265
  if (body.length > MAX_RESPONSE_BYTES) {
268
266
  request.destroy();
269
267
  finish(null);
270
268
  }
271
269
  });
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
- });
270
+ response.on('end', () => finish({ status, body }));
282
271
  response.on('error', () => finish(null));
283
272
  });
284
273
  activeRequest = request;
@@ -292,22 +281,105 @@ function fetchLatestVersion() {
292
281
  timer = setTimeout(() => {
293
282
  activeRequest?.destroy();
294
283
  finish(null);
295
- }, REQUEST_TIMEOUT_MS);
284
+ }, Math.max(0, options.deadline - Date.now()));
296
285
  }
297
286
  };
298
287
  send(url);
299
288
  });
300
289
  }
290
+ /**
291
+ * Reads the `latest` dist-tag. Sends no custom Accept header: the registry
292
+ * answers `/<pkg>/latest` with 406 for npm's abbreviated-metadata type, which
293
+ * it only serves on the full packument.
294
+ */
295
+ async function fetchLatestVersion() {
296
+ let url;
297
+ try {
298
+ const resolved = registryUrl();
299
+ if (resolved === null)
300
+ return null;
301
+ url = new URL(resolved);
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ const response = await httpsGet(url, {
307
+ deadline: Date.now() + REQUEST_TIMEOUT_MS,
308
+ followRedirects: true,
309
+ });
310
+ if (!response || response.status !== 200)
311
+ return null;
312
+ try {
313
+ const version = JSON.parse(response.body).version;
314
+ return typeof version === 'string' && SAFE_VERSION.test(version) ? version : null;
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
320
+ /**
321
+ * Where `openspec install` gets the standalone binary: install.sh and
322
+ * install.ps1 default to this releases page.
323
+ */
324
+ const RELEASES_URL = 'https://github.com/Fission-AI/OpenSpec/releases';
325
+ /**
326
+ * The release `openspec install` would install, found the way install.sh and
327
+ * install.ps1 find it: stable tags from the releases feed, newest first, and
328
+ * the first one whose SHA256SUMS (uploaded last) exists. npm's `latest` is not
329
+ * the answer here: it is published minutes before the standalone files are
330
+ * attached to the GitHub release (and for good, if that job fails), and
331
+ * offering a version the installer cannot get makes `openspec install`
332
+ * quietly reinstall the old one.
333
+ *
334
+ * Stops at the first tag that is not newer than this CLI, since nothing from
335
+ * there on is an update, so an up-to-date binary costs only the feed request.
336
+ * Null when the answer is not known: the feed or a probe failed or ran out of
337
+ * time, or every listed newer release is still incomplete.
338
+ */
339
+ async function fetchLatestStandaloneRelease() {
340
+ // The same total budget as the registry check, across every request.
341
+ const deadline = Date.now() + REQUEST_TIMEOUT_MS;
342
+ const feed = await httpsGet(new URL(`${RELEASES_URL}.atom`), { deadline, followRedirects: true });
343
+ if (!feed || feed.status !== 200)
344
+ return null;
345
+ const versions = [
346
+ ...new Set(Array.from(feed.body.matchAll(/\/releases\/tag\/v(\d+\.\d+\.\d+)"/g), (m) => m[1])),
347
+ ].filter((version) => SAFE_VERSION.test(version));
348
+ for (const version of versions) {
349
+ if (compareVersions(version, OPENSPEC_VERSION) <= 0)
350
+ return version;
351
+ // GitHub answers for an attached file with a redirect to its storage, so
352
+ // the file itself never needs downloading.
353
+ const probe = await httpsGet(new URL(`${RELEASES_URL}/download/v${version}/SHA256SUMS`), {
354
+ deadline,
355
+ followRedirects: false,
356
+ });
357
+ if (!probe)
358
+ return null;
359
+ if (probe.status === 200 || (probe.status >= 300 && probe.status < 400))
360
+ return version;
361
+ // Still being prepared: the installer moves on to the next release too.
362
+ if (probe.status === 404)
363
+ continue;
364
+ // Rate-limited or failing: not knowing beats a wrong answer.
365
+ return null;
366
+ }
367
+ return null;
368
+ }
301
369
  /**
302
370
  * Checks the registry while preserving enough detail for callers to explain
303
- * why no newer version was reported. Never throws.
371
+ * why no newer version was reported. Never throws. Pass `requested` only when
372
+ * the user explicitly asked for the check.
304
373
  */
305
- export async function checkForCliUpdate() {
306
- if (!isCheckEnabled() || registryUrl() === null) {
374
+ export async function checkForCliUpdate(options = {}) {
375
+ // The standalone binary updates from GitHub releases, not npm, so the
376
+ // registry neither answers for it nor can disable its check.
377
+ const standalone = standaloneBinaryPath !== null;
378
+ if (!isCheckEnabled(options.requested === true) || (!standalone && registryUrl() === null)) {
307
379
  return { status: 'disabled', latest: null };
308
380
  }
309
381
  try {
310
- const latest = await fetchLatestVersion();
382
+ const latest = standalone ? await fetchLatestStandaloneRelease() : await fetchLatestVersion();
311
383
  if (!latest)
312
384
  return { status: 'offline', latest: null };
313
385
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -43,15 +43,15 @@
43
43
  "devDependencies": {
44
44
  "@changesets/changelog-github": "^1.0.0",
45
45
  "@changesets/cli": "^3.0.1",
46
- "@types/node": "^20.19.43",
46
+ "@types/node": "^22.12.0",
47
47
  "@types/picomatch": "^4.0.3",
48
- "@vitest/ui": "^4.1.12",
48
+ "@vitest/ui": "^5.0.3",
49
49
  "eslint": "^10.5.0",
50
50
  "smol-toml": "^1.7.1",
51
51
  "typescript": "^6.0.3",
52
52
  "typescript-eslint": "^8.65.0",
53
53
  "vite": "^8.3.5",
54
- "vitest": "^4.1.12"
54
+ "vitest": "^5.0.3"
55
55
  },
56
56
  "dependencies": {
57
57
  "@inquirer/core": "^12.0.0",