// project detection — finds project root, detects framework, scans monorepo workspaces import { existsSync, readFileSync, readdirSync, statSync } from 'fs' import { basename, dirname, join } from 'path' export type Framework = 'one' | 'expo' | 'bare' | 'unknown' export type PackageManager = 'bun' | 'pnpm' | 'npm' | 'yarn' export type MonorepoType = 'turbo' | 'pnpm' | 'nx' | 'npm-workspaces' | 'single' export interface DetectedProject { dir: string name: string framework: Framework hasViteConfig: boolean hasMetroConfig: boolean devCommand: string | null } export interface DetectedWorkspace { root: string type: MonorepoType packageManager: PackageManager apps: DetectedProject[] } const CONFIG_EXTENSIONS = ['ts', 'js', 'cjs', 'mjs', 'mts', 'cts'] export function findProjectRoot(cwd: string): string { let dir = cwd while (true) { if ( existsSync(join(dir, 'turbo.json')) || existsSync(join(dir, 'nx.json')) || existsSync(join(dir, 'lerna.json')) || existsSync(join(dir, 'pnpm-workspace.yaml')) ) { return dir } const pkgPath = join(dir, 'package.json') if (existsSync(pkgPath)) { try { const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) if (pkg.workspaces) return dir } catch {} } const parent = dirname(dir) if (parent === dir) break dir = parent } return cwd } export function detectPackageManager(root: string): PackageManager { if (existsSync(join(root, 'bun.lock')) || existsSync(join(root, 'bun.lockb'))) { return 'bun' } if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm' if (existsSync(join(root, 'yarn.lock'))) return 'yarn' return 'npm' } export function detectMonorepoType(root: string): MonorepoType { if (existsSync(join(root, 'turbo.json'))) return 'turbo' if (existsSync(join(root, 'nx.json'))) return 'nx' if (existsSync(join(root, 'pnpm-workspace.yaml'))) return 'pnpm' const pkgPath = join(root, 'package.json') if (existsSync(pkgPath)) { try { const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) if (pkg.workspaces) return 'npm-workspaces' } catch {} } return 'single' } export function detectFramework(deps: Record): Framework { if (deps.one) return 'one' if (deps.expo) return 'expo' if (deps['react-native']) return 'bare' return 'unknown' } export function detectProject(dir: string): DetectedProject | null { const pkgPath = join(dir, 'package.json') if (!existsSync(pkgPath)) return null let pkg: any try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { return null } const deps = { ...pkg.dependencies, ...pkg.devDependencies } const framework = detectFramework(deps) if (framework === 'unknown') return null return { dir, name: pkg.name || basename(dir), framework, // a config file is a config file whatever module system it declares. real // apps write .cjs as often as .js, and missing one reads as "this project // has no bundler config" and sends the user to create a second one. hasViteConfig: CONFIG_EXTENSIONS.some((ext) => existsSync(join(dir, `vite.config.${ext}`)), ), hasMetroConfig: CONFIG_EXTENSIONS.some((ext) => existsSync(join(dir, `metro.config.${ext}`)), ), devCommand: pkg.scripts?.dev || null, } } function getWorkspaceDirs(root: string): string[] { const dirs: string[] = [] const pnpmWs = join(root, 'pnpm-workspace.yaml') if (existsSync(pnpmWs)) { const content = readFileSync(pnpmWs, 'utf8') const match = content.match(/packages:\s*\n((?:\s+-\s+.+\n?)+)/) if (match) { const lines = match[1].split('\n').filter(Boolean) for (const line of lines) { const glob = line.replace(/^\s*-\s*['"]?/, '').replace(/['"]?\s*$/, '') if (glob) dirs.push(...expandGlob(root, glob)) } } return dirs } const pkgPath = join(root, 'package.json') if (existsSync(pkgPath)) { try { const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages || [] for (const glob of workspaces) { dirs.push(...expandGlob(root, glob)) } } catch {} } return dirs } function expandGlob(root: string, pattern: string): string[] { const clean = pattern.replace(/\/\*\*?$/, '').replace(/\*$/, '') const baseDir = join(root, clean) if (!existsSync(baseDir)) return [] try { const entries = readdirSync(baseDir) return entries .map((entry) => join(baseDir, entry)) .filter((path) => { try { return statSync(path).isDirectory() && existsSync(join(path, 'package.json')) } catch { return false } }) } catch { return [] } } export function scanWorkspace(root: string): DetectedWorkspace { const type = detectMonorepoType(root) const packageManager = detectPackageManager(root) if (type === 'single') { const project = detectProject(root) return { root, type, packageManager, apps: project ? [project] : [], } } const workspaceDirs = getWorkspaceDirs(root) const apps: DetectedProject[] = [] const rootProject = detectProject(root) if (rootProject) apps.push(rootProject) for (const dir of workspaceDirs) { if (dir === root) continue const project = detectProject(dir) if (project) apps.push(project) } return { root, type, packageManager, apps } }