Skip to content

Commit 3f9fe80

Browse files
feat: add td update self-update command (#88)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f5968cb commit 3f9fe80

File tree

4 files changed

+332
-0
lines changed

4 files changed

+332
-0
lines changed

src/__tests__/update.test.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { Command } from 'commander'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
// Mock child_process
5+
vi.mock('node:child_process', () => ({
6+
spawn: vi.fn(),
7+
}))
8+
9+
// Mock chalk to avoid colors in tests
10+
vi.mock('chalk', () => ({
11+
default: {
12+
green: vi.fn((text: string) => text),
13+
red: vi.fn((text: string) => text),
14+
dim: vi.fn((text: string) => text),
15+
},
16+
}))
17+
18+
// Mock spinner — pass through to the callback
19+
vi.mock('../lib/spinner.js', () => ({
20+
withSpinner: vi.fn((_opts: unknown, fn: () => Promise<unknown>) => fn()),
21+
}))
22+
23+
import { spawn } from 'node:child_process'
24+
import { registerUpdateCommand } from '../commands/update.js'
25+
26+
const mockSpawn = vi.mocked(spawn)
27+
28+
function createProgram() {
29+
const program = new Command()
30+
program.exitOverride()
31+
registerUpdateCommand(program)
32+
return program
33+
}
34+
35+
function mockFetch(version: string) {
36+
vi.stubGlobal(
37+
'fetch',
38+
vi.fn().mockResolvedValue({
39+
ok: true,
40+
json: () => Promise.resolve({ version }),
41+
}),
42+
)
43+
}
44+
45+
function mockFetchError(status: number) {
46+
vi.stubGlobal(
47+
'fetch',
48+
vi.fn().mockResolvedValue({
49+
ok: false,
50+
status,
51+
}),
52+
)
53+
}
54+
55+
function mockFetchNetworkError(message: string) {
56+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error(message)))
57+
}
58+
59+
function mockSpawnSuccess() {
60+
mockSpawn.mockReturnValue({
61+
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
62+
if (event === 'close') cb(0)
63+
}),
64+
} as never)
65+
}
66+
67+
function mockSpawnFailure(exitCode: number) {
68+
mockSpawn.mockReturnValue({
69+
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
70+
if (event === 'close') cb(exitCode)
71+
}),
72+
} as never)
73+
}
74+
75+
function mockSpawnPermissionError() {
76+
mockSpawn.mockReturnValue({
77+
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
78+
if (event === 'error') {
79+
const err = Object.assign(new Error('EACCES'), { code: 'EACCES' })
80+
cb(err)
81+
}
82+
}),
83+
} as never)
84+
}
85+
86+
describe('update command', () => {
87+
let consoleSpy: ReturnType<typeof vi.spyOn>
88+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
89+
90+
beforeEach(() => {
91+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
92+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
93+
process.exitCode = undefined
94+
})
95+
96+
afterEach(() => {
97+
vi.restoreAllMocks()
98+
vi.unstubAllGlobals()
99+
process.exitCode = undefined
100+
})
101+
102+
describe('already up to date', () => {
103+
it('prints up-to-date message when versions match', async () => {
104+
const { version } = await import('../../package.json')
105+
mockFetch(version)
106+
107+
const program = createProgram()
108+
await program.parseAsync(['node', 'td', 'update'])
109+
110+
expect(consoleSpy).toHaveBeenCalledWith(
111+
expect.anything(),
112+
expect.stringContaining('Already up to date'),
113+
)
114+
expect(mockSpawn).not.toHaveBeenCalled()
115+
})
116+
})
117+
118+
describe('--check flag', () => {
119+
it('shows version info without installing when update available', async () => {
120+
mockFetch('99.99.99')
121+
122+
const program = createProgram()
123+
await program.parseAsync(['node', 'td', 'update', '--check'])
124+
125+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Update available'))
126+
expect(mockSpawn).not.toHaveBeenCalled()
127+
})
128+
129+
it('shows up-to-date message when already current', async () => {
130+
const { version } = await import('../../package.json')
131+
mockFetch(version)
132+
133+
const program = createProgram()
134+
await program.parseAsync(['node', 'td', 'update', '--check'])
135+
136+
expect(consoleSpy).toHaveBeenCalledWith(
137+
expect.anything(),
138+
expect.stringContaining('Already up to date'),
139+
)
140+
})
141+
})
142+
143+
describe('update available', () => {
144+
it('spawns npm install and reports success', async () => {
145+
mockFetch('99.99.99')
146+
mockSpawnSuccess()
147+
148+
const program = createProgram()
149+
await program.parseAsync(['node', 'td', 'update'])
150+
151+
expect(mockSpawn).toHaveBeenCalledWith(
152+
'npm',
153+
['install', '-g', '@doist/todoist-cli@latest'],
154+
{ stdio: 'inherit' },
155+
)
156+
expect(consoleSpy).toHaveBeenCalledWith(
157+
expect.anything(),
158+
expect.stringContaining('Updated to v99.99.99'),
159+
)
160+
})
161+
})
162+
163+
describe('registry errors', () => {
164+
it('handles HTTP errors from registry', async () => {
165+
mockFetchError(503)
166+
167+
const program = createProgram()
168+
await program.parseAsync(['node', 'td', 'update'])
169+
170+
expect(consoleErrorSpy).toHaveBeenCalledWith(
171+
expect.anything(),
172+
expect.stringContaining('Failed to check for updates'),
173+
)
174+
expect(process.exitCode).toBe(1)
175+
})
176+
177+
it('handles network failures', async () => {
178+
mockFetchNetworkError('getaddrinfo ENOTFOUND registry.npmjs.org')
179+
180+
const program = createProgram()
181+
await program.parseAsync(['node', 'td', 'update'])
182+
183+
expect(consoleErrorSpy).toHaveBeenCalledWith(
184+
expect.anything(),
185+
expect.stringContaining('Failed to check for updates'),
186+
)
187+
expect(process.exitCode).toBe(1)
188+
})
189+
})
190+
191+
describe('install errors', () => {
192+
it('suggests sudo on permission error', async () => {
193+
mockFetch('99.99.99')
194+
mockSpawnPermissionError()
195+
196+
const program = createProgram()
197+
await program.parseAsync(['node', 'td', 'update'])
198+
199+
expect(consoleErrorSpy).toHaveBeenCalledWith(
200+
expect.anything(),
201+
expect.stringContaining('Permission denied'),
202+
)
203+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('sudo'))
204+
expect(process.exitCode).toBe(1)
205+
})
206+
207+
it('handles non-zero exit code from npm', async () => {
208+
mockFetch('99.99.99')
209+
mockSpawnFailure(1)
210+
211+
const program = createProgram()
212+
await program.parseAsync(['node', 'td', 'update'])
213+
214+
expect(consoleErrorSpy).toHaveBeenCalledWith(
215+
expect.anything(),
216+
expect.stringContaining('exited with code 1'),
217+
)
218+
expect(process.exitCode).toBe(1)
219+
})
220+
})
221+
})

