From 1020d7bbebdf7a020f02bf5ee6f80d92e570a21b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:53:57 +0000 Subject: [PATCH 1/5] feat(cli): tell you when `--version` is out of date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agentworkforce --version` printed a bare version number, which says what you have but not whether it is current. It now also asks the npm registry for the published `latest` and, when the installed build is behind it, writes the upgrade command to stderr: $ agentworkforce --version 4.1.45 Update available: 4.1.45 → 4.2.0 Run `npm install -g agentworkforce@latest` to update. stdout stays exactly the version string, so `$(agentworkforce --version)` keeps working in scripts while the notice still reaches a human. The check is best-effort: it reads the few-dozen-byte `dist-tags` endpoint rather than the full packument, times out after 1.5s, and resolves to "no notice" on an unreachable registry, a non-JSON body, an unparsable version, or a local build that is ahead of `latest`. It honours `AGENTWORKFORCE_REGISTRY` / `npm_config_registry`, and is skipped entirely by `AGENTWORKFORCE_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`. The wrapper bin answers `--version` before the CLI module graph loads, so it delegates to the same `update-check` module in the implementation it just validated; an install predating that module simply prints no notice. The wrapper also reports which install won, so a project-local CLI is told to run `npm install agentworkforce@latest` rather than a `-g` command that would update a copy that did not run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNcR15rKHnYBiV3UxtynuF --- CHANGELOG.md | 5 + README.md | 16 + packages/agentworkforce/CHANGELOG.md | 6 + packages/agentworkforce/bin/agentworkforce.js | 29 +- packages/agentworkforce/test/version.test.js | 63 +++- packages/cli/CHANGELOG.md | 9 + packages/cli/src/cli-impl.ts | 9 +- packages/cli/src/cli.test.ts | 97 +++++- packages/cli/src/update-check.test.ts | 245 +++++++++++++++ packages/cli/src/update-check.ts | 278 ++++++++++++++++++ 10 files changed, 752 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/update-check.test.ts create mode 100644 packages/cli/src/update-check.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 70031754..1702814e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `agentworkforce --version` tells you when a newer release is published and + prints the command that installs it. + ## [4.1.39] - 2026-08-11 ### Added diff --git a/README.md b/README.md index 84f7af2e..14607222 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,22 @@ agentworkforce harness check agentworkforce --version ``` +`agentworkforce --version` prints the installed version on stdout and, when a +newer release is published, writes the upgrade command to stderr: + +```text +$ agentworkforce --version +4.1.45 +Update available: 4.1.45 → 4.2.0 +Run `npm install -g agentworkforce@latest` to update. +``` + +The check reads the `latest` dist-tag from the npm registry (or +`AGENTWORKFORCE_REGISTRY` / npm's configured registry), times out after 1.5s, +and stays silent when it cannot reach one — so the version itself always +prints. Set `AGENTWORKFORCE_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`) to +skip it entirely. + Local personas resolve from project-local files, configured source directories, the personal persona directory, and the small built-in catalog. Higher layers override lower layers field by field, so a repo can extend a reusable pack diff --git a/packages/agentworkforce/CHANGELOG.md b/packages/agentworkforce/CHANGELOG.md index e23d3c02..6d06faaf 100644 --- a/packages/agentworkforce/CHANGELOG.md +++ b/packages/agentworkforce/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `agentworkforce --version` surfaces the update notice from the + `@agentworkforce/cli` build it validated, and suggests `npm install + agentworkforce@latest` without `-g` when a project-local install is what ran. + ## [4.1.41] - 2026-08-14 ### Released diff --git a/packages/agentworkforce/bin/agentworkforce.js b/packages/agentworkforce/bin/agentworkforce.js index bcf60abe..d6ce3809 100755 --- a/packages/agentworkforce/bin/agentworkforce.js +++ b/packages/agentworkforce/bin/agentworkforce.js @@ -49,7 +49,12 @@ function resolveCli() { ].join('\n') ); } - return { version: project.cli.version, entryUrl: project.cli.entryUrl }; + return { + version: project.cli.version, + entryUrl: project.cli.entryUrl, + // A project dependency won: `-g` would update a different copy. + scope: 'project' + }; } // Only require the invoked wrapper's dependency after checking whether a @@ -79,7 +84,8 @@ function resolveCli() { return { version: bundled.version, - entryUrl: bundled.entryUrl + entryUrl: bundled.entryUrl, + scope: 'global' }; } @@ -218,6 +224,24 @@ function parseVersion(version, label = 'package') { }; } +/** + * Tell the user when the resolved implementation is behind the published + * `latest`, and how to update. The check lives in the CLI package so both this + * wrapper and a directly-invoked `dist/cli.js` print the same notice; an + * installation old enough to predate that module, or any failure inside it, + * simply prints no notice. + */ +async function reportAvailableUpdate(cli) { + try { + const { writeUpdateNotice } = await import( + new URL('./update-check.js', cli.entryUrl).href + ); + await writeUpdateNotice(cli.version, { scope: cli.scope }); + } catch { + // Never let an update check fail `--version`. + } +} + try { // Resolve and validate the implementation even for --version. Reporting the // wrapper version alone used to hide partially-updated installations where @@ -226,6 +250,7 @@ try { if (process.argv[2] === '-v' || process.argv[2] === '--version') { process.stdout.write(`${cli.version}\n`); + await reportAvailableUpdate(cli); process.exit(0); } diff --git a/packages/agentworkforce/test/version.test.js b/packages/agentworkforce/test/version.test.js index 018cd6eb..00726b13 100644 --- a/packages/agentworkforce/test/version.test.js +++ b/packages/agentworkforce/test/version.test.js @@ -48,6 +48,48 @@ test('agentworkforce --version prints the implementation version it validated', assert.equal(stdout, `${pkg.version}\n`); }); +test('--version delegates the update check to the CLI it validated', async (t) => { + const fixture = await createInstalledTree(t, { + wrapperVersion: '4.1.26', + cliVersion: '4.1.26', + updateNotice: true + }); + + const { exitCode, stdout, stderr } = await runBin( + fixture.binPath, + ['--version'], + { cwd: fixture.root } + ); + + assert.equal(exitCode, 0); + // The version stays alone on stdout; the notice is stderr-only. + assert.equal(stdout, '4.1.26\n'); + assert.equal(stderr, 'UPDATE NOTICE 4.1.26 global\n'); +}); + +test('--version reports a project-local install as project-scoped', async (t) => { + const fixture = await createInstalledTree(t, { + wrapperVersion: '4.1.25', + cliVersion: '4.1.25', + projectWrapperVersion: '4.1.26', + projectCliVersion: '4.1.26', + projectCliLayout: 'hoisted', + updateNotice: true + }); + + const { exitCode, stdout, stderr } = await runBin( + fixture.binPath, + ['--version'], + { cwd: fixture.projectRoot } + ); + + assert.equal(exitCode, 0); + assert.equal(stdout, '4.1.26\n'); + // The winning install is a project dependency, so the notice must not + // suggest updating a global copy that did not run. + assert.equal(stderr, 'UPDATE NOTICE 4.1.26 project\n'); +}); + test('refuses to execute a stale nested CLI and reports both resolved versions', async (t) => { const fixture = await createInstalledTree(t, { wrapperVersion: '4.1.26', @@ -323,7 +365,8 @@ async function createInstalledTree(t, { bareProjectCliVersion, omitCliPackage, omitCliEntry, - projectWrapperExports + projectWrapperExports, + updateNotice }) { const tempParent = await mkdtemp(path.join(os.tmpdir(), 'agentworkforce install ')); const root = path.join(tempParent, 'global tree'); @@ -356,6 +399,16 @@ async function createInstalledTree(t, { )}); }\n` ); } + if (updateNotice) { + // Stand-in for the CLI's compiled update-check module: echoes the + // arguments the wrapper handed it so the delegation is observable. + await writeFile( + path.join(cliRoot, 'dist', 'update-check.js'), + 'export async function writeUpdateNotice(version, options = {}) {\n' + + ' process.stderr.write(`UPDATE NOTICE ${version} ${options.scope}\\n`);\n' + + '}\n' + ); + } } const projectRoot = path.join(tempParent, 'project tree'); @@ -394,6 +447,14 @@ async function createInstalledTree(t, { `PROJECT CLI ${projectCliVersion} EXECUTED\n` )}); }\n` ); + if (updateNotice) { + await writeFile( + path.join(projectCliRoot, 'dist', 'update-check.js'), + 'export async function writeUpdateNotice(version, options = {}) {\n' + + ' process.stderr.write(`UPDATE NOTICE ${version} ${options.scope}\\n`);\n' + + '}\n' + ); + } } else if (bareProjectCliVersion) { const projectCliRoot = path.join(projectRoot, 'node_modules', '@agentworkforce', 'cli'); await mkdir(path.join(projectCliRoot, 'dist'), { recursive: true }); diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 043dac08..adb25c55 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `--version` now checks the npm registry for a newer published release and, + when the installed build is behind, writes `Update available: → + ` and the install command to stderr. stdout stays exactly the version + string. The check is best-effort (1.5s timeout, silent on any failure), + honours `AGENTWORKFORCE_REGISTRY` / `npm_config_registry`, and is skipped with + `AGENTWORKFORCE_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`. + ## [4.1.43] - 2026-08-16 ### Added diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index 8cf194ed..99be6e6f 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -376,7 +376,10 @@ Commands: Options: -h, --help Show this help text. - -v, --version Print the agentworkforce version. + -v, --version Print the agentworkforce version, and note on stderr + when a newer one is published along with the command + that installs it. Skip the check with + AGENTWORKFORCE_NO_UPDATE_CHECK=1. Local personas cascade: /.agentworkforce/workforce/personas/*.json → configured persona dirs → repo library. Each layer only needs to specify fields it overrides; everything else inherits @@ -5060,6 +5063,10 @@ export async function main(): Promise { if (subcommand === '-v' || subcommand === '--version') { process.stdout.write(`${CLI_VERSION}\n`); + // Best-effort, stderr-only, and never fatal: the version itself is already + // on stdout before the registry is asked anything. + const { writeUpdateNotice } = await import('./update-check.js'); + await writeUpdateNotice(CLI_VERSION); process.exit(0); } diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index b97e603a..55f27922 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -694,11 +694,106 @@ test('main: --version prints the package version', async () => { try { const { stderr, stdout, exitCode } = await runCliCapturingStderr( ['--version'], - { AGENT_WORKFORCE_HOME: workforceHome } + { + AGENT_WORKFORCE_HOME: workforceHome, + // Keep the registry out of this assertion; the update check has its + // own tests below. + AGENTWORKFORCE_NO_UPDATE_CHECK: '1' + } + ); + assert.equal(exitCode, 0); + assert.equal(stderr, ''); + assert.equal(stdout, `${CLI_VERSION}\n`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +/** + * Stand-in npm registry serving one `dist-tags` response, so the `--version` + * update check can be exercised end to end without the network. + */ +async function withStubRegistry( + latest: string, + run: (registry: string) => Promise +): Promise { + const { createServer } = await import('node:http'); + const server = createServer((req, res) => { + if (req.url === '/-/package/agentworkforce/dist-tags') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ latest })); + return; + } + res.writeHead(404).end('{}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + try { + return await run(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +test('main: --version reports a newer published release and how to install it', async () => { + const root = mkdtempSync(join(tmpdir(), 'aw-version-update-')); + const workforceHome = join(root, 'home', '.agentworkforce', 'workforce'); + mkdirSync(join(workforceHome, 'personas'), { recursive: true }); + try { + const { stderr, stdout, exitCode } = await withStubRegistry('999.0.0', (registry) => + runCliCapturingStderr(['--version'], { + AGENT_WORKFORCE_HOME: workforceHome, + AGENTWORKFORCE_REGISTRY: registry + }) + ); + assert.equal(exitCode, 0); + // stdout stays exactly the version so `$(agentworkforce --version)` keeps + // working; the notice goes to stderr. + assert.equal(stdout, `${CLI_VERSION}\n`); + assert.ok( + stderr.includes(`Update available: ${CLI_VERSION} → 999.0.0`), + `expected an update notice on stderr, got: ${JSON.stringify(stderr)}` + ); + assert.match(stderr, /npm install (-g )?agentworkforce@latest/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('main: --version says nothing when the installed build is current', async () => { + const root = mkdtempSync(join(tmpdir(), 'aw-version-current-')); + const workforceHome = join(root, 'home', '.agentworkforce', 'workforce'); + mkdirSync(join(workforceHome, 'personas'), { recursive: true }); + try { + const { stderr, stdout, exitCode } = await withStubRegistry(CLI_VERSION, (registry) => + runCliCapturingStderr(['--version'], { + AGENT_WORKFORCE_HOME: workforceHome, + AGENTWORKFORCE_REGISTRY: registry + }) ); assert.equal(exitCode, 0); + assert.equal(stdout, `${CLI_VERSION}\n`); assert.equal(stderr, ''); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('main: --version still prints when the registry is unreachable', async () => { + const root = mkdtempSync(join(tmpdir(), 'aw-version-offline-')); + const workforceHome = join(root, 'home', '.agentworkforce', 'workforce'); + mkdirSync(join(workforceHome, 'personas'), { recursive: true }); + try { + // Port 1 on loopback refuses connections immediately. + const { stderr, stdout, exitCode } = await runCliCapturingStderr(['--version'], { + AGENT_WORKFORCE_HOME: workforceHome, + AGENTWORKFORCE_REGISTRY: 'http://127.0.0.1:1', + AGENTWORKFORCE_UPDATE_CHECK_TIMEOUT_MS: '750' + }); + assert.equal(exitCode, 0); assert.equal(stdout, `${CLI_VERSION}\n`); + assert.equal(stderr, ''); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/packages/cli/src/update-check.test.ts b/packages/cli/src/update-check.test.ts new file mode 100644 index 00000000..131901d4 --- /dev/null +++ b/packages/cli/src/update-check.test.ts @@ -0,0 +1,245 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + DEFAULT_REGISTRY, + DEFAULT_UPDATE_CHECK_TIMEOUT_MS, + compareVersions, + fetchLatestVersion, + formatUpdateNotice, + isUpdateCheckDisabled, + parseVersion, + resolveInstallScope, + resolveRegistryBase, + resolveTimeoutMs, + resolveUpdateCommand, + resolveUpdateNotice, + writeUpdateNotice +} from './update-check.js'; + +/** A `fetch` stand-in that records its calls and answers with a fixed body. */ +function stubFetch( + body: unknown, + { ok = true, status = 200 }: { ok?: boolean; status?: number } = {} +) { + const calls: { url: string; init?: RequestInit }[] = []; + const impl = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return { + ok, + status, + json: async () => { + if (typeof body === 'string') throw new SyntaxError('not JSON'); + return body; + } + } as Response; + }) as unknown as typeof fetch; + return { impl, calls }; +} + +test('parseVersion rejects anything that is not strict semver', () => { + assert.deepEqual(parseVersion('4.1.45')?.numbers.map(String), ['4', '1', '45']); + assert.equal(parseVersion('4.1.45-rc.1')?.prerelease, 'rc.1'); + assert.equal(parseVersion('latest'), undefined); + assert.equal(parseVersion('4.1'), undefined); + assert.equal(parseVersion('4.1.045'), undefined); + assert.equal(parseVersion('4.1.0-rc.01'), undefined); + assert.equal(parseVersion(undefined), undefined); + assert.equal(parseVersion(42), undefined); +}); + +test('compareVersions orders releases, multi-digit parts, and prereleases', () => { + assert.equal(compareVersions('4.1.45', '4.1.45'), 0); + assert.equal(compareVersions('4.1.9', '4.1.10'), -1); + assert.equal(compareVersions('4.2.0', '4.1.99'), 1); + // A prerelease sorts below its own release, and below a later prerelease. + assert.equal(compareVersions('4.2.0-rc.1', '4.2.0'), -1); + assert.equal(compareVersions('4.2.0-rc.2', '4.2.0-rc.10'), -1); + // Unparsable input means "cannot compare", not "equal". + assert.equal(compareVersions('4.1.45', 'nightly'), undefined); +}); + +test('isUpdateCheckDisabled honours our flag and the ecosystem convention', () => { + assert.equal(isUpdateCheckDisabled({}), false); + assert.equal(isUpdateCheckDisabled({ AGENTWORKFORCE_NO_UPDATE_CHECK: '1' }), true); + assert.equal(isUpdateCheckDisabled({ NO_UPDATE_NOTIFIER: '1' }), true); + assert.equal(isUpdateCheckDisabled({ AGENTWORKFORCE_NO_UPDATE_CHECK: '0' }), false); +}); + +test('resolveRegistryBase prefers our override, then npm config, then the default', () => { + assert.equal(resolveRegistryBase({}), DEFAULT_REGISTRY); + assert.equal( + resolveRegistryBase({ npm_config_registry: 'https://npm.internal/' }), + 'https://npm.internal' + ); + assert.equal( + resolveRegistryBase({ + AGENTWORKFORCE_REGISTRY: 'https://mirror.internal//', + npm_config_registry: 'https://npm.internal' + }), + 'https://mirror.internal' + ); + // A non-http(s) or unparsable registry is ignored rather than fetched. + assert.equal(resolveRegistryBase({ AGENTWORKFORCE_REGISTRY: 'file:///tmp/reg' }), DEFAULT_REGISTRY); + assert.equal(resolveRegistryBase({ AGENTWORKFORCE_REGISTRY: 'not a url' }), DEFAULT_REGISTRY); + assert.equal(resolveRegistryBase({ AGENTWORKFORCE_REGISTRY: ' ' }), DEFAULT_REGISTRY); +}); + +test('resolveTimeoutMs accepts positive integers only', () => { + assert.equal(resolveTimeoutMs({}), DEFAULT_UPDATE_CHECK_TIMEOUT_MS); + assert.equal(resolveTimeoutMs({ AGENTWORKFORCE_UPDATE_CHECK_TIMEOUT_MS: '250' }), 250); + assert.equal( + resolveTimeoutMs({ AGENTWORKFORCE_UPDATE_CHECK_TIMEOUT_MS: '0' }), + DEFAULT_UPDATE_CHECK_TIMEOUT_MS + ); + assert.equal( + resolveTimeoutMs({ AGENTWORKFORCE_UPDATE_CHECK_TIMEOUT_MS: 'soon' }), + DEFAULT_UPDATE_CHECK_TIMEOUT_MS + ); +}); + +test('fetchLatestVersion reads the dist-tags endpoint, not the full packument', async () => { + const { impl, calls } = stubFetch({ latest: '4.2.0', next: '4.3.0-rc.1' }); + const latest = await fetchLatestVersion({ registry: 'https://npm.internal', fetchImpl: impl }); + assert.equal(latest, '4.2.0'); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://npm.internal/-/package/agentworkforce/dist-tags'); +}); + +test('fetchLatestVersion swallows registry failures', async () => { + const notFound = stubFetch({ latest: '4.2.0' }, { ok: false, status: 404 }); + assert.equal(await fetchLatestVersion({ fetchImpl: notFound.impl }), undefined); + + const nonJson = stubFetch('proxy error'); + assert.equal(await fetchLatestVersion({ fetchImpl: nonJson.impl }), undefined); + + const garbage = stubFetch({ latest: 'nightly' }); + assert.equal(await fetchLatestVersion({ fetchImpl: garbage.impl }), undefined); + + const empty = stubFetch(null); + assert.equal(await fetchLatestVersion({ fetchImpl: empty.impl }), undefined); + + const offline = (async () => { + throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }); + }) as unknown as typeof fetch; + assert.equal(await fetchLatestVersion({ fetchImpl: offline }), undefined); +}); + +test('fetchLatestVersion gives up once the timeout elapses', async () => { + const hang = ((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + })) as unknown as typeof fetch; + assert.equal(await fetchLatestVersion({ fetchImpl: hang, timeoutMs: 25 }), undefined); +}); + +test('resolveInstallScope only calls a cwd-local node_modules copy project-scoped', () => { + const cwd = path.resolve('/work/app'); + const projectModule = pathToFileURL( + path.join(cwd, 'node_modules', '@agentworkforce', 'cli', 'dist', 'update-check.js') + ).href; + const globalModule = pathToFileURL( + path.resolve('/usr/lib/node_modules/agentworkforce/node_modules/@agentworkforce/cli/dist/update-check.js') + ).href; + assert.equal(resolveInstallScope(projectModule, cwd), 'project'); + assert.equal(resolveInstallScope(globalModule, cwd), 'global'); + // A sibling directory that merely shares a prefix is not inside the project. + assert.equal(resolveInstallScope(globalModule, path.resolve('/work/app-2')), 'global'); + assert.equal(resolveInstallScope('not-a-url', cwd), 'global'); +}); + +test('resolveUpdateCommand drops -g for a project-local install', () => { + assert.equal(resolveUpdateCommand('global'), 'npm install -g agentworkforce@latest'); + assert.equal(resolveUpdateCommand('project'), 'npm install agentworkforce@latest'); +}); + +test('formatUpdateNotice names both versions and the exact command', () => { + assert.equal( + formatUpdateNotice('4.1.45', '4.2.0'), + 'Update available: 4.1.45 → 4.2.0\nRun `npm install -g agentworkforce@latest` to update.\n' + ); + assert.match(formatUpdateNotice('4.1.45', '4.2.0', 'project'), /npm install agentworkforce@latest/); +}); + +test('resolveUpdateNotice reports only a genuinely newer published version', async () => { + const newer = stubFetch({ latest: '4.2.0' }); + assert.match( + (await resolveUpdateNotice('4.1.45', { env: {}, fetchImpl: newer.impl, scope: 'global' })) ?? '', + /Update available: 4\.1\.45 → 4\.2\.0/ + ); + + const current = stubFetch({ latest: '4.1.45' }); + assert.equal(await resolveUpdateNotice('4.1.45', { env: {}, fetchImpl: current.impl }), undefined); + + // An unreleased local build is ahead of `latest`; it is not out of date. + const behind = stubFetch({ latest: '4.1.45' }); + assert.equal(await resolveUpdateNotice('4.2.0', { env: {}, fetchImpl: behind.impl }), undefined); + + // A prerelease is behind its own release. + const release = stubFetch({ latest: '4.2.0' }); + assert.match( + (await resolveUpdateNotice('4.2.0-rc.1', { env: {}, fetchImpl: release.impl })) ?? '', + /4\.2\.0-rc\.1 → 4\.2\.0/ + ); +}); + +test('resolveUpdateNotice never touches the network when opted out', async () => { + const stub = stubFetch({ latest: '99.0.0' }); + assert.equal( + await resolveUpdateNotice('4.1.45', { + env: { AGENTWORKFORCE_NO_UPDATE_CHECK: '1' }, + fetchImpl: stub.impl + }), + undefined + ); + assert.equal(stub.calls.length, 0); +}); + +test('resolveUpdateNotice skips the check for an unparsable running version', async () => { + const stub = stubFetch({ latest: '99.0.0' }); + assert.equal(await resolveUpdateNotice('dev', { env: {}, fetchImpl: stub.impl }), undefined); + assert.equal(stub.calls.length, 0); +}); + +test('resolveUpdateNotice routes through the configured registry', async () => { + const stub = stubFetch({ latest: '4.2.0' }); + await resolveUpdateNotice('4.1.45', { + env: { AGENTWORKFORCE_REGISTRY: 'https://mirror.internal' }, + fetchImpl: stub.impl + }); + assert.equal(stub.calls[0].url, 'https://mirror.internal/-/package/agentworkforce/dist-tags'); +}); + +test('writeUpdateNotice writes the notice and stays silent otherwise', async () => { + const written: string[] = []; + const newer = stubFetch({ latest: '4.2.0' }); + await writeUpdateNotice('4.1.45', { + env: {}, + fetchImpl: newer.impl, + scope: 'global', + write: (text) => written.push(text) + }); + assert.equal(written.length, 1); + assert.match(written[0], /npm install -g agentworkforce@latest/); + + const current = stubFetch({ latest: '4.1.45' }); + await writeUpdateNotice('4.1.45', { + env: {}, + fetchImpl: current.impl, + write: (text) => written.push(text) + }); + assert.equal(written.length, 1); +}); + +test('writeUpdateNotice swallows a writer that throws', async () => { + const newer = stubFetch({ latest: '4.2.0' }); + await writeUpdateNotice('4.1.45', { + env: {}, + fetchImpl: newer.impl, + write: () => { + throw new Error('EPIPE'); + } + }); +}); diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts new file mode 100644 index 00000000..eb3fd7de --- /dev/null +++ b/packages/cli/src/update-check.ts @@ -0,0 +1,278 @@ +/** + * `agentworkforce --version` update check. + * + * Printing a bare version number tells you what you have but not whether it is + * current, so `--version` also asks the npm registry for the published + * `latest` and — when the installed build is behind it — prints the upgrade + * command to stderr. stderr keeps `$(agentworkforce --version)` parsable in + * scripts while the notice still reaches a human at a terminal. + * + * The check is strictly best-effort: an offline machine, a private registry + * that does not carry the package, a slow proxy, or a malformed response all + * resolve to "no notice". Nothing in this module may throw or delay `--version` + * beyond the timeout, so every failure path is swallowed. + * + * This module is imported from the `agentworkforce` wrapper bin as well as from + * `cli-impl`, so it must stay free of non-builtin imports. + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** The package users install, and therefore the one whose `latest` we report. */ +export const UPDATE_CHECK_PACKAGE = 'agentworkforce'; + +/** Registry used when neither agentworkforce nor npm config names one. */ +export const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; + +/** Registry budget for the whole check. `--version` must stay near-instant. */ +export const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1500; + +/** Where the running CLI was resolved from; decides whether to suggest `-g`. */ +export type InstallScope = 'global' | 'project'; + +type ParsedVersion = { + numbers: [bigint, bigint, bigint]; + prerelease: string; +}; + +export type UpdateNoticeOptions = { + /** Install location of the CLI being run. Inferred from `moduleUrl` if omitted. */ + scope?: InstallScope; + /** Module whose location decides the scope. Defaults to this module. */ + moduleUrl?: string; + /** Working directory a project-local install would be resolved against. */ + cwd?: string; + env?: NodeJS.ProcessEnv; + /** Injected for tests; defaults to the global `fetch`. */ + fetchImpl?: typeof fetch; +}; + +/** + * Strict semver parse. Returns `undefined` instead of throwing: an unparsable + * version (a git build, a registry typo) means "cannot compare", not "fail". + */ +export function parseVersion(version: unknown): ParsedVersion | undefined { + if (typeof version !== 'string' || !version) return undefined; + const identifier = '[0-9A-Za-z-]+'; + const match = new RegExp( + `^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)` + + `(?:-(${identifier}(?:\\.${identifier})*))?` + + `(?:\\+${identifier}(?:\\.${identifier})*)?$` + ).exec(version); + if (!match) return undefined; + const prerelease = match[4] ?? ''; + const hasInvalidNumericIdentifier = prerelease + .split('.') + .some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0')); + if (hasInvalidNumericIdentifier) return undefined; + return { + numbers: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], + prerelease + }; +} + +function comparePrerelease(left: string, right: string): number { + const a = left.split('.'); + const b = right.split('.'); + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i += 1) { + const aPart = a[i]; + const bPart = b[i]; + if (aPart === undefined) return -1; + if (bPart === undefined) return 1; + if (aPart === bPart) continue; + const aNumeric = /^\d+$/.test(aPart); + const bNumeric = /^\d+$/.test(bPart); + if (aNumeric && bNumeric) { + const aNumber = BigInt(aPart); + const bNumber = BigInt(bPart); + if (aNumber < bNumber) return -1; + if (aNumber > bNumber) return 1; + continue; + } + if (aNumeric) return -1; + if (bNumeric) return 1; + return aPart < bPart ? -1 : 1; + } + return 0; +} + +/** + * Semver ordering, `undefined` when either side is unparsable. Mirrors the + * comparator the wrapper bin uses to pick between installs, including + * "a prerelease sorts below its own release". + */ +export function compareVersions(left: unknown, right: unknown): number | undefined { + const a = parseVersion(left); + const b = parseVersion(right); + if (!a || !b) return undefined; + for (let i = 0; i < 3; i += 1) { + if (a.numbers[i] < b.numbers[i]) return -1; + if (a.numbers[i] > b.numbers[i]) return 1; + } + if (a.prerelease === b.prerelease) return 0; + if (!a.prerelease) return 1; + if (!b.prerelease) return -1; + return comparePrerelease(a.prerelease, b.prerelease); +} + +/** + * Opt-out. `AGENTWORKFORCE_NO_UPDATE_CHECK=1` is ours; `NO_UPDATE_NOTIFIER=1` + * is the ecosystem-wide convention and images that set it mean it for us too. + */ +export function isUpdateCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.AGENTWORKFORCE_NO_UPDATE_CHECK === '1' || env.NO_UPDATE_NOTIFIER === '1'; +} + +/** + * Registry to query: our override first, then whatever npm was configured with + * (`npm_config_registry` is exported into `npm run`/`npx` environments), then + * the public registry. A non-http(s) value is ignored rather than fetched. + */ +export function resolveRegistryBase(env: NodeJS.ProcessEnv = process.env): string { + for (const candidate of [env.AGENTWORKFORCE_REGISTRY, env.npm_config_registry]) { + if (typeof candidate !== 'string' || !candidate.trim()) continue; + let parsed: URL; + try { + parsed = new URL(candidate.trim()); + } catch { + continue; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue; + return parsed.href.replace(/\/+$/, ''); + } + return DEFAULT_REGISTRY; +} + +/** Timeout budget, overridable for slow private registries. */ +export function resolveTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.AGENTWORKFORCE_UPDATE_CHECK_TIMEOUT_MS; + if (typeof raw !== 'string' || !/^\d+$/.test(raw.trim())) { + return DEFAULT_UPDATE_CHECK_TIMEOUT_MS; + } + const parsed = Number.parseInt(raw.trim(), 10); + return parsed > 0 ? parsed : DEFAULT_UPDATE_CHECK_TIMEOUT_MS; +} + +/** + * Read the `latest` dist-tag. The dist-tags endpoint is a few dozen bytes, + * unlike the full packument, which is megabytes for a package with our release + * cadence. + */ +export async function fetchLatestVersion(options: { + registry?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; +} = {}): Promise { + const registry = options.registry ?? DEFAULT_REGISTRY; + const timeoutMs = options.timeoutMs ?? DEFAULT_UPDATE_CHECK_TIMEOUT_MS; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== 'function') return undefined; + + const controller = new AbortController(); + // Cleared in `finally`, so a fast response never waits out the budget. + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl( + `${registry}/-/package/${UPDATE_CHECK_PACKAGE}/dist-tags`, + { + signal: controller.signal, + headers: { accept: 'application/json' }, + redirect: 'follow' + } + ); + if (!response.ok) return undefined; + const body = (await response.json()) as unknown; + if (!body || typeof body !== 'object') return undefined; + const latest = (body as { latest?: unknown }).latest; + return parseVersion(latest) ? (latest as string) : undefined; + } catch { + // Offline, DNS failure, timeout, 4xx from a private registry, non-JSON + // body: all mean "no answer", never "break --version". + return undefined; + } finally { + clearTimeout(timer); + } +} + +/** + * A CLI resolved from `/node_modules` was installed as a project + * dependency, so `-g` would update a different copy than the one that just ran. + */ +export function resolveInstallScope( + moduleUrl: string = import.meta.url, + cwd: string = process.cwd() +): InstallScope { + let modulePath: string; + try { + modulePath = path.resolve(fileURLToPath(moduleUrl)); + } catch { + return 'global'; + } + const projectModules = path.resolve(cwd, 'node_modules') + path.sep; + return modulePath.startsWith(projectModules) ? 'project' : 'global'; +} + +/** The command that replaces the running install with the published latest. */ +export function resolveUpdateCommand(scope: InstallScope): string { + return scope === 'project' + ? `npm install ${UPDATE_CHECK_PACKAGE}@latest` + : `npm install -g ${UPDATE_CHECK_PACKAGE}@latest`; +} + +/** The two-line notice; kept short so it does not bury the version itself. */ +export function formatUpdateNotice( + current: string, + latest: string, + scope: InstallScope = 'global' +): string { + return [ + `Update available: ${current} → ${latest}`, + `Run \`${resolveUpdateCommand(scope)}\` to update.`, + '' + ].join('\n'); +} + +/** + * The notice for this install, or `undefined` when the check is disabled, the + * registry is unreachable, or the running build is already current (or ahead of + * `latest`, as an unreleased local build is). + */ +export async function resolveUpdateNotice( + currentVersion: string, + options: UpdateNoticeOptions = {} +): Promise { + const env = options.env ?? process.env; + if (isUpdateCheckDisabled(env)) return undefined; + if (!parseVersion(currentVersion)) return undefined; + + const latest = await fetchLatestVersion({ + registry: resolveRegistryBase(env), + timeoutMs: resolveTimeoutMs(env), + fetchImpl: options.fetchImpl + }); + if (!latest) return undefined; + if ((compareVersions(currentVersion, latest) ?? 0) >= 0) return undefined; + + const scope = + options.scope ?? resolveInstallScope(options.moduleUrl ?? import.meta.url, options.cwd); + return formatUpdateNotice(currentVersion, latest, scope); +} + +/** + * Emit the notice on stderr, if there is one. Callers can `await` this without + * a try/catch: it resolves quietly on every failure. + */ +export async function writeUpdateNotice( + currentVersion: string, + options: UpdateNoticeOptions & { write?: (text: string) => void } = {} +): Promise { + try { + const notice = await resolveUpdateNotice(currentVersion, options); + if (!notice) return; + const write = options.write ?? ((text: string) => process.stderr.write(text)); + write(notice); + } catch { + // An update notice is never worth a non-zero exit from `--version`. + } +} From 23dbe6a48be2a95161cf43f61d065f5b8aa42b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 17:42:43 +0000 Subject: [PATCH 2/5] fix(cli): infer the update scope, and stop exiting before stderr flushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found three real defects in the `--version` update notice. **A project-local install was told to run `npm install -g`.** The wrapper hard-coded `scope: 'global'` on the bundled branch, but that branch is also taken when the invoked launcher *is* the project's own — `npx`, `node_modules/.bin`, an npm script — because `resolveProjectInstall()` skips a candidate that is this very file. Reproduced against a project tree: the notice suggested updating a global copy that had never run. The wrapper now asserts a scope only for the case it knows for certain (a newer project dependency beat the invoked install) and otherwise hands the validated entry URL to the module to infer from. `resolveInstallScope()` only recognised `/node_modules`, so it also mislabelled a project install when run from a subdirectory. It now locates the directory owning the outermost `node_modules` on the module's path — the install root under both the hoisted and nested layouts — and calls the install project-local when the command ran anywhere inside that root, which is exactly where node would resolve it from. **`process.exit(0)` could truncate the notice.** A write to a piped stdout/stderr is asynchronous, so exiting on the next line can drop it. Both entry points now finish by running out of work instead. **The registry budget bounded a delay that was never acknowledged.** `--version` does not exit until the check settles, so the timeout is also the worst case it adds against a black-holed registry. Cut to 1s and documented as such, alongside the existing opt-outs. Tests: registry-backed cases now clear both opt-out variables, which the child would otherwise inherit from a CI image that sets `NO_UPDATE_NOTIFIER`; the unreachable-registry case binds a socket that hangs up on contact rather than guessing a free low port; new coverage for the nested layout, an ancestor working directory, and the scope the wrapper declines to claim. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNcR15rKHnYBiV3UxtynuF --- README.md | 11 ++- packages/agentworkforce/CHANGELOG.md | 4 +- packages/agentworkforce/bin/agentworkforce.js | 29 ++++--- packages/agentworkforce/test/version.test.js | 76 ++++++++++++++----- packages/cli/CHANGELOG.md | 8 +- packages/cli/src/cli-impl.ts | 4 +- packages/cli/src/cli.test.ts | 31 ++++++-- packages/cli/src/update-check.test.ts | 23 +++++- packages/cli/src/update-check.ts | 33 ++++++-- 9 files changed, 167 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 14607222..f031e156 100644 --- a/README.md +++ b/README.md @@ -262,10 +262,13 @@ Run `npm install -g agentworkforce@latest` to update. ``` The check reads the `latest` dist-tag from the npm registry (or -`AGENTWORKFORCE_REGISTRY` / npm's configured registry), times out after 1.5s, -and stays silent when it cannot reach one — so the version itself always -prints. Set `AGENTWORKFORCE_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`) to -skip it entirely. +`AGENTWORKFORCE_REGISTRY` / npm's configured registry) and stays silent when it +cannot reach one, so the version itself always prints. It is bounded by +`AGENTWORKFORCE_UPDATE_CHECK_TIMEOUT_MS` (1s by default), which is also the +most it can add to a run against an unresponsive registry. Set +`AGENTWORKFORCE_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`) to skip it +entirely. A CLI installed as a project dependency is told to update itself +without `-g`. Local personas resolve from project-local files, configured source directories, the personal persona directory, and the small built-in catalog. Higher layers diff --git a/packages/agentworkforce/CHANGELOG.md b/packages/agentworkforce/CHANGELOG.md index 6d06faaf..a37f4477 100644 --- a/packages/agentworkforce/CHANGELOG.md +++ b/packages/agentworkforce/CHANGELOG.md @@ -11,7 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agentworkforce --version` surfaces the update notice from the `@agentworkforce/cli` build it validated, and suggests `npm install - agentworkforce@latest` without `-g` when a project-local install is what ran. + agentworkforce@latest` without `-g` when a project-local install is what ran — + including when the project's own launcher is the one invoked, via `npx`, + `node_modules/.bin`, or an npm script. ## [4.1.41] - 2026-08-14 diff --git a/packages/agentworkforce/bin/agentworkforce.js b/packages/agentworkforce/bin/agentworkforce.js index d6ce3809..2e1fd88f 100755 --- a/packages/agentworkforce/bin/agentworkforce.js +++ b/packages/agentworkforce/bin/agentworkforce.js @@ -52,7 +52,8 @@ function resolveCli() { return { version: project.cli.version, entryUrl: project.cli.entryUrl, - // A project dependency won: `-g` would update a different copy. + // A newer project dependency beat the invoked install, so this is + // definitively project-local: `-g` would update a different copy. scope: 'project' }; } @@ -85,7 +86,11 @@ function resolveCli() { return { version: bundled.version, entryUrl: bundled.entryUrl, - scope: 'global' + // Not necessarily global: this branch is also taken when the invoked + // wrapper *is* the project's own (npx, node_modules/.bin, an npm script), + // because resolveProjectInstall() skips a candidate that is this very + // file. Leave the scope to be inferred from where the entry resolved. + scope: undefined }; } @@ -236,7 +241,10 @@ async function reportAvailableUpdate(cli) { const { writeUpdateNotice } = await import( new URL('./update-check.js', cli.entryUrl).href ); - await writeUpdateNotice(cli.version, { scope: cli.scope }); + await writeUpdateNotice(cli.version, { + scope: cli.scope, + moduleUrl: cli.entryUrl + }); } catch { // Never let an update check fail `--version`. } @@ -251,14 +259,15 @@ try { if (process.argv[2] === '-v' || process.argv[2] === '--version') { process.stdout.write(`${cli.version}\n`); await reportAvailableUpdate(cli); - process.exit(0); + // Exit by running out of work rather than through process.exit(), which + // can terminate before a pending write to a piped stdout/stderr flushes. + } else { + // Import the entry from the exact package whose version was checked above; + // do not ask the module resolver a second time and risk selecting a + // different hoisted or nested copy. + const { main } = await import(cli.entryUrl); + await main(); } - - // Import the entry from the exact package whose version was checked above; - // do not ask the module resolver a second time and risk selecting a different - // hoisted or nested copy. - const { main } = await import(cli.entryUrl); - await main(); } catch (err) { process.stderr.write( `${err instanceof InstallationError ? err.message : (err?.stack ?? String(err))}\n` diff --git a/packages/agentworkforce/test/version.test.js b/packages/agentworkforce/test/version.test.js index 00726b13..194fb161 100644 --- a/packages/agentworkforce/test/version.test.js +++ b/packages/agentworkforce/test/version.test.js @@ -11,6 +11,30 @@ const pkg = JSON.parse( ); const binPath = fileURLToPath(new URL('../bin/agentworkforce.js', import.meta.url)); +/** + * Stand-in for the CLI's compiled update-check module. It echoes the arguments + * the wrapper handed it, so what the wrapper claims about the install — and + * what it leaves for the module to infer — is observable from the outside. + */ +const UPDATE_CHECK_STUB = [ + 'export async function writeUpdateNotice(version, options = {}) {', + ' const seen = {', + ' version,', + ' scope: options.scope ?? null,', + ' moduleUrl: options.moduleUrl ?? null', + ' };', + ' process.stderr.write(`UPDATE NOTICE ${JSON.stringify(seen)}\\n`);', + '}', + '' +].join('\n'); + +/** The single `UPDATE NOTICE` line the stub emitted, parsed back into an object. */ +function parseUpdateNotice(stderr) { + const match = /^UPDATE NOTICE (.*)$/m.exec(stderr); + assert.ok(match, `expected an UPDATE NOTICE line, got: ${JSON.stringify(stderr)}`); + return JSON.parse(match[1]); +} + async function runBin(targetBinPath, args, options = {}) { const child = spawn(process.execPath, [targetBinPath, ...args], { cwd: options.cwd, @@ -64,7 +88,35 @@ test('--version delegates the update check to the CLI it validated', async (t) = assert.equal(exitCode, 0); // The version stays alone on stdout; the notice is stderr-only. assert.equal(stdout, '4.1.26\n'); - assert.equal(stderr, 'UPDATE NOTICE 4.1.26 global\n'); + const notice = parseUpdateNotice(stderr); + assert.equal(notice.version, '4.1.26'); + // The wrapper hands over the entry it actually validated, so the module can + // work out where the running copy lives. + assert.ok( + notice.moduleUrl?.endsWith('/@agentworkforce/cli/dist/cli.js'), + `expected the validated entry URL, got: ${notice.moduleUrl}` + ); +}); + +test('--version does not claim a scope the wrapper cannot know', async (t) => { + // Invoking the project's own launcher (npx, node_modules/.bin, an npm + // script) takes the bundled branch, because the project candidate is this + // very file. Asserting 'global' there would send a project-local user to + // `npm install -g`, updating a copy that never ran. + const fixture = await createInstalledTree(t, { + wrapperVersion: '4.1.26', + cliVersion: '4.1.26', + updateNotice: true + }); + + const { exitCode, stderr } = await runBin( + fixture.binPath, + ['--version'], + { cwd: fixture.root } + ); + + assert.equal(exitCode, 0); + assert.equal(parseUpdateNotice(stderr).scope, null); }); test('--version reports a project-local install as project-scoped', async (t) => { @@ -85,9 +137,9 @@ test('--version reports a project-local install as project-scoped', async (t) => assert.equal(exitCode, 0); assert.equal(stdout, '4.1.26\n'); - // The winning install is a project dependency, so the notice must not - // suggest updating a global copy that did not run. - assert.equal(stderr, 'UPDATE NOTICE 4.1.26 project\n'); + // A newer project dependency beat the invoked install, which the wrapper — + // unlike the module — knows for certain, so it says so. + assert.equal(parseUpdateNotice(stderr).scope, 'project'); }); test('refuses to execute a stale nested CLI and reports both resolved versions', async (t) => { @@ -400,14 +452,7 @@ async function createInstalledTree(t, { ); } if (updateNotice) { - // Stand-in for the CLI's compiled update-check module: echoes the - // arguments the wrapper handed it so the delegation is observable. - await writeFile( - path.join(cliRoot, 'dist', 'update-check.js'), - 'export async function writeUpdateNotice(version, options = {}) {\n' + - ' process.stderr.write(`UPDATE NOTICE ${version} ${options.scope}\\n`);\n' + - '}\n' - ); + await writeFile(path.join(cliRoot, 'dist', 'update-check.js'), UPDATE_CHECK_STUB); } } @@ -448,12 +493,7 @@ async function createInstalledTree(t, { )}); }\n` ); if (updateNotice) { - await writeFile( - path.join(projectCliRoot, 'dist', 'update-check.js'), - 'export async function writeUpdateNotice(version, options = {}) {\n' + - ' process.stderr.write(`UPDATE NOTICE ${version} ${options.scope}\\n`);\n' + - '}\n' - ); + await writeFile(path.join(projectCliRoot, 'dist', 'update-check.js'), UPDATE_CHECK_STUB); } } else if (bareProjectCliVersion) { const projectCliRoot = path.join(projectRoot, 'node_modules', '@agentworkforce', 'cli'); diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index adb25c55..1547ba69 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -12,9 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--version` now checks the npm registry for a newer published release and, when the installed build is behind, writes `Update available: ` and the install command to stderr. stdout stays exactly the version - string. The check is best-effort (1.5s timeout, silent on any failure), - honours `AGENTWORKFORCE_REGISTRY` / `npm_config_registry`, and is skipped with - `AGENTWORKFORCE_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`. + string. The check is best-effort (1s timeout, silent on any failure), honours + `AGENTWORKFORCE_REGISTRY` / `npm_config_registry`, and is skipped with + `AGENTWORKFORCE_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`. A CLI resolved + from the working tree's own `node_modules` — including an ancestor's — is + told to update without `-g`. ## [4.1.43] - 2026-08-16 diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index 99be6e6f..74496acb 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -5067,7 +5067,9 @@ export async function main(): Promise { // on stdout before the registry is asked anything. const { writeUpdateNotice } = await import('./update-check.js'); await writeUpdateNotice(CLI_VERSION); - process.exit(0); + // Return rather than process.exit(0), which can terminate before a pending + // write to a piped stdout/stderr flushes. + return; } if (subcommand === 'list') { diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 55f27922..81e35694 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -709,6 +709,15 @@ test('main: --version prints the package version', async () => { } }); +/** + * Neutralizes both update-check opt-outs for a child process, so a test that + * asserts on the notice does not depend on the environment it runs in. + */ +const UPDATE_CHECK_ENABLED = { + AGENTWORKFORCE_NO_UPDATE_CHECK: '', + NO_UPDATE_NOTIFIER: '' +} as const; + /** * Stand-in npm registry serving one `dist-tags` response, so the `--version` * update check can be exercised end to end without the network. @@ -744,7 +753,10 @@ test('main: --version reports a newer published release and how to install it', const { stderr, stdout, exitCode } = await withStubRegistry('999.0.0', (registry) => runCliCapturingStderr(['--version'], { AGENT_WORKFORCE_HOME: workforceHome, - AGENTWORKFORCE_REGISTRY: registry + AGENTWORKFORCE_REGISTRY: registry, + // The child inherits this process's env, and CI images commonly set + // NO_UPDATE_NOTIFIER=1 — which would make this assertion vacuous. + ...UPDATE_CHECK_ENABLED }) ); assert.equal(exitCode, 0); @@ -769,7 +781,8 @@ test('main: --version says nothing when the installed build is current', async ( const { stderr, stdout, exitCode } = await withStubRegistry(CLI_VERSION, (registry) => runCliCapturingStderr(['--version'], { AGENT_WORKFORCE_HOME: workforceHome, - AGENTWORKFORCE_REGISTRY: registry + AGENTWORKFORCE_REGISTRY: registry, + ...UPDATE_CHECK_ENABLED }) ); assert.equal(exitCode, 0); @@ -784,17 +797,25 @@ test('main: --version still prints when the registry is unreachable', async () = const root = mkdtempSync(join(tmpdir(), 'aw-version-offline-')); const workforceHome = join(root, 'home', '.agentworkforce', 'workforce'); mkdirSync(join(workforceHome, 'personas'), { recursive: true }); + const { createServer } = await import('node:net'); + // A port we own that hangs up on contact, rather than a low port guessed to + // be free: the failure is then the same on any host. + const dead = createServer((socket) => socket.destroy()); + await new Promise((resolve) => dead.listen(0, '127.0.0.1', resolve)); + const address = dead.address(); + const port = typeof address === 'object' && address ? address.port : 0; try { - // Port 1 on loopback refuses connections immediately. const { stderr, stdout, exitCode } = await runCliCapturingStderr(['--version'], { AGENT_WORKFORCE_HOME: workforceHome, - AGENTWORKFORCE_REGISTRY: 'http://127.0.0.1:1', - AGENTWORKFORCE_UPDATE_CHECK_TIMEOUT_MS: '750' + AGENTWORKFORCE_REGISTRY: `http://127.0.0.1:${port}`, + AGENTWORKFORCE_UPDATE_CHECK_TIMEOUT_MS: '750', + ...UPDATE_CHECK_ENABLED }); assert.equal(exitCode, 0); assert.equal(stdout, `${CLI_VERSION}\n`); assert.equal(stderr, ''); } finally { + await new Promise((resolve) => dead.close(() => resolve())); rmSync(root, { recursive: true, force: true }); } }); diff --git a/packages/cli/src/update-check.test.ts b/packages/cli/src/update-check.test.ts index 131901d4..5feef064 100644 --- a/packages/cli/src/update-check.test.ts +++ b/packages/cli/src/update-check.test.ts @@ -135,18 +135,33 @@ test('fetchLatestVersion gives up once the timeout elapses', async () => { assert.equal(await fetchLatestVersion({ fetchImpl: hang, timeoutMs: 25 }), undefined); }); -test('resolveInstallScope only calls a cwd-local node_modules copy project-scoped', () => { +test('resolveInstallScope treats an install that owns the working tree as project-local', () => { const cwd = path.resolve('/work/app'); - const projectModule = pathToFileURL( + const hoisted = pathToFileURL( path.join(cwd, 'node_modules', '@agentworkforce', 'cli', 'dist', 'update-check.js') ).href; + // npm's nested layout: the CLI sits under the wrapper's own node_modules. + const nested = pathToFileURL( + path.join( + cwd, + 'node_modules', 'agentworkforce', + 'node_modules', '@agentworkforce', 'cli', 'dist', 'update-check.js' + ) + ).href; const globalModule = pathToFileURL( path.resolve('/usr/lib/node_modules/agentworkforce/node_modules/@agentworkforce/cli/dist/update-check.js') ).href; - assert.equal(resolveInstallScope(projectModule, cwd), 'project'); + + assert.equal(resolveInstallScope(hoisted, cwd), 'project'); + assert.equal(resolveInstallScope(nested, cwd), 'project'); + // Run from a subdirectory, node still resolves the ancestor's install — so + // `-g` would still update the wrong copy. + assert.equal(resolveInstallScope(hoisted, path.join(cwd, 'packages', 'api')), 'project'); assert.equal(resolveInstallScope(globalModule, cwd), 'global'); // A sibling directory that merely shares a prefix is not inside the project. - assert.equal(resolveInstallScope(globalModule, path.resolve('/work/app-2')), 'global'); + assert.equal(resolveInstallScope(hoisted, path.resolve('/work/app-2')), 'global'); + // A global install is not project-local just because the shell sits above it. + assert.equal(resolveInstallScope(globalModule, path.resolve('/usr')), 'global'); assert.equal(resolveInstallScope('not-a-url', cwd), 'global'); }); diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts index eb3fd7de..6986e9a5 100644 --- a/packages/cli/src/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -24,8 +24,12 @@ export const UPDATE_CHECK_PACKAGE = 'agentworkforce'; /** Registry used when neither agentworkforce nor npm config names one. */ export const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; -/** Registry budget for the whole check. `--version` must stay near-instant. */ -export const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1500; +/** + * Registry budget for the whole check. `--version` does not exit until the + * check settles, so this doubles as the worst case it can add to a run against + * a black-holed registry; the response itself is a few dozen bytes. + */ +export const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1000; /** Where the running CLI was resolved from; decides whether to suggest `-g`. */ export type InstallScope = 'global' | 'project'; @@ -195,9 +199,23 @@ export async function fetchLatestVersion(options: { } } +/** Whether `child` is `parent` or sits underneath it. */ +function isWithin(child: string, parent: string): boolean { + if (child === parent) return true; + const relative = path.relative(parent, child); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +} + /** - * A CLI resolved from `/node_modules` was installed as a project - * dependency, so `-g` would update a different copy than the one that just ran. + * A CLI installed as a project dependency must not be told to run `-g`: that + * would update a different copy than the one that just ran. + * + * The directory owning the *outermost* `node_modules` on the module's path is + * the install root — `` for both `/node_modules/@agentworkforce/cli` + * and the nested `/node_modules/agentworkforce/node_modules/…` layout. + * The install is project-local when the command was run from inside that root, + * which includes subdirectories: node resolves a dependency from an ancestor's + * `node_modules` just as readily as from the working directory's own. */ export function resolveInstallScope( moduleUrl: string = import.meta.url, @@ -209,8 +227,11 @@ export function resolveInstallScope( } catch { return 'global'; } - const projectModules = path.resolve(cwd, 'node_modules') + path.sep; - return modulePath.startsWith(projectModules) ? 'project' : 'global'; + const segments = modulePath.split(path.sep); + const depth = segments.indexOf('node_modules'); + if (depth < 0) return 'global'; + const installRoot = path.resolve(segments.slice(0, depth).join(path.sep) || path.sep); + return isWithin(path.resolve(cwd), installRoot) ? 'project' : 'global'; } /** The command that replaces the running install with the published latest. */ From 86cf93b3b774c644441fe76298245499bee88d17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 17:53:37 +0000 Subject: [PATCH 3/5] fix(cli): correct two path edge cases in install-scope detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reproduced before fixing. `isWithin()` rejected any relative path starting with `..`, but `path.relative('/work/app', '/work/app/..cache')` is `..cache` — an ordinary child whose name merely begins with dots. Running from such a directory inside a project install got `npm install -g`. Only a bare `..` or a `..` prefix means the path escaped the root. Deriving the install root by joining the segments before `node_modules` and resolving them breaks at a Windows drive root: the prefix of `C:\node_modules\…` is the bare `C:`, and `path.resolve('C:')` yields the current directory on that drive, not `C:\`. The root is now split off with `path.parse()` and rejoined, so the drive letter survives. The path arithmetic moves into `classifyInstallPath()`, which takes the `path` implementation to apply — `resolveInstallScope()` stays the URL-accepting entry point. Windows rules are then testable on Linux CI, which is where these would otherwise have gone unnoticed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNcR15rKHnYBiV3UxtynuF --- packages/cli/src/update-check.test.ts | 38 +++++++++++++++++++++++ packages/cli/src/update-check.ts | 43 ++++++++++++++++++++------- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/update-check.test.ts b/packages/cli/src/update-check.test.ts index 5feef064..c574b066 100644 --- a/packages/cli/src/update-check.test.ts +++ b/packages/cli/src/update-check.test.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'; import { DEFAULT_REGISTRY, + classifyInstallPath, DEFAULT_UPDATE_CHECK_TIMEOUT_MS, compareVersions, fetchLatestVersion, @@ -165,6 +166,43 @@ test('resolveInstallScope treats an install that owns the working tree as projec assert.equal(resolveInstallScope('not-a-url', cwd), 'global'); }); +test('resolveInstallScope treats a dot-prefixed directory name as an ordinary child', () => { + // path.relative() reports '..cache' for this descendant; only a real '..' + // segment means the working directory sits outside the install root. + const root = path.resolve('/work/app'); + const module = pathToFileURL( + path.join(root, 'node_modules', '@agentworkforce', 'cli', 'dist', 'update-check.js') + ).href; + assert.equal(resolveInstallScope(module, path.join(root, '..cache')), 'project'); + assert.equal(resolveInstallScope(module, path.join(root, '..cache', 'deep')), 'project'); + // A genuine parent traversal still lands outside. + assert.equal(resolveInstallScope(module, path.resolve('/work')), 'global'); +}); + +test('classifyInstallPath applies Windows path rules, including a drive root', () => { + const win = path.win32; + const driveRoot = 'C:\\node_modules\\agentworkforce\\node_modules\\@agentworkforce\\cli\\dist\\update-check.js'; + // The prefix before `node_modules` is the bare `C:`, which resolves to the + // current directory on that drive rather than to `C:\`. + assert.equal(classifyInstallPath(driveRoot, 'C:\\node_modules\\agentworkforce', win), 'project'); + assert.equal(classifyInstallPath(driveRoot, 'C:\\', win), 'project'); + + const project = 'C:\\work\\app\\node_modules\\@agentworkforce\\cli\\dist\\update-check.js'; + assert.equal(classifyInstallPath(project, 'C:\\work\\app', win), 'project'); + assert.equal(classifyInstallPath(project, 'C:\\work\\app\\src', win), 'project'); + assert.equal(classifyInstallPath(project, 'C:\\work\\other', win), 'global'); + + // POSIX semantics stay reachable through the same helper. + assert.equal( + classifyInstallPath('/node_modules/@agentworkforce/cli/dist/update-check.js', '/srv', path.posix), + 'project' + ); + assert.equal( + classifyInstallPath('/opt/tool/dist/update-check.js', '/opt/tool', path.posix), + 'global' + ); +}); + test('resolveUpdateCommand drops -g for a project-local install', () => { assert.equal(resolveUpdateCommand('global'), 'npm install -g agentworkforce@latest'); assert.equal(resolveUpdateCommand('project'), 'npm install agentworkforce@latest'); diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts index 6986e9a5..7e7a1da4 100644 --- a/packages/cli/src/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -199,11 +199,38 @@ export async function fetchLatestVersion(options: { } } -/** Whether `child` is `parent` or sits underneath it. */ -function isWithin(child: string, parent: string): boolean { +/** + * Whether `child` is `parent` or sits underneath it. Only a `..` segment means + * "outside"; a directory whose *name* merely starts with dots (`..cache`) is an + * ordinary child. + */ +function isWithin(child: string, parent: string, pathImpl: path.PlatformPath): boolean { if (child === parent) return true; - const relative = path.relative(parent, child); - return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); + const relative = pathImpl.relative(parent, child); + if (relative === '' || relative === '..') return false; + return !relative.startsWith(`..${pathImpl.sep}`) && !pathImpl.isAbsolute(relative); +} + +/** + * The scope decision as pure path arithmetic, split out from `resolveInstallScope` + * so both POSIX and Windows semantics can be exercised on either platform by + * passing `path.posix` / `path.win32`. + */ +export function classifyInstallPath( + modulePath: string, + cwd: string, + pathImpl: path.PlatformPath = path +): InstallScope { + const resolved = pathImpl.resolve(modulePath); + // Segment off the filesystem root first and rejoin onto it: on Windows the + // prefix before a drive-root `node_modules` is the bare `C:`, and resolving + // that yields the *current directory* on C:, not the drive root. + const { root } = pathImpl.parse(resolved); + const segments = pathImpl.relative(root, resolved).split(pathImpl.sep); + const depth = segments.indexOf('node_modules'); + if (depth < 0) return 'global'; + const installRoot = pathImpl.join(root, ...segments.slice(0, depth)); + return isWithin(pathImpl.resolve(cwd), installRoot, pathImpl) ? 'project' : 'global'; } /** @@ -221,17 +248,11 @@ export function resolveInstallScope( moduleUrl: string = import.meta.url, cwd: string = process.cwd() ): InstallScope { - let modulePath: string; try { - modulePath = path.resolve(fileURLToPath(moduleUrl)); + return classifyInstallPath(fileURLToPath(moduleUrl), cwd); } catch { return 'global'; } - const segments = modulePath.split(path.sep); - const depth = segments.indexOf('node_modules'); - if (depth < 0) return 'global'; - const installRoot = path.resolve(segments.slice(0, depth).join(path.sep) || path.sep); - return isWithin(path.resolve(cwd), installRoot) ? 'project' : 'global'; } /** The command that replaces the running install with the published latest. */ From d767fe1fab2d09275f81aa3216f54443a8a8ccf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:08:06 +0000 Subject: [PATCH 4/5] test(agentworkforce): exercise the project's own launcher for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope test claimed to cover `npx` / `node_modules/.bin` / npm scripts — where resolveProjectInstall() finds a candidate that is the invoked file and returns undefined — but its fixture had no project tree at all, so the bundled branch was reached because no candidate existed. It asserted the right thing about a path it never took, duplicating the test above it. The fixture can now install the real launcher as a project's own `agentworkforce` dependency, and the test invokes that binary from the project root, which is what makes the candidate the invoked file. It also asserts the entry handed to the update check lives inside the project tree — the input that makes resolveInstallScope() answer 'project'. Confirmed to fail when the bundled branch is changed back to claiming 'global', so it holds the regression it describes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNcR15rKHnYBiV3UxtynuF --- packages/agentworkforce/test/version.test.js | 50 +++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/agentworkforce/test/version.test.js b/packages/agentworkforce/test/version.test.js index 194fb161..5fbdab17 100644 --- a/packages/agentworkforce/test/version.test.js +++ b/packages/agentworkforce/test/version.test.js @@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'; import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const pkg = JSON.parse( await readFile(new URL('../package.json', import.meta.url), 'utf8') @@ -98,25 +98,41 @@ test('--version delegates the update check to the CLI it validated', async (t) = ); }); -test('--version does not claim a scope the wrapper cannot know', async (t) => { - // Invoking the project's own launcher (npx, node_modules/.bin, an npm - // script) takes the bundled branch, because the project candidate is this - // very file. Asserting 'global' there would send a project-local user to - // `npm install -g`, updating a copy that never ran. +test('--version leaves the scope open when the project runs its own launcher', async (t) => { + // `npx agentworkforce`, `node_modules/.bin/agentworkforce`, and npm scripts + // all execute the project's own launcher. resolveProjectInstall() then finds + // a candidate that is this very file and returns undefined, so the bundled + // branch runs for an install that is entirely project-local. Claiming + // 'global' there would send the user to `npm install -g`, updating a copy + // that never ran; the wrapper must leave the scope to be inferred from where + // the entry resolved. const fixture = await createInstalledTree(t, { wrapperVersion: '4.1.26', cliVersion: '4.1.26', + projectWrapperVersion: '4.1.26', + projectCliVersion: '4.1.26', + projectCliLayout: 'hoisted', + installProjectLauncher: true, updateNotice: true }); - const { exitCode, stderr } = await runBin( - fixture.binPath, + const { exitCode, stdout, stderr } = await runBin( + fixture.projectBinPath, ['--version'], - { cwd: fixture.root } + { cwd: fixture.projectRoot } ); assert.equal(exitCode, 0); - assert.equal(parseUpdateNotice(stderr).scope, null); + assert.equal(stdout, '4.1.26\n'); + const notice = parseUpdateNotice(stderr); + assert.equal(notice.scope, null); + // The entry it handed over is the project's own copy, which is what makes + // resolveInstallScope() answer 'project'. + const projectModules = `${pathToFileURL(fixture.projectRoot).href}/node_modules/`; + assert.ok( + notice.moduleUrl?.startsWith(projectModules), + `expected an entry inside the project tree, got: ${notice.moduleUrl}` + ); }); test('--version reports a project-local install as project-scoped', async (t) => { @@ -418,7 +434,8 @@ async function createInstalledTree(t, { omitCliPackage, omitCliEntry, projectWrapperExports, - updateNotice + updateNotice, + installProjectLauncher }) { const tempParent = await mkdtemp(path.join(os.tmpdir(), 'agentworkforce install ')); const root = path.join(tempParent, 'global tree'); @@ -457,6 +474,7 @@ async function createInstalledTree(t, { } const projectRoot = path.join(tempParent, 'project tree'); + let projectBinPath; if (projectWrapperVersion) { const projectWrapperRoot = path.join(projectRoot, 'node_modules', 'agentworkforce'); const projectCliRoot = projectCliLayout === 'hoisted' @@ -495,6 +513,14 @@ async function createInstalledTree(t, { if (updateNotice) { await writeFile(path.join(projectCliRoot, 'dist', 'update-check.js'), UPDATE_CHECK_STUB); } + if (installProjectLauncher) { + // The real launcher, installed as the project's own `agentworkforce` + // dependency — what `npx` and `node_modules/.bin` actually execute. + projectBinPath = path.join(projectWrapperRoot, 'bin', 'agentworkforce.js'); + await mkdir(path.dirname(projectBinPath), { recursive: true }); + await cp(binPath, projectBinPath); + await chmod(projectBinPath, 0o755); + } } else if (bareProjectCliVersion) { const projectCliRoot = path.join(projectRoot, 'node_modules', '@agentworkforce', 'cli'); await mkdir(path.join(projectCliRoot, 'dist'), { recursive: true }); @@ -514,7 +540,7 @@ async function createInstalledTree(t, { await mkdir(projectRoot, { recursive: true }); } - return { root, projectRoot, binPath: fixtureBinPath }; + return { root, projectRoot, binPath: fixtureBinPath, projectBinPath }; } function escapeRegExp(value) { From ff3f73b52a8c1a9605a0e0172942b1e8ca16a956 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:00:07 +0000 Subject: [PATCH 5/5] fix(cli): an empty relative path means the same directory, not outside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isWithin()` treated `path.relative()` returning '' as "not inside". That only happens when the two paths name the same location, and the identical strings are already caught above — so the case it actually covered was Windows comparing case-insensitively: `path.win32.relative('C:\Work\App', 'c:\work\app')` is '', and a project install run from a differently-cased spelling of its own root was told to `npm install -g`. Reproduced through the built module (`classifyInstallPath` returned 'global' for that pair, 'project' for the same-case one) and covered by a mixed-case assertion that fails without this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNcR15rKHnYBiV3UxtynuF --- packages/cli/src/update-check.test.ts | 10 ++++++++++ packages/cli/src/update-check.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/update-check.test.ts b/packages/cli/src/update-check.test.ts index c574b066..0f63d2ad 100644 --- a/packages/cli/src/update-check.test.ts +++ b/packages/cli/src/update-check.test.ts @@ -190,6 +190,16 @@ test('classifyInstallPath applies Windows path rules, including a drive root', ( const project = 'C:\\work\\app\\node_modules\\@agentworkforce\\cli\\dist\\update-check.js'; assert.equal(classifyInstallPath(project, 'C:\\work\\app', win), 'project'); assert.equal(classifyInstallPath(project, 'C:\\work\\app\\src', win), 'project'); + // Windows compares paths case-insensitively, so these name one directory and + // path.relative() collapses them to '' rather than to a traversal. + assert.equal( + classifyInstallPath( + 'C:\\Work\\App\\node_modules\\@agentworkforce\\cli\\dist\\update-check.js', + 'c:\\work\\app', + win + ), + 'project' + ); assert.equal(classifyInstallPath(project, 'C:\\work\\other', win), 'global'); // POSIX semantics stay reachable through the same helper. diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts index 7e7a1da4..67b18d43 100644 --- a/packages/cli/src/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -207,7 +207,11 @@ export async function fetchLatestVersion(options: { function isWithin(child: string, parent: string, pathImpl: path.PlatformPath): boolean { if (child === parent) return true; const relative = pathImpl.relative(parent, child); - if (relative === '' || relative === '..') return false; + // An empty relative path means the two name the same directory without being + // the same string — Windows compares case-insensitively, so `c:\work\app` + // and `C:\Work\App` are one place. + if (relative === '') return true; + if (relative === '..') return false; return !relative.startsWith(`..${pathImpl.sep}`) && !pathImpl.isAbsolute(relative); }