///
/**
* `estiva-ui` — the catalogue as a command (UIG-12, UIG-13; docs/GATES.md §23
* for why it ships in the package rather than as a script pasted into each repo).
*
* estiva-ui find [--here | --also [name=]…] [--json] [--limit n]
* estiva-ui build [--out ]
* estiva-ui check
* estiva-ui skill [--out ]
*
* Every command takes `--root ` (the library it reads; default: here)
* and `--repo ` (what an app's entries are called; default: its package name).
*
* **In the package** (`@estiva-app/ui` itself): `build` writes the committed
* `registry.json`, and `check` rebuilds it and fails on a single byte of drift.
*
* **In an app**: nothing is committed (Katerina, 18 September 2026 — the
* package is public, the apps are private). `check` builds the app's catalogue
* and fails when a part has no one-line description or cannot be sorted, or
* when a reusable part breaks the usage-page contract (UIG-19, `./contract`),
* or when the app's hand-written map of its Storybook no longer matches it
* (`./storymap`); CI's `gate` job runs it. `build` writes the catalogue to a file only when asked.
*
* `find` searches the package's catalogue — the one shipped inside the installed
* package, or this repo's own in the package — plus, in an app, the app's own,
* built fresh, plus every app beside it (UIG-20, `./siblings`): found, not
* named, so one command answers the same from any repository, or from the
* folder that holds them. `--here` searches no neighbour; `--also` names them.
*
* `skill` writes the repository's Claude skill loader (UIG-20, `./skill`), or
* the one for `--out `; `check` fails when the repository's is missing
* or differs.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { CONTRACT_KINDS, contractProblems, linkProblems, nameableParts, nameProblems } from './contract'
import { findInRegistries, formatFindings } from './find'
import { behindBy, findSiblings, workspaceOf } from './siblings'
import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
import { indexHeadings, storyMapProblems } from './storymap'
import { CLASSES, validateRegistry, type Registry } from './schema'
/**
* The builders, loaded only when they are used.
*
* They read TypeScript with TypeScript, and `typescript` is a dev dependency of
* the app that installs this package — a real one in Peek, Ship and this repo,
* and possibly absent elsewhere. A static import made the bundler put the whole
* builder in the same chunk as `find`, so `npx estiva-ui find …` died with
* ERR_MODULE_NOT_FOUND before opening the file it needed.
*/
const packageBuilder = () => import('./build')
const appBuilder = () => import('./app')
const gatedScan = () => import('./gated')
const [command, ...rest] = process.argv.slice(2)
const VALUED = ['--limit', '--out', '--file', '--root', '--repo', '--also']
const flag = (name: string) => rest.includes(`--${name}`)
const value = (name: string) => {
const at = rest.indexOf(`--${name}`)
return at === -1 ? undefined : rest[at + 1]
}
const values = (name: string) => rest.flatMap((word, i) => (word === `--${name}` && rest[i + 1] !== undefined ? [rest[i + 1]] : []))
/** Everything that is not a flag or a flag's value: the words of the question. */
const plain = () => {
const out: string[] = []
for (let i = 0; i < rest.length; i++) {
if (rest[i].startsWith('--')) {
if (VALUED.includes(rest[i])) i += 1
continue
}
out.push(rest[i])
}
return out
}
const root = resolve(value('root') ?? '.')
const manifestAt = (folder: string) => JSON.parse(readFileSync(resolve(folder, 'package.json'), 'utf8')) as { name?: string }
/** The package itself, or an app that installs it. */
const isPackage = existsSync(resolve(root, 'package.json')) && manifestAt(root).name === '@estiva-app/ui'
/** The package's committed catalogue: `--file`, this repo's own in the package, else the copy inside the installed package. */
function packageRegistryPath(): string {
const named = value('file')
if (named) return resolve(named)
if (isPackage) return resolve(root, 'registry.json')
// dist/registry/cli.js → the package's own root. Never an app's own folder:
// a registry.json there is the app's, written by `estiva-ui build`.
return fileURLToPath(new URL('../../registry.json', import.meta.url))
}
function readPackageRegistry(): Registry {
const path = packageRegistryPath()
if (!existsSync(path)) throw new Error(`no registry.json at ${path} — run "estiva-ui build" in the package first`)
const registry = JSON.parse(readFileSync(path, 'utf8')) as Registry
const problems = validateRegistry(registry)
// A malformed catalogue is worse than none: it would answer, wrongly.
if (problems.length) throw new Error(`${path} does not match the schema:\n ${problems.join('\n ')}`)
return registry
}
/** The installed package whose skill text a loader reads: this repo in the package, else the app's install. */
const packageRoot = () => (isPackage ? root : resolve(root, 'node_modules', '@estiva-app', 'ui'))
/** The Claude skill loader (UIG-20): reports, and says whether it passed. */
function checkLoader(): boolean {
const problem = loaderProblem(repositoryOf(root), packageRoot())
if (problem) {
process.stderr.write(`claude skill: ${problem}\n`)
return false
}
process.stdout.write('claude skill: the loader reads the package’s text\n')
return true
}
async function buildApp(folder: string, repo: string | undefined): Promise {
const { buildAppRegistry } = await appBuilder()
const registry = buildAppRegistry({ root: folder, repo })
const problems = validateRegistry(registry)
if (problems.length) throw new Error(`the catalogue of ${registry.builtFrom.repo} does not match its own schema:\n ${problems.join('\n ')}`)
return registry
}
/**
* Every story and docs link in a catalogue, against the Storybook beside it
* (UIG-19). `storybook index` writes the ids Storybook itself would serve — no
* server, no browser, seconds — so a renamed story, or a page that no longer
* compiles, fails here rather than as a dead link somebody finds later.
* Reports, and says whether it passed. A folder with no Storybook has nothing
* to check.
*/
function checkLinks(registry: Registry, folder: string): { ok: boolean; headings?: Set; titles?: Set } {
const bin = resolve(folder, 'node_modules', '.bin', process.platform === 'win32' ? 'storybook.cmd' : 'storybook')
if (!existsSync(resolve(folder, '.storybook')) || !existsSync(bin)) {
process.stdout.write('story links: no Storybook here, nothing to check\n')
return { ok: true }
}
const out = join(mkdtempSync(join(tmpdir(), 'estiva-ui-index-')), 'index.json')
// A `.cmd` needs a shell on Windows, and Node wants that as one quoted line
// rather than an argument list. Both paths are ours: the app's folder and a temp file.
const run =
process.platform === 'win32'
? spawnSync(`"${bin}" index -o "${out}" --quiet`, { cwd: folder, encoding: 'utf8', shell: true })
: spawnSync(bin, ['index', '-o', out, '--quiet'], { cwd: folder, encoding: 'utf8' })
if (run.status !== 0 || !existsSync(out)) {
process.stderr.write(`story links: Storybook could not index its stories, so no link can be trusted:\n${(run.stderr || run.stdout || '').trim()}\n`)
return { ok: false }
}
const entries = (JSON.parse(readFileSync(out, 'utf8')) as { entries: Record }).entries
const ids = new Set(Object.keys(entries))
const headings = indexHeadings(entries)
const titles = new Set(Object.values(entries).flatMap((e) => (e.title ? [e.title] : [])))
const dead = linkProblems(registry, ids)
const count = registry.entries.reduce((n, entry) => n + (entry.storyId ? 1 : 0) + (entry.docsId ? 1 : 0), 0)
if (dead.length) {
process.stderr.write(`story links: ${dead.length} of ${count} lead nowhere:\n ${dead.join('\n ')}\n`)
return { ok: false, headings, titles }
}
process.stdout.write(`story links: all ${count} open a story or docs page Storybook has\n`)
return { ok: true, headings, titles }
}
/**
* What a part draws only when a prop is passed, where no story passes it
* (UIG-19, `./gated`). Strict, by Katerina's ruling: a part's own stories show
* everything it can draw. In an app it reads the reusable parts; in the package,
* every component (`kinds` is the package's one class, `component`).
*/
async function checkGated(registry: Registry, folder: string, kinds?: string[]): Promise {
const { gatedFindings } = await gatedScan()
const found = gatedFindings(registry, folder, { ...(kinds ? { kinds } : {}), pictured: 'own' })
if (found.length) {
const lines = found.map((f) => `${f.part} (${f.file}) draws something only when given \`${f.prop}\`, and no story that draws it passes \`${f.prop}\`: pass it in a story`)
process.stderr.write(`stories: ${found.length === 1 ? 'one thing a part draws is' : `${found.length} things parts draw are`} never shown:\n ${lines.join('\n ')}\n`)
return false
}
process.stdout.write('stories: everything a part draws behind a prop is shown by a story\n')
return true
}
/**
* The repository's hand-written map of its Storybook — the heading order and the
* Introduction — against the Storybook (its own index, when there is one) and,
* in an app, the catalogue (`./storymap`). Reports, and says whether it passed.
*/
function checkStoryMap(folder: string, registry?: Registry, headings?: Set): boolean {
const problems = storyMapProblems(folder, { ...(registry ? { registry } : {}), ...(headings ? { real: headings } : {}) })
if (problems.length) {
process.stderr.write(`storybook map: ${problems.length === 1 ? 'one line no longer matches' : `${problems.length} lines no longer match`}:\n ${problems.join('\n ')}\n`)
return false
}
process.stdout.write('storybook map: the heading order and the Introduction match the Storybook and the catalogue\n')
return true
}
/** One line per kind, in a fixed order, so two runs read the same. */
function summary(registry: Registry): string {
const count = (cls: string) => registry.entries.filter((entry) => entry.app?.class === cls).length
const kinds = CLASSES.map((cls) => `${count(cls)} ${cls}`).join(', ')
const files = registry.builtFrom.files ?? 0
return `${registry.builtFrom.repo}: ${registry.entries.length} parts in ${files - registry.filesWithoutParts.length} files (${registry.filesWithoutParts.length} more hold none) — ${kinds}. Every part is described and sorted.`
}
async function main(): Promise {
switch (command) {
case 'find': {
const query = plain().join(' ')
if (!query.trim()) {
process.stderr.write('estiva-ui find — what the thing you need does, in words\n')
return 1
}
const registries: Registry[] = [readPackageRegistry()]
// An app that cannot be read is said out loud, and the rest still answer.
const add = async (folder: string, repo: string | undefined) => {
const behind = behindBy(folder)
if (behind) process.stderr.write(`note: ${repo ?? basename(folder)} (${folder}) is ${behind} commit${behind === 1 ? '' : 's'} behind main as last fetched — its answers may name parts that are gone; pull it\n`)
try {
registries.push(await buildApp(folder, repo))
} catch (error) {
const [first, ...more] = String(error instanceof Error ? error.message : error).split('\n')
process.stderr.write(`not searched: ${first}${more.length ? `\n${more.slice(0, 3).join('\n')}${more.length > 3 ? `\n …and ${more.length - 3} more — run "estiva-ui check" there` : ''}` : ''}\n`)
}
}
// The folder it runs in is searched when it is an app; the folder that
// holds the repositories is not one, and only its neighbours answer.
const here = !isPackage && existsSync(resolve(root, 'package.json')) ? (manifestAt(root).name ?? basename(root)) : null
if (here) await add(root, value('repo'))
if (!values('also').length && !flag('here')) {
for (const sibling of findSiblings(workspaceOf(root), { skip: here ? [here] : [] })) await add(sibling.folder, sibling.name)
}
for (const also of values('also')) {
const [name, folder] = also.includes('=') ? [also.slice(0, also.indexOf('=')), also.slice(also.indexOf('=') + 1)] : [undefined, also]
const at = resolve(root, folder)
if (!existsSync(resolve(at, 'package.json'))) {
process.stderr.write(`not searched: ${name ?? basename(at)} — nothing at ${at}\n`)
continue
}
await add(at, name)
}
const limit = Number(value('limit') ?? 5)
const findings = findInRegistries(registries, query, { limit: Number.isFinite(limit) && limit > 0 ? limit : 5 })
if (flag('json')) {
process.stdout.write(`${JSON.stringify(findings.map((finding) => finding.entry), null, 2)}\n`)
return 0
}
process.stdout.write(`${formatFindings(registries, findings, query)}\n`)
// Nothing found is not a failure — it is the answer that sends a session
// to ask rather than to invent.
return 0
}
case 'build': {
const out = resolve(root, value('out') ?? 'registry.json')
if (isPackage) {
const { buildRegistry, serializeRegistry } = await packageBuilder()
const registry = buildRegistry({ root })
const problems = validateRegistry(registry)
if (problems.length) {
process.stderr.write(`the registry this build produced does not match its own schema:\n ${problems.join('\n ')}\n`)
return 1
}
writeFileSync(out, serializeRegistry(registry), 'utf8')
process.stdout.write(`${out}: ${registry.entries.length} entries from ${registry.builtFrom.exports} exports\n`)
return 0
}
const { serializeRegistry } = await packageBuilder()
const registry = await buildApp(root, value('repo'))
writeFileSync(out, serializeRegistry(registry), 'utf8')
process.stdout.write(`${out}: ${summary(registry)}\n`)
return 0
}
case 'check': {
if (!isPackage) {
const registry = await buildApp(root, value('repo'))
process.stdout.write(`${summary(registry)}\n`)
// The usage-page contract and the story links (UIG-19): the one copy, run by every app's `gate` job.
// A When not may name a part of the package or of this app (B8).
const pkg = readPackageRegistry()
const known = nameableParts(registry, pkg)
const broken = contractProblems(registry, root, known)
if (broken.length) {
process.stderr.write(`${broken.length === 1 ? 'a part breaks' : `${broken.length} parts break`} the usage-page contract:\n ${broken.join('\n ')}\n`)
} else {
const owed = registry.entries.filter((entry) => (CONTRACT_KINDS as readonly string[]).includes(entry.app?.class ?? '')).length
process.stdout.write(`usage pages: all ${owed} reusable parts keep the contract and are drawn somewhere\n`)
}
const linked = checkLinks(registry, root)
const shown = await checkGated(registry, root)
const loaded = checkLoader()
const mapped = checkStoryMap(root, registry, linked.headings)
// What the pages name must still be there (R14): parts under When and When not,
// and the stories a Seen in line sends a reader to, here or in the package.
const stale = nameProblems(registry, root, {
known,
...(linked.titles ? { titles: linked.titles } : {}),
packageIds: pkg.entries.flatMap((e) => [e.storyId, e.docsId].filter((id): id is string => Boolean(id))),
})
if (stale.length) process.stderr.write(`usage pages: ${stale.length === 1 ? 'one name leads' : `${stale.length} names lead`} nowhere:\n ${stale.join('\n ')}\n`)
else process.stdout.write('usage pages: every part and story they name is there\n')
return broken.length || stale.length || !linked.ok || !shown || !loaded || !mapped ? 1 : 0
}
const { buildRegistry, serializeRegistry } = await packageBuilder()
const path = packageRegistryPath()
const built = buildRegistry({ root })
const problems = validateRegistry(built)
if (problems.length) {
process.stderr.write(`the registry does not match its own schema:\n ${problems.join('\n ')}\n`)
return 1
}
if (!existsSync(path)) {
process.stderr.write(`${path} is missing — run "npm run registry"\n`)
return 1
}
// Byte for byte. The point of the check is that the committed file cannot
// drift from the code; "near enough" is drift.
if (readFileSync(path, 'utf8').replace(/\r\n/g, '\n') !== serializeRegistry(built)) {
process.stderr.write(`${path} is not what the code produces — run "npm run registry" and commit the result\n`)
return 1
}
process.stdout.write(`${path}: current, ${built.entries.length} entries from ${built.builtFrom.exports} exports\n`)
const linked = checkLinks(built, root)
const shown = await checkGated(built, root, ['component'])
const loaded = checkLoader()
// The package keeps a heading order too (R16): it left out Layout, and nothing saw.
const mapped = checkStoryMap(root, undefined, linked.headings)
return linked.ok && shown && loaded && mapped ? 0 : 1
}
case 'skill': {
const out = value('out')
const folder = out ? resolve(out) : repositoryOf(root)
// The loader's path starts where the session does and cannot climb out:
// Claude Code refuses a `..` in it.
if (relative(folder, packageRoot()).startsWith('..')) {
process.stderr.write(`${folder} does not hold ${packageRoot()}: a loader there could not reach the skill's text\n`)
return 1
}
const path = loaderPath(folder)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, loaderText(folder, packageRoot()), 'utf8')
process.stdout.write(`${path}: the Claude skill, read from ${packageRoot()}\n`)
const settings = settingsWithSearch(folder)
if (settings) {
writeFileSync(settingsPath(folder), settings, 'utf8')
process.stdout.write(`${settingsPath(folder)}: the search allowed without a prompt\n`)
}
return 0
}
default:
process.stderr.write('estiva-ui \n')
return 1
}
}
try {
process.exitCode = await main()
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
}