feat(update): offer to upgrade a stale CLI during openspec update (#1470)

* fix(update): flag a stale global CLI during openspec update

Instruction files are generated by the installed CLI, so running
`openspec update` against an outdated global install printed
"All 1 tool(s) up to date (v1.6.0)" while the workflows newer releases
ship were never written. Users read that as success and reported the
missing workflows as bugs.

`openspec update` now checks the npm registry alongside the update and,
when the installed CLI is behind, prints the upgrade command instead of
leaving the up-to-date line to speak for itself.

The check never gets in the way: it runs concurrently with the update,
times out after 1.5s, caches the answer for 24h, returns null on any
failure, and is skipped in CI, under tests, and whenever
OPENSPEC_NO_UPDATE_CHECK is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): name the right install in the stale-CLI hint

The hint assumed a global install. A project-local dependency is now
pointed at that dependency instead of `npm install -g`, and every hint
prints the directory the running CLI was loaded from, so anyone who
upgraded but still runs an old pnpm/volta/npx shim can see which copy
answered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): drop the temp-file cache and fix prerelease ordering

CodeQL flagged the version-check cache twice: a predictable path in the
shared OS temp dir (js/insecure-temporary-file, high) and registry data
written to that file (js/http-to-file-access, medium). `openspec update`
is a rare, human-run command, so the cache bought little — removing it
resolves both alerts outright and deletes the code that needed them.

Also from review: CI=1 now opts out alongside CI=true, and prerelease
tags compare per SemVer (dot-separated identifiers, numeric compared
numerically) so 1.7.0-beta.10 outranks 1.7.0-beta.2. Build metadata is
ignored per spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): make the version check actually reach the registry

Adversarial review found the check could never fire: the request sent
`accept: application/vnd.npm.install-v1+json`, which npm serves only on
the full packument — on `/<pkg>/latest` it answers 406, so every real run
returned null. Every test mocked fetch, so nothing caught it. The header
is gone, and a new suite exercises the real fetch path against a local
HTTP server, including an assertion that we never send that Accept type.

Also from review:

- Validate the published version against a strict SemVer pattern before
  printing it. It lands in the terminal beside an install command, so an
  unvalidated string could smuggle ANSI cursor controls and repaint the
  surrounding lines.
- Honor DO_NOT_TRACK=1 and OPENSPEC_TELEMETRY=0, the opt-outs telemetry
  already respects, and update SECURITY.md, which promised telemetry was
  the only network egress.
- Anchor project-local detection on the path being updated and its
  ancestors instead of process.cwd(), so `openspec update <path>` and
  workspace sub-packages with a hoisted root node_modules are no longer
  told to install globally. It can no longer throw when the working
  directory has been deleted.
- Send npx/dlx users `npx @fission-ai/openspec@latest update` rather than
  advice that would create the global install they avoided.
- Query npm_config_registry when set, so private mirrors get an answer
  their own install command can deliver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(update): make version-check fixtures portable on Windows

The new fixtures mixed unresolved POSIX literals with path.join output.
On Windows path.resolve adds a drive letter and path.join does not, so
the prefix match could never succeed and two assertions failed there.
Fixtures now derive from resolved roots.

Real installs were unaffected — both sides come from resolved absolute
paths — but case and drive-letter casing can still differ between
require.resolve and path.resolve on Windows, so the comparison is now
case-insensitive on win32.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): stop a blackholed registry from holding the CLI open

Verification found the 1.5s timeout did not bound the command. Aborting
a fetch still completing its TCP handshake — a firewall dropping packets,
a captive portal — leaves the connect handle ref'd, so `openspec update`
sat for ~10s after printing everything. Measured against an unroutable
address: resolved at 1523ms, process exited at 10558ms.

The request now uses node:http(s), whose socket the timeout can actually
destroy: same probe resolves at 1547ms and exits at 1550ms.

Because the client is no longer fetch, the mocked tests would have gone
inert and silently reached the real registry. The whole suite now drives
the real code path against a local server, which is also the only way to
prove an opt-out sent nothing. Added a child-process guard for the
teardown itself (no in-process assertion can see it), a case for a
non-JSON body — the captive-portal login page — and order-independence
fixes: the mock leak between describes made the 406 regression guard the
first casualty under --sequence.shuffle.

Also: bound the version pattern and the response body so neither can be
absurdly long, and narrow the ephemeral-runner match so a user directory
named "dlx" is no longer mistaken for a pnpm cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(update): offer to run the upgrade instead of only printing it

Being told to run a command, then run the update again, is two steps the
CLI can take for you. `openspec update` now asks:

  A newer OpenSpec CLI is available (v1.6.0 -> v1.7.0).
    Running from: /usr/local/lib/node_modules/@fission-ai/openspec
  ? Upgrade to v1.7.0 now? (Y/n)

Yes runs `npm install -g` with stdio inherited — so any auth or sudo
prompt reaches the user directly — then re-runs the update with the new
CLI, because this process still holds the old templates and cannot write
the new workflows itself. No prints the command and updates with the CLI
you have.

It asks rather than acting: a CLI that mutates a global install without
consent is the wrong default. Guards:

- Interactive terminals only, via the repo's isInteractive() (no TTY, or
  CI set, means the note prints exactly as before).
- Global npm installs only. A project dependency belongs to that
  project's package manager, and an npx/dlx cache has nothing to
  upgrade; both get the command instead.
- The re-run carries OPENSPEC_NO_UPDATE_CHECK=1, so a PATH that still
  resolves to the old binary cannot loop.
- A failed upgrade, a missing openspec on PATH, and Ctrl-C at the prompt
  each fall back to the printed command rather than an error.

The check now runs before the update rather than alongside it, so an
accepted upgrade regenerates files with the new templates in one pass.

Verified end to end against a stubbed npm and openspec on PATH, both
answers, plus the unchanged non-interactive path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): offer the upgrade only where npm install -g would help

Review found `canSelfUpgrade` treated "not a project dependency and not an
npx cache" as proof of a global npm install. It is not: a pnpm, bun, yarn
or volta global, and a plain git clone, all qualified. Reproduced by
running the CLI from this repo — it offered to npm install -g over the
checkout, which would have shadowed it with a second copy.

The offer now requires npm to own the install, derived from the running
node's global root (and APPDATA/npm_config_prefix) rather than by
shelling out to `npm prefix -g`. Everything else gets the command that
matches how it was installed — `pnpm add -g`, `bun add -g`, `yarn global
add`, `volta install` — a project dependency is pointed at its own
package manager with no npm command at all, and a source checkout gets
no note, since its version is whatever the branch says.

Docs corrected where they had drifted from the code:

- The check runs before the update, not alongside it; it can delay the
  update by up to 1.5s. docs/cli.md and the changeset said otherwise.
- npm_config_registry is only honored when npm exports it; an .npmrc
  setting alone is invisible to us. Docs and JSDoc claimed more.
- SECURITY.md gains an "Installing software" row: running a package
  manager on the user's behalf is the most security-relevant behavior
  here and the table did not mention it. The "Running other programs"
  row now covers the re-run's path argument and cross-spawn's Windows
  shim escaping, and the network row lists every opt-out precisely.
- troubleshooting.md's "Commands don't show up" — the exact symptom this
  PR exists to fix — now explains that instruction files come from the
  installed CLI, and installation.md's Updating section links onward.
- The env-var table notes the CI and NODE_ENV skips, and that
  npm_config_registry must be an http(s) URL.
- "the new workflows land in the same command" no longer overpromises:
  when the upgraded openspec is not on PATH, the CLI now says the files
  were not regenerated instead of printing a dim aside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): make the upgrade offer tell the truth about what happened

Adversarial review found the flow could claim success it had not earned,
and could strand a non-interactive caller. All verified by running the
CLI, all fixed:

- `npm install -g` exits 0 even when it installs nothing, so "✓ Upgraded
  to vX" was an assertion, not a fact. The version is now read back from
  the installed binary; when another install earlier on PATH still
  answers with the old one — the exact silent staleness this feature
  exists to fix — it says so instead of claiming the upgrade landed.
- The prompt hung forever under `openspec update > log.txt`: the
  question went to the file while the user watched a blank terminal.
  The offer now requires stdout to be a terminal too.
- Ctrl-C at the prompt read as "no thanks" and carried on into the next
  prompt. It now stops the command with 130.
- `--force` never reached the re-run, so `openspec update --force` could
  regenerate nothing and exit 0. Flags are forwarded, with `--` before
  the path so a flag-shaped path stays a path.
- A signal-killed re-run, and a re-run with no CLI to hand off to, both
  reported 0. Both now report failure.
- `process.exit()` skipped commander's postAction hook, killing the
  telemetry flush mid-request. The action sets process.exitCode and
  returns instead.
- The check read only npm_config_registry, which npm exports only under
  `npm run` — so an enterprise user with a mirror in .npmrc got an
  unannounced call to public npm. It now reads .npmrc too.
- Two different CI predicates: `CI=yes` suppressed the prompt but not
  the request. One predicate now, and it treats any value except an
  explicit off-value as CI.
- A project-local install was offered a global one when updating a
  different directory; both anchors are checked now.

Tests: the re-run had no coverage at all and now has four cases.
Mutation testing over nine mutations (406 header, DO_NOT_TRACK, version
validation, prerelease ordering, canSelfUpgrade, the anti-loop env
guard, the cwd-vs-target anchor, the timeout) — one survived, the
anti-loop guard, so it has a test now and the mutation dies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(update): compare re-run arguments as tokens, not as a raw line

cmd.exe echoes `%*` with every argument quoted, so the Windows job saw
`"update" "--force" "--" "--weird-path"` and the substring assertion for
`-- --weird-path` failed. The forwarding itself was correct on both
platforms; the assertion now splits and unquotes before checking that
the separator immediately precedes the path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): read only the user's .npmrc for the registry

CodeQL flagged file data reaching an outbound request, and it has a
point: the project `.npmrc` travels with the repository, so honoring it
let a cloned repo choose where the version check sends its request.

Only `~/.npmrc` is read now — which is where a mirror is configured
anyway, since `npm config set registry` writes there — and a test pins
that a project `.npmrc` cannot redirect the request. Docs and changeset
say so explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): detect the install from its own layout, not from node's path

