Skip to content
This repository was archived by the owner on Mar 30, 2026. It is now read-only.
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
5 changes: 5 additions & 0 deletions .changeset/add-update-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fnebenfuehr/worktree-cli": minor
---

Add `worktree update` command to update CLI to the latest version
2 changes: 1 addition & 1 deletion src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
153 changes: 153 additions & 0 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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
*/
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'],
shell: true,
});

let stderr = '';

child.stderr?.on('data', (data) => {
stderr += data.toString();
});

child.on('close', (code) => {
if (code === 0) {
resolve({ success: true });
} else {
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
*/
export async function getInstalledVersion(packageName: string): Promise<string | null> {
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<number> {
intro('Update CLI');

const s = spinner();

s.start('Checking current version');
const installedVersion = (await getInstalledVersion(pkg.name)) || pkg.version;
s.stop(`Current version: ${installedVersion}`);

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}`);

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);
}

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,
};
}
22 changes: 21 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { prCommand } from '@/commands/pr';
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';
Expand Down Expand Up @@ -54,8 +55,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',
`
Expand Down Expand Up @@ -212,6 +226,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
Expand Down
4 changes: 2 additions & 2 deletions src/utils/update-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ async function writeCache(cache: UpdateCheckCache): Promise<void> {
}
}

async function fetchLatestVersion(packageName: string): Promise<string | null> {
export async function fetchLatestVersion(packageName: string): Promise<string | null> {
const { error, data: response } = await tryCatch(
fetch(`https://registry.npmjs.org/${packageName}/latest`, {
headers: { Accept: 'application/json' },
Expand All @@ -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;
}
Expand Down
Loading