/// /** * The apps beside this one, found rather than named (UIG-20). * * `estiva-ui find` with nothing else typed searches the package, the app it is * run in, and every app that sits beside it. Katerina's condition for having no * shared Storybook is that one search, run unasked from any repository, covers * every catalogue; a list of names written into each repository's script would * be the drifting copy the gates exist to remove, and would miss the next app. * * An app is a folder, or one folder inside it, whose `package.json` depends on * `@estiva-app/ui` and which has a `src` to read. A service that does not * install the package is not an app of this catalogue until it does. * * Each app is searched once. A second checkout of the same app (a worktree, or * somebody's copy) carries the same package name; the app the search runs in * wins, then the one whose top folder has the shortest name. */ import { execFileSync } from 'node:child_process' import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' export const PACKAGE_NAME = '@estiva-app/ui' export interface Sibling { /** What its entries are called: its top folder's name. */ name: string /** The folder the catalogue is built from. */ folder: string /** Its `package.json` name, which says two checkouts are one app. */ packageName: string } function manifest(folder: string): { name?: string; dependencies?: Record; devDependencies?: Record } | null { const file = join(folder, 'package.json') if (!existsSync(file)) return null try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null } } /** The name of the app in `folder`, or null when the folder is not one. */ export function appName(folder: string): string | null { const m = manifest(folder) if (!m || m.name === PACKAGE_NAME) return null const uses = Boolean(m.dependencies?.[PACKAGE_NAME] ?? m.devDependencies?.[PACKAGE_NAME]) return uses && existsSync(join(folder, 'src')) ? (m.name ?? basename(folder)) : null } const isDir = (path: string) => { try { return statSync(path).isDirectory() } catch { return false } } /** * The folder that holds the repositories: the parent of the repository `from` * is in, or `from` itself when it is in none (a folder that only holds them). */ export function workspaceOf(from: string): string { let at = resolve(from) for (;;) { if (existsSync(join(at, '.git'))) return dirname(at) const up = dirname(at) if (up === at) return resolve(from) at = up } } /** Every app in `workspace`, one per package name, except those named in `skip`. */ export function findSiblings(workspace: string, { skip = [] }: { skip?: string[] } = {}): Sibling[] { let tops: string[] try { tops = readdirSync(workspace).filter((name) => !name.startsWith('.') && name !== 'node_modules' && isDir(join(workspace, name))) } catch { return [] } const found: Sibling[] = [] for (const top of tops) { const folder = join(workspace, top) const inside = readdirSync(folder).filter((name) => !name.startsWith('.') && name !== 'node_modules' && isDir(join(folder, name))) for (const candidate of [folder, ...inside.map((name) => join(folder, name))]) { const packageName = appName(candidate) if (packageName) found.push({ name: top, folder: candidate, packageName }) } } const byPackage = new Map() for (const sibling of found.sort((a, b) => a.name.length - b.name.length || a.name.localeCompare(b.name))) { if (skip.includes(sibling.packageName) || byPackage.has(sibling.packageName)) continue byPackage.set(sibling.packageName, sibling) } return [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name)) } /** * How many commits the checkout that holds `folder` is behind its remote's main, * as last fetched: nothing is fetched, so a search stays quick. Null when it is * not a git checkout or has no remote main. A search that reads a stale copy * answers with parts that are gone (the re-review after the audit before UIG-26: * "files panel" found Peek's deleted FilesPanel in an old checkout), so `find` * says so beside its answer. */ export function behindBy(folder: string): number | null { const git = (...a: string[]) => { try { return execFileSync('git', ['-C', folder, ...a], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).toString().trim() } catch { return null } } const main = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD') ?? (git('rev-parse', '--verify', '--quiet', 'origin/main') ? 'origin/main' : null) if (!main) return null const count = Number(git('rev-list', '--count', `HEAD..${main}`)) return Number.isFinite(count) ? count : null }