fix(@cli/bitch): 🐛 resolve linting issues

This commit is contained in:
Lilith 2026-01-10 06:52:54 -08:00
commit 414af8d86b
20 changed files with 6120 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store

1
.npmrc Normal file
View file

@ -0,0 +1 @@
@lilith:registry=http://forge.nasty.sh/api/packages/lilith/npm/

2
bin/bitch.js Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/index.js'

4256
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

52
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}