From f29a7fd740b1cc49f10206c800306b26b3d47853 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 23:22:00 +0000 Subject: [PATCH 1/3] feat: add CLI update functionality - Enhanced --version flag to check npm registry and show update availability - Added 'worktree update' command to update CLI via npm - Export fetchLatestVersion and isNewerVersion from update-checker for reuse - Handle permission errors gracefully with helpful sudo instructions - Cache update check results for 24 hours - Added comprehensive tests with mocked npm registry responses --- src/commands/update.ts | 156 +++++++++++++++++++++++++++++ src/index.ts | 22 ++++- src/utils/update-checker.ts | 4 +- tests/update-command.test.ts | 183 +++++++++++++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 src/commands/update.ts create mode 100644 tests/update-command.test.ts diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..13a0c18 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,156 @@ +import { spawn } from 'node:child_process'; +import { WorktreeError } from '@/utils/errors'; +import { intro, log, outro, spinner } from '@/utils/prompts'; +import { fetchLatestVersion, isNewerVersion } from '@/utils/update-checker'; + +interface PackageJson { + name: string; + version: string; +} + +/** + * Runs npm update -g for the package and returns the new version + */ +async function runNpmUpdate(packageName: string): Promise<{ success: boolean; error?: string }> { + return new Promise((resolve) => { + const child = spawn('npm', ['update', '-g', packageName], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + }); + + let stderr = ''; + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve({ success: true }); + } else { + // Check for common permission errors + if (stderr.includes('EACCES') || stderr.includes('permission denied')) { + resolve({ + success: false, + error: `Permission denied. Try running with sudo:\n sudo npm update -g ${packageName}`, + }); + } else { + resolve({ + success: false, + error: stderr || `npm update failed with exit code ${code}`, + }); + } + } + }); + + child.on('error', (err) => { + if (err.message.includes('ENOENT')) { + resolve({ + success: false, + error: 'npm not found. Please ensure npm is installed and in your PATH.', + }); + } else { + resolve({ success: false, error: err.message }); + } + }); + }); +} + +/** + * Gets the currently installed version by running npm list + */ +async function getInstalledVersion(packageName: string): Promise { + return new Promise((resolve) => { + const child = spawn('npm', ['list', '-g', packageName, '--depth=0', '--json'], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + }); + + let stdout = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.on('close', () => { + try { + const result = JSON.parse(stdout); + const version = result.dependencies?.[packageName]?.version; + resolve(version || null); + } catch { + resolve(null); + } + }); + + child.on('error', () => { + resolve(null); + }); + }); +} + +export async function updateCommand(pkg: PackageJson): Promise { + intro('Update CLI'); + + const s = spinner(); + + // Check current installed version + s.start('Checking current version'); + const installedVersion = (await getInstalledVersion(pkg.name)) || pkg.version; + s.stop(`Current version: ${installedVersion}`); + + // Check for latest version + s.start('Checking for updates'); + const latestVersion = await fetchLatestVersion(pkg.name); + + if (!latestVersion) { + s.stop('Could not fetch latest version from npm registry'); + log.warn('Unable to check for updates. Please check your network connection.'); + return 1; + } + + if (!isNewerVersion(installedVersion, latestVersion)) { + s.stop(`Already up to date (${installedVersion})`); + outro('No update needed'); + return 0; + } + + s.stop(`Update available: ${installedVersion} → ${latestVersion}`); + + // Run update + s.start(`Updating to ${latestVersion}`); + const result = await runNpmUpdate(pkg.name); + + if (!result.success) { + s.stop('Update failed'); + throw new WorktreeError(result.error || 'Update failed', 'UPDATE_FAILED', 1); + } + + // Verify the update + const newVersion = await getInstalledVersion(pkg.name); + s.stop(`Updated successfully`); + + if (newVersion && newVersion !== installedVersion) { + log.success(`Updated: ${installedVersion} → ${newVersion}`); + } else if (newVersion === installedVersion) { + log.info('Version unchanged. You may already have the latest version.'); + } + + outro('Update complete'); + return 0; +} + +/** + * Check for updates and return version info (for --version flag enhancement) + */ +export async function getVersionInfo( + pkg: PackageJson +): Promise<{ current: string; latest: string | null; updateAvailable: boolean }> { + const latest = await fetchLatestVersion(pkg.name); + const updateAvailable = latest ? isNewerVersion(pkg.version, latest) : false; + + return { + current: pkg.version, + latest, + updateAvailable, + }; +} diff --git a/src/index.ts b/src/index.ts index 36efc2c..0e4600f 100755 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { mcpConfigCommand, mcpStartCommand, mcpTestCommand } from '@/commands/mc import { removeCommand } from '@/commands/remove'; import { setupCommand } from '@/commands/setup'; import { switchCommand } from '@/commands/switch'; +import { getVersionInfo, updateCommand } from '@/commands/update'; import { UserCancelledError, WorktreeError } from '@/utils/errors'; import { log } from '@/utils/prompts'; import { checkForUpdates } from '@/utils/update-checker'; @@ -53,8 +54,21 @@ const program = new Command(); program .name('worktree') .description('A modern CLI tool for managing git worktrees with ease') - .version(VERSION, '-v, --version', 'Show version') + .option('-v, --version', 'Show version') .option('--verbose', 'Enable verbose output') + .on('option:version', async () => { + console.log(VERSION); + try { + const info = await getVersionInfo(packageJson); + if (info.updateAvailable && info.latest) { + console.log(`\nUpdate available: ${info.current} → ${info.latest}`); + console.log(`Run: npm update -g ${packageJson.name}`); + } + } catch { + // Silently ignore update check errors + } + process.exit(0); + }) .addHelpText( 'after', ` @@ -188,6 +202,12 @@ mcpCommand .description('Test MCP server connection') .action(() => handleCommandError(() => mcpTestCommand())()); +// Update command +program + .command('update') + .description('Update CLI to the latest version') + .action(() => handleCommandError(() => updateCommand(packageJson))()); + // Fire-and-forget update check (non-blocking) checkForUpdates(packageJson, ONE_DAY_MS).catch(() => { // Silently ignore errors diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index ce15589..5c61a9a 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -68,7 +68,7 @@ async function writeCache(cache: UpdateCheckCache): Promise { } } -async function fetchLatestVersion(packageName: string): Promise { +export async function fetchLatestVersion(packageName: string): Promise { const { error, data: response } = await tryCatch( fetch(`https://registry.npmjs.org/${packageName}/latest`, { headers: { Accept: 'application/json' }, @@ -95,7 +95,7 @@ function isValidVersion(version: string): boolean { }); } -function isNewerVersion(current: string, latest: string): boolean { +export function isNewerVersion(current: string, latest: string): boolean { if (PRERELEASE_PATTERN.test(current) || PRERELEASE_PATTERN.test(latest)) { return false; } diff --git a/tests/update-command.test.ts b/tests/update-command.test.ts new file mode 100644 index 0000000..1e2d3e1 --- /dev/null +++ b/tests/update-command.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'bun'; +import { getVersionInfo, updateCommand } from '@/commands/update'; +import * as prompts from '@/utils/prompts'; +import { setCacheDir } from '@/utils/update-checker'; + +const CLI_PATH = join(import.meta.dir, '../src/index.ts'); + +describe('update command', () => { + let originalFetch: typeof globalThis.fetch; + let testCacheDir: string; + let mockLog: ReturnType; + let mockSpinner: ReturnType; + + beforeEach(() => { + originalFetch = globalThis.fetch; + testCacheDir = join(tmpdir(), `worktree-cli-test-${Date.now()}-${Math.random()}`); + setCacheDir(testCacheDir); + mockLog = mock(() => {}); + mockSpinner = mock(() => ({ + start: mock(() => {}), + stop: mock(() => {}), + })); + + spyOn(prompts.log, 'info').mockImplementation(mockLog); + spyOn(prompts.log, 'warn').mockImplementation(mockLog); + spyOn(prompts.log, 'success').mockImplementation(mockLog); + spyOn(prompts, 'intro').mockImplementation(mock(() => {})); + spyOn(prompts, 'outro').mockImplementation(mock(() => {})); + spyOn(prompts, 'spinner').mockImplementation(mockSpinner); + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + try { + await rm(testCacheDir, { recursive: true, force: true }); + } catch {} + }); + + describe('getVersionInfo', () => { + test('returns update available when newer version exists', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ version: '2.0.0' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ); + + const info = await getVersionInfo({ name: 'test-pkg', version: '1.0.0' }); + + expect(info.current).toBe('1.0.0'); + expect(info.latest).toBe('2.0.0'); + expect(info.updateAvailable).toBe(true); + }); + + test('returns no update when on latest version', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ version: '1.0.0' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ); + + const info = await getVersionInfo({ name: 'test-pkg', version: '1.0.0' }); + + expect(info.current).toBe('1.0.0'); + expect(info.latest).toBe('1.0.0'); + expect(info.updateAvailable).toBe(false); + }); + + test('handles fetch failure gracefully', async () => { + globalThis.fetch = mock(() => Promise.reject(new Error('Network error'))); + + const info = await getVersionInfo({ name: 'test-pkg', version: '1.0.0' }); + + expect(info.current).toBe('1.0.0'); + expect(info.latest).toBeNull(); + expect(info.updateAvailable).toBe(false); + }); + + test('ignores pre-release versions', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ version: '2.0.0-beta.1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ); + + const info = await getVersionInfo({ name: 'test-pkg', version: '1.0.0' }); + + expect(info.updateAvailable).toBe(false); + }); + }); + + describe('updateCommand', () => { + test('returns 0 when already up to date', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ version: '1.0.0' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ); + + const result = await updateCommand({ name: 'test-pkg', version: '1.0.0' }); + + expect(result).toBe(0); + }); + + test('returns 1 when cannot fetch latest version', async () => { + globalThis.fetch = mock(() => Promise.reject(new Error('Network error'))); + + const result = await updateCommand({ name: 'test-pkg', version: '1.0.0' }); + + expect(result).toBe(1); + }); + }); + + describe('CLI integration', () => { + test('--version flag exits with code 0', async () => { + const proc = spawn(['bun', CLI_PATH, '--version']); + await proc.exited; + expect(proc.exitCode).toBe(0); + }); + + test('-v flag exits with code 0', async () => { + const proc = spawn(['bun', CLI_PATH, '-v']); + await proc.exited; + expect(proc.exitCode).toBe(0); + }); + + test('update command is listed in help', async () => { + const proc = spawn(['bun', CLI_PATH, '--help']); + const output = await new Response(proc.stdout).text(); + await proc.exited; + + expect(output).toContain('update'); + expect(output).toContain('Update CLI to the latest version'); + }); + + test('update command shows help when --help flag used', async () => { + const proc = spawn(['bun', CLI_PATH, 'update', '--help']); + const output = await new Response(proc.stdout).text(); + await proc.exited; + + expect(proc.exitCode).toBe(0); + expect(output).toContain('Update CLI to the latest version'); + }); + }); +}); + +describe('update-checker exports', () => { + test('fetchLatestVersion is exported', async () => { + const { fetchLatestVersion } = await import('@/utils/update-checker'); + expect(typeof fetchLatestVersion).toBe('function'); + }); + + test('isNewerVersion is exported', async () => { + const { isNewerVersion } = await import('@/utils/update-checker'); + expect(typeof isNewerVersion).toBe('function'); + }); + + test('isNewerVersion correctly compares versions', async () => { + const { isNewerVersion } = await import('@/utils/update-checker'); + + expect(isNewerVersion('1.0.0', '2.0.0')).toBe(true); + expect(isNewerVersion('1.0.0', '1.1.0')).toBe(true); + expect(isNewerVersion('1.0.0', '1.0.1')).toBe(true); + expect(isNewerVersion('1.0.0', '1.0.0')).toBe(false); + expect(isNewerVersion('2.0.0', '1.0.0')).toBe(false); + expect(isNewerVersion('1.0.0', '1.0.0-beta')).toBe(false); + }); +}); From c417028318c711394985681133a698c3a210816a Mon Sep 17 00:00:00 2001 From: Florin Nebenfuehr Date: Sat, 22 Nov 2025 17:03:15 +0100 Subject: [PATCH 2/3] refactor: improve update command testability and clean up code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export npm functions for mocking in tests, add proper spyOn mocks to fix CI test failures, and remove redundant inline comments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/add-update-command.md | 5 +++++ src/commands/update.ts | 11 ++++------- tests/update-command.test.ts | 3 +++ tests/utils.test.ts | 5 +---- 4 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 .changeset/add-update-command.md diff --git a/.changeset/add-update-command.md b/.changeset/add-update-command.md new file mode 100644 index 0000000..309c408 --- /dev/null +++ b/.changeset/add-update-command.md @@ -0,0 +1,5 @@ +--- +"@fnebenfuehr/worktree-cli": minor +--- + +Add `worktree update` command to update CLI to the latest version diff --git a/src/commands/update.ts b/src/commands/update.ts index 13a0c18..c2e2d4b 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -11,7 +11,9 @@ interface PackageJson { /** * Runs npm update -g for the package and returns the new version */ -async function runNpmUpdate(packageName: string): Promise<{ success: boolean; error?: string }> { +export async function runNpmUpdate( + packageName: string +): Promise<{ success: boolean; error?: string }> { return new Promise((resolve) => { const child = spawn('npm', ['update', '-g', packageName], { stdio: ['ignore', 'pipe', 'pipe'], @@ -28,7 +30,6 @@ async function runNpmUpdate(packageName: string): Promise<{ success: boolean; er if (code === 0) { resolve({ success: true }); } else { - // Check for common permission errors if (stderr.includes('EACCES') || stderr.includes('permission denied')) { resolve({ success: false, @@ -59,7 +60,7 @@ async function runNpmUpdate(packageName: string): Promise<{ success: boolean; er /** * Gets the currently installed version by running npm list */ -async function getInstalledVersion(packageName: string): Promise { +export async function getInstalledVersion(packageName: string): Promise { return new Promise((resolve) => { const child = spawn('npm', ['list', '-g', packageName, '--depth=0', '--json'], { stdio: ['ignore', 'pipe', 'pipe'], @@ -93,12 +94,10 @@ export async function updateCommand(pkg: PackageJson): Promise { const s = spinner(); - // Check current installed version s.start('Checking current version'); const installedVersion = (await getInstalledVersion(pkg.name)) || pkg.version; s.stop(`Current version: ${installedVersion}`); - // Check for latest version s.start('Checking for updates'); const latestVersion = await fetchLatestVersion(pkg.name); @@ -116,7 +115,6 @@ export async function updateCommand(pkg: PackageJson): Promise { s.stop(`Update available: ${installedVersion} → ${latestVersion}`); - // Run update s.start(`Updating to ${latestVersion}`); const result = await runNpmUpdate(pkg.name); @@ -125,7 +123,6 @@ export async function updateCommand(pkg: PackageJson): Promise { throw new WorktreeError(result.error || 'Update failed', 'UPDATE_FAILED', 1); } - // Verify the update const newVersion = await getInstalledVersion(pkg.name); s.stop(`Updated successfully`); diff --git a/tests/update-command.test.ts b/tests/update-command.test.ts index 1e2d3e1..7dbf078 100644 --- a/tests/update-command.test.ts +++ b/tests/update-command.test.ts @@ -3,6 +3,7 @@ import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawn } from 'bun'; +import * as updateModule from '@/commands/update'; import { getVersionInfo, updateCommand } from '@/commands/update'; import * as prompts from '@/utils/prompts'; import { setCacheDir } from '@/utils/update-checker'; @@ -111,6 +112,7 @@ describe('update command', () => { }) ) ); + spyOn(updateModule, 'getInstalledVersion').mockResolvedValue(null); const result = await updateCommand({ name: 'test-pkg', version: '1.0.0' }); @@ -119,6 +121,7 @@ describe('update command', () => { test('returns 1 when cannot fetch latest version', async () => { globalThis.fetch = mock(() => Promise.reject(new Error('Network error'))); + spyOn(updateModule, 'getInstalledVersion').mockResolvedValue(null); const result = await updateCommand({ name: 'test-pkg', version: '1.0.0' }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index a1ccb3a..2761706 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,7 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import { realpath as fsRealpath, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { describe, expect, test } from 'bun:test'; import { branchToDirName, extractRepoName } from '@/utils/naming'; describe('fs utilities', () => { From 21ba8d386235032e5f5fe38d2a0940c329cee3a8 Mon Sep 17 00:00:00 2001 From: Florin Nebenfuehr Date: Sat, 22 Nov 2025 17:06:10 +0100 Subject: [PATCH 3/3] style: fix lint warning in remove command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert string concatenation to template literal. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/commands/remove.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 7e01c1e..1c9bc32 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -143,7 +143,7 @@ export async function removeCommand( } // If we were in the removed worktree (or its subdirectory), show message to switch to main - if (!currentDir || currentDir === worktreePath || currentDir.startsWith(worktreePath + '/')) { + if (!currentDir || currentDir === worktreePath || currentDir.startsWith(`${worktreePath}/`)) { outro(`Worktree for branch '${branch}' has been removed`); note(`cd ${defaultBranchPath}`, 'To return to main worktree, run:'); } else {