feat(@cli/bitch-cli): add upgrade command for package management

This commit is contained in:
Lilith 2026-01-10 21:47:09 -08:00
parent ec03500068
commit 1c5279b980
2 changed files with 283 additions and 0 deletions

281
src/commands/upgrade.ts Normal file
View file

@ -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<string, string>
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
[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<void> {
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<number> {
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<void> {
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<void> {
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>', '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)
}
})
}

View file

@ -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()