feat(@cli/bitch-cli): ✨ add upgrade command for package management
This commit is contained in:
parent
ec03500068
commit
1c5279b980
2 changed files with 283 additions and 0 deletions
281
src/commands/upgrade.ts
Normal file
281
src/commands/upgrade.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue