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..f031e156 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,25 @@ 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) 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 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..a37f4477 100644 --- a/packages/agentworkforce/CHANGELOG.md +++ b/packages/agentworkforce/CHANGELOG.md @@ -7,6 +7,14 @@ 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 — + 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 ### Released diff --git a/packages/agentworkforce/bin/agentworkforce.js b/packages/agentworkforce/bin/agentworkforce.js index bcf60abe..2e1fd88f 100755 --- a/packages/agentworkforce/bin/agentworkforce.js +++ b/packages/agentworkforce/bin/agentworkforce.js @@ -49,7 +49,13 @@ function resolveCli() { ].join('\n') ); } - return { version: project.cli.version, entryUrl: project.cli.entryUrl }; + return { + version: project.cli.version, + entryUrl: project.cli.entryUrl, + // A newer project dependency beat the invoked install, so this is + // definitively project-local: `-g` would update a different copy. + scope: 'project' + }; } // Only require the invoked wrapper's dependency after checking whether a @@ -79,7 +85,12 @@ function resolveCli() { return { version: bundled.version, - entryUrl: bundled.entryUrl + entryUrl: bundled.entryUrl, + // 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 }; } @@ -218,6 +229,27 @@ 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, + moduleUrl: cli.entryUrl + }); + } 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,14 +258,16 @@ try { if (process.argv[2] === '-v' || process.argv[2] === '--version') { process.stdout.write(`${cli.version}\n`); - process.exit(0); + await reportAvailableUpdate(cli); + // 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 018cd6eb..5fbdab17 100644 --- a/packages/agentworkforce/test/version.test.js +++ b/packages/agentworkforce/test/version.test.js @@ -4,13 +4,37 @@ 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') ); 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, @@ -48,6 +72,92 @@ 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'); + 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 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, stdout, stderr } = await runBin( + fixture.projectBinPath, + ['--version'], + { cwd: fixture.projectRoot } + ); + + assert.equal(exitCode, 0); + 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) => { + 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'); + // 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) => { const fixture = await createInstalledTree(t, { wrapperVersion: '4.1.26', @@ -323,7 +433,9 @@ async function createInstalledTree(t, { bareProjectCliVersion, omitCliPackage, omitCliEntry, - projectWrapperExports + projectWrapperExports, + updateNotice, + installProjectLauncher }) { const tempParent = await mkdtemp(path.join(os.tmpdir(), 'agentworkforce install ')); const root = path.join(tempParent, 'global tree'); @@ -356,9 +468,13 @@ async function createInstalledTree(t, { )}); }\n` ); } + if (updateNotice) { + await writeFile(path.join(cliRoot, 'dist', 'update-check.js'), UPDATE_CHECK_STUB); + } } const projectRoot = path.join(tempParent, 'project tree'); + let projectBinPath; if (projectWrapperVersion) { const projectWrapperRoot = path.join(projectRoot, 'node_modules', 'agentworkforce'); const projectCliRoot = projectCliLayout === 'hoisted' @@ -394,6 +510,17 @@ async function createInstalledTree(t, { `PROJECT CLI ${projectCliVersion} EXECUTED\n` )}); }\n` ); + 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 }); @@ -413,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) { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d64a7958..7ad5f2d2 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -7,6 +7,17 @@ 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 (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.46] - 2026-08-19 ### Added diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index 9dfa5014..5d8b5f09 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -377,7 +377,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 @@ -5069,7 +5072,13 @@ export async function main(): Promise { if (subcommand === '-v' || subcommand === '--version') { process.stdout.write(`${CLI_VERSION}\n`); - process.exit(0); + // 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); + // 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 7a1cef97..b4f43fb7 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -694,12 +694,128 @@ 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 }); + } +}); + +/** + * 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. + */ +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, + // 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); + // 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, + ...UPDATE_CHECK_ENABLED + }) ); 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 }); + 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 { + const { stderr, stdout, exitCode } = await runCliCapturingStderr(['--version'], { + AGENT_WORKFORCE_HOME: workforceHome, + 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 new file mode 100644 index 00000000..0f63d2ad --- /dev/null +++ b/packages/cli/src/update-check.test.ts @@ -0,0 +1,308 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + DEFAULT_REGISTRY, + classifyInstallPath, + 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 treats an install that owns the working tree as project-local', () => { + const cwd = path.resolve('/work/app'); + 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(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(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'); +}); + +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'); + // 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. + 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'); +}); + +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..67b18d43 --- /dev/null +++ b/packages/cli/src/update-check.ts @@ -0,0 +1,324 @@ +/** + * `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` 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'; + +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); + } +} + +/** + * 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 = pathImpl.relative(parent, child); + // 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); +} + +/** + * 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'; +} + +/** + * 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, + cwd: string = process.cwd() +): InstallScope { + try { + return classifyInstallPath(fileURLToPath(moduleUrl), cwd); + } catch { + return '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`. + } +}