Files
OpenSpec/test/core/version-check.test.ts
T
Clay Good 56528ea454 feat(cli): report version and update metadata (#2001)
* docs(openspec): propose version reporting command

* docs(openspec): clarify update guidance availability

* feat(cli): report version and update metadata

* fix(cli): harden version install detection
2026-09-29 18:26:04 +00:00

1245 lines
48 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import http from 'http';
import https from 'https';
import os from 'os';
import path from 'path';
import { execFile } from 'child_process';
import { createRequire } from 'module';
import {
compareVersions,
checkForCliUpdate,
getAvailableCliUpdate,
registryUrl,
getInstallDir,
isProjectLocalInstall,
isEphemeralRunnerInstall,
isNpmGlobalInstall,
isSourceCheckout,
detectPackageManager,
npmGlobalRoots,
npmPrefixFromInstallDir,
upgradedBinPath,
buildUpgradeCommandLines,
canSelfUpgrade,
shouldOfferUpgrade,
offerCliUpgrade,
readCliVersion,
rerunUpdateWithUpgradedCli,
buildCliUpdateLines,
displayCliUpdateNote,
getCliInstallInfo,
getCliUpdateCommand,
buildVersionReportLines,
} 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];
// The registry must be https — the CLI refuses a cleartext one — so the
// fixture speaks https and the TLS transport is swapped for the local
// plaintext server at the socket level. Everything above it (URL,
// headers, redirect policy, timeouts) is the real code path; only the
// handshake is stubbed, since no cert authority exists in-process.
process.env.npm_config_registry = `https://127.0.0.1:${port}/`;
vi.spyOn(https, 'get').mockImplementation(((target: URL, ...rest: unknown[]) => {
const plaintext = new URL(target.toString());
plaintext.protocol = 'http:';
return (http.get as unknown as (...args: unknown[]) => http.ClientRequest)(
plaintext,
...rest
);
}) as unknown as typeof https.get);
});
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('returns a structured available result for the version command', async () => {
await expect(checkForCliUpdate()).resolves.toEqual({
status: 'available',
latest: 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 a structured current result for the version command', async () => {
serveVersion(OPENSPEC_VERSION);
await expect(checkForCliUpdate()).resolves.toEqual({
status: 'current',
latest: OPENSPEC_VERSION,
});
});
it('returns null when the registry is unreachable', async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
await expect(checkForCliUpdate()).resolves.toEqual({ status: 'offline', latest: null });
});
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('refuses a redirect that leaves TLS', async () => {
// Cross-host is allowed on purpose - mirrors and corporate front-ends
// redirect, and the check would be permanently dead for them otherwise.
// Leaving TLS is not: a MITM on a cleartext hop would choose the answer.
// A second local server stands in for the redirect target, so proving the
// hop was refused costs no real network traffic.
let elsewhereHits = 0;
const elsewhere = http.createServer((_req, res) => {
elsewhereHits += 1;
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ version: bumpMajor(OPENSPEC_VERSION) }));
});
await new Promise<void>((resolve) => elsewhere.listen(0, '127.0.0.1', resolve));
const elsewherePort = (elsewhere.address() as { port: number }).port;
try {
for (const location of [
// The cleartext metadata service the hostile-.npmrc path aims for.
'http://169.254.169.254/latest/meta-data/',
'http://127.0.0.1:1/@fission-ai/openspec/latest',
]) {
requests = [];
respond = (res) => {
res.writeHead(302, { location });
res.end();
};
await expect(getAvailableCliUpdate()).resolves.toBeNull();
// Refused before a socket is opened: only the first hop was sent.
expect(requests).toHaveLength(1);
expect(elsewhereHits).toBe(0);
}
} finally {
await new Promise<void>((resolve) => elsewhere.close(() => resolve()));
}
});
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(checkForCliUpdate()).resolves.toEqual({ status: 'offline', latest: null });
});
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('tears down a redirected connection when the overall budget expires', async () => {
// The redirect target trickles bytes forever: steady data keeps resetting
// the per-request idle timeout, so only the overall budget timer can end
// the exchange — and it must destroy the redirected request, not the
// already-dead first hop, or the socket outlives the check.
let hop = 0;
let trickleClosed = false;
respond = (res) => {
hop += 1;
if (hop === 1) {
res.writeHead(302, { location: '/mirror/@fission-ai/openspec/latest' });
res.end();
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.write('{"ver');
const trickle = setInterval(() => res.write('x'), 200);
res.on('close', () => {
trickleClosed = true;
clearInterval(trickle);
});
};
const startedAt = Date.now();
await expect(getAvailableCliUpdate()).resolves.toBeNull();
expect(Date.now() - startedAt).toBeLessThan(5000);
await vi.waitFor(() => expect(trickleClosed).toBe(true), { timeout: 2000 });
}, 10000);
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(checkForCliUpdate()).resolves.toEqual({ status: 'offline', latest: null });
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'],
// Tolerant, like CI above: a user who wrote "true" opted out.
['DO_NOT_TRACK', 'true'],
['DO_NOT_TRACK', ' Yes '],
['OPENSPEC_TELEMETRY', '0'],
['OPENSPEC_TELEMETRY', 'false'],
['OPENSPEC_TELEMETRY', 'OFF'],
] as const) {
process.env[key] = value;
await expect(checkForCliUpdate()).resolves.toEqual({ status: 'disabled', latest: null });
await expect(getAvailableCliUpdate()).resolves.toBeNull();
delete process.env[key];
}
expect(requests).toHaveLength(0);
});
it('sends nothing when telemetry.enabled is false in global config', async () => {
const xdgHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-vc-telemetry-'));
const previousXdg = process.env.XDG_CONFIG_HOME;
try {
process.env.XDG_CONFIG_HOME = xdgHome;
const configDir = path.join(xdgHome, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.json'),
JSON.stringify({ telemetry: { enabled: false } })
);
await expect(getAvailableCliUpdate()).resolves.toBeNull();
expect(requests).toHaveLength(0);
} finally {
if (previousXdg === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = previousXdg;
}
fs.rmSync(xdgHome, { recursive: true, force: true });
}
});
it('sends nothing when the global config cannot be parsed', async () => {
const xdgHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-vc-unparseable-'));
const previousXdg = process.env.XDG_CONFIG_HOME;
vi.spyOn(console, 'error').mockImplementation(() => {});
try {
process.env.XDG_CONFIG_HOME = xdgHome;
const configDir = path.join(xdgHome, 'openspec');
fs.mkdirSync(configDir, { recursive: true });
// A hand-edit typo can hide the opt-out the file holds.
fs.writeFileSync(
path.join(configDir, 'config.json'),
'{\n "telemetry": {\n "enabled": false\n },\n}\n'
);
await expect(getAvailableCliUpdate()).resolves.toBeNull();
expect(requests).toHaveLength(0);
} finally {
if (previousXdg === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = previousXdg;
}
fs.rmSync(xdgHome, { recursive: true, force: true });
}
});
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('disables the check when the override is not an https URL', () => {
// Not a fallback to public npm: an organization on an internal mirror
// deliberately avoided that request, and a version resolved against a
// registry the eventual `npm install -g` does not use is worse than none.
// 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)',
// npm exports a cloned repository's .npmrc as npm_config_registry, so a
// cleartext registry is repo-content choosing a cleartext destination —
// here the cloud metadata service.
'http://169.254.169.254/',
'http://localhost:8500/',
'HTTP://registry.npmjs.org/',
]) {
process.env.npm_config_registry = bogus;
expect(registryUrl()).toBeNull();
}
delete process.env.npm_config_registry;
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');
});
it('reports a rejected registry as disabled without making a request', async () => {
process.env.npm_config_registry = 'http://169.254.169.254/';
await expect(checkForCliUpdate()).resolves.toEqual({ status: 'disabled', latest: null });
expect(requests).toHaveLength(0);
});
});
describe('getCliInstallInfo', () => {
it('reports known global package managers and their update commands', () => {
const cases: Array<[string, string]> = [
[path.join(npmGlobalRoots()[0], '@fission-ai', 'openspec'), 'npm'],
[path.join(HOME_ROOT, 'Library', 'pnpm', 'global', '5', 'node_modules', 'pkg'), 'pnpm'],
[path.join(HOME_ROOT, '.bun', 'install', 'global', 'node_modules', 'pkg'), 'bun'],
[path.join(HOME_ROOT, '.volta', 'tools', 'image', 'packages', 'x', 'pkg'), 'volta'],
[path.join(HOME_ROOT, '.config', 'yarn', 'global', 'node_modules', 'pkg'), 'yarn'],
['C:\\Users\\me\\AppData\\Local\\Volta\\tools\\image\\pkg', 'volta'],
['C:\\Users\\me\\AppData\\Local\\pnpm\\global\\5\\pkg', 'pnpm'],
['C:\\Users\\me\\AppData\\Local\\Yarn\\Data\\global\\pkg', 'yarn'],
];
for (const [installDir, packageManager] of cases) {
const install = getCliInstallInfo(installDir, PROJECT_ROOT);
expect(install).toEqual({ location: installDir, packageManager, scope: 'global' });
expect(getCliUpdateCommand(install)).toContain('@fission-ai/openspec@latest');
}
});
it('reports project and temporary installs without inventing update commands', () => {
const projectInstall = path.join(
PROJECT_ROOT,
'node_modules',
'.pnpm',
'@fission-ai+openspec',
'node_modules',
'@fission-ai',
'openspec'
);
const temporaryInstall = path.join(
HOME_ROOT,
'.npm',
'_npx',
'abc',
'node_modules',
'@fission-ai',
'openspec'
);
const project = getCliInstallInfo(projectInstall, PROJECT_ROOT);
expect(project).toEqual({
location: projectInstall,
packageManager: 'pnpm',
scope: 'project',
});
expect(getCliUpdateCommand(project)).toBeNull();
const temporary = getCliInstallInfo(temporaryInstall, PROJECT_ROOT);
expect(temporary).toEqual({
location: temporaryInstall,
packageManager: 'npm',
scope: 'temporary',
});
expect(getCliUpdateCommand(temporary)).toBeNull();
});
it('reports source and unknown installs honestly', () => {
const clone = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-version-source-'));
try {
fs.writeFileSync(path.join(clone, '.git'), 'gitdir: elsewhere\n');
expect(getCliInstallInfo(clone, PROJECT_ROOT)).toEqual({
location: clone,
packageManager: null,
scope: 'source',
});
} finally {
fs.rmSync(clone, { recursive: true, force: true });
}
const unknown = path.join(HOME_ROOT, 'portable', '@fission-ai', 'openspec');
expect(getCliInstallInfo(unknown, PROJECT_ROOT)).toEqual({
location: unknown,
packageManager: null,
scope: null,
});
expect(getCliInstallInfo(null, PROJECT_ROOT)).toEqual({
location: null,
packageManager: null,
scope: null,
});
});
it('classifies project installs through aliases while preserving the reported path', () => {
const realProject = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-version-project-'));
const aliasProject = `${realProject}-alias`;
const relativeInstall = path.join(
'node_modules',
'.pnpm',
'@fission-ai+openspec',
'node_modules',
'@fission-ai',
'openspec'
);
const realInstall = path.join(realProject, relativeInstall);
fs.mkdirSync(realInstall, { recursive: true });
try {
fs.symlinkSync(realProject, aliasProject, process.platform === 'win32' ? 'junction' : 'dir');
const aliasedInstall = path.join(aliasProject, relativeInstall);
expect(getCliInstallInfo(aliasedInstall, realProject)).toEqual({
location: aliasedInstall,
packageManager: 'pnpm',
scope: 'project',
});
} finally {
fs.rmSync(aliasProject, { recursive: true, force: true });
fs.rmSync(realProject, { recursive: true, force: true });
}
});
it('does not infer global ownership from unrelated path segments', () => {
const unrelated = path.join(
HOME_ROOT,
'pnpm',
'projects',
'global',
'node_modules',
'@fission-ai',
'openspec'
);
expect(getCliInstallInfo(unrelated, PROJECT_ROOT)).toEqual({
location: unrelated,
packageManager: null,
scope: null,
});
});
});
describe('buildVersionReportLines', () => {
const globalInstall = {
location: '/opt/lib/node_modules/@fission-ai/openspec',
packageManager: 'npm' as const,
scope: 'global' as const,
};
it('renders local information and available update guidance', () => {
expect(buildVersionReportLines('1.13.2', globalInstall)).toEqual([
'OpenSpec 1.13.2 (npm, global)',
]);
expect(
buildVersionReportLines(
'1.13.2',
globalInstall,
{ status: 'available', latest: '1.14.0' },
'npm install -g @fission-ai/openspec@latest'
)
).toEqual([
'OpenSpec 1.13.2 (npm, global)',
'Update available: 1.14.0',
' npm install -g @fission-ai/openspec@latest',
]);
});
it.each([
['current', '1.13.2', 'OpenSpec is up to date.'],
['disabled', null, 'Update check disabled.'],
['offline', null, 'Could not check for updates.'],
] as const)('renders the %s update status', (status, latest, message) => {
expect(buildVersionReportLines('1.13.2', globalInstall, { status, latest })).toEqual([
'OpenSpec 1.13.2 (npm, global)',
message,
]);
});
it('omits unknown install details and unavailable package-manager guidance', () => {
expect(
buildVersionReportLines(
'1.13.2',
{ location: '/portable/openspec', packageManager: null, scope: null },
{ status: 'available', latest: '1.14.0' }
)
).toEqual(['OpenSpec 1.13.2', 'Update available: 1.14.0']);
});
});
/**
* 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: 'https://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', () => {
// Whatever registry this machine (or `npm test`) exported must not decide
// these cases; the registry rule has its own test below.
let originalRegistry: string | undefined;
beforeEach(() => {
originalRegistry = process.env.npm_config_registry;
delete process.env.npm_config_registry;
});
afterEach(() => {
if (originalRegistry === undefined) {
delete process.env.npm_config_registry;
} else {
process.env.npm_config_registry = originalRegistry;
}
vi.restoreAllMocks();
vi.doUnmock('@inquirer/prompts');
vi.resetModules();
});
it('never offers to install from a registry that is not the public one', () => {
// npm resolves `npm install -g` against npm_config_registry, which npm may
// have read from a cloned repository's .npmrc — so a non-default registry
// may inform the check but must never drive an install prompt.
const npmGlobal = path.join(npmGlobalRoots()[0], '@fission-ai', 'openspec');
const base = { installDir: npmGlobal, projectPath: PROJECT_ROOT, interactive: true, stdoutIsTty: true };
expect(canSelfUpgrade(npmGlobal, PROJECT_ROOT)).toBe(true);
for (const registry of ['https://evil.example.com/', 'https://npm.internal.example.com']) {
process.env.npm_config_registry = registry;
expect(canSelfUpgrade(npmGlobal, PROJECT_ROOT)).toBe(false);
expect(shouldOfferUpgrade(base)).toBe(false);
}
// The public registry, however it is spelled, still qualifies.
for (const registry of ['https://registry.npmjs.org', 'https://registry.npmjs.org/']) {
process.env.npm_config_registry = registry;
expect(canSelfUpgrade(npmGlobal, PROJECT_ROOT)).toBe(true);
}
});
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 });
if (isWindows) {
// npm writes the .cmd shim beside node_modules; it is what separates
// a real prefix from a hand-copied portable tree.
fs.writeFileSync(path.join(prefix, 'openspec.cmd'), '@echo off\n');
} else {
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();
// The same shape with nothing npm wrote (no bin dir, no .cmd shim) is a
// hand-copied portable tree, not an npm install — no upgrade offer.
const portable = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-portable-'));
try {
const copied = isWindows
? path.join(portable, 'node_modules', '@fission-ai', 'openspec')
: path.join(portable, 'lib', 'node_modules', '@fission-ai', 'openspec');
fs.mkdirSync(copied, { recursive: true });
expect(
isNpmGlobalInstall(copied, [path.join(GLOBAL_ROOT, 'lib', 'node_modules')])
).toBe(false);
} finally {
fs.rmSync(portable, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
} 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('does not let a user or project directory named after a manager steal the install', () => {
// A person named volta with a plain npm prefix in their home directory:
// the undotted segment alone must not turn the hint into `volta install`.
expect(detectPackageManager('/home/volta/.npm-global/lib/node_modules/pkg')).toBe('npm');
expect(detectPackageManager('/srv/volta/apps/node_modules/pkg')).toBe('npm');
// Even alongside a generic "tools" dir — only volta's full tools/image
// layout counts.
expect(detectPackageManager('/srv/volta/tools/apps/node_modules/pkg')).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);
});
});