fix(@cli/bitch): 🐛 resolve linting issues
This commit is contained in:
commit
414af8d86b
20 changed files with 6120 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
@lilith:registry=http://forge.nasty.sh/api/packages/lilith/npm/
|
||||
2
bin/bitch.js
Executable file
2
bin/bitch.js
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env node
|
||||
import '../dist/index.js'
|
||||
4256
package-lock.json
generated
Normal file
4256
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
52
package.json
Normal file
52
package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "@lilith/bitch",
|
||||
"version": "1.0.0",
|
||||
"description": "Global development CLI for managing packages across workspaces",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"bitch": "./bin/bitch.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"glob": "^10.4.5",
|
||||
"ora": "^8.1.1",
|
||||
"simple-git": "^3.27.0",
|
||||
"undici": "^6.21.0",
|
||||
"yaml": "^2.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lilith/configs": "^1.2.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "http://forge.nasty.sh/api/packages/lilith/npm/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://forge.nasty.sh/lilith/bitch.git"
|
||||
},
|
||||
"keywords": [
|
||||
"cli",
|
||||
"development",
|
||||
"monorepo",
|
||||
"packages",
|
||||
"lilith"
|
||||
],
|
||||
"author": "Lilith Platform",
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
102
src/commands/bump.ts
Normal file
102
src/commands/bump.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Command } from 'commander'
|
||||
import ora from 'ora'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
findPackages,
|
||||
readPackageJson,
|
||||
writePackageJson,
|
||||
updatePyProjectVersion,
|
||||
bumpVersion,
|
||||
} from '../utils/package-json.js'
|
||||
import { colors, printTable, logInfo, logWarning, logSuccess } from '../utils/output.js'
|
||||
|
||||
export function createBumpCommand(): Command {
|
||||
return new Command('bump')
|
||||
.description('Bump versions across packages')
|
||||
.argument('<type>', 'Version bump type: major, minor, or patch')
|
||||
.option('-p, --package <name>', 'Only bump specific package')
|
||||
.option('--path <path>', 'Path to workspace (default: current directory)')
|
||||
.option('--dry-run', 'Show what would be changed without making changes')
|
||||
.action(async (type: string, options: {
|
||||
package?: string
|
||||
path?: string
|
||||
dryRun?: boolean
|
||||
}) => {
|
||||
if (!['major', 'minor', 'patch'].includes(type)) {
|
||||
logWarning(`Invalid bump type: ${type}. Must be major, minor, or patch.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const bumpType = type as 'major' | 'minor' | 'patch'
|
||||
const basePath = options.path || process.cwd()
|
||||
const spinner = ora('Finding packages...').start()
|
||||
|
||||
try {
|
||||
let packages = await findPackages(basePath)
|
||||
|
||||
// Filter to publishable packages
|
||||
packages = packages.filter(p => p.shouldPublish)
|
||||
|
||||
// Filter to specific package if requested
|
||||
if (options.package) {
|
||||
packages = packages.filter(p => p.name === options.package)
|
||||
}
|
||||
|
||||
if (packages.length === 0) {
|
||||
spinner.stop()
|
||||
logWarning('No packages found to bump')
|
||||
return
|
||||
}
|
||||
|
||||
spinner.stop()
|
||||
|
||||
console.log()
|
||||
logInfo(`Bumping ${bumpType} version for ${packages.length} package(s)`)
|
||||
console.log()
|
||||
|
||||
const changes: Array<{ name: string; oldVersion: string; newVersion: string }> = []
|
||||
|
||||
for (const pkg of packages) {
|
||||
const newVersion = bumpVersion(pkg.version, bumpType)
|
||||
changes.push({
|
||||
name: pkg.name,
|
||||
oldVersion: pkg.version,
|
||||
newVersion,
|
||||
})
|
||||
|
||||
if (!options.dryRun) {
|
||||
if (pkg.type === 'npm') {
|
||||
const pkgJsonPath = join(pkg.path, 'package.json')
|
||||
const pkgJson = await readPackageJson(pkgJsonPath)
|
||||
pkgJson.version = newVersion
|
||||
await writePackageJson(pkgJsonPath, pkgJson)
|
||||
} else {
|
||||
const pyprojectPath = join(pkg.path, 'pyproject.toml')
|
||||
await updatePyProjectVersion(pyprojectPath, newVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display changes
|
||||
const headers = ['Package', 'Old Version', 'New Version']
|
||||
const rows = changes.map(c => [
|
||||
c.name,
|
||||
colors.dim(c.oldVersion),
|
||||
colors.success(c.newVersion),
|
||||
])
|
||||
|
||||
printTable(headers, rows)
|
||||
console.log()
|
||||
|
||||
if (options.dryRun) {
|
||||
logInfo('Dry run - no changes made')
|
||||
} else {
|
||||
logSuccess(`Bumped ${changes.length} package(s) to ${bumpType} version`)
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to bump versions')
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
123
src/commands/ci.ts
Normal file
123
src/commands/ci.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { Command } from 'commander'
|
||||
import ora from 'ora'
|
||||
import { findPackages } from '../utils/package-json.js'
|
||||
import { getCIStatus, formatDate, type CIStatus } from '../utils/forgejo.js'
|
||||
import { colors, printTable, logInfo, logWarning, logError } from '../utils/output.js'
|
||||
|
||||
export function createCICommand(): Command {
|
||||
return new Command('ci')
|
||||
.description('Check Forgejo Actions CI status')
|
||||
.argument('[package]', 'Specific package to check (optional)')
|
||||
.option('-a, --all', 'Check all packages in workspace')
|
||||
.option('--path <path>', 'Path to workspace (default: current directory)')
|
||||
.action(async (packageName: string | undefined, options: {
|
||||
all?: boolean
|
||||
path?: string
|
||||
}) => {
|
||||
const basePath = options.path || process.cwd()
|
||||
|
||||
// Check for FORGEJO_TOKEN
|
||||
if (!process.env.FORGEJO_TOKEN) {
|
||||
logError('FORGEJO_TOKEN environment variable not set')
|
||||
console.log()
|
||||
console.log('Set it with:')
|
||||
console.log(colors.dim(' export FORGEJO_TOKEN="your-token-here"'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const spinner = ora('Checking CI status...').start()
|
||||
|
||||
try {
|
||||
let packagesToCheck: string[] = []
|
||||
|
||||
if (packageName) {
|
||||
packagesToCheck = [packageName]
|
||||
} else if (options.all) {
|
||||
const packages = await findPackages(basePath)
|
||||
packagesToCheck = packages
|
||||
.filter(p => p.shouldPublish)
|
||||
.map(p => p.name)
|
||||
} else {
|
||||
// Check current directory's package
|
||||
const packages = await findPackages(basePath)
|
||||
const currentPkg = packages.find(p => p.path === basePath)
|
||||
if (currentPkg) {
|
||||
packagesToCheck = [currentPkg.name]
|
||||
} else {
|
||||
spinner.stop()
|
||||
logWarning('No package found in current directory. Use --all to check all packages.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (packagesToCheck.length === 0) {
|
||||
spinner.stop()
|
||||
logWarning('No packages to check')
|
||||
return
|
||||
}
|
||||
|
||||
spinner.text = `Checking ${packagesToCheck.length} package(s)...`
|
||||
|
||||
const statuses: CIStatus[] = []
|
||||
for (const pkg of packagesToCheck) {
|
||||
const status = await getCIStatus(pkg)
|
||||
statuses.push(status)
|
||||
}
|
||||
|
||||
spinner.stop()
|
||||
|
||||
console.log()
|
||||
logInfo(`CI status for ${statuses.length} package(s)`)
|
||||
console.log()
|
||||
|
||||
const headers = ['Package', 'Status', 'Branch', 'Last Run']
|
||||
const rows = statuses.map(s => [
|
||||
s.repo,
|
||||
formatStatus(s.status),
|
||||
s.lastRun?.head_branch || colors.dim('-'),
|
||||
s.lastRun ? formatDate(s.lastRun.updated_at) : colors.dim('never'),
|
||||
])
|
||||
|
||||
printTable(headers, rows)
|
||||
console.log()
|
||||
|
||||
// Summary
|
||||
const success = statuses.filter(s => s.status === 'success').length
|
||||
const failure = statuses.filter(s => s.status === 'failure').length
|
||||
const pending = statuses.filter(s => s.status === 'pending').length
|
||||
const noRuns = statuses.filter(s => s.status === 'no-runs').length
|
||||
|
||||
if (failure > 0) {
|
||||
logError(`${failure} package(s) have failing CI`)
|
||||
}
|
||||
if (pending > 0) {
|
||||
logWarning(`${pending} package(s) have pending CI`)
|
||||
}
|
||||
if (success > 0) {
|
||||
console.log(colors.success(`${success} package(s) have passing CI`))
|
||||
}
|
||||
if (noRuns > 0) {
|
||||
console.log(colors.dim(`${noRuns} package(s) have no CI runs`))
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to check CI status')
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatStatus(status: CIStatus['status']): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return colors.success('✓ passing')
|
||||
case 'failure':
|
||||
return colors.error('✗ failing')
|
||||
case 'pending':
|
||||
return colors.warning('● pending')
|
||||
case 'no-runs':
|
||||
return colors.dim('○ no runs')
|
||||
case 'error':
|
||||
return colors.error('? error')
|
||||
}
|
||||
}
|
||||
185
src/commands/commits.ts
Normal file
185
src/commands/commits.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { Command } from 'commander'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { access, mkdir, copyFile, unlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { colors, logInfo, logSuccess, logError, logWarning } from '../utils/output.js'
|
||||
|
||||
const BITCH_DATA_DIR = join(homedir(), '.local', 'share', 'bitch')
|
||||
const COMMITS_SCRIPT_PATH = join(BITCH_DATA_DIR, 'commits-daemon.sh')
|
||||
const OLD_COMMITS_PATH = join(homedir(), '.local', 'bin', 'commits')
|
||||
|
||||
/**
|
||||
* Check if the commits script is installed in bitch's data directory
|
||||
*/
|
||||
async function isInstalled(): Promise<boolean> {
|
||||
try {
|
||||
await access(COMMITS_SCRIPT_PATH)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if standalone commits exists
|
||||
*/
|
||||
async function hasStandaloneCommits(): Promise<boolean> {
|
||||
try {
|
||||
await access(OLD_COMMITS_PATH)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the commits script with given arguments
|
||||
*/
|
||||
function runCommitsScript(scriptPath: string, args: string[]): void {
|
||||
const child = spawn('bash', [scriptPath, ...args], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
process.exit(code || 0)
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
logError(`Failed to run commits: ${err.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
export function createCommitsCommand(): Command {
|
||||
const cmd = new Command('commits')
|
||||
.description('Auto-commit daemon management (proxy to commits CLI)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
|
||||
// Install subcommand
|
||||
cmd
|
||||
.command('install')
|
||||
.description('Install commits script into bitch (migrates from standalone)')
|
||||
.action(async () => {
|
||||
console.log()
|
||||
logInfo('Installing commits into bitch...')
|
||||
console.log()
|
||||
|
||||
const hasStandalone = await hasStandaloneCommits()
|
||||
|
||||
if (!hasStandalone) {
|
||||
logError('Standalone commits script not found at ~/.local/bin/commits')
|
||||
console.log()
|
||||
console.log('Expected location:', colors.dim(OLD_COMMITS_PATH))
|
||||
console.log()
|
||||
console.log('If you have commits installed elsewhere, copy it to:')
|
||||
console.log(colors.dim(` cp /path/to/commits ${OLD_COMMITS_PATH}`))
|
||||
console.log('Then run this command again.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Create bitch data directory
|
||||
await mkdir(BITCH_DATA_DIR, { recursive: true })
|
||||
|
||||
// Copy script to bitch's data directory
|
||||
await copyFile(OLD_COMMITS_PATH, COMMITS_SCRIPT_PATH)
|
||||
|
||||
// Make it executable using execFileSync (safe, no shell injection)
|
||||
execFileSync('chmod', ['+x', COMMITS_SCRIPT_PATH])
|
||||
|
||||
logSuccess('Copied commits script to bitch data directory')
|
||||
|
||||
// Remove standalone script
|
||||
try {
|
||||
await unlink(OLD_COMMITS_PATH)
|
||||
logSuccess('Removed standalone commits from ~/.local/bin/')
|
||||
} catch (err) {
|
||||
logWarning(`Could not remove ${OLD_COMMITS_PATH}: ${err}`)
|
||||
}
|
||||
|
||||
console.log()
|
||||
logSuccess('Installation complete!')
|
||||
console.log()
|
||||
console.log('Usage:')
|
||||
console.log(colors.dim(' bitch commits start 5m -R # Start daemon'))
|
||||
console.log(colors.dim(' bitch commits status # Check status'))
|
||||
console.log(colors.dim(' bitch commits report # View report'))
|
||||
console.log(colors.dim(' bitch commits help # Full help'))
|
||||
})
|
||||
|
||||
// Uninstall subcommand
|
||||
cmd
|
||||
.command('uninstall')
|
||||
.description('Restore standalone commits script')
|
||||
.action(async () => {
|
||||
console.log()
|
||||
logInfo('Restoring standalone commits...')
|
||||
|
||||
const installed = await isInstalled()
|
||||
if (!installed) {
|
||||
logError('Commits is not installed in bitch')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Copy back to standalone location
|
||||
await mkdir(join(homedir(), '.local', 'bin'), { recursive: true })
|
||||
await copyFile(COMMITS_SCRIPT_PATH, OLD_COMMITS_PATH)
|
||||
execFileSync('chmod', ['+x', OLD_COMMITS_PATH])
|
||||
|
||||
// Remove from bitch
|
||||
await unlink(COMMITS_SCRIPT_PATH)
|
||||
|
||||
logSuccess('Restored standalone commits to ~/.local/bin/commits')
|
||||
})
|
||||
|
||||
// Main action - proxy to commits script
|
||||
cmd.action(async (_options, command) => {
|
||||
// Get all arguments after 'commits'
|
||||
const args = command.args
|
||||
|
||||
// Check if installed
|
||||
const installed = await isInstalled()
|
||||
|
||||
if (installed) {
|
||||
// Use bitch's copy
|
||||
runCommitsScript(COMMITS_SCRIPT_PATH, args)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for standalone
|
||||
const hasStandalone = await hasStandaloneCommits()
|
||||
|
||||
if (hasStandalone) {
|
||||
// Offer to install
|
||||
console.log()
|
||||
logWarning('Commits is not yet installed in bitch.')
|
||||
console.log()
|
||||
console.log('Found standalone commits at:', colors.dim(OLD_COMMITS_PATH))
|
||||
console.log()
|
||||
console.log('Run this to migrate to bitch:')
|
||||
console.log(colors.cyan(' bitch commits install'))
|
||||
console.log()
|
||||
console.log('Or use standalone for now:')
|
||||
runCommitsScript(OLD_COMMITS_PATH, args)
|
||||
return
|
||||
}
|
||||
|
||||
// Neither installed
|
||||
logError('Commits script not found.')
|
||||
console.log()
|
||||
console.log('To install commits:')
|
||||
console.log('1. Install auto-commit-service:')
|
||||
console.log(colors.dim(' cd ~/Code/@applications/@ml/auto-commit-service'))
|
||||
console.log(colors.dim(' pip install -e .'))
|
||||
console.log()
|
||||
console.log('2. Copy the commits CLI script to ~/.local/bin/commits')
|
||||
console.log()
|
||||
console.log('3. Run:')
|
||||
console.log(colors.cyan(' bitch commits install'))
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
177
src/commands/consumers.ts
Normal file
177
src/commands/consumers.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { Command } from 'commander'
|
||||
import ora from 'ora'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { glob } from 'glob'
|
||||
import { DEFAULT_CONFIG } from '../config/defaults.js'
|
||||
import { colors, printTable, logInfo, logWarning, logError } from '../utils/output.js'
|
||||
|
||||
interface Consumer {
|
||||
path: string
|
||||
name: string
|
||||
dependencyType: 'dependencies' | 'devDependencies' | 'peerDependencies'
|
||||
version: string
|
||||
isPathDep: boolean
|
||||
}
|
||||
|
||||
export function createConsumersCommand(): Command {
|
||||
return new Command('consumers')
|
||||
.description('Find consumers of a package across all workspaces')
|
||||
.argument('<package>', 'Package name to search for (e.g., @lilith/ui-theme)')
|
||||
.option('-i, --imports', 'Also search for import statements')
|
||||
.action(async (packageName: string, options: { imports?: boolean }) => {
|
||||
const spinner = ora(`Searching for consumers of ${packageName}...`).start()
|
||||
|
||||
try {
|
||||
const consumers: Consumer[] = []
|
||||
const importLocations: Array<{ file: string; line: number; content: string }> = []
|
||||
|
||||
// Search all configured workspaces
|
||||
for (const workspace of DEFAULT_CONFIG.workspaces) {
|
||||
// Find all package.json files
|
||||
const packageJsonPaths = await glob(join(workspace, '**/package.json'), {
|
||||
ignore: ['**/node_modules/**', '**/dist/**'],
|
||||
})
|
||||
|
||||
for (const pkgPath of packageJsonPaths) {
|
||||
try {
|
||||
const content = await readFile(pkgPath, 'utf-8')
|
||||
const pkg = JSON.parse(content)
|
||||
|
||||
// Check all dependency types
|
||||
for (const depType of ['dependencies', 'devDependencies', 'peerDependencies'] as const) {
|
||||
const deps = pkg[depType]
|
||||
if (deps && deps[packageName]) {
|
||||
const version = deps[packageName]
|
||||
consumers.push({
|
||||
path: pkgPath.replace('/package.json', ''),
|
||||
name: pkg.name || pkgPath,
|
||||
dependencyType: depType,
|
||||
version,
|
||||
isPathDep: version.startsWith('file:') || version.startsWith('link:'),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid package.json files
|
||||
}
|
||||
}
|
||||
|
||||
// Search pyproject.toml files
|
||||
const pyprojectPaths = await glob(join(workspace, '**/pyproject.toml'), {
|
||||
ignore: ['**/node_modules/**', '**/.venv/**', '**/venv/**'],
|
||||
})
|
||||
|
||||
for (const pyPath of pyprojectPaths) {
|
||||
try {
|
||||
const content = await readFile(pyPath, 'utf-8')
|
||||
|
||||
// Simple check for package name in dependencies
|
||||
// Python package names might use underscores instead of hyphens
|
||||
const pyPackageName = packageName.replace(/@lilith\//, 'lilith-').replace(/-/g, '[-_]')
|
||||
const regex = new RegExp(pyPackageName, 'i')
|
||||
|
||||
if (regex.test(content)) {
|
||||
consumers.push({
|
||||
path: pyPath.replace('/pyproject.toml', ''),
|
||||
name: pyPath.split('/').slice(-2, -1)[0] || pyPath,
|
||||
dependencyType: 'dependencies',
|
||||
version: '(pyproject.toml)',
|
||||
isPathDep: content.includes('path ='),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search for imports if requested
|
||||
if (options.imports) {
|
||||
spinner.text = 'Searching for import statements...'
|
||||
|
||||
for (const workspace of DEFAULT_CONFIG.workspaces) {
|
||||
const sourceFiles = await glob(join(workspace, '**/*.{ts,tsx,js,jsx}'), {
|
||||
ignore: ['**/node_modules/**', '**/dist/**', '**/*.d.ts'],
|
||||
})
|
||||
|
||||
for (const filePath of sourceFiles) {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Check for import statements
|
||||
if (
|
||||
line.includes(`from '${packageName}`) ||
|
||||
line.includes(`from "${packageName}`) ||
|
||||
line.includes(`require('${packageName}`) ||
|
||||
line.includes(`require("${packageName}`)
|
||||
) {
|
||||
importLocations.push({
|
||||
file: filePath,
|
||||
line: i + 1,
|
||||
content: line.trim(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spinner.stop()
|
||||
|
||||
if (consumers.length === 0) {
|
||||
logWarning(`No consumers found for ${packageName}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log()
|
||||
logInfo(`Found ${consumers.length} consumer(s) of ${packageName}`)
|
||||
console.log()
|
||||
|
||||
// Display consumers
|
||||
const headers = ['Package', 'Dep Type', 'Version', 'Flags']
|
||||
const rows = consumers.map(c => [
|
||||
c.name,
|
||||
colors.dim(c.dependencyType),
|
||||
c.version,
|
||||
c.isPathDep ? colors.warning('PATH') : colors.success('REGISTRY'),
|
||||
])
|
||||
|
||||
printTable(headers, rows)
|
||||
|
||||
// Warn about path dependencies
|
||||
const pathDeps = consumers.filter(c => c.isPathDep)
|
||||
if (pathDeps.length > 0) {
|
||||
console.log()
|
||||
logError(`${pathDeps.length} consumer(s) use path/link dependencies instead of registry`)
|
||||
}
|
||||
|
||||
// Show import locations
|
||||
if (options.imports && importLocations.length > 0) {
|
||||
console.log()
|
||||
logInfo(`Found ${importLocations.length} import location(s):`)
|
||||
console.log()
|
||||
|
||||
for (const loc of importLocations.slice(0, 20)) {
|
||||
console.log(colors.dim(` ${loc.file}:${loc.line}`))
|
||||
console.log(` ${colors.cyan(loc.content)}`)
|
||||
}
|
||||
|
||||
if (importLocations.length > 20) {
|
||||
console.log(colors.dim(` ... and ${importLocations.length - 20} more`))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to search for consumers')
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
324
src/commands/init.ts
Normal file
324
src/commands/init.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { Command } from 'commander'
|
||||
import ora from 'ora'
|
||||
import { mkdir, writeFile, access } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { simpleGit } from 'simple-git'
|
||||
import { colors, logInfo, logError } from '../utils/output.js'
|
||||
|
||||
type PackageType = 'react' | 'nestjs' | 'base' | 'python'
|
||||
|
||||
const TEMPLATES: Record<PackageType, {
|
||||
packageJson: (name: string) => object
|
||||
files: Record<string, string>
|
||||
}> = {
|
||||
react: {
|
||||
packageJson: (name: string) => ({
|
||||
name: `@lilith/${name}`,
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
main: './dist/index.js',
|
||||
module: './dist/index.mjs',
|
||||
types: './dist/index.d.ts',
|
||||
exports: {
|
||||
'.': {
|
||||
import: './dist/index.mjs',
|
||||
require: './dist/index.js',
|
||||
types: './dist/index.d.ts',
|
||||
},
|
||||
},
|
||||
scripts: {
|
||||
build: 'tsup src/index.ts --format cjs,esm --dts',
|
||||
dev: 'tsup src/index.ts --format cjs,esm --dts --watch',
|
||||
lint: 'eslint src/',
|
||||
typecheck: 'tsc --noEmit',
|
||||
},
|
||||
peerDependencies: {
|
||||
react: '>=18.0.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@lilith/eslint-config-react': '^1.0.0',
|
||||
'@lilith/typescript-config-react': '^1.0.0',
|
||||
'@types/react': '^18.2.0',
|
||||
eslint: '^9.0.0',
|
||||
react: '^18.2.0',
|
||||
tsup: '^8.0.0',
|
||||
typescript: '^5.3.0',
|
||||
},
|
||||
publishConfig: {
|
||||
registry: 'http://forge.nasty.sh/api/packages/lilith/npm/',
|
||||
},
|
||||
_: {
|
||||
publish: true,
|
||||
build: true,
|
||||
},
|
||||
}),
|
||||
files: {
|
||||
'src/index.ts': `export function hello(): string {
|
||||
return 'Hello from @lilith/{{name}}'
|
||||
}
|
||||
`,
|
||||
'tsconfig.json': `{
|
||||
"extends": "@lilith/typescript-config-react",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
nestjs: {
|
||||
packageJson: (name: string) => ({
|
||||
name: `@lilith/${name}`,
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
main: './dist/index.js',
|
||||
types: './dist/index.d.ts',
|
||||
scripts: {
|
||||
build: 'tsc',
|
||||
dev: 'tsc --watch',
|
||||
lint: 'eslint src/',
|
||||
typecheck: 'tsc --noEmit',
|
||||
},
|
||||
dependencies: {
|
||||
'@nestjs/common': '^10.0.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@lilith/eslint-config-nestjs': '^1.0.0',
|
||||
'@lilith/typescript-config-nestjs': '^1.0.0',
|
||||
'@types/node': '^22.0.0',
|
||||
eslint: '^9.0.0',
|
||||
typescript: '^5.3.0',
|
||||
},
|
||||
publishConfig: {
|
||||
registry: 'http://forge.nasty.sh/api/packages/lilith/npm/',
|
||||
},
|
||||
_: {
|
||||
publish: true,
|
||||
build: true,
|
||||
},
|
||||
}),
|
||||
files: {
|
||||
'src/index.ts': `export * from './module'
|
||||
`,
|
||||
'src/module.ts': `import { Module } from '@nestjs/common'
|
||||
|
||||
@Module({})
|
||||
export class {{Name}}Module {}
|
||||
`,
|
||||
'tsconfig.json': `{
|
||||
"extends": "@lilith/typescript-config-nestjs",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
base: {
|
||||
packageJson: (name: string) => ({
|
||||
name: `@lilith/${name}`,
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
main: './dist/index.js',
|
||||
types: './dist/index.d.ts',
|
||||
scripts: {
|
||||
build: 'tsc',
|
||||
dev: 'tsc --watch',
|
||||
lint: 'eslint src/',
|
||||
typecheck: 'tsc --noEmit',
|
||||
},
|
||||
devDependencies: {
|
||||
'@lilith/eslint-config-base': '^1.0.0',
|
||||
'@lilith/typescript-config-base': '^1.0.0',
|
||||
'@types/node': '^22.0.0',
|
||||
eslint: '^9.0.0',
|
||||
typescript: '^5.3.0',
|
||||
},
|
||||
publishConfig: {
|
||||
registry: 'http://forge.nasty.sh/api/packages/lilith/npm/',
|
||||
},
|
||||
_: {
|
||||
publish: true,
|
||||
build: true,
|
||||
},
|
||||
}),
|
||||
files: {
|
||||
'src/index.ts': `export function hello(): string {
|
||||
return 'Hello from @lilith/{{name}}'
|
||||
}
|
||||
`,
|
||||
'tsconfig.json': `{
|
||||
"extends": "@lilith/typescript-config-base",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
python: {
|
||||
packageJson: () => ({}), // Not used for Python
|
||||
files: {
|
||||
'pyproject.toml': `[project]
|
||||
name = "lilith-{{name}}"
|
||||
version = "1.0.0"
|
||||
description = "{{name}} package"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
`,
|
||||
'src/{{name_underscore}}/__init__.py': `"""{{name}} package."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
|
||||
def hello() -> str:
|
||||
"""Return a greeting."""
|
||||
return "Hello from lilith-{{name}}"
|
||||
`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function createInitCommand(): Command {
|
||||
return new Command('init')
|
||||
.description('Initialize a new package with standard configs')
|
||||
.argument('<name>', 'Package name (without @lilith/ prefix)')
|
||||
.option('-t, --type <type>', 'Package type: react, nestjs, base, python', 'base')
|
||||
.option('--path <path>', 'Parent directory (default: current directory)')
|
||||
.option('--no-git', 'Skip git initialization')
|
||||
.action(async (name: string, options: {
|
||||
type: string
|
||||
path?: string
|
||||
git: boolean
|
||||
}) => {
|
||||
const packageType = options.type as PackageType
|
||||
|
||||
if (!TEMPLATES[packageType]) {
|
||||
logError(`Invalid package type: ${options.type}`)
|
||||
console.log('Valid types: react, nestjs, base, python')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const basePath = options.path || process.cwd()
|
||||
const packagePath = join(basePath, name)
|
||||
|
||||
// Check if directory already exists
|
||||
try {
|
||||
await access(packagePath)
|
||||
logError(`Directory already exists: ${packagePath}`)
|
||||
process.exit(1)
|
||||
} catch {
|
||||
// Directory doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
const spinner = ora(`Creating ${name}...`).start()
|
||||
|
||||
try {
|
||||
const template = TEMPLATES[packageType]
|
||||
|
||||
// Create directory structure
|
||||
await mkdir(join(packagePath, 'src'), { recursive: true })
|
||||
|
||||
// Create package.json (for TypeScript packages)
|
||||
if (packageType !== 'python') {
|
||||
const pkgJson = template.packageJson(name)
|
||||
await writeFile(
|
||||
join(packagePath, 'package.json'),
|
||||
JSON.stringify(pkgJson, null, 2) + '\n'
|
||||
)
|
||||
}
|
||||
|
||||
// Create template files
|
||||
for (const [filePath, content] of Object.entries(template.files)) {
|
||||
const processedPath = filePath
|
||||
.replace('{{name}}', name)
|
||||
.replace('{{name_underscore}}', name.replace(/-/g, '_'))
|
||||
|
||||
const processedContent = content
|
||||
.replace(/\{\{name\}\}/g, name)
|
||||
.replace(/\{\{Name\}\}/g, toPascalCase(name))
|
||||
.replace(/\{\{name_underscore\}\}/g, name.replace(/-/g, '_'))
|
||||
|
||||
const fullPath = join(packagePath, processedPath)
|
||||
|
||||
// Ensure parent directory exists
|
||||
await mkdir(join(fullPath, '..'), { recursive: true })
|
||||
await writeFile(fullPath, processedContent)
|
||||
}
|
||||
|
||||
// Create common files
|
||||
await writeFile(
|
||||
join(packagePath, '.gitignore'),
|
||||
`node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
`
|
||||
)
|
||||
|
||||
await writeFile(
|
||||
join(packagePath, '.npmrc'),
|
||||
'@lilith:registry=http://forge.nasty.sh/api/packages/lilith/npm/\n'
|
||||
)
|
||||
|
||||
// Initialize git
|
||||
if (options.git) {
|
||||
const git = simpleGit(packagePath)
|
||||
await git.init()
|
||||
}
|
||||
|
||||
spinner.succeed(`Created ${name}`)
|
||||
|
||||
console.log()
|
||||
logInfo(`Package created at: ${packagePath}`)
|
||||
console.log()
|
||||
console.log('Next steps:')
|
||||
console.log(colors.dim(` cd ${name}`))
|
||||
if (packageType !== 'python') {
|
||||
console.log(colors.dim(' pnpm install'))
|
||||
console.log(colors.dim(' pnpm build'))
|
||||
} else {
|
||||
console.log(colors.dim(' python -m venv .venv'))
|
||||
console.log(colors.dim(' source .venv/bin/activate'))
|
||||
console.log(colors.dim(' pip install -e ".[dev]"'))
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create package')
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toPascalCase(str: string): string {
|
||||
return str
|
||||
.split('-')
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
}
|
||||
127
src/commands/publish.ts
Normal file
127
src/commands/publish.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Command } from 'commander'
|
||||
import ora from 'ora'
|
||||
import { findPackages, type PackageInfo } from '../utils/package-json.js'
|
||||
import { getNpmPackageVersion, getPypiPackageVersion, needsPublish } from '../utils/registry.js'
|
||||
import { colors, printTable, logInfo, logWarning, logSuccess } from '../utils/output.js'
|
||||
import { exec } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export function createPublishCommand(): Command {
|
||||
return new Command('publish')
|
||||
.description('Check and publish packages to registry')
|
||||
.option('-s, --status', 'Only show status, do not publish')
|
||||
.option('-p, --package <name>', 'Only check/publish specific package')
|
||||
.option('--dry-run', 'Show what would be published without publishing')
|
||||
.option('--path <path>', 'Path to workspace (default: current directory)')
|
||||
.action(async (options: {
|
||||
status?: boolean
|
||||
package?: string
|
||||
dryRun?: boolean
|
||||
path?: string
|
||||
}) => {
|
||||
const basePath = options.path || process.cwd()
|
||||
const spinner = ora('Finding packages...').start()
|
||||
|
||||
try {
|
||||
let packages = await findPackages(basePath)
|
||||
|
||||
// Filter to publishable packages
|
||||
packages = packages.filter(p => p.shouldPublish)
|
||||
|
||||
// Filter to specific package if requested
|
||||
if (options.package) {
|
||||
packages = packages.filter(p => p.name === options.package)
|
||||
}
|
||||
|
||||
if (packages.length === 0) {
|
||||
spinner.stop()
|
||||
logWarning('No publishable packages found')
|
||||
return
|
||||
}
|
||||
|
||||
spinner.text = 'Checking registry versions...'
|
||||
|
||||
// Get registry versions for all packages
|
||||
const packageStatuses: Array<{
|
||||
pkg: PackageInfo
|
||||
registryVersion: string | null
|
||||
needsPublish: boolean
|
||||
}> = []
|
||||
|
||||
for (const pkg of packages) {
|
||||
const registryVersion =
|
||||
pkg.type === 'npm'
|
||||
? await getNpmPackageVersion(pkg.name)
|
||||
: await getPypiPackageVersion(pkg.name)
|
||||
|
||||
packageStatuses.push({
|
||||
pkg,
|
||||
registryVersion,
|
||||
needsPublish: needsPublish(pkg.version, registryVersion),
|
||||
})
|
||||
}
|
||||
|
||||
spinner.stop()
|
||||
|
||||
console.log()
|
||||
logInfo(`Checked ${packageStatuses.length} packages`)
|
||||
console.log()
|
||||
|
||||
const headers = ['Package', 'Type', 'Local', 'Registry', 'Status']
|
||||
const rows = packageStatuses.map(({ pkg, registryVersion, needsPublish }) => [
|
||||
pkg.name,
|
||||
colors.dim(pkg.type),
|
||||
pkg.version,
|
||||
registryVersion || colors.cyan('(new)'),
|
||||
needsPublish ? colors.warning('needs publish') : colors.success('up to date'),
|
||||
])
|
||||
|
||||
printTable(headers, rows)
|
||||
console.log()
|
||||
|
||||
const toPublish = packageStatuses.filter(p => p.needsPublish)
|
||||
|
||||
if (toPublish.length === 0) {
|
||||
logSuccess('All packages are up to date')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(colors.warning(`${toPublish.length} package(s) need publishing`))
|
||||
console.log()
|
||||
|
||||
// If --status flag, stop here
|
||||
if (options.status) {
|
||||
return
|
||||
}
|
||||
|
||||
// Publish packages
|
||||
for (const { pkg } of toPublish) {
|
||||
if (options.dryRun) {
|
||||
logInfo(`Would publish ${pkg.name}@${pkg.version}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const publishSpinner = ora(`Publishing ${pkg.name}@${pkg.version}...`).start()
|
||||
|
||||
try {
|
||||
if (pkg.type === 'npm') {
|
||||
await execAsync('pnpm publish --no-git-checks', { cwd: pkg.path })
|
||||
} else {
|
||||
// Python packages
|
||||
await execAsync('python -m build && twine upload dist/*', { cwd: pkg.path })
|
||||
}
|
||||
publishSpinner.succeed(`Published ${pkg.name}@${pkg.version}`)
|
||||
} catch (error) {
|
||||
publishSpinner.fail(`Failed to publish ${pkg.name}`)
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to check packages')
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
96
src/commands/status.ts
Normal file
96
src/commands/status.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Command } from 'commander'
|
||||
import ora from 'ora'
|
||||
import { getAllRepoStatuses, type RepoStatus } from '../utils/git.js'
|
||||
import { colors, printTable, logInfo, logWarning } from '../utils/output.js'
|
||||
|
||||
export function createStatusCommand(): Command {
|
||||
return new Command('status')
|
||||
.description('Show status of all package repos in workspace')
|
||||
.option('-d, --dirty', 'Only show repos with changes')
|
||||
.option('-p, --path <path>', 'Path to workspace (default: current directory)')
|
||||
.action(async (options: { dirty?: boolean; path?: string }) => {
|
||||
const basePath = options.path || process.cwd()
|
||||
const spinner = ora('Scanning repositories...').start()
|
||||
|
||||
try {
|
||||
const statuses = await getAllRepoStatuses(basePath, { dirty: options.dirty })
|
||||
spinner.stop()
|
||||
|
||||
if (statuses.length === 0) {
|
||||
if (options.dirty) {
|
||||
logInfo('No repositories with uncommitted changes')
|
||||
} else {
|
||||
logWarning('No repositories found')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
console.log()
|
||||
logInfo(`Found ${statuses.length} ${options.dirty ? 'dirty ' : ''}repositories in ${basePath}`)
|
||||
console.log()
|
||||
|
||||
const headers = ['Package', 'Branch', 'Staged', 'Modified', 'Ahead', 'Status']
|
||||
const rows = statuses.map(formatStatusRow)
|
||||
|
||||
printTable(headers, rows)
|
||||
console.log()
|
||||
|
||||
// Summary
|
||||
const dirty = statuses.filter(s => s.hasChanges).length
|
||||
const unpushed = statuses.filter(s => s.ahead > 0).length
|
||||
|
||||
if (dirty > 0) {
|
||||
console.log(colors.warning(`${dirty} repos with uncommitted changes`))
|
||||
}
|
||||
if (unpushed > 0) {
|
||||
console.log(colors.cyan(`${unpushed} repos with unpushed commits`))
|
||||
}
|
||||
if (dirty === 0 && unpushed === 0) {
|
||||
console.log(colors.success('All repos are clean and up to date'))
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to scan repositories')
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatStatusRow(status: RepoStatus): string[] {
|
||||
const statusIndicator = getStatusIndicator(status)
|
||||
|
||||
return [
|
||||
status.name,
|
||||
colors.dim(status.branch),
|
||||
status.staged > 0 ? colors.success(String(status.staged)) : colors.dim('0'),
|
||||
status.modified > 0 ? colors.warning(String(status.modified)) : colors.dim('0'),
|
||||
status.ahead > 0 ? colors.cyan(String(status.ahead)) : colors.dim('0'),
|
||||
statusIndicator,
|
||||
]
|
||||
}
|
||||
|
||||
function getStatusIndicator(status: RepoStatus): string {
|
||||
const indicators: string[] = []
|
||||
|
||||
if (status.staged > 0) {
|
||||
indicators.push(colors.success('●'))
|
||||
}
|
||||
if (status.modified > 0) {
|
||||
indicators.push(colors.warning('●'))
|
||||
}
|
||||
if (status.untracked > 0) {
|
||||
indicators.push(colors.magenta('●'))
|
||||
}
|
||||
if (status.ahead > 0) {
|
||||
indicators.push(colors.cyan('↑'))
|
||||
}
|
||||
if (status.behind > 0) {
|
||||
indicators.push(colors.error('↓'))
|
||||
}
|
||||
|
||||
if (indicators.length === 0) {
|
||||
return colors.dim('✓')
|
||||
}
|
||||
|
||||
return indicators.join(' ')
|
||||
}
|
||||
33
src/config/defaults.ts
Normal file
33
src/config/defaults.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export const CONFIG_DIR = join(homedir(), '.config', 'lilith')
|
||||
export const CONFIG_FILE = join(CONFIG_DIR, 'cli.yaml')
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
registries: {
|
||||
npm: 'http://forge.nasty.sh/api/packages/lilith/npm/',
|
||||
pypi: 'http://forge.nasty.sh/api/packages/lilith/pypi/',
|
||||
},
|
||||
forgejo: {
|
||||
url: 'https://forge.nasty.sh',
|
||||
api: 'https://forge.nasty.sh/api/v1',
|
||||
},
|
||||
workspaces: [
|
||||
join(homedir(), 'Code/@packages'),
|
||||
join(homedir(), 'Code/@applications'),
|
||||
join(homedir(), 'Code/@services'),
|
||||
],
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
registries: {
|
||||
npm: string
|
||||
pypi: string
|
||||
}
|
||||
forgejo: {
|
||||
url: string
|
||||
api: string
|
||||
}
|
||||
workspaces: string[]
|
||||
}
|
||||
28
src/index.ts
Normal file
28
src/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env node
|
||||
import { Command } from 'commander'
|
||||
import { createStatusCommand } from './commands/status.js'
|
||||
import { createPublishCommand } from './commands/publish.js'
|
||||
import { createBumpCommand } from './commands/bump.js'
|
||||
import { createConsumersCommand } from './commands/consumers.js'
|
||||
import { createCICommand } from './commands/ci.js'
|
||||
import { createInitCommand } from './commands/init.js'
|
||||
import { createCommitsCommand } from './commands/commits.js'
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program
|
||||
.name('bitch')
|
||||
.description('Global development CLI for managing packages across workspaces')
|
||||
.version('1.0.0')
|
||||
|
||||
// Add commands
|
||||
program.addCommand(createStatusCommand())
|
||||
program.addCommand(createPublishCommand())
|
||||
program.addCommand(createBumpCommand())
|
||||
program.addCommand(createConsumersCommand())
|
||||
program.addCommand(createCICommand())
|
||||
program.addCommand(createInitCommand())
|
||||
program.addCommand(createCommitsCommand())
|
||||
|
||||
// Parse arguments
|
||||
program.parse()
|
||||
140
src/utils/forgejo.ts
Normal file
140
src/utils/forgejo.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { request } from 'undici'
|
||||
import { DEFAULT_CONFIG } from '../config/defaults.js'
|
||||
|
||||
export interface WorkflowRun {
|
||||
id: number
|
||||
status: 'success' | 'failure' | 'pending' | 'running' | 'cancelled'
|
||||
conclusion: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
head_branch: string
|
||||
event: string
|
||||
}
|
||||
|
||||
export interface CIStatus {
|
||||
repo: string
|
||||
lastRun: WorkflowRun | null
|
||||
status: 'success' | 'failure' | 'pending' | 'no-runs' | 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Forgejo API token from environment
|
||||
*/
|
||||
function getToken(): string | null {
|
||||
return process.env.FORGEJO_TOKEN || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to the Forgejo API
|
||||
*/
|
||||
async function forgejoRequest<T>(endpoint: string): Promise<T | null> {
|
||||
const token = getToken()
|
||||
|
||||
if (!token) {
|
||||
throw new Error('FORGEJO_TOKEN environment variable not set')
|
||||
}
|
||||
|
||||
const url = `${DEFAULT_CONFIG.forgejo.api}${endpoint}`
|
||||
|
||||
try {
|
||||
const { statusCode, body } = await request(url, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (statusCode === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
throw new Error(`Forgejo API returned ${statusCode}`)
|
||||
}
|
||||
|
||||
return (await body.json()) as T
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('FORGEJO_TOKEN')) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest workflow runs for a repository
|
||||
*/
|
||||
export async function getWorkflowRuns(repoName: string): Promise<WorkflowRun[]> {
|
||||
// Remove @lilith/ prefix if present
|
||||
const cleanName = repoName.replace('@lilith/', '').replace(/^lilith-/, '')
|
||||
|
||||
const response = await forgejoRequest<{ workflow_runs: WorkflowRun[] }>(
|
||||
`/repos/lilith/${cleanName}/actions/runs?per_page=5`
|
||||
)
|
||||
|
||||
return response?.workflow_runs || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CI status for a single repository
|
||||
*/
|
||||
export async function getCIStatus(repoName: string): Promise<CIStatus> {
|
||||
try {
|
||||
const runs = await getWorkflowRuns(repoName)
|
||||
|
||||
if (runs.length === 0) {
|
||||
return {
|
||||
repo: repoName,
|
||||
lastRun: null,
|
||||
status: 'no-runs',
|
||||
}
|
||||
}
|
||||
|
||||
const lastRun = runs[0]
|
||||
let status: CIStatus['status'] = 'pending'
|
||||
|
||||
if (lastRun.status === 'success' || lastRun.conclusion === 'success') {
|
||||
status = 'success'
|
||||
} else if (lastRun.status === 'failure' || lastRun.conclusion === 'failure') {
|
||||
status = 'failure'
|
||||
} else if (lastRun.status === 'running' || lastRun.status === 'pending') {
|
||||
status = 'pending'
|
||||
}
|
||||
|
||||
return {
|
||||
repo: repoName,
|
||||
lastRun,
|
||||
status,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
repo: repoName,
|
||||
lastRun: null,
|
||||
status: 'error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date for display
|
||||
*/
|
||||
export function formatDate(isoDate: string): string {
|
||||
const date = new Date(isoDate)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) {
|
||||
return 'just now'
|
||||
} else if (diffMins < 60) {
|
||||
return `${diffMins}m ago`
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}h ago`
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}d ago`
|
||||
} else {
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
}
|
||||
103
src/utils/git.ts
Normal file
103
src/utils/git.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { simpleGit, SimpleGit, StatusResult } from 'simple-git'
|
||||
import { glob } from 'glob'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
export interface RepoStatus {
|
||||
path: string
|
||||
name: string
|
||||
staged: number
|
||||
modified: number
|
||||
untracked: number
|
||||
ahead: number
|
||||
behind: number
|
||||
branch: string
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all git repositories in a directory up to maxDepth levels
|
||||
*/
|
||||
export async function findGitRepos(basePath: string, maxDepth = 3): Promise<string[]> {
|
||||
const gitDirs = await glob(join(basePath, '**/.git'), {
|
||||
maxDepth: maxDepth + 1,
|
||||
dot: true,
|
||||
})
|
||||
|
||||
// Return parent directories (the actual repo paths)
|
||||
return gitDirs.map(gitDir => dirname(gitDir))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of a git repository
|
||||
*/
|
||||
export async function getRepoStatus(repoPath: string): Promise<RepoStatus> {
|
||||
const git: SimpleGit = simpleGit(repoPath)
|
||||
const status: StatusResult = await git.status()
|
||||
|
||||
// Try to get package name from package.json or pyproject.toml
|
||||
let name = repoPath.split('/').pop() || repoPath
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
const pkgPath = join(repoPath, 'package.json')
|
||||
const pyprojectPath = join(repoPath, 'pyproject.toml')
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'))
|
||||
name = pkg.name || name
|
||||
} catch {
|
||||
// Try pyproject.toml
|
||||
try {
|
||||
const content = await fs.readFile(pyprojectPath, 'utf-8')
|
||||
const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m)
|
||||
if (nameMatch) {
|
||||
name = nameMatch[1]
|
||||
}
|
||||
} catch {
|
||||
// Use directory name
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Use directory name
|
||||
}
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
name,
|
||||
staged: status.staged.length,
|
||||
modified: status.modified.length + status.not_added.length,
|
||||
untracked: status.not_added.length,
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
branch: status.current || 'unknown',
|
||||
hasChanges:
|
||||
status.staged.length > 0 ||
|
||||
status.modified.length > 0 ||
|
||||
status.not_added.length > 0 ||
|
||||
status.ahead > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all repo statuses for a workspace
|
||||
*/
|
||||
export async function getAllRepoStatuses(
|
||||
basePath: string,
|
||||
options: { dirty?: boolean } = {}
|
||||
): Promise<RepoStatus[]> {
|
||||
const repoPaths = await findGitRepos(basePath)
|
||||
const statuses: RepoStatus[] = []
|
||||
|
||||
for (const repoPath of repoPaths) {
|
||||
try {
|
||||
const status = await getRepoStatus(repoPath)
|
||||
if (!options.dirty || status.hasChanges) {
|
||||
statuses.push(status)
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip repos that fail to get status (maybe not initialized properly)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name
|
||||
return statuses.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
82
src/utils/output.ts
Normal file
82
src/utils/output.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import chalk from 'chalk'
|
||||
|
||||
export const colors = {
|
||||
success: chalk.green,
|
||||
error: chalk.red,
|
||||
warning: chalk.yellow,
|
||||
info: chalk.blue,
|
||||
dim: chalk.dim,
|
||||
bold: chalk.bold,
|
||||
cyan: chalk.cyan,
|
||||
magenta: chalk.magenta,
|
||||
}
|
||||
|
||||
export function logSuccess(message: string): void {
|
||||
console.log(colors.success('✓'), message)
|
||||
}
|
||||
|
||||
export function logError(message: string): void {
|
||||
console.log(colors.error('✗'), message)
|
||||
}
|
||||
|
||||
export function logWarning(message: string): void {
|
||||
console.log(colors.warning('⚠'), message)
|
||||
}
|
||||
|
||||
export function logInfo(message: string): void {
|
||||
console.log(colors.info('ℹ'), message)
|
||||
}
|
||||
|
||||
export function logDim(message: string): void {
|
||||
console.log(colors.dim(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a table with aligned columns
|
||||
*/
|
||||
export function printTable(
|
||||
headers: string[],
|
||||
rows: string[][],
|
||||
options: { padding?: number } = {}
|
||||
): void {
|
||||
const { padding = 2 } = options
|
||||
|
||||
// Calculate column widths
|
||||
const columnWidths = headers.map((header, i) => {
|
||||
const maxDataWidth = Math.max(...rows.map(row => (row[i] || '').length))
|
||||
return Math.max(header.length, maxDataWidth)
|
||||
})
|
||||
|
||||
// Print header
|
||||
const headerLine = headers
|
||||
.map((h, i) => colors.bold(h.padEnd(columnWidths[i] + padding)))
|
||||
.join('')
|
||||
console.log(headerLine)
|
||||
|
||||
// Print separator
|
||||
const separator = columnWidths
|
||||
.map(w => colors.dim('─'.repeat(w + padding)))
|
||||
.join('')
|
||||
console.log(separator)
|
||||
|
||||
// Print rows
|
||||
for (const row of rows) {
|
||||
const line = row
|
||||
.map((cell, i) => (cell || '').padEnd(columnWidths[i] + padding))
|
||||
.join('')
|
||||
console.log(line)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a version comparison for display
|
||||
*/
|
||||
export function formatVersionComparison(local: string, remote: string | null): string {
|
||||
if (remote === null) {
|
||||
return colors.cyan('(not published)')
|
||||
}
|
||||
if (local === remote) {
|
||||
return colors.dim(remote)
|
||||
}
|
||||
return `${colors.dim(remote)} → ${colors.success(local)}`
|
||||
}
|
||||
173
src/utils/package-json.ts
Normal file
173
src/utils/package-json.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { glob } from 'glob'
|
||||
|
||||
export interface PackageJson {
|
||||
name: string
|
||||
version: string
|
||||
private?: boolean
|
||||
_?: {
|
||||
publish?: boolean
|
||||
build?: boolean | string
|
||||
registry?: string
|
||||
}
|
||||
publishConfig?: {
|
||||
registry?: string
|
||||
}
|
||||
scripts?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface PyProjectToml {
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface PackageInfo {
|
||||
path: string
|
||||
name: string
|
||||
version: string
|
||||
type: 'npm' | 'pypi'
|
||||
shouldPublish: boolean
|
||||
raw: PackageJson | PyProjectToml
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a package.json file
|
||||
*/
|
||||
export async function readPackageJson(filePath: string): Promise<PackageJson> {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
return JSON.parse(content) as PackageJson
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a package.json file (preserves formatting)
|
||||
*/
|
||||
export async function writePackageJson(filePath: string, pkg: PackageJson): Promise<void> {
|
||||
const content = JSON.stringify(pkg, null, 2) + '\n'
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a pyproject.toml file and extract name/version
|
||||
*/
|
||||
export async function readPyProjectToml(filePath: string): Promise<PyProjectToml | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
|
||||
// Simple parsing for name and version
|
||||
const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m)
|
||||
const versionMatch = content.match(/^version\s*=\s*"([^"]+)"/m)
|
||||
|
||||
if (!nameMatch || !versionMatch) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
name: nameMatch[1],
|
||||
version: versionMatch[1],
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update version in pyproject.toml
|
||||
*/
|
||||
export async function updatePyProjectVersion(filePath: string, newVersion: string): Promise<void> {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const updated = content.replace(
|
||||
/^(version\s*=\s*)"[^"]+"/m,
|
||||
`$1"${newVersion}"`
|
||||
)
|
||||
await writeFile(filePath, updated, 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all packages in a workspace
|
||||
*/
|
||||
export async function findPackages(basePath: string): Promise<PackageInfo[]> {
|
||||
const packages: PackageInfo[] = []
|
||||
|
||||
// Find package.json files (exclude node_modules)
|
||||
const packageJsonPaths = await glob(join(basePath, '**/package.json'), {
|
||||
ignore: ['**/node_modules/**', '**/dist/**'],
|
||||
})
|
||||
|
||||
for (const pkgPath of packageJsonPaths) {
|
||||
try {
|
||||
const pkg = await readPackageJson(pkgPath)
|
||||
|
||||
// Skip private packages or those without publish config
|
||||
const shouldPublish =
|
||||
!pkg.private && (pkg._?.publish === true || !!pkg.publishConfig?.registry)
|
||||
|
||||
packages.push({
|
||||
path: pkgPath.replace('/package.json', ''),
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
type: 'npm',
|
||||
shouldPublish,
|
||||
raw: pkg,
|
||||
})
|
||||
} catch {
|
||||
// Skip invalid package.json files
|
||||
}
|
||||
}
|
||||
|
||||
// Find pyproject.toml files
|
||||
const pyprojectPaths = await glob(join(basePath, '**/pyproject.toml'), {
|
||||
ignore: ['**/node_modules/**', '**/.venv/**', '**/venv/**'],
|
||||
})
|
||||
|
||||
for (const pyPath of pyprojectPaths) {
|
||||
try {
|
||||
const pyproject = await readPyProjectToml(pyPath)
|
||||
if (pyproject) {
|
||||
packages.push({
|
||||
path: pyPath.replace('/pyproject.toml', ''),
|
||||
name: pyproject.name,
|
||||
version: pyproject.version,
|
||||
type: 'pypi',
|
||||
shouldPublish: true, // Assume all Python packages should be published
|
||||
raw: pyproject,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid pyproject.toml files
|
||||
}
|
||||
}
|
||||
|
||||
return packages.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump a version string
|
||||
*/
|
||||
export function bumpVersion(
|
||||
currentVersion: string,
|
||||
type: 'major' | 'minor' | 'patch'
|
||||
): string {
|
||||
const parts = currentVersion.split('.').map(Number)
|
||||
|
||||
while (parts.length < 3) {
|
||||
parts.push(0)
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'major':
|
||||
parts[0]++
|
||||
parts[1] = 0
|
||||
parts[2] = 0
|
||||
break
|
||||
case 'minor':
|
||||
parts[1]++
|
||||
parts[2] = 0
|
||||
break
|
||||
case 'patch':
|
||||
parts[2]++
|
||||
break
|
||||
}
|
||||
|
||||
return parts.join('.')
|
||||
}
|
||||
98
src/utils/registry.ts
Normal file
98
src/utils/registry.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { request } from 'undici'
|
||||
import { DEFAULT_CONFIG } from '../config/defaults.js'
|
||||
|
||||
export interface PackageVersion {
|
||||
name: string
|
||||
localVersion: string
|
||||
registryVersion: string | null
|
||||
needsPublish: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest version of an NPM package from the registry
|
||||
*/
|
||||
export async function getNpmPackageVersion(packageName: string): Promise<string | null> {
|
||||
const encodedName = packageName.replace('/', '%2F')
|
||||
const url = `${DEFAULT_CONFIG.registries.npm}${encodedName}`
|
||||
|
||||
try {
|
||||
const { statusCode, body } = await request(url)
|
||||
|
||||
if (statusCode === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
throw new Error(`Registry returned ${statusCode}`)
|
||||
}
|
||||
|
||||
const data = await body.json() as { 'dist-tags'?: { latest?: string } }
|
||||
return data['dist-tags']?.latest || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest version of a PyPI package from the registry
|
||||
*/
|
||||
export async function getPypiPackageVersion(packageName: string): Promise<string | null> {
|
||||
const url = `${DEFAULT_CONFIG.registries.pypi}simple/${packageName}/`
|
||||
|
||||
try {
|
||||
const { statusCode, body } = await request(url)
|
||||
|
||||
if (statusCode === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
return null
|
||||
}
|
||||
|
||||
// PyPI simple index returns HTML with links to versions
|
||||
const html = await body.text()
|
||||
|
||||
// Extract versions from href attributes like "package-1.0.0.tar.gz"
|
||||
const versionRegex = new RegExp(`${packageName.replace(/-/g, '[-_]')}-([\\d.]+)`, 'gi')
|
||||
const matches = [...html.matchAll(versionRegex)]
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Return the highest version
|
||||
const versions = matches.map(m => m[1])
|
||||
return versions.sort((a, b) => compareVersions(b, a))[0] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings
|
||||
*/
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const partsA = a.split('.').map(Number)
|
||||
const partsB = b.split('.').map(Number)
|
||||
|
||||
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
||||
const numA = partsA[i] || 0
|
||||
const numB = partsB[i] || 0
|
||||
|
||||
if (numA > numB) return 1
|
||||
if (numA < numB) return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a package version needs to be published
|
||||
*/
|
||||
export function needsPublish(localVersion: string, registryVersion: string | null): boolean {
|
||||
if (registryVersion === null) {
|
||||
return true
|
||||
}
|
||||
return compareVersions(localVersion, registryVersion) > 0
|
||||
}
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "@lilith/configs/typescript/base",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue