From 1c5279b9806e86e185f30c738f9d130636ad9a3b Mon Sep 17 00:00:00 2001 From: Lilith Date: Sat, 10 Jan 2026 21:47:09 -0800 Subject: [PATCH] =?UTF-8?q?feat(@cli/bitch-cli):=20=E2=9C=A8=20add=20upgra?= =?UTF-8?q?de=20command=20for=20package=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/upgrade.ts | 281 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 + 2 files changed, 283 insertions(+) create mode 100644 src/commands/upgrade.ts diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts new file mode 100644 index 0000000..04cb242 --- /dev/null +++ b/src/commands/upgrade.ts @@ -0,0 +1,281 @@ +import { Command } from 'commander' +import { spawn } from 'node:child_process' +import { access } from 'node:fs/promises' +import { join } from 'node:path' +import ora from 'ora' +import { + findPackages, + readPackageJson, + writePackageJson, + type PackageInfo, +} from '../utils/package-json.js' +import { colors, logInfo, logSuccess, logWarning, printTable } from '../utils/output.js' + +interface UpgradeOptions { + all?: boolean + onlyPython?: boolean + onlyTs?: boolean + onlyNpm?: boolean + dryRun?: boolean + interactive?: boolean + fixWorkspaceDeps?: boolean + commit?: boolean + path?: string +} + +interface ExtendedPackageJson { + name: string + version: string + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + [key: string]: unknown +} + +/** + * Detect which Python package manager is in use + */ +async function detectPythonPackageManager( + pkgPath: string +): Promise<'poetry' | 'uv' | 'pip' | null> { + const checks = [ + { file: 'poetry.lock', manager: 'poetry' as const }, + { file: 'uv.lock', manager: 'uv' as const }, + { file: 'requirements.txt', manager: 'pip' as const }, + ] + + for (const { file, manager } of checks) { + try { + await access(join(pkgPath, file)) + return manager + } catch { + continue + } + } + return null +} + +/** + * Run a command and return a promise + */ +function runCommand( + command: string, + args: string[], + cwd: string, + stdio: 'inherit' | 'pipe' = 'inherit' +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio, cwd }) + child.on('close', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`${command} ${args.join(' ')} failed with code ${code}`)) + } + }) + child.on('error', reject) + }) +} + +/** + * Fix hardcoded @lilith/* dependencies to use workspace:* + */ +async function fixWorkspaceDependencies( + packages: PackageInfo[], + options: UpgradeOptions +): Promise { + const spinner = ora('Fixing hardcoded workspace dependencies...').start() + let fixCount = 0 + + for (const pkg of packages.filter((p) => p.type === 'npm')) { + const pkgJsonPath = join(pkg.path, 'package.json') + const pkgJson = (await readPackageJson(pkgJsonPath)) as ExtendedPackageJson + let modified = false + + // Check all dependency types + for (const depType of ['dependencies', 'devDependencies', 'peerDependencies'] as const) { + const deps = pkgJson[depType] + if (!deps) continue + + for (const [name, version] of Object.entries(deps)) { + // Convert hardcoded @lilith/* versions to workspace:* + if ( + (name.startsWith('@lilith/') || name.startsWith('@transftw/') || name.startsWith('@3viky/')) && + version !== 'workspace:*' && + !version.startsWith('workspace:') + ) { + deps[name] = 'workspace:*' + modified = true + } + } + } + + if (modified) { + if (options.dryRun) { + spinner.info(`Would fix workspace dependencies in ${pkg.name}`) + fixCount++ + } else { + await writePackageJson(pkgJsonPath, pkgJson as any) + fixCount++ + } + } + } + + if (fixCount > 0) { + spinner.succeed(`Fixed ${fixCount} package(s) with hardcoded workspace dependencies`) + } else { + spinner.info('No hardcoded workspace dependencies found') + } + + return fixCount +} + +/** + * Upgrade npm packages + */ +async function upgradeNpmPackages( + packages: PackageInfo[], + options: UpgradeOptions +): Promise { + const basePath = options.path || process.cwd() + + // Fix workspace dependencies first if requested + if (options.fixWorkspaceDeps) { + await fixWorkspaceDependencies(packages, options) + } + + const spinner = ora('Updating npm dependencies...').start() + + if (options.dryRun) { + spinner.info('Would run: pnpm update -r') + return + } + + try { + await runCommand('pnpm', ['update', '-r'], basePath) + spinner.succeed('npm dependencies updated') + } catch (error) { + spinner.fail('Failed to update npm dependencies') + throw error + } +} + +/** + * Upgrade Python packages + */ +async function upgradePythonPackages( + packages: PackageInfo[], + options: UpgradeOptions +): Promise { + const pythonPackages = packages.filter((p) => p.type === 'pypi') + const results: Array<{ name: string; manager: string; status: string }> = [] + + for (const pkg of pythonPackages) { + const manager = await detectPythonPackageManager(pkg.path) + + if (!manager) { + results.push({ name: pkg.name, manager: 'unknown', status: colors.dim('skipped') }) + continue + } + + const spinner = ora(`Updating ${colors.dim(pkg.name)} (${manager})...`).start() + + if (options.dryRun) { + spinner.info(`Would update ${pkg.name} using ${manager}`) + results.push({ name: pkg.name, manager, status: colors.dim('dry-run') }) + continue + } + + try { + const commands = { + poetry: { cmd: 'poetry', args: ['update'] }, + uv: { cmd: 'uv', args: ['lock', '--upgrade'] }, + pip: { cmd: 'pip-compile', args: ['--upgrade', 'requirements.in', '-o', 'requirements.txt'] }, + } + + const { cmd, args } = commands[manager] + await runCommand(cmd, args, pkg.path, 'pipe') + + spinner.succeed(`Updated ${colors.dim(pkg.name)}`) + results.push({ name: pkg.name, manager, status: colors.success('updated') }) + } catch (error) { + spinner.fail(`Failed to update ${pkg.name}`) + results.push({ + name: pkg.name, + manager, + status: colors.error('failed'), + }) + } + } + + // Display summary + if (results.length > 0) { + console.log() + printTable(['Package', 'Manager', 'Status'], results.map((r) => [r.name, r.manager, r.status])) + } +} + +export function createUpgradeCommand(): Command { + return new Command('upgrade') + .description('Upgrade dependencies across TypeScript and Python packages') + .option('-A, --all', 'Upgrade all repos (default: current directory only)') + .option('--only-python', 'Only upgrade Python packages') + .option('--only-ts', 'Only upgrade TypeScript/npm packages') + .option('--only-npm', 'Alias for --only-ts') + .option('--dry-run', 'Show what would be upgraded without making changes') + .option('--interactive', 'Interactive mode (not yet implemented)') + .option('--fix-workspace-deps', 'Fix hardcoded @lilith/* dependencies to workspace:*', true) + .option('--no-fix-workspace-deps', 'Skip fixing workspace dependencies') + .option('--commit', 'Auto-commit changes after upgrade') + .option('--path ', 'Path to workspace (default: current directory)') + .action(async (options: UpgradeOptions) => { + const basePath = options.path || process.cwd() + const spinner = ora('Finding packages...').start() + + try { + const packages = await findPackages(basePath) + spinner.stop() + + // Apply filters + const onlyTs = options.onlyTs || options.onlyNpm + const onlyPython = options.onlyPython + + if (onlyTs && onlyPython) { + logWarning('Cannot specify both --only-ts and --only-python. Upgrading both.') + } + + const npmPackages = packages.filter((p) => p.type === 'npm') + const pythonPackages = packages.filter((p) => p.type === 'pypi') + + console.log() + logInfo( + `Found ${colors.cyan(npmPackages.length.toString())} npm packages and ${colors.cyan(pythonPackages.length.toString())} Python packages` + ) + console.log() + + // Upgrade npm packages + if (!onlyPython && npmPackages.length > 0) { + await upgradeNpmPackages(npmPackages, options) + } + + // Upgrade Python packages + if (!onlyTs && pythonPackages.length > 0) { + await upgradePythonPackages(pythonPackages, options) + } + + // Auto-commit if requested + if (options.commit && !options.dryRun) { + console.log() + logInfo('Committing changes...') + await runCommand('bitch', ['commit', '--all'], basePath) + } + + console.log() + logSuccess('Upgrade complete!') + } catch (error) { + spinner.fail('Upgrade failed') + console.error(error instanceof Error ? error.message : error) + process.exit(1) + } + }) +} diff --git a/src/index.ts b/src/index.ts index 7a12db1..feb33fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { createCICommand } from './commands/ci.js' import { createInitCommand } from './commands/init.js' import { createCommitsCommand } from './commands/commits.js' import { createCommitCommand } from './commands/commit.js' +import { createUpgradeCommand } from './commands/upgrade.js' const program = new Command() @@ -25,6 +26,7 @@ program.addCommand(createCICommand()) program.addCommand(createInitCommand()) program.addCommand(createCommitsCommand()) program.addCommand(createCommitCommand()) +program.addCommand(createUpgradeCommand()) // Parse arguments program.parse()