@fission-ai/openspec 1.15.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.
@@ -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.15.0",
3
+ "version": "1.16.1",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -38,20 +38,20 @@
38
38
  "!dist/**/*.map"
39
39
  ],
40
40
  "engines": {
41
- "node": ">=20.19.0"
41
+ "node": ">=22.12.0"
42
42
  },
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
- "vite": "^7.3.7",
54
- "vitest": "^4.1.12"
53
+ "vite": "^8.3.5",
54
+ "vitest": "^5.0.3"
55
55
  },
56
56
  "dependencies": {
57
57
  "@inquirer/core": "^12.0.0",
@@ -105,6 +105,7 @@ artifacts:
105
105
  - Each scenario: `#### Scenario: <name>` with WHEN/THEN format
106
106
  - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
107
107
  - Every requirement MUST have at least one scenario.
108
+ - Every scenario MUST have a WHEN/GIVEN step (its trigger) and a THEN step (its outcome). A scenario with only AND lines or plain prose still counts as a scenario, but `openspec validate` flags it as a warning: normal validation still passes, but `openspec validate --strict` fails on it.
108
109
  - Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions in ADDED requirements and in the main spec. This is a warning: normal validation still passes, but `openspec validate --strict` fails on it. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length. Split an existing long requirement only when the user asks for it, in a change made for that purpose: under MODIFIED, keep its header and every scenario and cut its description down to one behavior without changing its meaning, then add each behavior you removed as its own ADDED requirement with its own scenarios.
109
110
 
110
111
  New capabilities only: the delta spec's first section is `## Purpose` -