diff --git a/package.json b/package.json index 2d54ffa..6833517 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "scripts": { "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finalize-build.mjs", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.cjs.json --noEmit", - "test": "node --test tests/feedback-*.test.mjs", + "test": "node --test tests/feedback-*.test.mjs tests/update.test.mjs", "ci:generate": "hollywood generate 'ci/**/*.ts' --source-root ci --output .", "test:package": "node scripts/verify-staging-package.mjs" }, diff --git a/scripts/verify-staging-package.mjs b/scripts/verify-staging-package.mjs index 697b6bd..ef22409 100644 --- a/scripts/verify-staging-package.mjs +++ b/scripts/verify-staging-package.mjs @@ -43,11 +43,11 @@ try { [ '--test', ...readdirSync('tests') - .filter((name) => /^feedback-.*\.test\.mjs$/.test(name)) + .filter((name) => /^feedback-.*\.test\.mjs$/.test(name) || name === 'update.test.mjs') .map((name) => join('tests', name)), ], { - env: { ...process.env, FEEDBACK_PACKAGE_ROOT: installed }, + env: { ...process.env, FEEDBACK_PACKAGE_ROOT: installed, UPDATE_PACKAGE_ROOT: installed }, stdio: 'inherit', }, ); diff --git a/src/custom/README.md b/src/custom/README.md index de39bee..09d1446 100644 --- a/src/custom/README.md +++ b/src/custom/README.md @@ -22,3 +22,20 @@ node --test tests/machine-aliases.test.mjs node --test tests/ssh-paths.test.mjs python3 tests/ssh-picker-pty.py ``` + +# Update command + +`dedalus update --check` reports the current and latest stable GitHub release +without probing or modifying the installation. `dedalus update` upgrades a +verified global npm installation to that exact version, or upgrades a verified +Homebrew formula after checking that Homebrew publishes the same version. +A GitHub release that has not reached the package manager does not authorize +an upgrade. Equal or older releases never trigger an installation. + +Standalone downloads, Homebrew casks, local npm installs, Windows installs and +unrecognized layouts receive manual update instructions. No removed installer +scripts are invoked, and the Node executable is never replaced. Startup update +prompts are not included. Development and prerelease versions require an +explicit update through the original package manager. + +Run `npm run build && node --test tests/update.test.mjs` to check this contract. diff --git a/src/custom/machines.ts b/src/custom/machines.ts index 8341b8d..481beb2 100644 --- a/src/custom/machines.ts +++ b/src/custom/machines.ts @@ -1,6 +1,7 @@ /** SSH selection that shares the CLI's authentication and error settings. */ import { Command, Option } from 'commander' +import { registerUpdateCommand } from './update.js' import SDK from '../sdk/index.js' import type { CliAuthDefinition } from '../cli/login.js' import { @@ -62,7 +63,7 @@ export const addMachineAliases = ( }, options.auth) }) program.addCommand(ssh) - return program + return registerUpdateCommand(program) } const aliasCommand = (program: Command, name: string): Command => { diff --git a/src/custom/update.ts b/src/custom/update.ts new file mode 100644 index 0000000..c56621d --- /dev/null +++ b/src/custom/update.ts @@ -0,0 +1,119 @@ +/** Updates only installations whose package manager ownership can be proved. */ +import { execFileSync, spawnSync } from 'node:child_process' +import { realpathSync } from 'node:fs' +import { join, relative, isAbsolute } from 'node:path' +import { Command } from 'commander' + +const latestURL = 'https://api.github.com/repos/dedalus-labs/dedalus-cli/releases/latest' + +type Effects = { + current: string + entry: string + executable: string + platform: string + latest: () => Promise + realpath: (path: string) => string + output: (name: string, args: string[]) => string + run: (name: string, args: string[]) => void + print: (message: string) => void +} + +// Only stable release numbers authorize an installation change. Development and +// prerelease builds need an explicit package-manager command from their owner. +export const newerStable = (candidate: string, current: string): boolean => { + const parse = (value: string) => { + if (!/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.test(value)) return undefined + return value.replace(/^v/u, '').split('.').map(BigInt) + } + const next = parse(candidate) + const previous = parse(current) + if (!next || !previous) throw new Error('Cannot safely compare release versions; update with your original package manager.') + for (let i = 0; i < 3; i++) { + if (next[i]! !== previous[i]!) return next[i]! > previous[i]! + } + return false +} + +const within = (path: string, directory: string): boolean => { + const child = relative(directory, path) + return child !== '' && child !== '..' && !child.startsWith('../') && !child.startsWith('..\\') && !isAbsolute(child) +} + +export const updateCLI = async (check: boolean, effects: Effects): Promise => { + const latest = await effects.latest() + effects.print(`Current version: ${effects.current}\nLatest version: ${latest}`) + const newer = newerStable(latest, effects.current) + if (!newer) { + effects.print('The installed version is current or newer than the latest stable release.') + return + } + if (check) return + const version = latest.replace(/^v/u, '') + const query = (name: string, args: string[]): string | undefined => { + try { return effects.output(name, args).trim() || undefined } catch { return undefined } + } + const resolve = (path: string): string | undefined => { + try { return effects.realpath(path) } catch { return undefined } + } + const executable = resolve(effects.executable) + const entry = resolve(effects.entry) + + // A Node launch updates the package, never process.execPath (the Node runtime). + if (entry && effects.platform !== 'win32') { + const root = query('npm', ['root', '--global']) + const installedEntry = root && resolve(join(root, 'dedalus-cli', 'dist', 'esm', 'bin.js')) + if (installedEntry === entry) { + const published = query('npm', ['view', `dedalus-cli@${version}`, 'version']) + if (published !== version) throw new Error(`Release ${version} is not available from npm yet; no changes made.`) + effects.print(`Running: npm install --global dedalus-cli@${version}`) + effects.run('npm', ['install', '--global', `dedalus-cli@${version}`]) + return + } + } + if (executable && effects.platform !== 'win32') { + const formula = query('brew', ['--prefix', 'dedalus']) + const prefix = formula && resolve(formula) + if (prefix && within(executable, prefix)) { + effects.run('brew', ['update']) + const info = query('brew', ['info', '--json=v2', '--formula', 'dedalus']) + const available: unknown = info ? JSON.parse(info) : undefined + const formulaVersion = (available as { formulae?: { versions?: { stable?: string } }[] } | undefined)?.formulae?.[0]?.versions?.stable + if (formulaVersion !== version) throw new Error(`Homebrew has not published ${version}; no package upgrade performed.`) + effects.print('Running: brew upgrade --formula dedalus') + effects.run('brew', ['upgrade', '--formula', 'dedalus']) + return + } + } + effects.print('Automatic update is unavailable for this installation. Update with the package manager you originally used. For standalone binaries, download the matching release at https://github.com/dedalus-labs/dedalus-cli/releases/latest and replace the CLI after it exits.') +} + +export const registerUpdateCommand = (program: Command): Command => { + program.command('update').description('Update the CLI using its owning package manager') + .option('--check', 'Check for a newer stable release without installing') + .action(async (flags: { check?: boolean }, command: Command) => { + try { + await updateCLI(Boolean(flags.check), { + current: program.version() ?? '', entry: process.argv[1] ?? '', + executable: process.execPath, platform: process.platform, + latest: async () => { + const response = await fetch(latestURL, { signal: AbortSignal.timeout(10_000), headers: { Accept: 'application/vnd.github+json' } }) + if (!response.ok) throw new Error(`Release check failed: HTTP ${response.status}`) + const release: unknown = await response.json() + if (typeof release !== 'object' || release === null || !('tag_name' in release) || typeof release.tag_name !== 'string') throw new Error('Release response is missing tag_name') + return release.tag_name + }, + realpath: realpathSync, + output: (name, args) => execFileSync(name, args, { encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'ignore'] }), + run: (name, args) => { + const result = spawnSync(name, args, { stdio: 'inherit' }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`${name} failed${result.signal ? ` (${result.signal})` : ` (exit ${result.status})`}`) + }, + print: (message) => console.log(message), + }) + } catch (error) { + command.error(error instanceof Error ? error.message : String(error), { exitCode: 1 }) + } + }) + return program +} diff --git a/tests/update.test.mjs b/tests/update.test.mjs new file mode 100644 index 0000000..55be87f --- /dev/null +++ b/tests/update.test.mjs @@ -0,0 +1,98 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const packageRoot = process.env.UPDATE_PACKAGE_ROOT ?? '.' +const { newerStable, updateCLI } = await import(pathToFileURL(resolve(packageRoot, 'dist/esm/custom/update.js')).href) +const { getProgram } = await import(pathToFileURL(resolve(packageRoot, 'dist/esm/index.js')).href) + +const fixture = (overrides = {}) => { + const runs = [] + const queries = [] + const effects = { + current: '0.7.0', entry: '/global/dedalus-cli/dist/esm/bin.js', executable: '/usr/bin/node', platform: 'linux', + latest: async () => 'v0.8.0', realpath: (path) => path, + output: (name, args) => { + queries.push([name, args]) + if (name === 'npm' && args[0] === 'root') return '/global' + if (name === 'npm' && args[0] === 'view') return '0.8.0' + throw new Error('not installed') + }, + run: (name, args) => runs.push([name, args]), print: () => {}, ...overrides, + } + return { effects, runs, queries } +} + +test('invariant check mode never probes or mutates an installation', async () => { + const { effects, runs, queries } = fixture({ realpath: () => { throw new Error('must not resolve') } }) + await updateCLI(true, effects) + assert.deepEqual(runs, []) + assert.deepEqual(queries, []) +}) + +test('invariant equal or older releases never install', async () => { + for (const current of ['0.8.0', '0.9.0', '1.0.0']) { + const { effects, runs, queries } = fixture({ current }) + await updateCLI(false, effects) + assert.deepEqual(runs, []) + assert.deepEqual(queries, []) + } +}) + +test('invariant global npm updates pin the release and never replace Node', async () => { + const { effects, runs } = fixture() + await updateCLI(false, effects) + assert.deepEqual(runs, [['npm', ['install', '--global', 'dedalus-cli@0.8.0']]]) +}) + +test('invariant missing npm publication leaves the installation unchanged', async () => { + const { effects, runs } = fixture({ output: (_name, args) => args[0] === 'root' ? '/global' : '0.6.0' }) + await assert.rejects(updateCLI(false, effects), /not available from npm/) + assert.deepEqual(runs, []) +}) + +test('invariant local, unknown and Windows installations are never mutated', async () => { + for (const overrides of [{ entry: '/project/node_modules/dedalus-cli/dist/esm/bin.js' }, { entry: '/standalone/dedalus', executable: '/standalone/dedalus' }, { platform: 'win32' }]) { + const { effects, runs } = fixture(overrides) + await updateCLI(false, effects) + assert.deepEqual(runs, []) + } +}) + +test('invariant Homebrew ownership and publication must both match before upgrade', async () => { + for (const [executable, published, upgrade] of [['/brew/Cellar/dedalus/0.7.0/bin/dedalus', '0.8.0', true], ['/brew/Cellar/dedalus-other/bin/dedalus', '0.8.0', false], ['/brew/Cellar/dedalus/0.7.0/bin/dedalus', '0.6.0', false]]) { + const { effects, runs } = fixture({ entry: '/standalone/dedalus', executable, + output: (name, args) => { + if (name !== 'brew') throw new Error('unavailable') + return args[0] === '--prefix' ? '/brew/Cellar/dedalus/0.7.0' : JSON.stringify({ formulae: [{ versions: { stable: published } }] }) + }, + }) + if (published === '0.6.0') await assert.rejects(updateCLI(false, effects), /has not published/) + else await updateCLI(false, effects) + assert.equal(runs.some(([, args]) => args[0] === 'upgrade'), upgrade) + } +}) + +test('invariant unrecognized versions cannot authorize installation', () => { + for (const value of ['dev', '0.8.0-rc.1', '0.8', '0.8.0; echo unsafe']) assert.throws(() => newerStable(value, '0.7.0')) + assert.equal(newerStable('0.10.0', '0.9.0'), true) +}) + +test('design update is registered with a read-only check option', () => { + const update = getProgram().commands.find((command) => command.name() === 'update') + assert.ok(update) + assert.ok(update.options.some((option) => option.long === '--check')) +}) + +test('invariant release lookup failure never touches an installation', async () => { + const { effects, queries, runs } = fixture({ latest: async () => { throw new Error('offline') } }) + await assert.rejects(updateCLI(false, effects), /offline/) + assert.deepEqual(queries, []) + assert.deepEqual(runs, []) +}) + +test('invariant installer failure propagates to the caller', async () => { + const { effects } = fixture({ run: () => { throw new Error('permission denied') } }) + await assert.rejects(updateCLI(false, effects), /permission denied/) +})