diff --git a/skills/outline-cli/SKILL.md b/skills/outline-cli/SKILL.md index 7791794..fe716ae 100644 --- a/skills/outline-cli/SKILL.md +++ b/skills/outline-cli/SKILL.md @@ -82,6 +82,8 @@ ol auth logout # Clear saved credentials ```bash ol update # Update CLI to latest version ol update --check # Check for updates without installing, show channel +ol update --check --json # Check for updates as a JSON envelope +ol update --check --ndjson # Check for updates as NDJSON output ol update --channel # Show current update channel ol update switch --stable # Switch to stable release channel ol update switch --pre-release # Switch to pre-release (next) channel diff --git a/src/__tests__/update-surface.test.ts b/src/__tests__/update-surface.test.ts new file mode 100644 index 0000000..b91abe2 --- /dev/null +++ b/src/__tests__/update-surface.test.ts @@ -0,0 +1,41 @@ +import { Command } from 'commander' +import { describe, expect, it, vi } from 'vitest' +import { registerUpdateCommand } from '../commands/update/index.js' + +// Stub out config + spinner — this test only cares about the command surface +// (subcommand names + flags) wired up via the real cli-core, so a bump to +// cli-core can't silently change `ol update`'s public CLI shape. +vi.mock('../lib/config.js', () => ({ + getConfigPath: () => '/tmp/outline-cli-test/config.json', +})) + +vi.mock('../lib/spinner.js', () => ({ + withSpinner: vi.fn((_opts: unknown, fn: () => Promise) => fn()), +})) + +describe('ol update command surface (integration with real cli-core)', () => { + it('exposes `update` with --check and --channel flags', () => { + const program = new Command() + registerUpdateCommand(program) + + const update = program.commands.find((c) => c.name() === 'update') + expect(update).toBeDefined() + + const longs = update?.options.map((o) => o.long) ?? [] + expect(longs).toContain('--check') + expect(longs).toContain('--channel') + }) + + it('exposes `update switch` with --stable and --pre-release flags', () => { + const program = new Command() + registerUpdateCommand(program) + + const update = program.commands.find((c) => c.name() === 'update') + const switchCmd = update?.commands.find((c) => c.name() === 'switch') + expect(switchCmd).toBeDefined() + + const longs = switchCmd?.options.map((o) => o.long) ?? [] + expect(longs).toContain('--stable') + expect(longs).toContain('--pre-release') + }) +}) diff --git a/src/__tests__/update.test.ts b/src/__tests__/update.test.ts index ce85073..3ee2c0a 100644 --- a/src/__tests__/update.test.ts +++ b/src/__tests__/update.test.ts @@ -1,454 +1,45 @@ import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import packageJson from '../../package.json' with { type: 'json' } -// Mock child_process -vi.mock('node:child_process', () => ({ - spawn: vi.fn(), -})) +const registerCoreUpdateCommandMock = vi.fn() +const withSpinnerMock = vi.fn((_opts: unknown, fn: () => Promise) => fn()) -// Mock spinner — pass through to the callback -vi.mock('../lib/spinner.js', () => ({ - withSpinner: vi.fn((_opts: unknown, fn: () => Promise) => fn()), +vi.mock('@doist/cli-core/commands', () => ({ + registerUpdateCommand: registerCoreUpdateCommandMock, })) -// Mock fetchWithRetry to use global fetch (which we stub per-test) -vi.mock('../transport/fetch-with-retry.js', () => ({ - fetchWithRetry: vi.fn(({ url }: { url: string }) => fetch(url)), +vi.mock('../lib/config.js', () => ({ + getConfigPath: () => '/tmp/outline-cli-test/config.json', })) -// Mock update-config module -vi.mock('../lib/update-config.js', () => ({ - getUpdateChannel: vi.fn().mockReturnValue('stable'), - setUpdateChannel: vi.fn(), +vi.mock('../lib/spinner.js', () => ({ + withSpinner: withSpinnerMock, })) -import { spawn } from 'node:child_process' -import { registerUpdateCommand } from '../commands/update/index.js' -import { getUpdateChannel, setUpdateChannel } from '../lib/update-config.js' - -const mockSpawn = vi.mocked(spawn) -const mockGetUpdateChannel = vi.mocked(getUpdateChannel) -const mockSetUpdateChannel = vi.mocked(setUpdateChannel) - -function createProgram() { - const program = new Command() - program.exitOverride() - registerUpdateCommand(program) - return program -} - -function mockFetch(version: string) { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ version }), - }), - ) -} - -function mockFetchError(status: number) { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status, - }), - ) -} - -function mockFetchNetworkError(message: string) { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error(message))) -} - -function mockSpawnSuccess() { - mockSpawn.mockReturnValue({ - stderr: { - on: vi.fn(), - }, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - if (event === 'close') cb(0) - }), - } as never) -} - -function mockSpawnFailure(exitCode: number) { - mockSpawn.mockReturnValue({ - stderr: { - on: vi.fn(), - }, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - if (event === 'close') cb(exitCode) - }), - } as never) -} - -function mockSpawnPermissionError() { - mockSpawn.mockReturnValue({ - stderr: { - on: vi.fn((event: string, cb: (data: Buffer) => void) => { - if (event === 'data') cb(Buffer.from('npm ERR! code EACCES\n')) - }), - }, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - if (event === 'close') cb(243) - }), - } as never) -} - -describe('update command', () => { - let consoleSpy: ReturnType - let consoleErrorSpy: ReturnType - +describe('ol update wiring', () => { beforeEach(() => { - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - process.exitCode = undefined - mockGetUpdateChannel.mockReturnValue('stable') - mockSpawn.mockClear() - mockSetUpdateChannel.mockClear() + registerCoreUpdateCommandMock.mockClear() }) afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() - vi.unstubAllEnvs() - process.exitCode = undefined + vi.clearAllMocks() }) - describe('already up to date', () => { - it('prints up-to-date message when versions match', async () => { - const { - default: { version }, - } = await import('../../package.json') - mockFetch(version) - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(consoleSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Already up to date'), - ) - expect(mockSpawn).not.toHaveBeenCalled() - }) - }) - - describe('--check flag', () => { - it('shows version info without installing when update available', async () => { - mockFetch('99.99.99') - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', '--check']) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Update available')) - expect(mockSpawn).not.toHaveBeenCalled() - }) - - it('shows up-to-date message when already current', async () => { - const { - default: { version }, - } = await import('../../package.json') - mockFetch(version) - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', '--check']) - - expect(consoleSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Already up to date'), - ) - }) - - it('shows channel info', async () => { - mockFetch('99.99.99') - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', '--check']) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Channel:')) - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('stable')) - }) - - it('shows pre-release channel when configured', async () => { - mockGetUpdateChannel.mockReturnValue('pre-release') - mockFetch('1.5.0-next.1') - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', '--check']) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Channel:')) - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('pre-release')) - }) - }) - - describe('update available', () => { - it('spawns npm install and reports success', async () => { - mockFetch('99.99.99') - mockSpawnSuccess() - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(mockSpawn).toHaveBeenCalledWith( - 'npm', - ['install', '-g', '@doist/outline-cli@latest'], - { stdio: ['ignore', 'ignore', 'pipe'], shell: process.platform === 'win32' }, - ) - expect(consoleSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Updated to v99.99.99'), - ) - }) - - it('uses pnpm add when pnpm is detected', async () => { - mockFetch('99.99.99') - mockSpawnSuccess() - vi.stubEnv('npm_execpath', '/usr/local/lib/node_modules/pnpm/bin/pnpm.cjs') - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(mockSpawn).toHaveBeenCalledWith( - 'pnpm', - ['add', '-g', '@doist/outline-cli@latest'], - { stdio: ['ignore', 'ignore', 'pipe'], shell: process.platform === 'win32' }, - ) - }) - }) - - describe('registry errors', () => { - it('handles HTTP errors from registry', async () => { - mockFetchError(503) - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Failed to check for updates'), - ) - expect(process.exitCode).toBe(1) - }) - - it('handles network failures', async () => { - mockFetchNetworkError('getaddrinfo ENOTFOUND registry.npmjs.org') - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Failed to check for updates'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('install errors', () => { - it('suggests sudo on permission error', async () => { - mockFetch('99.99.99') - mockSpawnPermissionError() - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Permission denied'), - ) - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('sudo')) - expect(process.exitCode).toBe(1) - }) - - it('handles non-zero exit code from npm', async () => { - mockFetch('99.99.99') - mockSpawnFailure(1) - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('exited with code 1'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('pre-release channel', () => { - beforeEach(() => { - mockGetUpdateChannel.mockReturnValue('pre-release') - }) - - it('fetches from next registry URL', async () => { - mockFetch('1.5.0-next.1') - mockSpawnSuccess() - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/@doist/outline-cli/next') - }) - - it('installs with @next tag', async () => { - mockFetch('1.5.0-next.1') - mockSpawnSuccess() - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(mockSpawn).toHaveBeenCalledWith( - 'npm', - ['install', '-g', '@doist/outline-cli@next'], - { stdio: ['ignore', 'ignore', 'pipe'], shell: process.platform === 'win32' }, - ) - }) - - it('does not suggest ol changelog after pre-release update', async () => { - mockFetch('1.5.0-next.1') - mockSpawnSuccess() - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(consoleSpy).not.toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('ol changelog'), - expect.anything(), - ) - }) - - it('--check respects pre-release channel', async () => { - mockFetch('1.5.0-next.1') - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', '--check']) - - expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/@doist/outline-cli/next') - expect(mockSpawn).not.toHaveBeenCalled() - }) - - it('treats next.10 as newer than next.2 (multi-digit prerelease)', async () => { - mockFetch('1.6.0-next.10') - mockSpawnSuccess() - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Update available')) - expect(mockSpawn).toHaveBeenCalled() - }) - - it('warns but still installs when channel tag resolves to older version', async () => { - mockFetch('1.4.0-next.1') - mockSpawnSuccess() - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update']) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Downgrade available')) - expect(mockSpawn).toHaveBeenCalledWith( - 'npm', - ['install', '-g', '@doist/outline-cli@next'], - { stdio: ['ignore', 'ignore', 'pipe'], shell: process.platform === 'win32' }, - ) - }) - }) - - describe('switch subcommand', () => { - it('sets channel to stable', async () => { - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', 'switch', '--stable']) - - expect(mockSetUpdateChannel).toHaveBeenCalledWith('stable') - expect(consoleSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('stable'), - ) - }) - - it('sets channel to pre-release with warning', async () => { - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', 'switch', '--pre-release']) - - expect(mockSetUpdateChannel).toHaveBeenCalledWith('pre-release') - expect(consoleSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('pre-release'), - ) - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Remember to switch back'), - ) - }) - - it('errors when both flags provided', async () => { - const program = createProgram() - await program.parseAsync([ - 'node', - 'ol', - 'update', - 'switch', - '--stable', - '--pre-release', - ]) - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('not both'), - ) - expect(process.exitCode).toBe(1) - }) - - it('errors when no flag provided', async () => { - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', 'switch']) - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('--stable or --pre-release'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('--channel flag', () => { - it('shows stable when no config set', async () => { - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', '--channel']) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('stable')) - expect(mockSpawn).not.toHaveBeenCalled() - }) - - it('shows pre-release when configured', async () => { - mockGetUpdateChannel.mockReturnValue('pre-release') - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', '--channel']) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('pre-release')) - expect(mockSpawn).not.toHaveBeenCalled() - }) - - it('does not fetch from registry', async () => { - mockFetch('99.99.99') - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', '--channel']) - - expect(fetch).not.toHaveBeenCalled() - }) - - it('errors when combined with --check', async () => { - const program = createProgram() - await program.parseAsync(['node', 'ol', 'update', '--check', '--channel']) + it('forwards packageName, currentVersion, configPath, changelogCommandName, withSpinner', async () => { + const { registerUpdateCommand } = await import('../commands/update/index.js') + const program = new Command() + registerUpdateCommand(program) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('not both'), - ) - expect(process.exitCode).toBe(1) + expect(registerCoreUpdateCommandMock).toHaveBeenCalledTimes(1) + const [passedProgram, options] = registerCoreUpdateCommandMock.mock.calls[0]! + expect(passedProgram).toBe(program) + expect(options).toMatchObject({ + packageName: '@doist/outline-cli', + configPath: '/tmp/outline-cli-test/config.json', + changelogCommandName: 'ol changelog', }) + expect(options.currentVersion).toBe(packageJson.version) + expect(options.withSpinner).toBe(withSpinnerMock) }) }) diff --git a/src/commands/update/action.ts b/src/commands/update/action.ts deleted file mode 100644 index 7a5975c..0000000 --- a/src/commands/update/action.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { spawn } from 'node:child_process' -import chalk from 'chalk' -import packageJson from '../../../package.json' with { type: 'json' } -import { withSpinner } from '../../lib/spinner.js' -import { getUpdateChannel, type UpdateChannel } from '../../lib/update-config.js' -import { fetchWithRetry } from '../../transport/fetch-with-retry.js' - -const PACKAGE_NAME = '@doist/outline-cli' - -interface RegistryResponse { - version: string -} - -function getInstallTag(channel: UpdateChannel): string { - return channel === 'pre-release' ? 'next' : 'latest' -} - -interface ParsedVersion { - major: number - minor: number - patch: number - prerelease: string | undefined -} - -function parseVersion(version: string): ParsedVersion { - const [core, ...rest] = version.split('-') - const [major, minor, patch] = core.split('.').map(Number) - return { major, minor, patch, prerelease: rest.length > 0 ? rest.join('-') : undefined } -} - -/** Returns true when `candidate` is strictly newer than `current` per semver. */ -function isNewer(current: string, candidate: string): boolean { - const a = parseVersion(current) - const b = parseVersion(candidate) - - // Compare major.minor.patch - for (const key of ['major', 'minor', 'patch'] as const) { - if (b[key] !== a[key]) return b[key] > a[key] - } - - // Equal core: release > pre-release - if (!a.prerelease && b.prerelease) return false - if (a.prerelease && !b.prerelease) return true - - // Both pre-release: numeric-aware comparison (handles "next.10" > "next.2" etc.) - if (a.prerelease && b.prerelease) - return b.prerelease.localeCompare(a.prerelease, undefined, { numeric: true }) > 0 - return false -} - -async function fetchVersion(channel: UpdateChannel): Promise { - const tag = getInstallTag(channel) - const url = `https://registry.npmjs.org/${PACKAGE_NAME}/${tag}` - const response = await fetchWithRetry({ url }) - if (!response.ok) { - throw new Error(`Registry request failed (HTTP ${response.status})`) - } - const data = (await response.json()) as RegistryResponse - return data.version -} - -function detectPackageManager(): string { - const execPath = process.env.npm_execpath || process.argv[1] || '' - if (execPath.includes('pnpm')) return 'pnpm' - return 'npm' -} - -function runInstall(pm: string, tag: string): Promise<{ exitCode: number; stderr: string }> { - const command = pm === 'pnpm' ? 'add' : 'install' - return new Promise((resolve, reject) => { - const child = spawn(pm, [command, '-g', `${PACKAGE_NAME}@${tag}`], { - stdio: ['ignore', 'ignore', 'pipe'], - shell: process.platform === 'win32', - }) - - let stderr = '' - child.stderr?.on('data', (data: Buffer) => { - stderr += data.toString() - }) - - child.on('error', reject) - child.on('close', (code) => resolve({ exitCode: code ?? 1, stderr })) - }) -} - -function channelLabel(channel: UpdateChannel): string { - return channel === 'pre-release' ? ` ${chalk.magenta('(pre-release)')}` : '' -} - -export async function updateAction(options: { check?: boolean; channel?: boolean }): Promise { - if (options.check && options.channel) { - console.error(chalk.red('Error:'), 'Specify either --check or --channel, not both.') - process.exitCode = 1 - return - } - - if (options.channel) { - const ch = await getUpdateChannel() - if (ch === 'pre-release') { - console.log(`Update channel: ${chalk.magenta('pre-release')}`) - } else { - console.log(`Update channel: ${chalk.green('stable')}`) - } - return - } - - const channel = await getUpdateChannel() - const tag = getInstallTag(channel) - const label = channelLabel(channel) - - const currentVersion = packageJson.version - - let latestVersion: string - try { - latestVersion = await withSpinner( - { text: `Checking for updates${label}...`, color: 'blue' }, - () => fetchVersion(channel), - ) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(chalk.red('Error:'), `Failed to check for updates: ${message}`) - process.exitCode = 1 - return - } - - const updateAvailable = isNewer(currentVersion, latestVersion) - - if (options.check) { - const channelLine = - channel === 'pre-release' - ? ` Channel: ${chalk.magenta('pre-release')}` - : ` Channel: ${chalk.green('stable')}` - - if (currentVersion === latestVersion) { - console.log(chalk.green('✓'), `Already up to date (v${currentVersion})`) - } else if (updateAvailable) { - console.log( - `Update available: ${chalk.dim(`v${currentVersion}`)} → ${chalk.green(`v${latestVersion}`)}`, - ) - } else { - console.log( - `Downgrade available: ${chalk.dim(`v${currentVersion}`)} → ${chalk.yellow(`v${latestVersion}`)}`, - ) - } - console.log(channelLine) - return - } - - if (currentVersion === latestVersion) { - console.log(chalk.green('✓'), `Already up to date${label} (v${currentVersion})`) - return - } - - if (updateAvailable) { - console.log( - `Update available${label}: ${chalk.dim(`v${currentVersion}`)} → ${chalk.green(`v${latestVersion}`)}`, - ) - } else { - console.log( - `Downgrade available${label}: ${chalk.dim(`v${currentVersion}`)} → ${chalk.yellow(`v${latestVersion}`)}`, - ) - } - - const pm = detectPackageManager() - - let result: { exitCode: number; stderr: string } - try { - result = await withSpinner( - { text: `Updating to v${latestVersion}${label}...`, color: 'blue' }, - () => runInstall(pm, tag), - ) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(chalk.red('Error:'), `Install failed: ${message}`) - process.exitCode = 1 - return - } - - if (result.exitCode !== 0) { - if ( - result.stderr && - (result.stderr.includes('EACCES') || result.stderr.includes('EPERM')) - ) { - console.error(chalk.red('Error:'), 'Permission denied. Try running with sudo:') - console.error( - chalk.dim( - ` sudo ${pm} ${pm === 'pnpm' ? 'add' : 'install'} -g ${PACKAGE_NAME}@${tag}`, - ), - ) - } else { - console.error(chalk.red('Error:'), `${pm} exited with code ${result.exitCode}`) - if (result.stderr) { - console.error(chalk.dim(result.stderr.trim())) - } - } - process.exitCode = 1 - return - } - - console.log(chalk.green('✓'), `Updated to v${latestVersion}${label}`) - if (channel === 'stable') { - console.log( - chalk.dim(' Run'), - chalk.cyan('ol changelog'), - chalk.dim('to see what changed'), - ) - } -} diff --git a/src/commands/update/index.ts b/src/commands/update/index.ts index b0e56c4..a88f809 100644 --- a/src/commands/update/index.ts +++ b/src/commands/update/index.ts @@ -1,19 +1,15 @@ -import { Command } from 'commander' -import { updateAction } from './action.js' -import { switchChannel } from './switch.js' +import { registerUpdateCommand as registerCoreUpdateCommand } from '@doist/cli-core/commands' +import type { Command } from 'commander' +import packageJson from '../../../package.json' with { type: 'json' } +import { getConfigPath } from '../../lib/config.js' +import { withSpinner } from '../../lib/spinner.js' export function registerUpdateCommand(program: Command): void { - const update = program - .command('update') - .description('Update the CLI to the latest version for the configured channel') - .option('--check', 'Check for updates without installing') - .option('--channel', 'Show the current update channel') - .action(updateAction) - - update - .command('switch') - .description('Switch update channel between stable and pre-release') - .option('--stable', 'Use the stable release channel') - .option('--pre-release', 'Use the pre-release (next) channel') - .action(switchChannel) + registerCoreUpdateCommand(program, { + packageName: packageJson.name, + currentVersion: packageJson.version, + configPath: getConfigPath(), + changelogCommandName: 'ol changelog', + withSpinner, + }) } diff --git a/src/commands/update/switch.ts b/src/commands/update/switch.ts deleted file mode 100644 index 40ebdb1..0000000 --- a/src/commands/update/switch.ts +++ /dev/null @@ -1,40 +0,0 @@ -import chalk from 'chalk' -import { setUpdateChannel, type UpdateChannel } from '../../lib/update-config.js' - -export async function switchChannel(options: { - stable?: boolean - preRelease?: boolean -}): Promise { - if (options.stable && options.preRelease) { - console.error(chalk.red('Error:'), 'Specify either --stable or --pre-release, not both.') - process.exitCode = 1 - return - } - - if (!options.stable && !options.preRelease) { - console.error(chalk.red('Error:'), 'Specify --stable or --pre-release.') - process.exitCode = 1 - return - } - - const channel: UpdateChannel = options.preRelease ? 'pre-release' : 'stable' - - await setUpdateChannel(channel) - - if (channel === 'pre-release') { - console.log(chalk.green('✓'), `Update channel set to ${chalk.magenta('pre-release')}`) - console.log() - console.log( - chalk.yellow('Note:'), - 'Pre-release updates follow the', - chalk.cyan('next'), - 'branch.', - ) - console.log('When pre-release changes are merged into a stable release, no further') - console.log('pre-release updates will be published until a new pre-release cycle begins.') - console.log('Remember to switch back to stable when done:') - console.log(chalk.dim(' ol update switch --stable')) - } else { - console.log(chalk.green('✓'), 'Update channel set to stable') - } -} diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 95b3e4b..772c967 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -81,6 +81,8 @@ ol auth logout # Clear saved credentials \`\`\`bash ol update # Update CLI to latest version ol update --check # Check for updates without installing, show channel +ol update --check --json # Check for updates as a JSON envelope +ol update --check --ndjson # Check for updates as NDJSON output ol update --channel # Show current update channel ol update switch --stable # Switch to stable release channel ol update switch --pre-release # Switch to pre-release (next) channel diff --git a/src/lib/update-config.ts b/src/lib/update-config.ts deleted file mode 100644 index 1694603..0000000 --- a/src/lib/update-config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getConfig, updateConfig } from './config.js' - -export type UpdateChannel = 'stable' | 'pre-release' - -export async function getUpdateChannel(): Promise { - const config = await getConfig() - return config.update_channel ?? 'stable' -} - -export async function setUpdateChannel(channel: UpdateChannel): Promise { - await updateConfig({ update_channel: channel }) -}