Adversarial review found the offer never appeared on Homebrew — a
mainstream macOS install — and I reproduced it on this machine:

  npm root -g:    /opt/homebrew/lib/node_modules
  derived roots:  /opt/homebrew/Cellar/node/25.8.1_1/lib/node_modules

process.execPath is realpath'd through Homebrew's symlink into the
Cellar, so a root derived from the node binary never matches the prefix
npm installs into. The same mismatch hits Debian-style layouts.

The install's own shape is now the primary signal:
<prefix>/lib/node_modules/<pkg> (POSIX) or <prefix>/node_modules/<pkg>
(Windows), confirmed by the bin directory npm would have written the
shim into. The node-derived roots stay as a fast path.

Also from the same review, each reproduced first:

- volta nests a whole node install, so its packages sit in exactly npm's
  layout: we called it npm-owned, ran `npm install -g`, and on failure
  told the user to run volta. Ownership is now decided before location.
- upgradedBinPath returned the first prefix that merely had an openspec
  in it, preferring a stale one over the prefix npm just wrote to. It
  now derives from the running install first.
- readCliVersion took the first version-shaped token anywhere in stdout,
  so a wrapper banner ("Node.js v25.8.1 | OpenSpec") was read as the
  answer — turning a real upgrade into a false "still reports vX", or
  worse, claiming success for a version nobody installed. It now takes
  the line that is only a version.
- The probe child could outlive its 5s timeout indefinitely: SIGTERM
  with no escalation and no unref, so a signal-trapping wrapper held the
  CLI open for as long as it ran.
- "Another install earlier on your PATH is answering first" was a
  misdiagnosis whenever we had asked a known binary directly.
- A `registry=${VAR}` or `@scope:registry=` line in .npmrc — both npm's
  documented syntax, the latter being how a scoped package is normally
  routed to a mirror — silently fell back to the public registry.
- A 3xx from the registry disabled the check permanently and silently.
  Redirects are followed, bounded, under one timeout budget.
- An incidental directory named "pnpm" or "yarn" was read as a global
  install of one, printing the wrong upgrade command.

Plus the earlier docs-audit round: the npx branch no longer tells users
to run an update they were just handed, the check no longer fires for a
source checkout whose answer is discarded, the offer gate moved into a
tested pure function, and the declined command now prints below the
update output instead of scrolling away above it.

Docs: install-flavor table, CI off-values, empty-value opt-out, the
"no cache" fact in SECURITY.md, and a changeset trimmed to a summary
that points at the CLI reference. The changeset is now `minor` — this
adds a prompt, an env var, an outbound request, and the ability to
install software; that is not a patch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): stop reading .npmrc for the registry

CodeQL flagged file data reaching an outbound request (js/file-access-to-http),
and it is right that a file choosing where a request goes is a flow worth
avoiding. The convenience did not earn it: reading ~/.npmrc needed three
follow-up fixes in one review round (project-vs-user precedence, ${VAR}
expansion, scoped registry keys), and none of it is necessary — anyone on a
private mirror can export npm_config_registry, which is still honored, or
turn the check off.

