From e21f050a832cea98bf103cc735cc70a41ef7804d Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Thu, 8 Oct 2026 17:31:23 -0700 Subject: [PATCH] feat: update the active CLI install automatically --- README.md | 14 ++ build.ts | 2 + skill/SKILL.md | 7 + src/commands/update.ts | 82 +---------- src/install.ts | 76 ++++++++++ src/main.ts | 2 + src/registry.ts | 2 +- src/telemetry/environment.ts | 8 +- src/update.ts | 155 ++++++++++++++++++++ test/update-main.test.ts | 93 ++++++++++++ test/update.test.ts | 265 +++++++++++++++++++++++++++++++++++ 11 files changed, 624 insertions(+), 82 deletions(-) create mode 100644 src/install.ts create mode 100644 src/update.ts create mode 100644 test/update-main.test.ts create mode 100644 test/update.test.ts diff --git a/README.md b/README.md index ddb6820..db49545 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,20 @@ POLYLANE_VERSION=v0.1.0 curl -fsSL https://polylane.com/install.sh | bash $env:POLYLANE_VERSION='v0.1.0'; irm https://polylane.com/install.ps1 | iex ``` +The CLI checks for updates after successful commands, at most once per day, +and updates the active install without a prompt. npm, Bun, and Homebrew installs +use their own package manager. Standalone installs verify the release checksum +and version before replacing the bundle. The current command finishes on its +original version; the next command uses the update. Progress goes to stderr. + +Run `polylane update` to update now, or `polylane update --dry-run` to inspect +the detected install and available update. Set `POLYLANE_NO_AUTO_UPDATE=1` to +disable automatic updates. Keep `POLYLANE_VERSION` set when running the CLI to +retain a version pin. Homebrew pins are also respected. CI, development, +prerelease, and unrecognized installs do not update automatically. An automatic +update failure does not change the command's result; `polylane update` reports +update errors and exits nonzero. + ### Wire it into your coding agents ```bash diff --git a/build.ts b/build.ts index c2140ab..c6abb39 100644 --- a/build.ts +++ b/build.ts @@ -36,6 +36,8 @@ async function main(): Promise { 'POLYLANE_ONBOARDING_RUN', 'POLYLANE_TELEMETRY_NOTICE_ACK', 'POLYLANE_TERMS_NOTICE_ACK', + 'POLYLANE_NO_AUTO_UPDATE', + 'POLYLANE_VERSION', ]); // Bake every other POLYLANE_* env var visible at build time into the bundle, so // the produced binary works without needing those vars set at runtime. diff --git a/skill/SKILL.md b/skill/SKILL.md index a34fe05..85cbc50 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -31,6 +31,13 @@ polylane auth signup --email --code # finish signup with the polylane auth status ``` +The CLI updates its active install after successful commands, at most once +per day, without a prompt. Run `polylane update` to update now or add +`--dry-run` to inspect the update. Set `POLYLANE_NO_AUTO_UPDATE=1` to disable +automatic updates. Keep `POLYLANE_VERSION` set to retain a version pin. +CI, development, prerelease, and unknown installs do not update automatically. +The next command uses the updated version; stdout stays pure data. + **API key** persists to `~/.polylane/config.json`. **OAuth** credentials persist to `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. Credential precedence: `--api-key` flag > `POLYLANE_API_KEY` > `~/.polylane/credentials.json` > `api_key` in `~/.polylane/config.json` (an exported key always beats a stale OAuth token). **Signup** generates a strong random password when `--password` is omitted and prints it once on stderr (also `generated_password` in JSON output); store it, or change it later via password reset. Weak or known-leaked passwords are challenged at the edge (exit `2`, "challenged by the edge, usually because the password is weak or known-leaked"): do not invent one, let the CLI generate it. Signup emails a 6-digit verification code to the address; the account is unusable until the code is confirmed (interactively, or with `--code`). The confirmed session token is stored under the same OAuth credential shape — for long-lived agent access, create an API key right after signup and switch to it. Account-lifecycle operations beyond signup/login (reset password, update profile, delete account, notification settings) live in the web console. Reach them from the CLI via `polylane api call ` if you must. diff --git a/src/commands/update.ts b/src/commands/update.ts index c0876b6..2f0b5de 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,84 +1,10 @@ import type { Command } from '../command'; -import type { Config } from '../config/schema'; -import { UPDATE_STATE_FILE } from '../config/paths'; -import { readJsonFile, writeJsonFile } from '../utils/fs'; -import { isCI } from '../utils/env'; -import { getCliVersion } from '../version'; - -interface UpdateState { - lastCheck: number; - latest: string | null; -} - -const CACHE_TTL_MS = 24 * 60 * 60 * 1000; - -async function fetchLatest(): Promise { - try { - const res = await fetch('https://registry.npmjs.org/@coreplane/polylane/latest', { - signal: AbortSignal.timeout(5000), - }); - if (!res.ok) return null; - const data = (await res.json()) as { version?: string }; - return data.version ?? null; - } catch { - return null; - } -} - -function compareVersions(a: string, b: string): number { - const pa = a.split('.').map(Number); - const pb = b.split('.').map(Number); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const x = pa[i] ?? 0; - const y = pb[i] ?? 0; - if (x !== y) return x - y; - } - return 0; -} +import { updateCli } from '../update'; export const updateCommand: Command = { name: 'update', - description: 'Check for CLI updates', - async execute(config: Config): Promise { - const current = getCliVersion(); - process.stderr.write(`Current version: ${current}\n`); - - if (isCI() && !config.verbose) { - process.stderr.write('Skipping update check in CI\n'); - return; - } - - const latest = await fetchLatest(); - if (!latest) { - process.stderr.write("Couldn't reach npm to check for updates. Try again later.\n"); - return; - } - - writeJsonFile(UPDATE_STATE_FILE, { lastCheck: Date.now(), latest }); - - if (compareVersions(latest, current) > 0) { - process.stderr.write(`Update available: ${latest}\n`); - process.stderr.write(`Run: npm install -g @coreplane/polylane@${latest}\n`); - process.stderr.write(` or: brew upgrade polylane\n`); - } else { - process.stderr.write(`You're up to date.\n`); - } + description: 'Update the active CLI install', + async execute(config): Promise { + await updateCli(config); }, }; - -export async function checkForUpdateAsync(): Promise { - if (isCI()) return null; - const state = readJsonFile(UPDATE_STATE_FILE); - if (state && Date.now() - state.lastCheck < CACHE_TTL_MS) { - const current = getCliVersion(); - if (state.latest && compareVersions(state.latest, current) > 0) { - return state.latest; - } - return null; - } - const latest = await fetchLatest(); - writeJsonFile(UPDATE_STATE_FILE, { lastCheck: Date.now(), latest }); - const current = getCliVersion(); - if (latest && compareVersions(latest, current) > 0) return latest; - return null; -} diff --git a/src/install.ts b/src/install.ts new file mode 100644 index 0000000..580feb2 --- /dev/null +++ b/src/install.ts @@ -0,0 +1,76 @@ +import { execFile } from 'node:child_process'; +import { existsSync, realpathSync } from 'node:fs'; +import { delimiter, dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { readJsonFile } from './utils/fs'; + +export interface Installation { + kind: 'standalone' | 'npm' | 'bun' | 'brew' | 'dev' | 'unknown'; + file: string; + prefix?: string; + pinned?: boolean; +} + +const exec = promisify(execFile); + +export async function runProgram(program: string, args: string[], timeout = 10_000): Promise { + let bin = program; + let argv = args; + if (process.platform === 'win32' && program === 'npm') { + const directories = [dirname(process.execPath), ...(process.env.PATH ?? '').split(delimiter)]; + const script = directories.map((dir) => join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js')) + .find((path) => existsSync(path)); + if (!script) throw new Error('Cannot find npm-cli.js for the active Node installation'); + bin = process.execPath; + argv = [script, ...args]; + } + const { stdout } = await exec(bin, argv, { + timeout, maxBuffer: 2 * 1024 * 1024, windowsHide: true, + env: { ...process.env, POLYLANE_NO_AUTO_UPDATE: '1', npm_config_yes: 'true', NONINTERACTIVE: '1' }, + }); + return stdout.trim(); +} + +export async function detectInstallation(launcher = process.argv[1] ?? ''): Promise { + let file: string; + try { file = realpathSync(launcher); } + catch { return { kind: 'unknown', file: resolve(launcher) }; } + const normalized = file.replaceAll('\\', '/'); + if (normalized.endsWith('/src/main.ts')) return { kind: 'dev', file }; + + // The CLI file, not the Node executable, must belong to this Cellar. + if (/\/Cellar\/polylane\//i.test(normalized)) { + const cellar = await runProgram('brew', ['--cellar', 'polylane']); + if (file.startsWith(realpathSync(cellar) + '/')) { + const pinned = await runProgram('brew', ['list', '--pinned', 'polylane']); + return { kind: 'brew', file, pinned: pinned.split(/\s+/).includes('polylane') }; + } + return { kind: 'unknown', file }; + } + + const packageRoot = dirname(dirname(file)); + const pkg = readJsonFile<{ name?: string }>(join(packageRoot, 'package.json')); + if (pkg?.name === '@coreplane/polylane') { + if (existsSync(join(packageRoot, 'src', 'main.ts'))) return { kind: 'dev', file }; + try { + const binDir = await runProgram('bun', ['pm', 'bin', '-g']); + if (realpathSync(join(binDir, 'polylane')) === file) return { kind: 'bun', file }; + } catch { /* Bun is optional. */ } + try { + const root = await runProgram('npm', ['root', '--global']); + if (realpathSync(join(root, '@coreplane', 'polylane')) === packageRoot) { + const prefix = await runProgram('npm', ['prefix', '--global']); + return { kind: 'npm', file, prefix }; + } + } catch { /* Local and npx installs are not global installs. */ } + return { kind: 'unknown', file }; + } + + if (normalized.endsWith('/polylane.mjs') && !normalized.includes('/node_modules/')) { + try { + if (realpathSync(join(dirname(file), 'polylane')) === file) return { kind: 'standalone', file }; + } catch { /* Windows uses a cmd shim. */ } + if (process.platform === 'win32' && existsSync(join(dirname(file), 'polylane.cmd'))) return { kind: 'standalone', file }; + } + return { kind: 'unknown', file }; +} diff --git a/src/main.ts b/src/main.ts index 1b391b2..1d98546 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { import type { Credential } from './auth/types'; import { buildEvent, dispatch, maybeShowTelemetryNotice } from './telemetry'; import { getCliVersion } from './version'; +import { updateCli } from './update'; const NO_AUTH_COMMANDS = new Set([ 'auth login', @@ -165,6 +166,7 @@ async function run(): Promise { // that timed out) flag it on process.exitCode; a hard exit(0) here used to // discard it. const exitCode = settledExitCode(); + if (exitCode === 0 && command.name !== 'update') await updateCli(config, true); if (!isTelemetryCommand) { await dispatch( config, diff --git a/src/registry.ts b/src/registry.ts index 587a050..d438d65 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -35,7 +35,7 @@ const RESOURCE_ORDER: Record = { telemetry: { name: 'telemetry', description: 'Anonymous usage telemetry (status/enable/disable)', order: 95 }, api: { name: 'api', description: 'Raw API access (advanced)', order: 100 }, help: { name: 'help', description: 'Show help for a command', order: 110 }, - update: { name: 'update', description: 'Check for CLI updates', order: 120 }, + update: { name: 'update', description: 'Update the active CLI install', order: 120 }, }; export class CommandRegistry { diff --git a/src/telemetry/environment.ts b/src/telemetry/environment.ts index 41855b7..3d72d9c 100644 --- a/src/telemetry/environment.ts +++ b/src/telemetry/environment.ts @@ -1,6 +1,6 @@ import { sep } from 'node:path'; import { release as osRelease, cpus as osCpus, totalmem } from 'node:os'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { INSTALL_REF_FILE } from '../config/paths'; export type InstallSource = 'npm' | 'bun' | 'brew' | 'curl' | 'dev' | 'unknown'; @@ -9,7 +9,9 @@ export type InstallSource = 'npm' | 'bun' | 'brew' | 'curl' | 'dev' | 'unknown'; // the launcher came from. False positives go to "unknown" rather than a wrong // label. Used purely for distribution-channel analytics. export function detectInstallSource(): InstallSource { - const candidates: string[] = [process.argv[1] ?? '', process.execPath ?? '']; + let launcher = process.argv[1] ?? ''; + try { launcher = realpathSync(launcher); } catch { /* Keep an unresolved launcher as a best-effort label. */ } + const candidates: string[] = [launcher]; const haystack = candidates.join('|').toLowerCase(); if (!haystack) return 'unknown'; @@ -23,7 +25,7 @@ export function detectInstallSource(): InstallSource { if (haystack.includes(`${sep}.bun${sep}`) || haystack.includes('/.bun/')) return 'bun'; // Homebrew Cellar / opt - if (haystack.includes(`${sep}cellar${sep}`) || haystack.includes(`${sep}homebrew${sep}`)) return 'brew'; + if (haystack.includes(`${sep}cellar${sep}polylane${sep}`)) return 'brew'; // Generic npm-global location if (haystack.includes(`${sep}npm${sep}`) || haystack.includes(`${sep}node_modules${sep}`)) return 'npm'; diff --git a/src/update.ts b/src/update.ts new file mode 100644 index 0000000..4c42844 --- /dev/null +++ b/src/update.ts @@ -0,0 +1,155 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { Config } from './config/schema'; +import { UPDATE_STATE_FILE } from './config/paths'; +import { CLIError } from './errors/base'; +import { ExitCode } from './errors/codes'; +import { detectInstallation, runProgram, type Installation } from './install'; +import { ensureDir, readJsonFile, writeJsonFile } from './utils/fs'; +import { isCI } from './utils/env'; + +const PACKAGE = '@coreplane/polylane'; +const RELEASES = 'https://github.com/coreplanelabs/cli/releases/download'; +const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const CHECK_INTERVAL = 24 * 60 * 60 * 1000; + +function recentlyChecked(file: string): boolean { + const state = readJsonFile<{ file: string; lastCheck: number }>(UPDATE_STATE_FILE); + const age = Date.now() - (state?.lastCheck ?? NaN); + return state?.file === file && age >= 0 && age < CHECK_INTERVAL; +} + +function isNewer(latest: string, current: string): boolean { + const a = latest.split('.').map(Number); + const b = current.split(/[.+-]/).slice(0, 3).map(Number); + for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! > b[i]!; + return current.includes('-'); +} + +async function getVersion(file: string): Promise { + const output = await runProgram(process.execPath, [file, '--version']); + const match = /^polylane (\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)$/.exec(output); + if (!match) throw new Error('Installed CLI did not report a valid version'); + return match[1]!; +} + +async function get(url: string): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!response.ok) throw new Error(`Update download failed (${response.status})`); + return response; +} + +async function replaceStandalone(install: Installation, latest: string): Promise { + const base = `${RELEASES}/v${latest}`; + const checksums = await (await get(`${base}/checksums.txt`)).text(); + const checksum = checksums.split('\n').map((line) => /^([a-fA-F0-9]{64})\s+\*?polylane\.mjs\s*$/.exec(line)) + .find((match) => match !== null)?.[1]?.toLowerCase(); + if (!checksum) throw new Error('Release has no checksum for polylane.mjs'); + const response = await get(`${base}/polylane.mjs`); + if (Number(response.headers.get('content-length')) > 20 * 1024 * 1024) throw new Error('Update bundle is too large'); + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > 20 * 1024 * 1024) throw new Error('Update bundle is too large'); + if (createHash('sha256').update(bytes).digest('hex') !== checksum) throw new Error('Update checksum mismatch'); + const original = statSync(install.file); + const staged = join(dirname(install.file), `.polylane-update-${randomUUID()}.mjs`); + try { + writeFileSync(staged, bytes, { flag: 'wx', mode: original.mode & 0o777 }); + if (await getVersion(staged) !== latest) throw new Error('Downloaded CLI version does not match the release'); + const now = statSync(install.file); + if (now.ino !== original.ino || now.mtimeMs !== original.mtimeMs || now.size !== original.size) { + throw new Error('Installed CLI changed during the update; retry'); + } + renameSync(staged, install.file); + } finally { rmSync(staged, { force: true }); } +} + +async function installLatest(install: Installation, latest: string): Promise { + if (install.kind === 'standalone') { + await replaceStandalone(install, latest); + return; + } + if (install.kind === 'npm') { + await runProgram('npm', ['install', '--global', '--prefix', install.prefix!, `${PACKAGE}@${latest}`, '--ignore-scripts', '--no-audit', '--no-fund'], 120_000); + } else if (install.kind === 'bun') { + await runProgram('bun', ['add', '--global', `${PACKAGE}@${latest}`, '--ignore-scripts'], 120_000); + } else if (install.kind === 'brew') { + await runProgram('brew', ['update', '--quiet'], 120_000); + await runProgram('brew', ['upgrade', '--formula', 'coreplanelabs/tap/polylane'], 120_000); + const prefix = await runProgram('brew', ['--prefix', 'polylane']); + if (await getVersion(join(prefix, 'bin', 'polylane')) !== latest) throw new Error('Homebrew has not installed the latest CLI version yet'); + return; + } + if (await getVersion(install.file) !== latest) throw new Error('Package manager did not install the requested CLI version'); +} + +export async function updateCli(config: Config, automatic = false): Promise { + const say = (message: string): void => { if (!config.quiet) process.stderr.write(message + '\n'); }; + if (isCI()) { + if (!automatic) say('Skipping updates in CI'); + return; + } + if (process.env.POLYLANE_VERSION && process.env.POLYLANE_VERSION !== 'latest') { + if (!automatic) say(`CLI version is pinned by POLYLANE_VERSION=${process.env.POLYLANE_VERSION}`); + return; + } + if (automatic && (config.dryRun || process.env.POLYLANE_NO_AUTO_UPDATE === '1')) return; + let lock: string | undefined; + let updating = false; + try { + if (automatic && recentlyChecked(realpathSync(process.argv[1] ?? ''))) return; + const install = await detectInstallation(); + if (install.kind === 'dev' || install.kind === 'unknown') { + if (automatic) return; + throw new Error(`Cannot update this ${install.kind} install; use a global npm, Bun, Homebrew, or standalone install`); + } + if (install.pinned) { + if (!automatic) say('CLI version is pinned by Homebrew'); + return; + } + if (!config.dryRun) { + ensureDir(dirname(UPDATE_STATE_FILE)); + const path = `${UPDATE_STATE_FILE}.${createHash('sha256').update(install.file).digest('hex').slice(0, 16)}.lock`; + try { + if (Date.now() - statSync(path).mtimeMs > 10 * 60 * 1000) rmSync(path, { recursive: true }); + } catch { /* No existing lock. */ } + try { mkdirSync(path); lock = path; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + if (!automatic) say('Another CLI update is running'); + return; + } + throw error; + } + // Recheck after taking the lock: another process may have just updated. + if (automatic && recentlyChecked(install.file)) return; + writeJsonFile(UPDATE_STATE_FILE, { file: install.file, lastCheck: Date.now() }); + } + const current = await getVersion(install.file); + if (automatic && current.includes('-')) return; + const data = await (await get(`https://registry.npmjs.org/${PACKAGE}/latest`)).json() as { version?: string }; + const latest = data.version; + if (!latest || !STABLE_VERSION.test(latest)) throw new Error('npm returned an invalid latest version'); + if (!isNewer(latest, current)) { + if (!automatic) say(`You're up to date (${current}, ${install.kind})`); + return; + } + if (config.dryRun) { + say(`Would update ${install.kind} install from ${current} to ${latest}: ${install.file}`); + return; + } + say(`Updating ${install.kind} install from ${current} to ${latest}`); + updating = true; + await installLatest(install, latest); + say(`Updated to ${latest}. The next command uses the new version.`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (automatic) { if (updating || config.verbose) say(`Automatic CLI update skipped: ${message}`); } + else throw new CLIError(message, ExitCode.GENERAL, 'Retry with: polylane update'); + } finally { + if (lock) { + try { rmSync(lock, { recursive: true, force: true }); } + catch { /* Lock cleanup must not change the user's command result. */ } + } + } +} diff --git a/test/update-main.test.ts b/test/update-main.test.ts new file mode 100644 index 0000000..e95a479 --- /dev/null +++ b/test/update-main.test.ts @@ -0,0 +1,93 @@ +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { build } from 'esbuild'; + +const directory = mkdtempSync(join(tmpdir(), 'polylane-update-main-')); +const bundle = join(directory, 'main.mjs'); +const replacement = "#!/usr/bin/env node\nprocess.stdout.write('polylane 2.0.0\\n');\n"; +const checksum = createHash('sha256').update(replacement).digest('hex'); +const preload = join(directory, 'preload.mjs'); + +before(async () => { + await build({ + entryPoints: ['src/main.ts'], bundle: true, platform: 'node', format: 'esm', + alias: { 'jsonc-parser': 'jsonc-parser/lib/esm/main.js' }, outfile: bundle, + define: { 'process.env.POLYLANE_CLI_VERSION': '"1.0.0"' }, + banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" }, + }); + writeFileSync(preload, ` + globalThis.fetch = async (input) => { + const url = String(input); + if (process.env.FAIL_UPDATE === '1') throw new Error('network unavailable'); + if (url.endsWith('/latest')) return Response.json({ version: '2.0.0' }); + if (url.endsWith('/checksums.txt')) return new Response(${JSON.stringify(checksum + ' polylane.mjs\n')}); + if (url.endsWith('/polylane.mjs')) return new Response(${JSON.stringify(replacement)}); + throw new Error('Unexpected request: ' + url); + }; + `); +}); +after(() => rmSync(directory, { recursive: true, force: true })); + +function installed(name: string): { file: string; launcher: string; home: string } { + const home = join(directory, name); + const bin = join(home, '.polylane', 'bin'); + mkdirSync(bin, { recursive: true }); + const file = join(bin, 'polylane.mjs'); + writeFileSync(file, readFileSync(bundle), { mode: 0o755 }); + const launcher = join(bin, 'polylane'); + symlinkSync('polylane.mjs', launcher); + return { file, launcher, home }; +} + +function run(home: string, launcher: string, options: Record = {}, args = ['config', 'show', '--output', 'json']) { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith('POLYLANE_') || [ + 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', 'CIRCLECI', 'TRAVIS', + 'BUILDKITE', 'TEAMCITY_VERSION', 'BITBUCKET_BUILD_NUMBER', 'CODEBUILD_BUILD_ID', + 'TF_BUILD', 'VERCEL', 'NETLIFY', 'CF_PAGES', 'CLOUDFLARE_PAGES', 'RENDER', + 'RAILWAY_ENVIRONMENT', 'FLY_APP_NAME', 'NODE_OPTIONS', + ].includes(key)) delete env[key]; + } + return spawnSync(process.execPath, ['--import', preload, launcher, ...args], { + encoding: 'utf8', timeout: 20_000, + env: { ...env, HOME: home, USERPROFILE: home, POLYLANE_TELEMETRY: '0', ...options }, + }); +} + +describe('bundled CLI automatic update', { skip: process.platform === 'win32' }, () => { + it('keeps JSON clean and installs the version used by the next invocation', () => { + const { file, launcher, home } = installed('success'); + const result = run(home, launcher); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).domain, 'api.polylane.com'); + assert.match(result.stderr, /Updated to 2\.0\.0/); + assert.equal(readFileSync(file, 'utf8'), replacement); + const next = run(home, launcher, {}, ['--version']); + assert.equal(next.status, 0); + assert.equal(next.stdout, 'polylane 2.0.0\n'); + }); + + it('preserves a successful command and the old executable when the network is down', () => { + const { file, launcher, home } = installed('offline'); + const result = run(home, launcher, { FAIL_UPDATE: '1' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).domain, 'api.polylane.com'); + assert.equal(readFileSync(file, 'utf8'), readFileSync(bundle, 'utf8')); + const next = run(home, launcher, {}, ['--version']); + assert.equal(next.stdout, 'polylane 1.0.0\n'); + }); + + it('respects the automatic-update opt-out in the bundled executable', () => { + const { file, launcher, home } = installed('opt-out'); + const result = run(home, launcher, { POLYLANE_NO_AUTO_UPDATE: '1' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).domain, 'api.polylane.com'); + assert.equal(readFileSync(file, 'utf8'), readFileSync(bundle, 'utf8')); + }); +}); diff --git a/test/update.test.ts b/test/update.test.ts new file mode 100644 index 0000000..8d9f27e --- /dev/null +++ b/test/update.test.ts @@ -0,0 +1,265 @@ +import { after, afterEach, beforeEach, describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const home = realpathSync(mkdtempSync(join(tmpdir(), 'polylane-update-test-'))); +process.env.HOME = home; +const { updateCommand } = await import('../src/commands/update'); +const { updateCli } = await import('../src/update'); +const { detectInstallSource } = await import('../src/telemetry/environment'); +const { mockConfig } = await import('./helpers/config'); +const originalArgv = [...process.argv]; +const originalEnv = { ...process.env }; +const oldBundle = "#!/usr/bin/env node\nprocess.stdout.write('polylane 1.0.0\\n');\n"; +const newBundle = "#!/usr/bin/env node\nprocess.stdout.write('polylane 2.0.0\\n');\n"; +let file: string; +let stderr: string; + +function serve(bundle = newBundle, checksum = createHash('sha256').update(bundle).digest('hex')): void { + mock.method(globalThis, 'fetch', async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith('/latest')) return Response.json({ version: '2.0.0' }); + if (url.endsWith('/checksums.txt')) return new Response(`${checksum} polylane.mjs\n`); + if (url.endsWith('/polylane.mjs')) return new Response(bundle); + throw new Error(`Unexpected request: ${url}`); + }); +} + + beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('POLYLANE_') || ['CI', 'GITHUB_ACTIONS'].includes(key)) delete process.env[key]; + } + rmSync(join(home, '.polylane'), { recursive: true, force: true }); + file = join(home, '.polylane', 'bin', 'polylane.mjs'); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, oldBundle, { mode: 0o755 }); + symlinkSync('polylane.mjs', join(dirname(file), 'polylane')); + process.argv[1] = join(dirname(file), 'polylane'); + stderr = ''; + mock.method(process.stderr, 'write', (chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }); + }); + + afterEach(() => { + mock.restoreAll(); + process.argv = [...originalArgv]; + process.env = { ...originalEnv, HOME: home }; + }); + after(() => rmSync(home, { recursive: true, force: true })); + +describe('polylane update', () => { + it('updates the active standalone install without a prompt', async () => { + serve(); + assert.equal(detectInstallSource(), 'curl'); + await updateCommand.execute(mockConfig(), {}, {}); + assert.equal(readFileSync(file, 'utf8'), newBundle); + const next = spawnSync(process.execPath, [process.argv[1], '--version'], { encoding: 'utf8' }); + assert.equal(next.status, 0); + assert.equal(next.stdout, 'polylane 2.0.0\n'); + assert.match(stderr, /Updated to 2\.0\.0/); + }); + + it('refuses a checksum mismatch and preserves the working copy', async () => { + serve(newBundle, '0'.repeat(64)); + await assert.rejects(updateCommand.execute(mockConfig(), {}, {}), /checksum/i); + assert.equal(readFileSync(file, 'utf8'), oldBundle); + }); + + it('does not replace the working copy with a bundle that reports the wrong version', async () => { + serve(oldBundle); + await assert.rejects(updateCommand.execute(mockConfig(), {}, {}), /version/i); + assert.equal(readFileSync(file, 'utf8'), oldBundle); + }); + + it('shows a dry run without changing the install', async () => { + serve(); + await updateCommand.execute(mockConfig({ dryRun: true }), {}, {}); + assert.equal(readFileSync(file, 'utf8'), oldBundle); + assert.match(stderr, /Would update.*2\.0\.0/); + }); + + it('leaves CI and version-pinned runs unchanged', async () => { + serve(); + process.env.CI = 'true'; + await updateCommand.execute(mockConfig({ verbose: true }), {}, {}); + assert.match(stderr, /Skipping.*CI/); + delete process.env.CI; + process.env.POLYLANE_VERSION = '1.0.0'; + await updateCommand.execute(mockConfig(), {}, {}); + assert.match(stderr, /pinned/i); + assert.equal(readFileSync(file, 'utf8'), oldBundle); + }); +}); + +describe('automatic updates', () => { + it('updates once, then honors the daily check interval', async () => { + serve(); + await updateCli(mockConfig(), true); + assert.equal(readFileSync(file, 'utf8'), newBundle); + stderr = ''; + mock.method(globalThis, 'fetch', async () => { throw new Error('Should be cached'); }); + await updateCli(mockConfig({ verbose: true }), true); + assert.equal(readFileSync(file, 'utf8'), newBundle); + assert.equal(stderr, ''); + const state = JSON.parse(readFileSync(join(home, '.polylane', 'update-state.json'), 'utf8')); + assert.equal(state.file, file); + assert.equal(typeof state.lastCheck, 'number'); + }); + + it('keeps updates disabled until an explicit update is requested', async () => { + serve(); + process.env.POLYLANE_NO_AUTO_UPDATE = '1'; + await updateCli(mockConfig(), true); + assert.equal(readFileSync(file, 'utf8'), oldBundle); + await updateCommand.execute(mockConfig(), {}, {}); + assert.equal(readFileSync(file, 'utf8'), newBundle); + }); + + it('contains a failed update without breaking the command result', async () => { + serve(newBundle, '0'.repeat(64)); + await updateCli(mockConfig(), true); + assert.equal(readFileSync(file, 'utf8'), oldBundle); + // A failed automatic check must not block an explicit retry. + serve(); + await updateCommand.execute(mockConfig(), {}, {}); + assert.equal(readFileSync(file, 'utf8'), newBundle); + }); + + it('updates only once when two commands check concurrently', async () => { + let release!: () => void; + let started!: () => void; + const checked = new Promise((resolve) => { started = resolve; }); + const wait = new Promise((resolve) => { release = resolve; }); + serve(); + const fetch = globalThis.fetch; + mock.method(globalThis, 'fetch', async (...args: Parameters) => { + if (String(args[0]).endsWith('/latest')) { started(); await wait; } + return fetch(...args); + }); + const first = updateCli(mockConfig(), true); + await checked; + await updateCli(mockConfig(), true); + release(); + await first; + assert.equal(readFileSync(file, 'utf8'), newBundle); + }); +}); + +function manager(name: string, body: string): void { + const binDir = join(home, 'managers'); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, name), `#!${process.execPath}\n${body}\n`, { mode: 0o755 }); + process.env.PATH = binDir + ':' + (originalEnv.PATH ?? ''); +} + +function packageAt(root: string): string { + const path = join(root, '@coreplane', 'polylane'); + mkdirSync(join(path, 'dist'), { recursive: true }); + writeFileSync(join(path, 'package.json'), '{"name":"@coreplane/polylane"}'); + const bundle = join(path, 'dist', 'polylane.mjs'); + writeFileSync(bundle, oldBundle, { mode: 0o755 }); + return bundle; +} + +describe('package-manager ownership', { skip: process.platform === 'win32' }, () => { + it('updates the active npm prefix and leaves the standalone copy unchanged', async () => { + const standalone = file; + const prefix = join(home, 'opt', 'homebrew'); + const root = join(prefix, 'lib', 'node_modules'); + file = packageAt(root); + process.argv[1] = file; + assert.equal(detectInstallSource(), 'npm'); + manager('bun', 'process.exit(1)'); + manager('brew', 'throw new Error("must not use brew for npm")'); + manager('npm', ` + const fs = require('node:fs'); + const args = process.argv.slice(2); + if (args[0] === 'root') console.log(${JSON.stringify(root)}); + else if (args[0] === 'prefix') console.log(${JSON.stringify(prefix)}); + else if (args[0] === 'install') { + fs.writeFileSync(${JSON.stringify(file)}, ${JSON.stringify(newBundle)}); + fs.writeFileSync(${JSON.stringify(join(home, 'npm-args.json'))}, JSON.stringify(args)); + console.log('package manager progress'); + } else process.exit(1); + `); + serve(); + await updateCommand.execute(mockConfig(), {}, {}); + assert.equal(readFileSync(file, 'utf8'), newBundle); + assert.equal(readFileSync(standalone, 'utf8'), oldBundle); + assert.deepEqual(JSON.parse(readFileSync(join(home, 'npm-args.json'), 'utf8')), [ + 'install', '--global', '--prefix', prefix, '@coreplane/polylane@2.0.0', '--ignore-scripts', '--no-audit', '--no-fund', + ]); + assert.match(stderr, /Updating npm install/); + }); + + it('refuses a local package when npm owns a different prefix', async () => { + file = packageAt(join(home, 'project', 'node_modules')); + process.argv[1] = file; + const otherRoot = join(home, 'different-prefix', 'node_modules'); + packageAt(otherRoot); + manager('bun', 'process.exit(1)'); + manager('npm', `console.log(${JSON.stringify(otherRoot)})`); + serve(); + await assert.rejects(updateCommand.execute(mockConfig(), {}, {}), /unknown install/); + assert.equal(readFileSync(file, 'utf8'), oldBundle); + }); + + it('updates a Bun install only when its global launcher resolves to the active file', async () => { + const root = join(home, 'bun-global', 'node_modules'); + file = packageAt(root); + const binDir = join(home, 'bun-bin'); + mkdirSync(binDir, { recursive: true }); + symlinkSync(file, join(binDir, 'polylane')); + process.argv[1] = file; + manager('bun', ` + const fs = require('node:fs'); + const args = process.argv.slice(2); + if (args[0] === 'pm') console.log(${JSON.stringify(binDir)}); + else if (args[0] === 'add') { + fs.writeFileSync(${JSON.stringify(file)}, ${JSON.stringify(newBundle)}); + console.log('Bun progress'); + } else process.exit(1); + `); + serve(); + await updateCommand.execute(mockConfig(), {}, {}); + assert.equal(readFileSync(file, 'utf8'), newBundle); + assert.match(stderr, /Updating bun install/); + }); + + it('respects a Homebrew pin, then updates after the pin is removed', async () => { + const cellar = join(home, 'Cellar', 'polylane'); + const prefix = join(cellar, '1.0.0'); + const root = join(prefix, 'libexec', 'lib', 'node_modules'); + file = packageAt(root); + const binDir = join(prefix, 'bin'); + mkdirSync(binDir, { recursive: true }); + symlinkSync(file, join(binDir, 'polylane')); + process.argv[1] = file; + const pin = join(home, 'brew-pin'); + writeFileSync(pin, 'pinned'); + manager('brew', ` + const fs = require('node:fs'); + const args = process.argv.slice(2); + if (args[0] === '--cellar') console.log(${JSON.stringify(cellar)}); + else if (args[0] === 'list') console.log(fs.existsSync(${JSON.stringify(pin)}) ? 'polylane' : ''); + else if (args[0] === '--prefix') console.log(${JSON.stringify(prefix)}); + else if (args[0] === 'update') process.exit(0); + else if (args[0] === 'upgrade') fs.writeFileSync(${JSON.stringify(file)}, ${JSON.stringify(newBundle)}); + else process.exit(1); + `); + serve(); + await updateCommand.execute(mockConfig(), {}, {}); + assert.equal(readFileSync(file, 'utf8'), oldBundle); + assert.match(stderr, /pinned by Homebrew/); + rmSync(pin); + await updateCommand.execute(mockConfig(), {}, {}); + assert.equal(readFileSync(file, 'utf8'), newBundle); + assert.match(stderr, /Updating brew install/); + }); +});