import { spawn } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { rnxPublicBrand } from '../src/public-brand' export type SetupPackageManager = 'bun' | 'pnpm' | 'npm' export interface RepositoryOperation { command: string args: string[] description: string } export interface RepositorySetupPlan { appDir: string packageManager: SetupPackageManager operations: RepositoryOperation[] } interface PackageManifest { scripts?: Record devDependencies?: Record } export function findRepositoryPackageRoot(startDir: string): string | null { let current = path.resolve(startDir) let packageRoot: string | null = null while (true) { if (!packageRoot && fs.existsSync(path.join(current, 'package.json'))) { packageRoot = current } if (fs.existsSync(path.join(current, '.git'))) return packageRoot const parent = path.dirname(current) if (parent === current) return null current = parent } } export function detectPreferredPackageManager(startDir: string): SetupPackageManager { let current = path.resolve(startDir) while (true) { if (fs.existsSync(path.join(current, 'bun.lock'))) return 'bun' if (fs.existsSync(path.join(current, 'bun.lockb'))) return 'bun' if (fs.existsSync(path.join(current, 'pnpm-lock.yaml'))) return 'pnpm' if (fs.existsSync(path.join(current, 'package-lock.json'))) return 'npm' const parent = path.dirname(current) if (parent === current) return 'npm' current = parent } } export function planRepositorySetup(input: { appDir: string cliVersion: string }): RepositorySetupPlan { const appDir = path.resolve(input.appDir) const manifest = readManifest(path.join(appDir, 'package.json')) const expectedScript = `${rnxPublicBrand.commandName} open` const existingScript = manifest.scripts?.[rnxPublicBrand.commandName] if (existingScript && existingScript !== expectedScript) { throw new Error( `package.json already defines scripts.${rnxPublicBrand.commandName} as ${JSON.stringify(existingScript)}; resolve that conflict before setup`, ) } const packageManager = detectPreferredPackageManager(appDir) const operations: RepositoryOperation[] = [] if (manifest.devDependencies?.[rnxPublicBrand.packageName] !== input.cliVersion) { operations.push(addDependencyOperation(packageManager, input.cliVersion)) } if (existingScript !== expectedScript) { operations.push(scriptOperation(packageManager)) } return { appDir, packageManager, operations, } } export async function applyRepositorySetup( plan: RepositorySetupPlan, options: { dryRun: boolean; json: boolean }, ): Promise { for (const operation of plan.operations) { if (options.dryRun) continue await runOperation(operation, plan.appDir, options.json) } } export function formatRepositoryOperations(plan: RepositorySetupPlan): string[] { return plan.operations.map( (operation) => `${operation.command} ${operation.args.map(quoteArg).join(' ')}`, ) } function readManifest(packagePath: string): PackageManifest { let parsed: unknown try { parsed = JSON.parse(fs.readFileSync(packagePath, 'utf8')) } catch (error) { const message = error instanceof Error ? error.message : String(error) throw new Error(`could not read ${packagePath}: ${message}`) } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`${packagePath} must contain a JSON object`) } const scripts = readStringRecord(Reflect.get(parsed, 'scripts')) const devDependencies = readStringRecord(Reflect.get(parsed, 'devDependencies')) return { ...(scripts ? { scripts } : {}), ...(devDependencies ? { devDependencies } : {}), } } function readStringRecord(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null const entries = Object.entries(value).filter( (entry): entry is [string, string] => typeof entry[1] === 'string', ) return Object.fromEntries(entries) } function addDependencyOperation( packageManager: SetupPackageManager, version: string, ): RepositoryOperation { const spec = `${rnxPublicBrand.packageName}@${version}` switch (packageManager) { case 'bun': return { command: 'bun', args: ['add', '--dev', '--exact', spec], description: `install ${spec} as an exact dev dependency`, } case 'pnpm': return { command: 'pnpm', args: ['add', '--save-dev', '--save-exact', spec], description: `install ${spec} as an exact dev dependency`, } case 'npm': return { command: 'npm', args: ['install', '--save-dev', '--save-exact', spec], description: `install ${spec} as an exact dev dependency`, } } } function quoteArg(value: string): string { return /^[a-zA-Z0-9_./@=-]+$/.test(value) ? value : JSON.stringify(value) } function runOperation( operation: RepositoryOperation, cwd: string, json: boolean, ): Promise { return new Promise((resolve, reject) => { const child = spawn(operation.command, operation.args, { cwd, stdio: json ? ['ignore', 2, 'inherit'] : 'inherit', }) child.once('error', reject) child.once('exit', (code) => { if (code === 0) resolve() else reject(new Error(`${operation.command} exited with code ${code ?? 'unknown'}`)) }) }) } function scriptOperation(packageManager: SetupPackageManager): RepositoryOperation { return { command: packageManager, args: packageManager === 'bun' ? [ 'pm', 'pkg', 'set', `scripts.${rnxPublicBrand.commandName}=${rnxPublicBrand.commandName} open`, ] : [ 'pkg', 'set', `scripts.${rnxPublicBrand.commandName}=${rnxPublicBrand.commandName} open`, ], description: `add the \`${rnxPublicBrand.commandName}\` package script`, } }