Removes the .npmrc read and its two helpers; a test pins that a
registry= line in a .npmrc cannot steer the request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clay Good
2026-07-28 15:39:54 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent ec6cbb4b0b
commit 6295515d4d
8 changed files with 1734 additions and 7 deletions
+15
View File
@@ -0,0 +1,15 @@
---
"@fission-ai/openspec": minor
---
`openspec update` now offers to upgrade the CLI when yours is behind the published one. Instruction files are generated by the installed CLI, so a stale install reported `✓ All 1 tool(s) up to date (v1.6.0)` while the workflows added in newer releases were never written:
```text
A newer OpenSpec CLI is available (v1.6.0 → v1.7.0).
Running from: /usr/local/lib/node_modules/@fission-ai/openspec
? Upgrade to v1.7.0 now? (Y/n)
```
Say yes and it upgrades, confirms the new version is the one that answers, then re-runs the update so the new workflows arrive in the same command. Say no and it prints the command matching how you installed OpenSpec, and updates with what you have. Nothing happens to your machine that you did not agree to: the offer appears only in an interactive terminal and only where `npm install -g` would help, and the check is skipped in CI or when `OPENSPEC_NO_UPDATE_CHECK`, `DO_NOT_TRACK=1`, or `OPENSPEC_TELEMETRY=0` is set.
See [CLI reference → `openspec update`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/cli.md#openspec-update) for the per-install-method behavior and every opt-out.
+4 -3
View File
@@ -12,7 +12,7 @@ Fixes ship in the latest published version on npm. Older versions are not patche
## Threat model
OpenSpec is a local command-line tool. It has no server, no network listener, and no privileged daemon. It reads and writes markdown under the directory you run it in, using paths you supply, with your own user permissions. It sends anonymous usage telemetry, which you can disable with `OPENSPEC_TELEMETRY=0`.
OpenSpec is a local command-line tool. It has no server, no network listener, and no privileged daemon. It reads and writes markdown under the directory you run it in, using paths you supply, with your own user permissions. It can offer to upgrade itself during `openspec update`, and only with your say-so. It sends anonymous usage telemetry, which you can disable with `OPENSPEC_TELEMETRY=0`.
That shapes what is and isn't a vulnerability here:
@@ -43,9 +43,10 @@ ls node_modules | grep -E '^(vite|rollup|vitest|eslint|js-yaml|minimatch)$' #
| Surface | Behavior |
| --- | --- |
| Install script | `scripts/postinstall.js` prints one line suggesting shell completions. It makes no network request, writes no files, and runs no shell. Completions are opt-in via `openspec completion install`. |
| Running other programs | Every call that goes through a shell uses a fixed literal (`which gh`, `gh auth status`). Anything carrying your input — issue text, editor paths, workset commands — uses an argument array with `shell: false`. |
| Running other programs | Every call that goes through a shell uses a fixed literal (`which gh`, `gh auth status`). Anything carrying your input — issue text, editor paths, workset commands, the path passed to `openspec update` — uses an argument array, never string interpolation into a shell. On Windows, `.cmd` shims are launched through `cross-spawn`, which escapes arguments rather than concatenating them. |
| Installing software | `openspec update` can run `npm install -g @fission-ai/openspec@latest` and then re-run `openspec update` with the upgraded CLI. It does this only after you answer yes to a prompt, only for the OpenSpec package itself, only when npm owns the install, and never in CI or a non-interactive shell. A global install lives outside your project, so it runs with your permissions there and executes whatever lifecycle scripts the published package ships. It then reads the installed binary's version back rather than assuming the upgrade took. Decline and it prints the command for you to run yourself. |
| Telemetry | Command name, OpenSpec version, and a locally generated random UUID. No file paths, no file contents, no environment, no hostname, and IP capture is explicitly disabled. Opt out with `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1`; it is off in CI automatically. |
| Network | Only telemetry, and only when enabled. Reading, writing, and validating specs is entirely local. |
| Network | Telemetry when enabled, and one npm registry request during `openspec update` to check whether a newer CLI has been published. That request sends no data about you beyond what any HTTP request reveals, runs once per `openspec update` with nothing cached, and is skipped when `CI` is set to anything but an explicit off-value, under `NODE_ENV=test`, or when `OPENSPEC_NO_UPDATE_CHECK`, `DO_NOT_TRACK=1`, or `OPENSPEC_TELEMETRY=0` is set. Reading, writing, and validating specs is entirely local. |
## Automated checks
+31 -3
View File
@@ -173,10 +173,36 @@ openspec update [path] [options]
```bash
# Update instruction files after npm upgrade
npm update @fission-ai/openspec
npm install -g @fission-ai/openspec@latest
openspec update
```
Upgrade the package first. Instruction files are generated by the installed CLI, so running `openspec update` against a stale install reports everything up to date without adding the workflows newer releases ship.
To make that visible, `openspec update` asks the npm registry whether a newer CLI has been published. When yours is behind, it offers to upgrade:
```text
A newer OpenSpec CLI is available (v1.6.0 → v1.7.0).
Running from: /usr/local/lib/node_modules/@fission-ai/openspec
? Upgrade to v1.7.0 now? (Y/n)
```
Answer yes and it runs `npm install -g @fission-ai/openspec@latest`, then re-runs the update with the new CLI so the new workflows land in the same command. It confirms the upgrade by asking the installed binary its version rather than trusting npm's exit code, so if another install earlier on your `PATH` is still answering, it tells you instead of claiming success. Answer no and it prints the command and updates with the CLI you have. Ctrl-C stops the command.
The offer appears only in an interactive terminal, and only when npm owns the install — the one case `npm install -g` actually fixes. Everything else gets the command that matches how it was installed instead:
| How OpenSpec is installed | What you get |
|---------------------------|--------------|
| Global npm install | The prompt, and the upgrade run for you — in an interactive terminal; piped output gets the printed command instead |
| Global pnpm, bun, yarn, or volta install | That manager's own command: `pnpm add -g …@latest`, `bun add -g …@latest`, `yarn global add …@latest`, or `volta install …@latest` |
| A dependency of the project | A note to update the dependency, since its package manager owns the lockfile |
| An `npx` / `dlx` cache | `npx @fission-ai/openspec@latest update` — that command is the update, so there is no second step |
| A git clone | Nothing — your version is whatever the branch says |
Whenever anything is printed, it names the directory the running CLI was loaded from — the thing to check when you did upgrade but a stale shim still owns your `PATH`.
It asks the registry in `npm_config_registry` when npm exports it, and `https://registry.npmjs.org` otherwise. No `.npmrc` is read: letting file contents choose where an outbound request goes is a flow worth avoiding, and a project's `.npmrc` travels with the repository. On a private mirror, export `npm_config_registry` — or set `OPENSPEC_NO_UPDATE_CHECK` to skip the check entirely. The check is skipped when `CI` is set to anything but an explicit off-value (`false`, `0`, `no`, `off`, or empty), under `NODE_ENV=test`, and whenever `OPENSPEC_NO_UPDATE_CHECK` (any value), `DO_NOT_TRACK=1`, or `OPENSPEC_TELEMETRY=0` is set. It runs before the update and can delay it by at most 1.5 seconds — it gives up after that even when the network drops packets silently, and stays quiet when the registry is unreachable.
---
## Stores (standalone OpenSpec repos)
@@ -1204,12 +1230,14 @@ openspec completion uninstall
| Variable | Description |
|----------|-------------|
| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry |
| `DO_NOT_TRACK` | Set to `1` to disable telemetry (standard DNT signal) |
| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry and the `openspec update` version check |
| `DO_NOT_TRACK` | Set to `1` to disable telemetry and the `openspec update` version check (standard DNT signal) |
| `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) |
| `EDITOR` or `VISUAL` | Editor for `openspec config edit` |
| `NO_COLOR` | Disable color output when set |
| `OPENSPEC_NO_ANIMATION` | Disable the `openspec init` welcome animation when set |
| `OPENSPEC_NO_UPDATE_CHECK` | Disable the `openspec update` check for a newer published CLI when set (any value, including empty). Also skipped when `CI` is set (unless `false`/`0`/`no`/`off`) or `NODE_ENV=test` |
| `npm_config_registry` | Registry the `openspec update` version check asks. Must be an `http(s)` URL or it falls back to `https://registry.npmjs.org`. No `.npmrc` file is read |
---
+1 -1
View File
@@ -152,7 +152,7 @@ npm install -g @fission-ai/openspec@latest # or pnpm/yarn/bun equivalent
openspec update # run inside each project
```
`openspec update` regenerates the skill and command files for the tools you've configured, so your slash commands stay current with the installed version.
`openspec update` regenerates the skill and command files for the tools you've configured, so your slash commands stay current with the installed version. It also checks whether a newer CLI has been published and offers to upgrade, since upgrading is what makes new workflows available in the first place — see [CLI Reference](cli.md#openspec-update).
## Uninstalling
+2
View File
@@ -51,6 +51,8 @@ If `/opsx:propose` (or your tool's equivalent) doesn't appear or doesn't do anyt
This rewrites the skill and command files for every tool you've configured.
Instruction files come from the *installed* CLI, so an outdated CLI reports everything up to date without ever writing the newer workflows. `openspec update` now checks for that and offers to upgrade — take the offer if you see it.
3. **Restart your assistant.** Most tools scan for skills and commands at startup. A fresh window often does it.
4. **Confirm the files exist.** For Claude Code, check that `.claude/skills/` contains `openspec-*` folders. Other tools use their own directories, all listed in [Supported Tools](supported-tools.md).
+62
View File
@@ -7,6 +7,16 @@ import { fileURLToPath } from 'url';
import { promises as fs } from 'fs';
import { AI_TOOLS } from '../core/config.js';
import { UpdateCommand } from '../core/update.js';
import {
getAvailableCliUpdate,
displayCliUpdateNote,
shouldOfferUpgrade,
getInstallDir,
offerCliUpgrade,
rerunUpdateWithUpgradedCli,
displayUpgradeCommand,
isSourceCheckout,
} from '../core/version-check.js';
import { ListCommand } from '../core/list.js';
import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js';
import { ViewCommand } from '../core/view.js';
@@ -40,6 +50,7 @@ import {
} from '../commands/workflow/index.js';
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
import { isInteractive } from '../utils/interactive.js';
const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;
@@ -207,8 +218,59 @@ program
.option('--force', 'Force update even when tools are up to date')
.action(async (targetPath = '.', options?: { force?: boolean }) => {
try {
const installDir = getInstallDir();
// Running from a clone: the version is whatever the branch says, so any
// upgrade advice would be noise. Decided before the request, so a
// contributor never waits on an answer that gets thrown away.
const latestVersion = isSourceCheckout(installDir) ? null : await getAvailableCliUpdate();
const announce = latestVersion !== null;
// Offer to upgrade first: this process generates files from its own
// templates, so upgrading afterwards would leave the old ones on disk.
// Both streams must be a terminal — with stdout redirected the question
// lands in the file and the user waits at a blank screen forever.
const canOffer =
announce &&
shouldOfferUpgrade({
installDir,
projectPath: targetPath,
interactive: isInteractive(),
stdoutIsTty: Boolean(process.stdout.isTTY),
});
let declined = false;
if (latestVersion && canOffer) {
displayCliUpdateNote(latestVersion, targetPath, { withCommand: false });
const outcome = await offerCliUpgrade(latestVersion);
// Set the code and return rather than process.exit: exiting here would
// skip commander's postAction hook, killing the telemetry flush
// mid-request.
if (outcome === 'cancelled') {
// Ctrl-C means stop the command, not fall through to more prompts.
process.exitCode = 130;
return;
}
if (outcome === 'upgraded') {
process.exitCode = await rerunUpdateWithUpgradedCli(targetPath, {
force: options?.force,
});
return;
}
// Declined, failed, or upgraded-but-unreachable: fall through to the
// update, then leave the command on screen underneath it.
declined = true;
}
const updateCommand = new UpdateCommand({ force: options?.force });
await updateCommand.execute(targetPath);
if (declined) {
// The headline was printed before the prompt; only the manual route is
// still owed, and it belongs where the user is looking now.
displayUpgradeCommand(targetPath);
} else if (latestVersion) {
displayCliUpdateNote(latestVersion, targetPath);
}
} catch (error) {
failWithError(error);
process.exit(1);
+795
View File
@@ -0,0 +1,795 @@
import fs from 'fs';
import http from 'http';
import https from 'https';
import path from 'path';
import { createRequire } from 'module';
import chalk from 'chalk';
const require = createRequire(import.meta.url);
const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
const REQUEST_TIMEOUT_MS = 1500;
const MAX_RESPONSE_BYTES = 256 * 1024;
const VERSION_PROBE_TIMEOUT_MS = 5000;
const MAX_REDIRECTS = 3;
/**
* `CI` set to anything meaningful means CI. Providers use "true", "1", "yes";
* only an explicit off-value counts as "not CI", so a value we do not know
* still suppresses the request rather than surprising a build.
*/
const CI_DISABLED_VALUES = new Set(['', 'false', '0', 'no', 'off']);
function isCiEnvironment(): boolean {
const value = process.env.CI;
return value !== undefined && !CI_DISABLED_VALUES.has(value.trim().toLowerCase());
}
/**
* A version we are willing to print. The registry only ever serves SemVer here,
* so anything else is either a broken mirror or a hostile response — and since
* this string lands in the terminal next to an install command, an unvalidated
* one could smuggle ANSI cursor controls and repaint the lines around it.
*/
const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
/**
* The check is opt-out and must never get in the way: no network in CI or
* tests, an explicit escape hatch for anyone offline or air-gapped, and the
* same privacy signals telemetry already honors — a user who set DO_NOT_TRACK
* did not agree to a different outbound request.
*/
function isCheckEnabled(): boolean {
if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined) return false;
if (process.env.DO_NOT_TRACK === '1') return false;
if (process.env.OPENSPEC_TELEMETRY === '0') return false;
if (isCiEnvironment()) return false;
if (process.env.NODE_ENV === 'test') return false;
return true;
}
/**
* The registry to ask: only the environment variable npm exports (under
* `npm run`, or an explicit export). Deliberately not a `registry=` line from
* any .npmrc — letting file contents choose the destination of an outbound
* request is a flow worth avoiding for a convenience this small, and a project
* file would travel with a cloned repository. Anyone on a private mirror can
* export `npm_config_registry`, or turn the check off entirely.
*/
export function registryUrl(): string {
const configured = process.env.npm_config_registry?.trim();
const base = configured && /^https?:\/\//i.test(configured) ? configured : DEFAULT_REGISTRY;
return `${base.replace(/\/+$/, '')}/${PACKAGE_NAME}/latest`;
}
/**
* Compares two prerelease tags per SemVer: dot-separated identifiers compared
* one by one, numeric identifiers numerically (so beta.10 > beta.2), numeric
* ranking below alphanumeric, and a longer identifier list winning ties.
*/
function comparePrerelease(a: string, b: string): number {
if (a === b) return 0;
if (a === '') return 1;
if (b === '') return -1;
const left = a.split('.');
const right = b.split('.');
for (let i = 0; i < Math.max(left.length, right.length); i++) {
const l = left[i];
const r = right[i];
if (l === undefined) return -1;
if (r === undefined) return 1;
const lNumeric = /^\d+$/.test(l);
const rNumeric = /^\d+$/.test(r);
if (lNumeric && rNumeric) {
const diff = Number.parseInt(l, 10) - Number.parseInt(r, 10);
if (diff !== 0) return diff > 0 ? 1 : -1;
continue;
}
if (lNumeric !== rNumeric) return lNumeric ? -1 : 1;
if (l !== r) return l > r ? 1 : -1;
}
return 0;
}
/**
* Compares two semver-ish versions. Returns 1 when a > b, -1 when a < b, 0
* otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
*/
export function compareVersions(a: string, b: string): number {
const parse = (version: string) => {
const withoutBuild = version.trim().replace(/^v/, '').split('+', 1)[0] ?? '';
const separator = withoutBuild.indexOf('-');
const core = separator === -1 ? withoutBuild : withoutBuild.slice(0, separator);
const prerelease = separator === -1 ? '' : withoutBuild.slice(separator + 1);
const parts = core.split('.').map((n) => Number.parseInt(n, 10));
return {
numbers: [parts[0] || 0, parts[1] || 0, parts[2] || 0],
prerelease,
};
};
const left = parse(a);
const right = parse(b);
for (let i = 0; i < 3; i++) {
if (left.numbers[i] > right.numbers[i]) return 1;
if (left.numbers[i] < right.numbers[i]) return -1;
}
return comparePrerelease(left.prerelease, right.prerelease);
}
/**
* Reads the `latest` dist-tag. Sends no custom Accept header: the registry
* answers `/<pkg>/latest` with 406 for npm's abbreviated-metadata type, which
* it only serves on the full packument.
*
* Uses node:http(s) rather than fetch so the timeout can destroy the socket.
* Aborting a fetch that is still completing its TCP handshake — a firewall
* dropping packets, a captive portal — leaves the connect handle open and the
* CLI cannot exit until the OS gives up, long after the hint has printed.
*/
function fetchLatestVersion(): Promise<string | null> {
return new Promise((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (version: string | null) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve(version);
};
let url: URL;
try {
url = new URL(registryUrl());
} catch {
resolve(null);
return;
}
// Mirrors and corporate front-ends redirect; without following one the
// check would be permanently and silently dead for them.
let redirectsLeft = MAX_REDIRECTS;
const send = (target: URL): void => {
const request = (target.protocol === 'http:' ? http : https).get(
target,
{ timeout: REQUEST_TIMEOUT_MS },
(response) => {
const status = response.statusCode ?? 0;
const location = response.headers.location;
if (status >= 300 && status < 400 && location) {
response.resume();
request.destroy();
if (redirectsLeft <= 0) {
finish(null);
return;
}
redirectsLeft -= 1;
try {
const next = new URL(location, target);
if (next.protocol === 'http:' || next.protocol === 'https:') {
send(next);
return;
}
} catch {
// Unparseable Location.
}
finish(null);
return;
}
if (status !== 200) {
response.resume();
request.destroy();
finish(null);
return;
}
let body = '';
response.setEncoding('utf-8');
response.on('data', (chunk: string) => {
body += chunk;
// The dist-tag document is small; refuse to buffer a firehose.
if (body.length > MAX_RESPONSE_BYTES) {
request.destroy();
finish(null);
}
});
response.on('end', () => {
try {
const parsed = JSON.parse(body) as { version?: unknown };
const version = parsed.version;
finish(typeof version === 'string' && SAFE_VERSION.test(version) ? version : null);
} catch {
finish(null);
}
});
response.on('error', () => finish(null));
}
);
request.on('timeout', () => {
request.destroy();
finish(null);
});
request.on('error', () => finish(null));
// One budget for the whole exchange, redirects included.
if (!timer) {
timer = setTimeout(() => {
request.destroy();
finish(null);
}, REQUEST_TIMEOUT_MS);
}
};
send(url);
});
}
/**
* Returns the published version when the installed CLI is behind it, otherwise
* null. Never throws and never blocks for longer than the request timeout.
*/
export async function getAvailableCliUpdate(): Promise<string | null> {
if (!isCheckEnabled()) return null;
try {
const latest = await fetchLatestVersion();
if (!latest) return null;
return compareVersions(latest, OPENSPEC_VERSION) > 0 ? latest : null;
} catch {
return null;
}
}
/**
* Directory the running CLI was loaded from, or null when it cannot be
* resolved. Shown in the upgrade hint so anyone who upgraded but still runs an
* old binary — a stale pnpm/volta/npx shim, or two installs on PATH — can see
* which copy is actually answering.
*/
export function getInstallDir(): string | null {
try {
return path.dirname(require.resolve('../../package.json'));
} catch {
return null;
}
}
/**
* True when the running CLI resolves from a `node_modules` belonging to the
* project being updated or any ancestor of it — the hoisted-root layout npm and
* pnpm workspaces produce. Anchored on the target path rather than the working
* directory, since `openspec update <path>` and running from a sub-package are
* both normal. Never throws: process.cwd() fails when the directory has been
* deleted, and a wrong upgrade hint must not take down a successful update.
*/
export function isProjectLocalInstall(
installDir: string | null,
projectPath: string = '.'
): boolean {
if (!installDir) return false;
// Windows paths differ in case and drive-letter casing between sources.
const normalize = (value: string) =>
process.platform === 'win32' ? value.toLowerCase() : value;
try {
let dir = path.resolve(projectPath);
const target = normalize(installDir);
for (;;) {
if (target.startsWith(normalize(path.join(dir, 'node_modules') + path.sep))) {
return true;
}
const parent = path.dirname(dir);
if (parent === dir) return false;
dir = parent;
}
} catch {
return false;
}
}
/**
* True for the throwaway caches npx/pnpm dlx/bunx unpack into. Telling those
* users to install globally would create the second copy on PATH they were
* deliberately avoiding.
*/
export function isEphemeralRunnerInstall(installDir: string | null): boolean {
if (!installDir) return false;
const segments = installDir.split(/[\\/]/).map((segment) => segment.toLowerCase());
return segments.some(
(segment, i) =>
segment === '_npx' ||
segment === '_bunx' ||
// Only a package manager's own cache, never a user directory that
// happens to be called "dlx". Windows uses pnpm-cache for the same job.
(segment === 'dlx' &&
['pnpm', 'bun', '.pnpm', 'pnpm-cache', 'bun-cache'].includes(segments[i - 1] ?? ''))
);
}
/**
* Directories npm installs global packages into. Derived from the running node
* rather than by shelling out to `npm prefix -g`, which would cost more than
* the version check itself. Only a hint: `process.execPath` is realpath'd, so
* on Homebrew it lands in the Cellar rather than the brew prefix — which is
* why the install's own layout is the primary signal below.
*/
export function npmGlobalRoots(): string[] {
const roots: string[] = [];
const nodeDir = path.dirname(process.execPath);
if (process.platform === 'win32') {
roots.push(path.join(nodeDir, 'node_modules'));
if (process.env.APPDATA) {
roots.push(path.join(process.env.APPDATA, 'npm', 'node_modules'));
}
} else {
roots.push(path.resolve(nodeDir, '..', 'lib', 'node_modules'));
}
const prefix = process.env.npm_config_prefix;
if (prefix) {
roots.push(
process.platform === 'win32'
? path.join(prefix, 'node_modules')
: path.join(prefix, 'lib', 'node_modules')
);
}
return roots;
}
/**
* The prefix of an npm global install, read from the install's own shape:
* `<prefix>/lib/node_modules/<pkg>` on POSIX, `<prefix>/node_modules/<pkg>` on
* Windows. Self-describing, so it holds for Homebrew, nvm, Debian and anywhere
* else npm's prefix is not derivable from the node binary. Null when the
* layout does not match.
*/
export function npmPrefixFromInstallDir(installDir: string | null): string | null {
if (!installDir) return null;
let dir = installDir;
for (;;) {
const parent = path.dirname(dir);
if (parent === dir) return null;
if (path.basename(dir).toLowerCase() === 'node_modules') break;
dir = parent;
}
const container = path.dirname(dir);
if (process.platform === 'win32') return container;
// POSIX npm always nests the root under lib/.
return path.basename(container).toLowerCase() === 'lib' ? path.dirname(container) : null;
}
/**
* True only when npm itself owns this copy. Everything else — a pnpm, bun,
* yarn or volta global — would be made worse by `npm install -g`, which adds a
* second copy that may not even be the one on PATH.
*/
export function isNpmGlobalInstall(
installDir: string | null,
roots: string[] = npmGlobalRoots()
): boolean {
if (!installDir) return false;
// Another manager's layout can still look like npm's (volta nests a whole
// node install), so who owns it is decided before where it sits.
if (detectPackageManager(installDir) !== 'npm') return false;
const normalize = (value: string) =>
process.platform === 'win32' ? value.toLowerCase() : value;
const target = normalize(installDir);
if (roots.some((root) => target.startsWith(normalize(root + path.sep)))) return true;
// The derived roots miss any prefix that is not beside the node binary, so
// fall back to the install's own shape plus the bin directory npm would
// have written the shim into.
const prefix = npmPrefixFromInstallDir(installDir);
if (!prefix) return false;
try {
return fs.existsSync(process.platform === 'win32' ? prefix : path.join(prefix, 'bin'));
} catch {
return false;
}
}
/**
* True when the CLI is running from a clone rather than an install. Upgrade
* advice is meaningless there: the version is whatever the branch says.
*/
export function isSourceCheckout(installDir: string | null): boolean {
if (!installDir) return false;
try {
return fs.existsSync(path.join(installDir, '.git'));
} catch {
return false;
}
}
export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'yarn' | 'volta';
/**
* The package manager that owns this copy, so the printed command is one the
* user's setup will actually honor.
*/
export function detectPackageManager(installDir: string | null): PackageManager {
// Lowercased because the Windows directories are capitalized and undotted:
// %LOCALAPPDATA%\\Volta, \\Yarn\\Data, \\pnpm-cache.
const segments = (installDir ?? '').split(/[\\/]/).map((segment) => segment.toLowerCase());
const has = (...names: string[]) => names.some((name) => segments.includes(name));
if (has('.volta', 'volta')) return 'volta';
if (has('.bun')) return 'bun';
// These two need a corroborating segment: a directory merely named "pnpm" or
// "yarn" (a user's home, a project) is not a global install of one.
if (has('.pnpm-global', 'pnpm-cache')) return 'pnpm';
if (has('pnpm') && has('global', 'dlx', 'store')) return 'pnpm';
if (has('.yarn') || (has('yarn') && has('global'))) return 'yarn';
return 'npm';
}
const GLOBAL_UPGRADE_COMMANDS: Record<PackageManager, string> = {
npm: `npm install -g ${PACKAGE_NAME}@latest`,
pnpm: `pnpm add -g ${PACKAGE_NAME}@latest`,
bun: `bun add -g ${PACKAGE_NAME}@latest`,
yarn: `yarn global add ${PACKAGE_NAME}@latest`,
volta: `volta install ${PACKAGE_NAME}@latest`,
};
/**
* Builds the hint, with the upgrade command chosen for how this copy of the CLI
* was installed. Pure so every branch is assertable.
*/
export function buildCliUpdateLines(
latestVersion: string,
installDir: string | null,
projectPath: string,
options: { withCommand?: boolean } = {}
): string[] {
const lines = [`A newer OpenSpec CLI is available (v${OPENSPEC_VERSION} → v${latestVersion}).`];
// Omitted when we are about to offer to run it — printing a command and then
// asking to run that same command reads like the user has to do both.
if (options.withCommand !== false) {
lines.push(...buildUpgradeCommandLines(installDir, projectPath));
}
if (installDir) {
lines.push(` Running from: ${installDir}`);
}
return lines;
}
/**
* The upgrade command for however this copy was installed, plus the reminder
* that instruction files come from the CLI and so need a second pass.
*/
export function buildUpgradeCommandLines(
installDir: string | null,
projectPath: string
): string[] {
const lines: string[] = [];
if (isEphemeralRunnerInstall(installDir)) {
// That command *is* the update, so there is nothing to run afterwards.
lines.push(` npx ${PACKAGE_NAME}@latest update`);
return lines;
}
if (isProjectLocalInstall(installDir, projectPath)) {
// Its package manager owns the lockfile; naming npm could be wrong.
lines.push(` Update the ${PACKAGE_NAME} dependency in this project.`);
} else {
lines.push(` ${GLOBAL_UPGRADE_COMMANDS[detectPackageManager(installDir)]}`);
}
lines.push(' Then run "openspec update" again to pick up new workflows.');
return lines;
}
// cross-spawn resolves npm's shim on Windows, where spawning "npm" directly
// fails. Loaded lazily so ordinary runs skip its module graph.
let cachedSpawn: typeof import('child_process').spawn | undefined;
function loadSpawn(): typeof import('child_process').spawn {
if (cachedSpawn === undefined) {
cachedSpawn = require('cross-spawn') as typeof import('child_process').spawn;
}
return cachedSpawn;
}
/**
* Whether we can run the upgrade for the user instead of only printing it.
*
* Only an npm-owned global install qualifies, because `npm install -g` is the
* only command we run: a pnpm/bun/yarn/volta global would get a second copy
* that may not be the one on PATH, a project dependency belongs to that
* project's package manager, an npx/dlx cache has nothing to upgrade, and a
* source checkout is not an install at all.
*/
export function canSelfUpgrade(installDir: string | null, projectPath: string): boolean {
if (!installDir) return false;
if (isEphemeralRunnerInstall(installDir)) return false;
// Both anchors matter: `openspec update ../other` from a project that owns
// the CLI as a dependency is still a project-local install.
if (isProjectLocalInstall(installDir, projectPath)) return false;
if (isProjectLocalInstall(installDir)) return false;
if (isSourceCheckout(installDir)) return false;
return isNpmGlobalInstall(installDir);
}
/**
* Whether to offer the upgrade rather than just print the command. Kept here,
* as a pure function of the environment, because the interesting mistakes live
* in this decision: offering where `npm install -g` cannot help, or asking a
* question no one can answer.
*/
export function shouldOfferUpgrade(params: {
installDir: string | null;
projectPath: string;
interactive: boolean;
stdoutIsTty: boolean;
}): boolean {
// A prompt written to a redirected stdout is a question the user never sees
// and the command waits on forever.
if (!params.interactive || !params.stdoutIsTty) return false;
return canSelfUpgrade(params.installDir, params.projectPath);
}
/**
* Runs `npm install -g <pkg>@latest`, inheriting stdio so npm's own output —
* including any auth or permission prompt — reaches the user directly.
* Resolves true only on a clean exit.
*/
async function runGlobalUpgrade(): Promise<boolean> {
const spawn = loadSpawn();
return new Promise((resolve) => {
const child = spawn('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
stdio: 'inherit',
});
child.on('error', () => resolve(false));
child.on('close', (code) => resolve(code === 0));
});
}
/**
* The `openspec` npm installs alongside its global package, so the upgrade can
* be handed to the copy npm just wrote rather than to whatever PATH resolves.
* Null when it cannot be found, in which case PATH is the only option left.
*/
export function upgradedBinPath(
roots: string[] = npmGlobalRoots(),
installDir: string | null = getInstallDir()
): string | null {
// The copy npm just replaced tells us exactly which prefix it wrote to;
// a root derived from the node binary can point at an unrelated install.
const ownPrefix = npmPrefixFromInstallDir(installDir);
const ordered = ownPrefix
? [
process.platform === 'win32'
? path.join(ownPrefix, 'node_modules')
: path.join(ownPrefix, 'lib', 'node_modules'),
...roots,
]
: roots;
for (const root of ordered) {
// npm writes the shim beside the global root on Windows
// (%APPDATA%\\npm\\openspec.cmd) and in <prefix>/bin on POSIX.
const candidates =
process.platform === 'win32'
? [path.join(path.dirname(root), 'openspec.cmd')]
: [path.resolve(root, '..', '..', 'bin', 'openspec')];
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// Unreadable candidate; try the next one.
}
}
}
return null;
}
/**
* Asks a CLI binary its version. Used to confirm an upgrade actually landed:
* `npm install -g` exits 0 even when it installed nothing, so its exit code
* alone cannot justify telling the user they are on a new version.
*/
export function readCliVersion(binPath: string): Promise<string | null> {
const spawn = loadSpawn();
return new Promise((resolve) => {
let output = '';
let child;
try {
child = spawn(binPath, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
} catch {
resolve(null);
return;
}
// Never let a probe hold the CLI open: a wrapper that traps SIGTERM would
// otherwise keep the process alive for as long as it runs.
child.unref();
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolve(null);
}, VERSION_PROBE_TIMEOUT_MS);
child.stdout?.on('data', (chunk: Buffer) => {
output += chunk.toString();
});
child.on('error', () => {
clearTimeout(timer);
resolve(null);
});
child.on('close', () => {
clearTimeout(timer);
// A line that is only a version, not the first version-shaped token
// anywhere: a wrapper banner ("Node.js v25.8.1 | OpenSpec") would
// otherwise be read as the answer.
const version = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => SAFE_VERSION.test(line.replace(/^v/, '')))
.pop();
resolve(version ? version.replace(/^v/, '') : null);
});
});
}
export type UpgradeOutcome = 'upgraded' | 'declined' | 'failed' | 'cancelled' | 'not-on-path';
function isPromptCancellation(error: unknown): boolean {
const name = (error as { name?: string } | undefined)?.name;
return name === 'ExitPromptError' || name === 'AbortPromptError';
}
/**
* Offers to run the upgrade and reports what actually happened. The version is
* read back from the installed binary rather than assumed, so "upgraded" is a
* fact and a PATH that still answers with the old copy is caught here instead
* of silently doing nothing.
*/
export async function offerCliUpgrade(latestVersion: string): Promise<UpgradeOutcome> {
const { confirm } = await import('@inquirer/prompts');
let accepted = false;
try {
accepted = await confirm({
message: `Upgrade to v${latestVersion} now?`,
default: true,
});
} catch (error) {
// Ctrl-C means stop, not "no thanks, carry on with everything else".
return isPromptCancellation(error) ? 'cancelled' : 'declined';
}
if (!accepted) return 'declined';
console.log();
const installed = await runGlobalUpgrade();
console.log();
if (!installed) {
console.log(chalk.yellow('The upgrade did not complete. A global install may need'));
console.log(chalk.yellow('elevated permissions, or a different package manager.'));
return 'failed';
}
const binPath = upgradedBinPath();
const version = await readCliVersion(binPath ?? 'openspec');
if (!version) {
console.log(chalk.yellow('Upgrade finished, but no "openspec" could be run to confirm it.'));
return 'not-on-path';
}
if (compareVersions(version, OPENSPEC_VERSION) <= 0) {
console.log(chalk.yellow(`Upgrade finished, but "openspec" still reports v${version}.`));
console.log(
chalk.dim(
binPath
? // We asked the installed copy directly, so PATH is not the story.
` npm reported success, but ${binPath} did not change.`
: ' Another install earlier on your PATH is answering first.'
)
);
return 'not-on-path';
}
console.log(chalk.green(`✓ Upgraded to v${version}.`));
return 'upgraded';
}
/**
* Runs `openspec update` again with the CLI that was just installed — this
* process is still the old code, so it cannot write the new workflows itself.
* Resolves the exit code to pass along; when no `openspec` is on PATH the
* upgrade still landed but nothing was regenerated, so it says so and
* resolves 0 rather than reporting a failure the upgrade did not have.
*/
export async function rerunUpdateWithUpgradedCli(
projectPath: string,
options: { force?: boolean; binPath?: string } = {}
): Promise<number> {
const spawn = loadSpawn();
const binPath = options.binPath ?? upgradedBinPath() ?? 'openspec';
// The re-run stands in for the command the user typed, so it has to carry
// the flags they typed with it.
const args = ['update'];
if (options.force) args.push('--force');
// `--` so a path that looks like a flag stays a path.
args.push('--', projectPath);
return new Promise((resolve) => {
const child = spawn(binPath, args, {
stdio: 'inherit',
env: {
...process.env,
// The child must not offer the upgrade again: if PATH still resolves
// to the old binary, prompting would loop forever.
OPENSPEC_NO_UPDATE_CHECK: '1',
// This is a continuation of the command the user already ran, and the
// parent recorded it; counting it twice would overstate usage.
OPENSPEC_TELEMETRY: '0',
},
});
child.on('error', () => {
// Nothing to hand off to: the upgrade landed but the instruction files
// are still the old ones, so this run did not do what was asked.
console.log(chalk.yellow('Instruction files were not regenerated.'));
console.log(chalk.dim(' Run "openspec update" to pick up the new workflows.'));
resolve(1);
});
// A child killed by a signal reports no code; that is not success.
child.on('close', (code) => resolve(code ?? 1));
});
}
/**
* Prints the upgrade hint. Instruction files are generated by the installed
* CLI, so "up to date" only ever means "matches this CLI" — without this note
* a stale install looks like a successful update.
*/
export function displayCliUpdateNote(
latestVersion: string,
projectPath: string = '.',
options: { withCommand?: boolean } = {}
): void {
const [headline, ...rest] = buildCliUpdateLines(
latestVersion,
getInstallDir(),
projectPath,
options
);
console.log();
console.log(chalk.yellow(headline));
for (const line of rest) {
console.log(chalk.dim(line));
}
}
/**
* Prints just the manual command, for when the offer was declined or failed.
*/
export function displayUpgradeCommand(projectPath: string = '.'): void {
for (const line of buildUpgradeCommandLines(getInstallDir(), projectPath)) {
console.log(chalk.dim(line));
}
}
+824
View File
@@ -0,0 +1,824 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import http from 'http';
import os from 'os';
import path from 'path';
import { execFile } from 'child_process';
import { createRequire } from 'module';
import {
compareVersions,
getAvailableCliUpdate,
registryUrl,
getInstallDir,
isProjectLocalInstall,
isEphemeralRunnerInstall,
isNpmGlobalInstall,
isSourceCheckout,
detectPackageManager,
npmGlobalRoots,
npmPrefixFromInstallDir,
upgradedBinPath,
buildUpgradeCommandLines,
canSelfUpgrade,
shouldOfferUpgrade,
offerCliUpgrade,
readCliVersion,
rerunUpdateWithUpgradedCli,
buildCliUpdateLines,
displayCliUpdateNote,
} from '../../src/core/version-check.js';
const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');
// Resolved so the fixtures carry a drive letter on Windows, where an
// unresolved POSIX path can never prefix-match a resolved one.
const PROJECT_ROOT = path.resolve(path.join('tmp-fixture', 'proj'));
const GLOBAL_ROOT = path.resolve(path.join('tmp-fixture', 'global'));
const HOME_ROOT = path.resolve(path.join('tmp-fixture', 'home'));
function bumpMajor(version: string): string {
const major = Number.parseInt(version.split('.')[0] ?? '0', 10);
return `${major + 1}.0.0`;
}
describe('compareVersions', () => {
it('orders release versions numerically', () => {
expect(compareVersions('1.7.0', '1.6.0')).toBe(1);
expect(compareVersions('1.6.0', '1.7.0')).toBe(-1);
expect(compareVersions('1.6.0', '1.6.0')).toBe(0);
expect(compareVersions('1.10.0', '1.9.0')).toBe(1);
expect(compareVersions('2.0.0', '1.99.99')).toBe(1);
});
it('sorts prereleases below their release', () => {
expect(compareVersions('1.7.0-beta.1', '1.7.0')).toBe(-1);
expect(compareVersions('1.7.0', '1.7.0-beta.1')).toBe(1);
expect(compareVersions('1.7.0-beta.1', '1.6.0')).toBe(1);
});
it('compares prerelease identifiers per SemVer', () => {
expect(compareVersions('1.7.0-beta.10', '1.7.0-beta.2')).toBe(1);
expect(compareVersions('1.7.0-beta.2', '1.7.0-beta.10')).toBe(-1);
expect(compareVersions('1.7.0-beta.2', '1.7.0-beta.2')).toBe(0);
// Numeric identifiers rank below alphanumeric ones.
expect(compareVersions('1.7.0-1', '1.7.0-alpha')).toBe(-1);
// A longer identifier list wins an otherwise equal comparison.
expect(compareVersions('1.7.0-beta.1.1', '1.7.0-beta.1')).toBe(1);
expect(compareVersions('1.7.0-alpha', '1.7.0-beta')).toBe(-1);
});
it('tolerates a leading v, build metadata, and partial versions', () => {
expect(compareVersions('v1.7.0', '1.6.0')).toBe(1);
expect(compareVersions('1.7', '1.7.0')).toBe(0);
expect(compareVersions('1.7.0+build.5', '1.7.0')).toBe(0);
});
});
/**
* Every case runs against a local registry rather than a stubbed HTTP client.
* A mocked client cannot catch a request the real registry rejects — an Accept
* header that made npm answer 406 on this endpoint shipped past mocks once
* already — and it cannot prove that an opt-out sent nothing.
*/
describe('getAvailableCliUpdate', () => {
let server: http.Server;
let requests: Array<{ url: string; method: string; headers: http.IncomingHttpHeaders }>;
let respond: (res: http.ServerResponse) => void;
let originalEnv: Record<string, string | undefined>;
const ENV_KEYS = [
'NODE_ENV',
'CI',
'OPENSPEC_NO_UPDATE_CHECK',
'DO_NOT_TRACK',
'OPENSPEC_TELEMETRY',
'npm_config_registry',
] as const;
function serveVersion(version: unknown) {
respond = (res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ version }));
};
}
beforeEach(async () => {
requests = [];
serveVersion(bumpMajor(OPENSPEC_VERSION));
server = http.createServer((req, res) => {
requests.push({ url: req.url ?? '', method: req.method ?? '', headers: req.headers });
respond(res);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as { port: number }).port;
originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
// The check is disabled under test/CI by design; opt back in to exercise it.
for (const key of ENV_KEYS) delete process.env[key];
process.env.npm_config_registry = `http://127.0.0.1:${port}/`;
});
afterEach(async () => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
vi.restoreAllMocks();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('reports the published version when the installed CLI is behind', async () => {
await expect(getAvailableCliUpdate()).resolves.toBe(bumpMajor(OPENSPEC_VERSION));
});
it('asks the dist-tag endpoint, and never with an Accept type it answers 406 for', async () => {
await getAvailableCliUpdate();
expect(requests).toHaveLength(1);
expect(requests[0].method).toBe('GET');
expect(requests[0].url).toBe('/@fission-ai/openspec/latest');
// npm serves application/vnd.npm.install-v1+json only on the full
// packument; asking for it here returns 406 and silently disables the
// whole check.
expect(requests[0].headers.accept ?? '').not.toContain('vnd.npm.install-v1+json');
});
it('returns null when the installed CLI is current', async () => {
serveVersion(OPENSPEC_VERSION);
await expect(getAvailableCliUpdate()).resolves.toBeNull();
});
it('returns null when the registry is unreachable', async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
await expect(getAvailableCliUpdate()).resolves.toBeNull();
});
it('follows a redirect, as mirrors and corporate front-ends send', async () => {
let hop = 0;
respond = (res) => {
hop += 1;
if (hop === 1) {
res.writeHead(302, { location: '/elsewhere/@fission-ai/openspec/latest' });
res.end();
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ version: bumpMajor(OPENSPEC_VERSION) }));
};
await expect(getAvailableCliUpdate()).resolves.toBe(bumpMajor(OPENSPEC_VERSION));
expect(requests[1].url).toBe('/elsewhere/@fission-ai/openspec/latest');
});
it('gives up rather than following a redirect loop', async () => {
respond = (res) => {
res.writeHead(302, { location: '/round/and/round' });
res.end();
};
await expect(getAvailableCliUpdate()).resolves.toBeNull();
// Bounded: the first request plus a fixed number of hops.
expect(requests.length).toBeLessThanOrEqual(5);
});
it('returns null on a non-OK registry response', async () => {
respond = (res) => {
res.writeHead(500);
res.end('nope');
};
await expect(getAvailableCliUpdate()).resolves.toBeNull();
});
it('returns null on a response that is not JSON', async () => {
respond = (res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end('<html>proxy login</html>');
};
await expect(getAvailableCliUpdate()).resolves.toBeNull();
});
it('rejects a version that is not plain SemVer', async () => {
// A hostile or broken response must never reach the terminal: this one
// carries ANSI cursor controls that would repaint the lines around it.
serveVersion('9.9.9 malicious');
await expect(getAvailableCliUpdate()).resolves.toBeNull();
serveVersion(42);
await expect(getAvailableCliUpdate()).resolves.toBeNull();
serveVersion(`9.9.9-${'a'.repeat(500)}`);
await expect(getAvailableCliUpdate()).resolves.toBeNull();
});
it('gives up rather than hanging when the registry stalls mid-response', async () => {
respond = (res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.write('{"ver');
// Never finishes the body; only the request timeout can end this.
};
const startedAt = Date.now();
await expect(getAvailableCliUpdate()).resolves.toBeNull();
expect(Date.now() - startedAt).toBeLessThan(5000);
}, 10000);
it('sends nothing at all when opted out', async () => {
for (const [key, value] of [
['OPENSPEC_NO_UPDATE_CHECK', '1'],
['OPENSPEC_NO_UPDATE_CHECK', ''],
['CI', 'true'],
['CI', '1'],
['CI', 'TRUE'],
// An unknown value still means CI: suppressing is the safe direction,
// and it keeps this in step with isInteractive() in utils/interactive.
['CI', 'yes'],
['NODE_ENV', 'test'],
['DO_NOT_TRACK', '1'],
['OPENSPEC_TELEMETRY', '0'],
] as const) {
process.env[key] = value;
await expect(getAvailableCliUpdate()).resolves.toBeNull();
delete process.env[key];
}
expect(requests).toHaveLength(0);
});
it('still runs when CI is explicitly switched off', async () => {
for (const value of ['false', '0', 'no', '']) {
process.env.CI = value;
await expect(getAvailableCliUpdate()).resolves.toBe(bumpMajor(OPENSPEC_VERSION));
}
});
it('asks the registry npm exported, and only that', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-npmrc-'));
try {
// A .npmrc must not steer the request: file contents choosing an
// outbound destination is a flow this deliberately does not have.
fs.writeFileSync(path.join(home, '.npmrc'), 'registry=https://from-file.example.com/\n');
vi.spyOn(os, 'homedir').mockReturnValue(home);
vi.spyOn(process, 'cwd').mockReturnValue(home);
delete process.env.npm_config_registry;
expect(registryUrl()).toBe('https://registry.npmjs.org/@fission-ai/openspec/latest');
process.env.npm_config_registry = 'https://env.example.com';
expect(registryUrl()).toBe('https://env.example.com/@fission-ai/openspec/latest');
} finally {
fs.rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
it('falls back to the public registry when the override is not an http(s) URL', () => {
// Asserted on the URL rather than by calling: the fallback would send a
// real request to npmjs.org, which no test should depend on.
// No ' ' case: a blank value falls through to ~/.npmrc, and this test
// must not depend on whatever the machine has configured there.
for (const bogus of ['not-a-url', 'file:///etc/passwd', 'javascript:alert(1)']) {
process.env.npm_config_registry = bogus;
expect(registryUrl()).toBe('https://registry.npmjs.org/@fission-ai/openspec/latest');
}
process.env.npm_config_registry = 'https://npm.internal.example.com/';
expect(registryUrl()).toBe('https://npm.internal.example.com/@fission-ai/openspec/latest');
});
});
/**
* Guards the teardown, which no in-process assertion can prove: aborting a
* request still completing its TCP handshake used to leave a ref'd connect
* handle, so the CLI sat for ~10s after printing everything.
*/
describe('getAvailableCliUpdate against an unroutable registry', () => {
it('lets the process exit as soon as it gives up', async () => {
// A file:// URL, not a path: import() rejects a bare Windows path.
const distModule = new URL('../../dist/core/version-check.js', import.meta.url).href;
const env = { ...process.env, npm_config_registry: 'http://192.0.2.1:81/' };
// TEST-NET-1 (RFC 5737) is routable nowhere, so the connection can only
// end by our own teardown. Windows drops empty env vars, so unset rather
// than blank the guards that would otherwise skip the check.
delete env.NODE_ENV;
delete env.CI;
const startedAt = Date.now();
const { code, stderr } = await new Promise<{ code: number; stderr: string }>((resolve) => {
let stderr = '';
const child = execFile(
process.execPath,
['-e', `import(${JSON.stringify(distModule)}).then((m) => m.getAvailableCliUpdate())`],
{ env },
() => undefined
);
child.stderr?.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('close', (exitCode) => resolve({ code: exitCode ?? 0, stderr }));
});
expect(stderr).toBe('');
expect(code).toBe(0);
expect(Date.now() - startedAt).toBeLessThan(process.platform === 'win32' ? 12000 : 6000);
}, 30000);
});
/**
* The upgrade is offered, never performed unasked: a CLI that mutates the
* user's global environment without consent is the wrong default.
*/
describe('offerCliUpgrade', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.doUnmock('@inquirer/prompts');
vi.resetModules();
});
it('offers only for an npm-owned global install', () => {
// Anchored on this machine's real npm root so the case is not fictional.
const npmGlobal = path.join(npmGlobalRoots()[0], '@fission-ai', 'openspec');
expect(canSelfUpgrade(npmGlobal, PROJECT_ROOT)).toBe(true);
// `npm install -g` is the only command we run, so anything npm does not
// own would get a second copy that may not be the one on PATH.
const notOurs = [
path.join(HOME_ROOT, 'Library', 'pnpm', 'global', '5', 'node_modules', 'pkg'),
path.join(HOME_ROOT, '.volta', 'tools', 'image', 'packages', 'x', 'node_modules', 'pkg'),
path.join(HOME_ROOT, '.bun', 'install', 'global', 'node_modules', 'pkg'),
path.join(HOME_ROOT, '.npm', '_npx', 'a', 'node_modules', 'pkg'),
path.join(PROJECT_ROOT, 'node_modules', '@fission-ai', 'openspec'),
null,
];
for (const dir of notOurs) {
expect(canSelfUpgrade(dir, PROJECT_ROOT)).toBe(false);
}
});
it('asks only where the answer can be given and acted on', () => {
const npmGlobal = path.join(npmGlobalRoots()[0], '@fission-ai', 'openspec');
const base = { installDir: npmGlobal, projectPath: PROJECT_ROOT };
expect(shouldOfferUpgrade({ ...base, interactive: true, stdoutIsTty: true })).toBe(true);
// A prompt on a redirected stdout is a question nobody sees, and the
// command would wait on it forever.
expect(shouldOfferUpgrade({ ...base, interactive: true, stdoutIsTty: false })).toBe(false);
expect(shouldOfferUpgrade({ ...base, interactive: false, stdoutIsTty: true })).toBe(false);
// Interactive, but nothing `npm install -g` can fix.
expect(
shouldOfferUpgrade({
installDir: path.join(HOME_ROOT, 'Library', 'pnpm', 'global', '5', 'node_modules', 'pkg'),
projectPath: PROJECT_ROOT,
interactive: true,
stdoutIsTty: true,
})
).toBe(false);
});
it('never offers to install over a source checkout', () => {
const clone = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-clone-'));
try {
fs.mkdirSync(path.join(clone, '.git'));
expect(isSourceCheckout(clone)).toBe(true);
expect(canSelfUpgrade(clone, PROJECT_ROOT)).toBe(false);
const installed = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-installed-'));
try {
expect(isSourceCheckout(installed)).toBe(false);
} finally {
fs.rmSync(installed, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
} finally {
fs.rmSync(clone, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
expect(isSourceCheckout(null)).toBe(false);
});
it('recognizes an npm prefix that the node binary does not point at', () => {
// Homebrew realpaths node into the Cellar, so a root derived from
// process.execPath never matches the prefix npm actually installs into.
// The install's own shape is what settles it.
const prefix = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-brew-'));
try {
const isWindows = process.platform === 'win32';
const installed = isWindows
? path.join(prefix, 'node_modules', '@fission-ai', 'openspec')
: path.join(prefix, 'lib', 'node_modules', '@fission-ai', 'openspec');
fs.mkdirSync(installed, { recursive: true });
fs.mkdirSync(path.join(prefix, 'bin'), { recursive: true });
expect(npmPrefixFromInstallDir(installed)).toBe(prefix);
// Deliberately an unrelated root, standing in for the Cellar path.
expect(isNpmGlobalInstall(installed, [path.join(GLOBAL_ROOT, 'lib', 'node_modules')])).toBe(
true
);
expect(npmPrefixFromInstallDir(path.join(HOME_ROOT, 'not', 'an', 'install'))).toBeNull();
expect(npmPrefixFromInstallDir(null)).toBeNull();
} finally {
fs.rmSync(prefix, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
it('does not mistake another manager\'s npm-shaped layout for an npm install', () => {
// volta nests a whole node install, so its packages sit in exactly the
// <prefix>/lib/node_modules shape npm uses.
const volta = path.join(
HOME_ROOT,
'.volta',
'tools',
'image',
'node',
'22.0.0',
'lib',
'node_modules',
'@fission-ai',
'openspec'
);
expect(isNpmGlobalInstall(volta, [path.join(GLOBAL_ROOT, 'lib', 'node_modules')])).toBe(false);
expect(canSelfUpgrade(volta, PROJECT_ROOT)).toBe(false);
// And the printed command matches the manager that does own it.
expect(buildUpgradeCommandLines(volta, PROJECT_ROOT)[0]).toContain('volta install');
});
it('does not read a package manager into an incidental directory name', () => {
// A user directory called "pnpm", or a project called "yarn", is not a
// global install of either.
expect(detectPackageManager('/home/pnpm/npm-global/lib/node_modules/pkg')).toBe('npm');
expect(detectPackageManager(path.join(HOME_ROOT, 'projects', 'yarn', 'node_modules', 'pkg'))).toBe(
'npm'
);
// The real layouts still resolve.
expect(detectPackageManager(path.join(HOME_ROOT, 'Library', 'pnpm', 'global', '5', 'pkg'))).toBe(
'pnpm'
);
expect(
detectPackageManager(path.join(HOME_ROOT, '.config', 'yarn', 'global', 'node_modules', 'pkg'))
).toBe('yarn');
});
it('recognizes npm global roots without shelling out', () => {
const roots = [path.join(GLOBAL_ROOT, 'lib', 'node_modules')];
expect(isNpmGlobalInstall(path.join(roots[0], '@fission-ai', 'openspec'), roots)).toBe(true);
expect(isNpmGlobalInstall(path.join(GLOBAL_ROOT, 'lib', 'node_modules'), roots)).toBe(false);
expect(isNpmGlobalInstall(path.join(HOME_ROOT, 'elsewhere', 'pkg'), roots)).toBe(false);
expect(isNpmGlobalInstall(null, roots)).toBe(false);
// A sibling whose name merely starts with the root.
expect(isNpmGlobalInstall(`${roots[0]}-other${path.sep}pkg`, roots)).toBe(false);
});
it('names the command the owning package manager understands', () => {
const cases: Array<[string, string]> = [
[path.join(HOME_ROOT, 'Library', 'pnpm', 'global', '5', 'node_modules', 'pkg'), 'pnpm add -g'],
[path.join(HOME_ROOT, '.bun', 'install', 'global', 'node_modules', 'pkg'), 'bun add -g'],
[path.join(HOME_ROOT, '.volta', 'tools', 'image', 'packages', 'x', 'pkg'), 'volta install'],
[path.join(HOME_ROOT, '.config', 'yarn', 'global', 'node_modules', 'pkg'), 'yarn global add'],
[path.join(GLOBAL_ROOT, 'lib', 'node_modules', 'pkg'), 'npm install -g'],
];
for (const [dir, expected] of cases) {
expect(buildUpgradeCommandLines(dir, PROJECT_ROOT)[0]).toContain(expected);
}
expect(detectPackageManager(null)).toBe('npm');
});
it('recognizes the Windows spellings of those install directories', () => {
// %LOCALAPPDATA%\Volta, \Yarn\Data, \pnpm-cache — capitalized, undotted,
// and nothing like their POSIX equivalents.
expect(detectPackageManager('C:\\Users\\me\\AppData\\Local\\Volta\\tools\\image\\pkg')).toBe(
'volta'
);
expect(detectPackageManager('C:\\Users\\me\\AppData\\Local\\pnpm\\global\\5\\pkg')).toBe('pnpm');
expect(detectPackageManager('C:\\Users\\me\\AppData\\Local\\Yarn\\Data\\global\\pkg')).toBe(
'yarn'
);
expect(isEphemeralRunnerInstall('C:\\Users\\me\\AppData\\Local\\pnpm-cache\\dlx\\a\\pkg')).toBe(
true
);
});
it('asks before touching anything, and does nothing when declined', async () => {
const confirm = vi.fn(async () => false);
vi.doMock('@inquirer/prompts', () => ({ confirm }));
const { offerCliUpgrade: offer } = await import('../../src/core/version-check.js?decline');
await expect(offer('9.9.9')).resolves.toBe('declined');
// Proves the prompt drove the result rather than an unrelated failure.
expect(confirm).toHaveBeenCalledTimes(1);
expect(confirm.mock.calls[0][0]).toMatchObject({ message: expect.stringContaining('9.9.9') });
});
it('reports Ctrl-C as cancelled, so the caller can stop instead of prompting on', async () => {
const cancellation = Object.assign(new Error('User force closed the prompt'), {
name: 'ExitPromptError',
});
const confirm = vi.fn(async () => {
throw cancellation;
});
vi.doMock('@inquirer/prompts', () => ({ confirm }));
const { offerCliUpgrade: offer } = await import('../../src/core/version-check.js?ctrlc');
await expect(offer('9.9.9')).resolves.toBe('cancelled');
expect(confirm).toHaveBeenCalledTimes(1);
});
it('treats an unexpected prompt failure as a decline rather than a crash', async () => {
const confirm = vi.fn(async () => {
throw new Error('tty exploded');
});
vi.doMock('@inquirer/prompts', () => ({ confirm }));
const { offerCliUpgrade: offer } = await import('../../src/core/version-check.js?boom');
await expect(offer('9.9.9')).resolves.toBe('declined');
});
it('reads the version line, not the first version-shaped token in a banner', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-banner-'));
try {
const isWindows = process.platform === 'win32';
const bin = path.join(dir, isWindows ? 'banner.cmd' : 'banner.sh');
// A wrapper that greets before answering: taking the first match would
// report the Node version as OpenSpec's.
fs.writeFileSync(
bin,
isWindows
? '@echo Node.js v25.8.1 ^| OpenSpec\r\n@echo 1.7.0\r\n'
: '#!/bin/sh\necho "Node.js v25.8.1 | OpenSpec"\necho "1.7.0"\n'
);
fs.chmodSync(bin, 0o755);
await expect(readCliVersion(bin)).resolves.toBe('1.7.0');
} finally {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}, 30000);
it('reads a version back from a binary rather than trusting an exit code', async () => {
// `npm install -g` exits 0 even when it installed nothing, so the version
// has to be read from whatever now answers.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-bin-'));
try {
const isWindows = process.platform === 'win32';
const bin = path.join(dir, isWindows ? 'fake.cmd' : 'fake.sh');
fs.writeFileSync(bin, isWindows ? '@echo 9.9.9\r\n' : '#!/bin/sh\necho 9.9.9\n');
fs.chmodSync(bin, 0o755);
await expect(readCliVersion(bin)).resolves.toBe('9.9.9');
await expect(readCliVersion(path.join(dir, 'does-not-exist'))).resolves.toBeNull();
} finally {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}, 20000);
});
/**
* The re-run stands in for the command the user typed, so what it forwards and
* what it reports are both load-bearing.
*/
describe('rerunUpdateWithUpgradedCli', () => {
let dir: string;
const isWindows = process.platform === 'win32';
function writeFakeCli(body: string): string {
const bin = path.join(dir, isWindows ? 'openspec.cmd' : 'openspec');
fs.writeFileSync(bin, body);
fs.chmodSync(bin, 0o755);
return bin;
}
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-rerun-'));
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
it('forwards --force and separates the path from any flag-shaped value', async () => {
const log = path.join(dir, 'args.txt');
const bin = writeFakeCli(
isWindows
? `@echo %* > "${log}"\r\n@exit /b 0\r\n`
: `#!/bin/sh\necho "$@" > "${log}"\nexit 0\n`
);
await expect(
rerunUpdateWithUpgradedCli('--weird-path', { force: true, binPath: bin })
).resolves.toBe(0);
// cmd.exe echoes each argument quoted, so compare on tokens rather than
// on the raw line.
const args = fs
.readFileSync(log, 'utf-8')
.trim()
.split(/\s+/)
.map((token) => token.replace(/^"|"$/g, ''));
expect(args).toContain('--force');
// Without the separator the path would be parsed as an option.
expect(args.indexOf('--')).toBeGreaterThan(-1);
expect(args[args.indexOf('--') + 1]).toBe('--weird-path');
}, 30000);
it('disables the check in the child, so a stale PATH cannot loop forever', async () => {
const log = path.join(dir, 'env.txt');
const bin = writeFakeCli(
isWindows
? `@echo %OPENSPEC_NO_UPDATE_CHECK% > "${log}"\r\n@exit /b 0\r\n`
: `#!/bin/sh\necho "$OPENSPEC_NO_UPDATE_CHECK" > "${log}"\nexit 0\n`
);
await rerunUpdateWithUpgradedCli('.', { binPath: bin });
// Without this, a PATH still resolving to the old binary would prompt
// again, and again.
expect(fs.readFileSync(log, 'utf-8').trim()).toBe('1');
}, 30000);
it('passes the child exit code through instead of claiming success', async () => {
const bin = writeFakeCli(isWindows ? '@exit /b 7\r\n' : '#!/bin/sh\nexit 7\n');
await expect(rerunUpdateWithUpgradedCli('.', { binPath: bin })).resolves.toBe(7);
}, 30000);
it('reports a failure when there is no upgraded CLI to hand off to', async () => {
const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((line?: unknown) => {
lines.push(String(line ?? ''));
});
await expect(
rerunUpdateWithUpgradedCli('.', { binPath: path.join(dir, 'not-installed') })
).resolves.toBe(1);
expect(lines.join('\n')).toContain('were not regenerated');
}, 30000);
});
describe('displayCliUpdateNote', () => {
afterEach(() => {
vi.restoreAllMocks();
});
function capture(run: () => void): string {
const lines: string[] = [];
const spy = vi.spyOn(console, 'log').mockImplementation((line?: unknown) => {
lines.push(String(line ?? ''));
});
try {
run();
} finally {
spy.mockRestore();
}
return lines.join('\n');
}
it('names the global install command and the copy that answered', () => {
const output = capture(() => displayCliUpdateNote('9.9.9'));
expect(output).toContain(`v${OPENSPEC_VERSION} → v9.9.9`);
expect(output).toContain('npm install -g @fission-ai/openspec@latest');
expect(output).toContain('Then run "openspec update" again');
expect(output).toContain(`Running from: ${getInstallDir()}`);
});
it('picks the upgrade command that matches how the CLI was installed', () => {
const globalDir = path.join(GLOBAL_ROOT, 'lib', 'node_modules', '@fission-ai', 'openspec');
const globalLines = buildCliUpdateLines('9.9.9', globalDir, PROJECT_ROOT).join('\n');
expect(globalLines).toContain('npm install -g @fission-ai/openspec@latest');
// Hoisted workspace layout: run from a sub-package, dependency at the root.
const local = buildCliUpdateLines(
'9.9.9',
path.join(PROJECT_ROOT, 'node_modules', '@fission-ai', 'openspec'),
path.join(PROJECT_ROOT, 'packages', 'app')
).join('\n');
// No npm command: the project's own package manager owns its lockfile.
expect(local).toContain('Update the @fission-ai/openspec dependency in this project.');
expect(local).not.toContain('npm install');
const npx = buildCliUpdateLines(
'9.9.9',
path.join(GLOBAL_ROOT, '.npm', '_npx', 'abc123', 'node_modules', '@fission-ai', 'openspec'),
PROJECT_ROOT
).join('\n');
expect(npx).toContain('npx @fission-ai/openspec@latest update');
expect(npx).not.toContain('npm install -g');
});
it('omits the install path only when it cannot be resolved', () => {
const dir = path.join(GLOBAL_ROOT, 'openspec');
expect(buildCliUpdateLines('9.9.9', null, '.').join('\n')).not.toContain('Running from:');
expect(buildCliUpdateLines('9.9.9', dir, '.').join('\n')).toContain(`Running from: ${dir}`);
});
it('recognizes project-local installs from any directory under the project', () => {
const local = path.join(PROJECT_ROOT, 'node_modules', '@fission-ai', 'openspec');
expect(isProjectLocalInstall(local, PROJECT_ROOT)).toBe(true);
// Workspace sub-package with a hoisted root node_modules.
expect(isProjectLocalInstall(local, path.join(PROJECT_ROOT, 'packages', 'app'))).toBe(true);
// pnpm's real path still lives under the same node_modules.
expect(
isProjectLocalInstall(
path.join(PROJECT_ROOT, 'node_modules', '.pnpm', 'x', 'node_modules', 'y'),
PROJECT_ROOT
)
).toBe(true);
expect(
isProjectLocalInstall(
path.join(GLOBAL_ROOT, 'lib', 'node_modules', '@fission-ai', 'openspec'),
PROJECT_ROOT
)
).toBe(false);
// A sibling directory whose name merely starts with the project path.
expect(
isProjectLocalInstall(
`${PROJECT_ROOT}-other${path.sep}node_modules${path.sep}pkg`,
PROJECT_ROOT
)
).toBe(false);
expect(isProjectLocalInstall(null, PROJECT_ROOT)).toBe(false);
});
it('never throws when the working directory has been deleted', () => {
const anywhere = path.join(GLOBAL_ROOT, 'node_modules', 'pkg');
vi.spyOn(process, 'cwd').mockImplementation(() => {
throw new Error('ENOENT: uv_cwd');
});
expect(() => isProjectLocalInstall(anywhere)).not.toThrow();
expect(isProjectLocalInstall(anywhere)).toBe(false);
expect(() => capture(() => displayCliUpdateNote('9.9.9'))).not.toThrow();
});
it('does not tell npx users to run an update they were just handed', () => {
// `npx …@latest update` IS the update, so a "then run it again" line
// would be nonsense.
const npx = buildUpgradeCommandLines(
path.join(HOME_ROOT, '.npm', '_npx', 'abc', 'node_modules', 'pkg'),
PROJECT_ROOT
);
expect(npx).toEqual([' npx @fission-ai/openspec@latest update']);
// Every other flavor does need the second pass.
expect(buildUpgradeCommandLines(path.join(GLOBAL_ROOT, 'lib', 'node_modules', 'pkg'), PROJECT_ROOT))
.toContain(' Then run "openspec update" again to pick up new workflows.');
});
it('finds the binary npm installs beside its global root', () => {
const prefix = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-prefix-'));
try {
const isWindows = process.platform === 'win32';
// npm's layout: <prefix>/lib/node_modules on POSIX, <prefix>/node_modules
// on Windows, with the shim one level up from the root's parent.
const root = isWindows
? path.join(prefix, 'node_modules')
: path.join(prefix, 'lib', 'node_modules');
fs.mkdirSync(root, { recursive: true });
// Nothing installed yet: nothing to hand off to.
expect(upgradedBinPath([root])).toBeNull();
const bin = isWindows
? path.join(prefix, 'openspec.cmd')
: path.join(prefix, 'bin', 'openspec');
fs.mkdirSync(path.dirname(bin), { recursive: true });
fs.writeFileSync(bin, '');
expect(upgradedBinPath([root])).toBe(bin);
} finally {
fs.rmSync(prefix, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
it('tells npx and dlx users to re-run rather than install globally', () => {
// Matched on whole path segments, and "dlx" only under its package
// manager's own cache — a user directory named "dlx" is not a throwaway one.
expect(
isEphemeralRunnerInstall(path.join(GLOBAL_ROOT, '.npm', '_npx', 'abc', 'node_modules', 'pkg'))
).toBe(true);
expect(
isEphemeralRunnerInstall(path.join(GLOBAL_ROOT, 'pnpm', 'dlx', 'abc', 'node_modules', 'pkg'))
).toBe(true);
expect(
isEphemeralRunnerInstall(
path.join(GLOBAL_ROOT, 'lib', 'node_modules', '@fission-ai', 'openspec')
)
).toBe(false);
expect(
isEphemeralRunnerInstall(path.join(path.sep, 'Users', 'dlx', 'app', 'node_modules', 'pkg'))
).toBe(false);
expect(isEphemeralRunnerInstall(null)).toBe(false);
});
});