Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,12 @@ registerUpdateCommand(program, {
currentVersion: packageJson.version,
configPath: getConfigPath('todoist-cli'),
changelogCommandName: 'td changelog',
brewFormula: 'doist/tap/todoist-cli',
withSpinner,
})
```

`update` checks the configured channel's npm dist-tag (`stable` → `latest`, `pre-release` → `next`), compares against `currentVersion`, and shells out to `npm i -g` (or `pnpm add -g` if `npm_execpath` indicates pnpm). `update switch --stable | --pre-release` flips the persisted `update_channel` field via `updateConfig`, preserving any sibling keys. Both subcommands accept `--json` / `--ndjson`. Errors are `CliError` (`INVALID_FLAGS`, `UPDATE_CHECK_FAILED`, `UPDATE_INSTALL_FAILED`, or the canonical `CONFIG_*` codes if the config file is broken).
`update` checks the configured channel's npm dist-tag (`stable` → `latest`, `pre-release` → `next`), compares against `currentVersion`, and shells out to `npm i -g` (or `pnpm add -g` if `npm_execpath` indicates pnpm). When the CLI was installed via Homebrew (its binary resolves into a brew `Cellar`), it runs `brew upgrade <brewFormula>` instead — set `brewFormula` on brew-distributed CLIs (the brew formula may lag the npm publish, so an upgrade can be a no-op until the formula is bumped). `update switch --stable | --pre-release` flips the persisted `update_channel` field via `updateConfig`, preserving any sibling keys. Both subcommands accept `--json` / `--ndjson`. Errors are `CliError` (`INVALID_FLAGS`, `UPDATE_CHECK_FAILED`, `UPDATE_INSTALL_FAILED`, or the canonical `CONFIG_*` codes if the config file is broken).

The semver helpers (`parseVersion`, `compareVersions`, `isNewer`, `getInstallTag`, `fetchLatestVersion`, `getConfiguredUpdateChannel`) are also exported for ad-hoc use outside the registered command.

Expand Down
166 changes: 164 additions & 2 deletions src/commands/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {

vi.mock('node:child_process', () => ({ spawn: vi.fn() }))

vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return { ...actual, realpathSync: vi.fn(actual.realpathSync) }
})

vi.mock('../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config.js')>()
return {
Expand All @@ -23,8 +28,10 @@ vi.mock('../config.js', async (importOriginal) => {
})

const { spawn } = await import('node:child_process')
const { realpathSync } = await import('node:fs')
const config = await import('../config.js')
const mockSpawn = vi.mocked(spawn)
const mockRealpathSync = vi.mocked(realpathSync)
const mockReadConfigOrThrow = vi.mocked(config.readConfigOrThrow)
const mockUpdateConfigOrThrow = vi.mocked(config.updateConfigOrThrow)

Expand All @@ -35,10 +42,10 @@ const BASE_OPTIONS: UpdateCommandOptions = {
changelogCommandName: 'td changelog',
}

function createProgram(): Command {
function createProgram(overrides?: Partial<UpdateCommandOptions>): Command {
const program = new Command()
program.name('td').exitOverride()
registerUpdateCommand(program, BASE_OPTIONS)
registerUpdateCommand(program, { ...BASE_OPTIONS, ...overrides })
return program
}

Expand Down Expand Up @@ -79,6 +86,9 @@ beforeEach(() => {
mockReadConfigOrThrow.mockReset().mockResolvedValue({})
mockUpdateConfigOrThrow.mockReset().mockResolvedValue(undefined)
mockSpawn.mockClear()
// Identity by default → resolved path has no `/Cellar/`, so the npm/pnpm
// cases stay brew-negative. Brew tests override this per-test.
mockRealpathSync.mockReset().mockImplementation((p) => String(p))
})

afterEach(() => {
Expand Down Expand Up @@ -257,8 +267,160 @@ describe('update install flow', () => {
latestVersion: '99.99.99',
channel: 'stable',
installed: true,
via: 'npm',
})
})
})

describe('update brew install flow', () => {
Comment thread
scottlovegrove marked this conversation as resolved.
const CELLAR_PATH = '/opt/homebrew/Cellar/todoist-cli/1.1.0/bin/td'
const FORMULA = 'doist/tap/todoist-cli'
const realPlatform = process.platform

// `brew upgrade` exits `upgradeExit`; the follow-up `brew list --versions`
// reports `listedVersion` on stdout (used to derive the installed result).
function mockBrew({
upgradeExit = 0,
listedVersion,
}: { upgradeExit?: number; listedVersion?: string } = {}) {
mockSpawn.mockImplementation(((_cmd: string, args: readonly string[]) => ({
stdout: {
on: vi.fn((event: string, cb: (chunk: Buffer) => void) => {
if (event === 'data' && args[0] === 'list' && listedVersion) {
cb(Buffer.from(`todoist-cli ${listedVersion}\n`))
}
}),
},
stderr: { on: vi.fn() },
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
if (event === 'close') cb(args[0] === 'list' ? 0 : upgradeExit)
}),
})) as never)
}

beforeEach(() => {
// isBrewInstall() short-circuits on win32, bypassing the realpathSync
// mock; pin a posix platform so these tests are deterministic on any host.
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
})

afterEach(() => {
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true })
})

it('runs `brew upgrade <formula>` with inherited stdio and a neutral success line', async () => {
mockRealpathSync.mockReturnValue(CELLAR_PATH)
mockFetchOk('99.99.99')
mockBrew({ listedVersion: '1.1.0' })
await createProgram({ brewFormula: FORMULA }).parseAsync(['node', 'td', 'update'])
expect(mockSpawn).toHaveBeenCalledWith('brew', ['upgrade', FORMULA], { stdio: 'inherit' })
// The success line must not claim the npm dist-tag version was installed
// (the pre-install headline may still mention it as the available target).
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('brew upgrade complete'))
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Updated to v'))
})

it.each([
['version changed → installed', '1.1.0', true],
['no-op (formula lags npm) → not installed', '1.0.0', false],
])(
'derives installed from the on-disk brew version, keeping a stable --json schema: %s',
async (_, listedVersion, installed) => {
mockRealpathSync.mockReturnValue(CELLAR_PATH)
mockFetchOk('99.99.99')
mockBrew({ listedVersion })
await createProgram({ brewFormula: FORMULA }).parseAsync([
'node',
'td',
'update',
'--json',
])
// stdout piped (not inherited) under --json so brew can't corrupt the stream.
expect(mockSpawn).toHaveBeenCalledWith('brew', ['upgrade', FORMULA], {
stdio: ['ignore', 'ignore', 'pipe'],
})
const payloads = consoleSpy.mock.calls.map((call: unknown[]) =>
JSON.parse(call[0] as string),
)
expect(payloads).toContainEqual({
currentVersion: '1.0.0',
latestVersion: '99.99.99',
channel: 'stable',
installed,
via: 'brew',
installedVersion: listedVersion,
})
},
)

it('throws UPDATE_INSTALL_FAILED on a non-zero brew exit', async () => {
mockRealpathSync.mockReturnValue(CELLAR_PATH)
mockFetchOk('99.99.99')
mockBrew({ upgradeExit: 1 })
await expect(
createProgram({ brewFormula: FORMULA }).parseAsync(['node', 'td', 'update']),
).rejects.toMatchObject({
code: 'UPDATE_INSTALL_FAILED',
message: expect.stringContaining('brew exited with code 1'),
})
})

it('fails fast (before any registry call) when brew-installed without a formula', async () => {
mockRealpathSync.mockReturnValue(CELLAR_PATH)
mockFetchOk('99.99.99')
await expect(createProgram().parseAsync(['node', 'td', 'update'])).rejects.toMatchObject({
code: 'UPDATE_INSTALL_FAILED',
hints: [expect.stringContaining('brew upgrade')],
})
expect(fetch).not.toHaveBeenCalled()
expect(mockSpawn).not.toHaveBeenCalled()
})

it('uses npm/pnpm (not brew) when a formula is set but the install is not brew-managed', async () => {
// realpathSync defaults to identity (no `/Cellar/`), so this is a plain
// npm global install even though brewFormula is configured.
mockFetchOk('99.99.99')
mockSpawnExit()
await createProgram({ brewFormula: FORMULA }).parseAsync(['node', 'td', 'update'])
expect(mockSpawn).toHaveBeenCalledWith(
'npm',
['install', '-g', '@doist/todoist-cli@latest'],
{
stdio: ['ignore', 'ignore', 'pipe'],
shell: false,
},
)
expect(mockSpawn).not.toHaveBeenCalledWith('brew', expect.anything(), expect.anything())
})

it.each([
['interactive: no install spinner', [] as string[], false],
['--json: install spinner (stdout silenced)', ['--json'], true],
])(
'threads the install spinner for brew only when quiet (%s)',
async (_, flags, expectSpinner) => {
mockRealpathSync.mockReturnValue(CELLAR_PATH)
mockFetchOk('99.99.99')
mockBrew({ listedVersion: '1.1.0' })
const withSpinner = vi.fn((_opts: SpinnerOptions, op: () => Promise<unknown>) => op())
const program = new Command()
program.name('td').exitOverride()
registerUpdateCommand(program, {
...BASE_OPTIONS,
brewFormula: FORMULA,
withSpinner: withSpinner as unknown as UpdateCommandOptions['withSpinner'],
})
await program.parseAsync(['node', 'td', 'update', ...flags])
const installSpinner = expect.objectContaining({
text: expect.stringContaining('Updating to v'),
})
if (expectSpinner) {
expect(withSpinner).toHaveBeenCalledWith(installSpinner, expect.any(Function))
} else {
expect(withSpinner).not.toHaveBeenCalledWith(installSpinner, expect.any(Function))
}
},
)
})

describe('update error paths', () => {
Expand Down
Loading
Loading