Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions scripts/verify-staging-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
);
Expand Down
17 changes: 17 additions & 0 deletions src/custom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion src/custom/machines.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -62,7 +63,7 @@ export const addMachineAliases = (
}, options.auth)
})
program.addCommand(ssh)
return program
return registerUpdateCommand(program)
}

const aliasCommand = (program: Command, name: string): Command => {
Expand Down
119 changes: 119 additions & 0 deletions src/custom/update.ts
Original file line number Diff line number Diff line change
@@ -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<string>
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<void> => {
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
}
98 changes: 98 additions & 0 deletions tests/update.test.mjs
Original file line number Diff line number Diff line change
@@ -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/)
})