src/commands/update.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { spawn } from 'node:child_process'
2+
import chalk from 'chalk'
3+
import { Command } from 'commander'
4+
import packageJson from '../../package.json' with { type: 'json' }
5+
import { withSpinner } from '../lib/spinner.js'
6+
7+
const PACKAGE_NAME = '@doist/todoist-cli'
8+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
9+
10+
interface RegistryResponse {
11+
version: string
12+
}
13+
14+
async function fetchLatestVersion(): Promise<string> {
15+
const response = await fetch(REGISTRY_URL)
16+
if (!response.ok) {
17+
throw new Error(`Registry request failed (HTTP ${response.status})`)
18+
}
19+
const data = (await response.json()) as RegistryResponse
20+
return data.version
21+
}
22+
23+
function detectPackageManager(): string {
24+
const execPath = process.env.npm_execpath ?? ''
25+
if (execPath.includes('pnpm')) return 'pnpm'
26+
return 'npm'
27+
}
28+
29+
function runInstall(pm: string): Promise<number> {
30+
return new Promise((resolve, reject) => {
31+
const child = spawn(pm, ['install', '-g', `${PACKAGE_NAME}@latest`], {
32+
stdio: 'inherit',
33+
})
34+
35+
child.on('error', reject)
36+
child.on('close', (code) => resolve(code ?? 1))
37+
})
38+
}
39+
40+
export async function updateAction(options: { check?: boolean }): Promise<void> {
41+
const currentVersion = packageJson.version
42+
43+
let latestVersion: string
44+
try {
45+
latestVersion = await withSpinner(
46+
{ text: 'Checking for updates...', color: 'blue' },
47+
fetchLatestVersion,
48+
)
49+
} catch (error) {
50+
const message = error instanceof Error ? error.message : String(error)
51+
console.error(chalk.red('Error:'), `Failed to check for updates: ${message}`)
52+
process.exitCode = 1
53+
return
54+
}
55+
56+
if (currentVersion === latestVersion) {
57+
console.log(chalk.green('✓'), `Already up to date (v${currentVersion})`)
58+
return
59+
}
60+
61+
console.log(
62+
`Update available: ${chalk.dim(`v${currentVersion}`)}${chalk.green(`v${latestVersion}`)}`,
63+
)
64+
65+
if (options.check) {
66+
return
67+
}
68+
69+
const pm = detectPackageManager()
70+
console.log(chalk.dim(`Updating to v${latestVersion}...`))
71+
72+
try {
73+
const exitCode = await runInstall(pm)
74+
if (exitCode !== 0) {
75+
console.error(chalk.red('Error:'), `${pm} exited with code ${exitCode}`)
76+
process.exitCode = 1
77+
return
78+
}
79+
} catch (error) {
80+
if (error instanceof Error && 'code' in error && error.code === 'EACCES') {
81+
console.error(chalk.red('Error:'), 'Permission denied. Try running with sudo:')
82+
console.error(chalk.dim(` sudo ${pm} install -g ${PACKAGE_NAME}@latest`))
83+
} else {
84+
const message = error instanceof Error ? error.message : String(error)
85+
console.error(chalk.red('Error:'), `Install failed: ${message}`)
86+
}
87+
process.exitCode = 1
88+
return
89+
}
90+
91+
console.log(chalk.green('✓'), `Updated to v${latestVersion}`)
92+
}
93+
94+
export function registerUpdateCommand(program: Command): void {
95+
program
96+
.command('update')
97+
.description('Update the CLI to the latest version')
98+
.option('--check', 'Check for updates without installing')
99+
.action(updateAction)
100+
}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ const commands: Record<string, [string, () => Promise<(p: Command) => void>]> =
104104
'View a Todoist entity or page by URL',
105105
async () => (await import('./commands/view.js')).registerViewCommand,
106106
],
107+
update: [
108+
'Update the CLI to the latest version',
109+
async () => (await import('./commands/update.js')).registerUpdateCommand,
110+
],
107111
}
108112

109113
// Register placeholders so --help lists all commands

src/lib/skills/content.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Use this skill when the user wants to interact with their Todoist tasks.
2626
- \`td settings view\` - User settings
2727
- \`td completion install\` - Install shell completions
2828
- \`td view <url>\` - View supported Todoist entities/pages by URL
29+
- \`td update\` - Self-update the CLI to the latest version
2930
3031
## Output Formats
3132
@@ -286,6 +287,12 @@ td view <url> --json # JSON output for entity views
286287
td view <url> --limit 25 --ndjson # Passthrough list options where supported
287288
\`\`\`
288289
290+
### Update
291+
\`\`\`bash
292+
td update # Update CLI to latest version
293+
td update --check # Check for updates without installing
294+
\`\`\`
295+
289296
## Examples
290297
291298
### Daily workflow

0 commit comments

Comments
 (0)