@cellajs/cli 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/drive.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/tools/drive.ts","../src/utils/app-facts.ts","../src/utils/env.ts"],"sourcesContent":["import { spawnSync } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport process from 'node:process';\nimport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\nimport { importFromWorkspace } from '../utils/app-facts';\n\nexport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\n\nexport type Playwright = typeof import('playwright');\n\n/** A signed-in user of the app: where it answers and the cookie that signs a browser or a request in. */\nexport interface Session {\n email: string;\n frontendUrl: string;\n /** The API, a path under the frontend's origin. */\n backendUrl: string;\n /** The app's slug, read off the cookie name; the persisted UI store's key starts with it. */\n slug: string;\n /** In the shape `context.addCookies` takes. */\n cookie: { name: string; value: string; url: string; httpOnly: true; sameSite: 'Strict' };\n /** `name=value`, for a request's cookie header. */\n header: string;\n}\n\nexport interface MintOptions {\n /** The app's root; default the current directory. */\n forkPath?: string;\n /** The environment the mint script runs in: a testbed's names its database. Default this process's. */\n env?: NodeJS.ProcessEnv;\n /** Session lifetime; default 2. */\n hours?: number;\n}\n\n/** Playwright as the app's frontend installed it, so the CLI ships none. */\nexport function loadPlaywright(forkPath = process.cwd()): Promise<Playwright> {\n return importFromWorkspace<Playwright>(forkPath, 'frontend', 'playwright');\n}\n\n/**\n * Checks that Playwright has its Chromium, before anything slow starts.\n * @throws with the install command when it has none\n */\nexport async function requireChromium(forkPath = process.cwd()): Promise<void> {\n const { chromium } = await loadPlaywright(forkPath);\n if (!existsSync(chromium.executablePath())) {\n throw new Error('Playwright has no Chromium yet: run `pnpm --filter frontend exec playwright install chromium`');\n }\n}\n\n/**\n * Signs `email` in through the backend's `session:mint` script, which refuses outside development and test. The script\n * prints `<cookie name>=<signed value>` and a curl line with the API URL, both from the config of the environment it runs\n * in, so a testbed's shifted ports and a linked worktree's are written down nowhere.\n * @throws when the script prints no session, as for an email with no user\n */\nexport function mintSession(email: string, { forkPath = process.cwd(), env, hours = 2 }: MintOptions = {}): Session {\n const minted = spawnSync('pnpm', ['--silent', '--filter', 'backend', 'session:mint', email, String(hours)], {\n cwd: forkPath,\n env,\n encoding: 'utf8',\n });\n const cookie = /^((?:__Host-)?([\\w-]+)-session-v\\d+)=(\\S+)$/m.exec(minted.stdout);\n const api = /^curl (\\S+)\\/me /m.exec(minted.stdout);\n if (!cookie || !api) throw new Error(`could not mint a session for ${email}:\\n${minted.stdout}\\n${minted.stderr}`);\n const frontendUrl = new URL(api[1]).origin;\n return {\n email,\n frontendUrl,\n backendUrl: api[1],\n slug: cookie[2],\n cookie: { name: cookie[1], value: cookie[3], url: frontendUrl, httpOnly: true, sameSite: 'Strict' },\n header: `${cookie[1]}=${cookie[3]}`,\n };\n}\n\n/**\n * Signs a minted session out, so a finished run leaves no live session in the user's sessions list. A JSON content type\n * passes the CSRF check without an Origin header; a stack that is already down leaves the session to expire.\n */\nexport async function signOut(session: Session | null | undefined): Promise<void> {\n if (!session) return;\n const headers = { cookie: session.header, 'content-type': 'application/json' };\n await fetch(`${session.backendUrl}/auth/sign-out`, { method: 'POST', headers }).catch(() => {});\n}\n\nexport interface ContextOptions extends BrowserContextOptions {\n /** Signs the context in. */\n session?: Session;\n /**\n * Fields written into the app's persisted UI store (`<slug>-ui`) before any page script runs, so the first paint is\n * already in the mode they set. Merged into what the store holds: a list joins the stored one, any other value wins.\n */\n uiStore?: { slug: string; state: Record<string, unknown> };\n}\n\n/** A browser context, signed in and with its UI store seeded when asked. */\nexport async function newContext(\n browser: Browser,\n { session, uiStore, ...options }: ContextOptions = {},\n): Promise<BrowserContext> {\n const context = await browser.newContext(options);\n // tsx and esbuild's keepNames wrap named functions in an `__name` helper that a page script does not have\n await context.addInitScript({ content: 'window.__name = (fn) => fn;' });\n if (session) await context.addCookies([session.cookie]);\n if (uiStore) {\n await context.addInitScript(\n ([key, fields]) => {\n const stored = JSON.parse(localStorage.getItem(key) ?? '{}');\n const state = { ...stored.state };\n for (const [name, value] of Object.entries(fields)) {\n const before = state[name];\n state[name] = Array.isArray(value) && Array.isArray(before) ? [...new Set([...before, ...value])] : value;\n }\n // A fresh entry needs the store's own version, or zustand drops it as written by an older build\n localStorage.setItem(key, JSON.stringify({ version: 1, ...stored, state }));\n },\n [`${uiStore.slug}-ui`, uiStore.state] as const,\n );\n }\n return context;\n}\n\n/**\n * Resolves once the page has stopped adding or changing content for `quiet` ms, or after `cap` ms. Lazy sections, query\n * results and opening overlays all show up as DOM changes; the network never goes idle in development.\n */\nexport async function settle(page: Page, { quiet = 400, cap = 3000 } = {}): Promise<void> {\n const waited = page.evaluate(\n ([quietMs, capMs]) =>\n new Promise<void>((resolve) => {\n const done = () => {\n observer.disconnect();\n clearTimeout(timer);\n clearTimeout(limit);\n resolve();\n };\n let timer = setTimeout(done, quietMs);\n const limit = setTimeout(done, capMs);\n const observer = new MutationObserver(() => {\n clearTimeout(timer);\n timer = setTimeout(done, quietMs);\n });\n observer.observe(document, { subtree: true, childList: true, characterData: true });\n }),\n [quiet, cap] as const,\n );\n // A navigation during the wait tears down the page's script context\n await waited.catch(() => page.waitForTimeout(quiet));\n}\n\n/**\n * Opens `url` in a new page and settles once the route has rendered its content.\n * @throws when the route redirects: a check or a shot would judge another page under this one's name\n */\nexport async function openPage(context: BrowserContext, url: string, { timeout = 30_000 } = {}): Promise<Page> {\n const page = await context.newPage();\n await page.goto(url, { waitUntil: 'domcontentloaded', timeout });\n await page.locator('main, [role=\"main\"], #accessibility-content, form, h1, footer').first().waitFor({ timeout });\n await settle(page);\n const landed = new URL(page.url()).pathname;\n const asked = new URL(url).pathname;\n if (landed !== asked) throw new Error(`${asked} redirected to ${landed}`);\n return page;\n}\n","import { execFileSync, spawnSync } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { dirname, join, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { z } from 'zod';\nimport { setEnv } from './env';\n\nconst appConfigSchema = z.object({\n frontendUrl: z.string(),\n backendUrl: z.string(),\n devPortOffset: z.number(),\n services: z.record(\n z.string(),\n z.object({ enabled: z.boolean(), port: z.number(), internalPort: z.number().optional() }),\n ),\n});\n\n/** The part of the app's development config the develop commands read. */\nexport type AppFacts = z.infer<typeof appConfigSchema>;\n\n/** The app's local database helpers (`shared/src/local-database.ts`), as the develop commands call them. */\nexport interface LocalDatabases {\n port: (database: string) => number;\n url: (database: string, role: 'postgres' | 'runtime_role' | 'admin_role') => string;\n /** The env `backend/compose.yaml` reads: the project name and each database's host port. */\n composeEnv: () => Record<string, string>;\n}\n\n/** Node strips the types of a file outside node_modules, so the app's own TS loads without a build or tsx. */\nexport async function importFromApp(forkPath: string, file: string): Promise<Record<string, unknown>> {\n const path = join(forkPath, file);\n if (!existsSync(path)) throw new Error(`not an app built from cella: ${path} is missing`);\n // The app's config reads the mode once, at its first import, and the dev port offset applies in development mode only\n setEnv('NODE_ENV', 'development');\n return import(pathToFileURL(path).href);\n}\n\n/**\n * The app's config in development mode, from its own `shared` package. In a linked worktree the import claims that\n * worktree's port slot, as `pnpm dev` would.\n */\nexport async function loadAppFacts(forkPath: string): Promise<AppFacts> {\n const parsed = appConfigSchema.safeParse((await importFromApp(forkPath, 'shared/index.ts')).appConfig);\n if (!parsed.success)\n throw new Error(`shared/index.ts exports no appConfig this CLI can read: ${parsed.error.message}`);\n return parsed.data;\n}\n\n/** The app's local database helpers. */\nexport async function loadLocalDatabases(forkPath: string): Promise<LocalDatabases> {\n const module = await importFromApp(forkPath, 'shared/src/local-database.ts');\n const { localDatabasePort, localDatabaseUrl, composeEnv } = module;\n if (\n typeof localDatabasePort !== 'function' ||\n typeof localDatabaseUrl !== 'function' ||\n typeof composeEnv !== 'function'\n ) {\n throw new Error('shared/src/local-database.ts lacks localDatabasePort, localDatabaseUrl or composeEnv');\n }\n return {\n port: (database) => localDatabasePort(database),\n url: (database, role) => localDatabaseUrl(database, role),\n composeEnv: () => composeEnv(),\n };\n}\n\n/**\n * A package as the app's `workspace` installed it, so a tool drives the app's own Playwright, TypeScript or Tailwind and\n * the CLI ships none. The package's ESM entry when it has one, else its CommonJS exports.\n * @throws when the workspace does not have the package installed\n */\nexport async function importFromWorkspace<T>(forkPath: string, workspace: string, name: string): Promise<T> {\n const require = createRequire(join(forkPath, workspace, 'package.json'));\n let manifestPath: string | undefined;\n try {\n manifestPath = require.resolve(`${name}/package.json`);\n } catch {\n // A package whose exports leave out package.json resolves by its main entry below\n }\n const root = manifestPath ? JSON.parse(readFileSync(manifestPath, 'utf8')).exports?.['.'] : undefined;\n const esm = typeof root?.import === 'string' ? root.import : root?.import?.default;\n if (manifestPath && typeof esm === 'string') return import(pathToFileURL(join(dirname(manifestPath), esm)).href);\n try {\n return require(name);\n } catch {\n throw new Error(`${name} is not installed in ${workspace}: run \\`pnpm --filter ${workspace} add -D ${name}\\``);\n }\n}\n\n/**\n * A Docker image as a tool runs it, pinned by digest (`name[:tag]@sha256:…`) so every run scans with the same code.\n * Pulls it on first use; the pull shows Docker's own progress.\n * @throws when the reference has no digest, Docker does not answer, or the pull fails\n */\nexport function resolveImage(reference: string): string {\n if (!/@sha256:[0-9a-f]{64}$/.test(reference)) throw new Error(`${reference} is not pinned by digest`);\n if (spawnSync('docker', ['info'], { stdio: 'ignore' }).status !== 0) {\n throw new Error('Docker does not answer: start Docker (or OrbStack) first');\n }\n if (spawnSync('docker', ['image', 'inspect', reference], { stdio: 'ignore' }).status === 0) return reference;\n if (spawnSync('docker', ['pull', reference], { stdio: ['ignore', 'inherit', 'inherit'] }).status !== 0) {\n throw new Error(`could not pull ${reference}`);\n }\n return reference;\n}\n\n/**\n * Where a tool keeps its results and state: `<git dir>/cella-<tool>/` of this checkout, or of the git directory every\n * worktree shares. Inside the git directory nothing shows in `git status`.\n */\nexport function toolDir(forkPath: string, tool: string, scope: 'checkout' | 'repository' = 'checkout'): string {\n const flag = scope === 'checkout' ? '--absolute-git-dir' : '--git-common-dir';\n const gitDir = execFileSync('git', ['rev-parse', flag], { cwd: forkPath, encoding: 'utf8' }).trim();\n return join(resolve(forkPath, gitDir), `cella-${tool}`);\n}\n","import process from 'node:process';\n\n/**\n * Centralized environment access for CLI/runtime integration points.\n */\nexport function getEnv(name: string): string | undefined {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return process.env[name];\n}\n\n/** Whether the variable is set at all, even to an empty string. */\nexport function hasEnv(name: string): boolean {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return name in process.env;\n}\n\n/** A mutable copy of the full environment, for passing to child processes. */\nexport function getEnvSnapshot(): NodeJS.ProcessEnv {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return { ...process.env };\n}\n\n/** Set a variable for this process and the children it spawns from here on. */\nexport function setEnv(name: string, value: string): void {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n process.env[name] = value;\n}\n"],"mappings":"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","names":["spawnSync","existsSync","process","require","process","existsSync","spawnSync","resolve"]}
1
+ {"version":3,"sources":["../src/tools/drive.ts","../src/utils/app-facts.ts","../src/utils/env.ts"],"sourcesContent":["import { spawnSync } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport process from 'node:process';\nimport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\nimport { importFromWorkspace } from '../utils/app-facts';\n\nexport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\n\nexport type Playwright = typeof import('playwright');\n\n/** A signed-in user of the app: where it answers and the cookie that signs a browser or a request in. */\nexport interface Session {\n email: string;\n frontendUrl: string;\n /** The API, a path under the frontend's origin. */\n backendUrl: string;\n /** The app's slug, read off the cookie name; the persisted UI store's key starts with it. */\n slug: string;\n /** In the shape `context.addCookies` takes. */\n cookie: { name: string; value: string; url: string; httpOnly: true; sameSite: 'Strict' };\n /** `name=value`, for a request's cookie header. */\n header: string;\n}\n\nexport interface MintOptions {\n /** The app's root; default the current directory. */\n forkPath?: string;\n /** The environment the mint script runs in: a testbed's names its database. Default this process's. */\n env?: NodeJS.ProcessEnv;\n /** Session lifetime; default 2. */\n hours?: number;\n}\n\n/** Playwright as the app's frontend installed it, so the CLI ships none. */\nexport function loadPlaywright(forkPath = process.cwd()): Promise<Playwright> {\n return importFromWorkspace<Playwright>(forkPath, 'frontend', 'playwright');\n}\n\n/**\n * Checks that Playwright has its Chromium, before anything slow starts.\n * @throws with the install command when it has none\n */\nexport async function requireChromium(forkPath = process.cwd()): Promise<void> {\n const { chromium } = await loadPlaywright(forkPath);\n if (!existsSync(chromium.executablePath())) {\n throw new Error('Playwright has no Chromium yet: run `pnpm --filter frontend exec playwright install chromium`');\n }\n}\n\n/**\n * Signs `email` in through the backend's `session:mint` script, which refuses outside development and test. The script\n * prints `<cookie name>=<signed value>` and a curl line with the API URL, both from the config of the environment it runs\n * in, so a testbed's shifted ports and a linked worktree's are written down nowhere.\n * @throws when the script prints no session, as for an email with no user\n */\nexport function mintSession(email: string, { forkPath = process.cwd(), env, hours = 2 }: MintOptions = {}): Session {\n const minted = spawnSync('pnpm', ['--silent', '--filter', 'backend', 'session:mint', email, String(hours)], {\n cwd: forkPath,\n env,\n encoding: 'utf8',\n });\n const cookie = /^((?:__Host-)?([\\w-]+)-session-v\\d+)=(\\S+)$/m.exec(minted.stdout);\n const api = /^curl (\\S+)\\/me /m.exec(minted.stdout);\n if (!cookie || !api) throw new Error(`could not mint a session for ${email}:\\n${minted.stdout}\\n${minted.stderr}`);\n const frontendUrl = new URL(api[1]).origin;\n return {\n email,\n frontendUrl,\n backendUrl: api[1],\n slug: cookie[2],\n cookie: { name: cookie[1], value: cookie[3], url: frontendUrl, httpOnly: true, sameSite: 'Strict' },\n header: `${cookie[1]}=${cookie[3]}`,\n };\n}\n\n/**\n * Signs a minted session out, so a finished run leaves no live session in the user's sessions list. A JSON content type\n * passes the CSRF check without an Origin header; a stack that is already down leaves the session to expire.\n */\nexport async function signOut(session: Session | null | undefined): Promise<void> {\n if (!session) return;\n const headers = { cookie: session.header, 'content-type': 'application/json' };\n await fetch(`${session.backendUrl}/auth/sign-out`, { method: 'POST', headers }).catch(() => {});\n}\n\nexport interface ContextOptions extends BrowserContextOptions {\n /** Signs the context in. */\n session?: Session;\n /**\n * Fields written into the app's persisted UI store (`<slug>-ui`) before any page script runs, so the first paint is\n * already in the mode they set. Merged into what the store holds: a list joins the stored one, any other value wins.\n */\n uiStore?: { slug: string; state: Record<string, unknown> };\n}\n\n/** A browser context, signed in and with its UI store seeded when asked. */\nexport async function newContext(\n browser: Browser,\n { session, uiStore, ...options }: ContextOptions = {},\n): Promise<BrowserContext> {\n const context = await browser.newContext(options);\n // tsx and esbuild's keepNames wrap named functions in an `__name` helper that a page script does not have\n await context.addInitScript({ content: 'window.__name = (fn) => fn;' });\n if (session) await context.addCookies([session.cookie]);\n if (uiStore) {\n await context.addInitScript(\n ([key, fields]) => {\n const stored = JSON.parse(localStorage.getItem(key) ?? '{}');\n const state = { ...stored.state };\n for (const [name, value] of Object.entries(fields)) {\n const before = state[name];\n state[name] = Array.isArray(value) && Array.isArray(before) ? [...new Set([...before, ...value])] : value;\n }\n // A fresh entry needs the store's own version, or zustand drops it as written by an older build\n localStorage.setItem(key, JSON.stringify({ version: 1, ...stored, state }));\n },\n [`${uiStore.slug}-ui`, uiStore.state] as const,\n );\n }\n return context;\n}\n\n/**\n * Resolves once the page has stopped adding or changing content for `quiet` ms, or after `cap` ms. Lazy sections, query\n * results and opening overlays all show up as DOM changes; the network never goes idle in development.\n */\nexport async function settle(page: Page, { quiet = 400, cap = 3000 } = {}): Promise<void> {\n const waited = page.evaluate(\n ([quietMs, capMs]) =>\n new Promise<void>((resolve) => {\n const done = () => {\n observer.disconnect();\n clearTimeout(timer);\n clearTimeout(limit);\n resolve();\n };\n let timer = setTimeout(done, quietMs);\n const limit = setTimeout(done, capMs);\n const observer = new MutationObserver(() => {\n clearTimeout(timer);\n timer = setTimeout(done, quietMs);\n });\n observer.observe(document, { subtree: true, childList: true, characterData: true });\n }),\n [quiet, cap] as const,\n );\n // A navigation during the wait tears down the page's script context\n await waited.catch(() => page.waitForTimeout(quiet));\n}\n\n/**\n * Opens `url` in a new page and settles once the route has rendered its content.\n * @throws when the route redirects: a check or a shot would judge another page under this one's name\n */\nexport async function openPage(context: BrowserContext, url: string, { timeout = 30_000 } = {}): Promise<Page> {\n const page = await context.newPage();\n await page.goto(url, { waitUntil: 'domcontentloaded', timeout });\n await page.locator('main, [role=\"main\"], #accessibility-content, form, h1, footer').first().waitFor({ timeout });\n await settle(page);\n const landed = new URL(page.url()).pathname;\n const asked = new URL(url).pathname;\n if (landed !== asked) throw new Error(`${asked} redirected to ${landed}`);\n return page;\n}\n","import { execFileSync, spawnSync } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { dirname, join, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { z } from 'zod';\nimport { setEnv } from './env';\n\nconst appConfigSchema = z.object({\n frontendUrl: z.string(),\n backendUrl: z.string(),\n devPortOffset: z.number(),\n services: z.record(\n z.string(),\n z.object({ enabled: z.boolean(), port: z.number(), internalPort: z.number().optional() }),\n ),\n});\n\n/** The part of the app's development config the develop commands read. */\nexport type AppFacts = z.infer<typeof appConfigSchema>;\n\n/** The app's local database helpers (`shared/src/local-database.ts`), as the develop commands call them. */\nexport interface LocalDatabases {\n port: (database: string) => number;\n url: (database: string, role: 'postgres' | 'runtime_role' | 'admin_role') => string;\n /** The env `backend/compose.yaml` reads: the project name and each database's host port. */\n composeEnv: () => Record<string, string>;\n}\n\n/** Node strips the types of a file outside node_modules, so the app's own TS loads without a build or tsx. */\nexport async function importFromApp(forkPath: string, file: string): Promise<Record<string, unknown>> {\n const path = join(forkPath, file);\n if (!existsSync(path)) throw new Error(`not an app built from cella: ${path} is missing`);\n // The app's config reads the mode once, at its first import, and the dev port offset applies in development mode only\n setEnv('NODE_ENV', 'development');\n return import(pathToFileURL(path).href);\n}\n\n/** An app path a template alignment moved: `current`, or `legacy` in an app that has only the old one. */\nexport const movedPath = (forkPath: string, current: string, legacy: string): string =>\n !existsSync(join(forkPath, current)) && existsSync(join(forkPath, legacy)) ? legacy : current;\n\n/**\n * The app's config in development mode, from its own `shared` package. In a linked worktree the import claims that\n * worktree's port slot, as `pnpm dev` would.\n */\nexport async function loadAppFacts(forkPath: string): Promise<AppFacts> {\n const parsed = appConfigSchema.safeParse((await importFromApp(forkPath, 'shared/index.ts')).appConfig);\n if (!parsed.success)\n throw new Error(`shared/index.ts exports no appConfig this CLI can read: ${parsed.error.message}`);\n return parsed.data;\n}\n\n/** The app's local database helpers. */\nexport async function loadLocalDatabases(forkPath: string): Promise<LocalDatabases> {\n const module = await importFromApp(forkPath, 'shared/src/local-database.ts');\n const { localDatabasePort, localDatabaseUrl, composeEnv } = module;\n if (\n typeof localDatabasePort !== 'function' ||\n typeof localDatabaseUrl !== 'function' ||\n typeof composeEnv !== 'function'\n ) {\n throw new Error('shared/src/local-database.ts lacks localDatabasePort, localDatabaseUrl or composeEnv');\n }\n return {\n port: (database) => localDatabasePort(database),\n url: (database, role) => localDatabaseUrl(database, role),\n composeEnv: () => composeEnv(),\n };\n}\n\n/**\n * A package as the app's `workspace` installed it, so a tool drives the app's own Playwright, TypeScript or Tailwind and\n * the CLI ships none. The package's ESM entry when it has one, else its CommonJS exports.\n * @throws when the workspace does not have the package installed\n */\nexport async function importFromWorkspace<T>(forkPath: string, workspace: string, name: string): Promise<T> {\n const require = createRequire(join(forkPath, workspace, 'package.json'));\n let manifestPath: string | undefined;\n try {\n manifestPath = require.resolve(`${name}/package.json`);\n } catch {\n // A package whose exports leave out package.json resolves by its main entry below\n }\n const root = manifestPath ? JSON.parse(readFileSync(manifestPath, 'utf8')).exports?.['.'] : undefined;\n const esm = typeof root?.import === 'string' ? root.import : root?.import?.default;\n if (manifestPath && typeof esm === 'string') return import(pathToFileURL(join(dirname(manifestPath), esm)).href);\n try {\n return require(name);\n } catch {\n throw new Error(`${name} is not installed in ${workspace}: run \\`pnpm --filter ${workspace} add -D ${name}\\``);\n }\n}\n\n/**\n * A Docker image as a tool runs it, pinned by digest (`name[:tag]@sha256:…`) so every run scans with the same code.\n * Pulls it on first use; the pull shows Docker's own progress.\n * @throws when the reference has no digest, Docker does not answer, or the pull fails\n */\nexport function resolveImage(reference: string): string {\n if (!/@sha256:[0-9a-f]{64}$/.test(reference)) throw new Error(`${reference} is not pinned by digest`);\n if (spawnSync('docker', ['info'], { stdio: 'ignore' }).status !== 0) {\n throw new Error('Docker does not answer: start Docker (or OrbStack) first');\n }\n if (spawnSync('docker', ['image', 'inspect', reference], { stdio: 'ignore' }).status === 0) return reference;\n if (spawnSync('docker', ['pull', reference], { stdio: ['ignore', 'inherit', 'inherit'] }).status !== 0) {\n throw new Error(`could not pull ${reference}`);\n }\n return reference;\n}\n\n/**\n * Where a tool keeps its results and state: `<git dir>/cella-<tool>/` of this checkout, or of the git directory every\n * worktree shares. Inside the git directory nothing shows in `git status`.\n */\nexport function toolDir(forkPath: string, tool: string, scope: 'checkout' | 'repository' = 'checkout'): string {\n const flag = scope === 'checkout' ? '--absolute-git-dir' : '--git-common-dir';\n const gitDir = execFileSync('git', ['rev-parse', flag], { cwd: forkPath, encoding: 'utf8' }).trim();\n return join(resolve(forkPath, gitDir), `cella-${tool}`);\n}\n","import process from 'node:process';\n\n/**\n * Centralized environment access for CLI/runtime integration points.\n */\nexport function getEnv(name: string): string | undefined {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return process.env[name];\n}\n\n/** Whether the variable is set at all, even to an empty string. */\nexport function hasEnv(name: string): boolean {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return name in process.env;\n}\n\n/** A mutable copy of the full environment, for passing to child processes. */\nexport function getEnvSnapshot(): NodeJS.ProcessEnv {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return { ...process.env };\n}\n\n/** Set a variable for this process and the children it spawns from here on. */\nexport function setEnv(name: string, value: string): void {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n process.env[name] = value;\n}\n"],"mappings":"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","names":["spawnSync","existsSync","process","require","process","existsSync","spawnSync","resolve"]}
package/dist/index.js CHANGED
@@ -49,6 +49,7 @@ async function importFromApp(forkPath, file) {
49
49
  setEnv("NODE_ENV", "development");
50
50
  return import(pathToFileURL(path).href);
51
51
  }
52
+ var movedPath = (forkPath, current, legacy) => !existsSync(join(forkPath, current)) && existsSync(join(forkPath, legacy)) ? legacy : current;
52
53
  async function loadAppFacts(forkPath) {
53
54
  const parsed2 = appConfigSchema.safeParse((await importFromApp(forkPath, "shared/index.ts")).appConfig);
54
55
  if (!parsed2.success)
@@ -233,7 +234,7 @@ import process6 from "process";
233
234
  // package.json
234
235
  var package_default = {
235
236
  name: "@cellajs/cli",
236
- version: "0.6.5",
237
+ version: "0.6.6",
237
238
  private: false,
238
239
  license: "MIT",
239
240
  description: "CLI for managing your monorepo and upgrading it with upstream Cella.",
@@ -761,6 +762,15 @@ async function gitMv(cwd, oldPath, newPath) {
761
762
  await mkdir2(newDir, { recursive: true });
762
763
  await git(["mv", "-f", oldPath, newPath], cwd);
763
764
  }
765
+ async function moveFromRef(cwd, ref, from, to) {
766
+ const [mode, , blob] = (await git(["ls-tree", ref, "--", from], cwd)).split(/\s+/);
767
+ if (!blob) throw new Error(`${ref} holds no ${from}`);
768
+ await gitRm(cwd, [to], { cached: true });
769
+ await git(["update-index", "--add", "--cacheinfo", `${mode},${blob},${to}`], cwd);
770
+ await mkdir2(dirname3(join3(cwd, to)), { recursive: true });
771
+ await git(["checkout-index", "-f", "--", to], cwd);
772
+ await removeFileFully(cwd, from);
773
+ }
764
774
  async function fileExistsInWorktree(cwd, filePath) {
765
775
  return existsSync3(join3(cwd, filePath));
766
776
  }
@@ -1373,11 +1383,12 @@ var STARTUP_TIMEOUT_MS = 5 * 6e4;
1373
1383
  var STOP_TIMEOUT_MS = 1e4;
1374
1384
  var DATABASE = "testbed";
1375
1385
  var DATABASE_SERVICE = "db_testbed";
1376
- var showcasePath = (forkPath) => join4(forkPath, "json/showcase.json");
1386
+ var showcaseFile = (forkPath) => movedPath(forkPath, "backend/scripts/seeds/showcase.json", "json/showcase.json");
1387
+ var showcasePath = (forkPath) => join4(forkPath, showcaseFile(forkPath));
1377
1388
  var hasShowcase = (forkPath) => existsSync4(showcasePath(forkPath));
1378
1389
  function showcaseAdmin(forkPath) {
1379
1390
  const { admin } = JSON.parse(readFileSync2(showcasePath(forkPath), "utf8"));
1380
- if (typeof admin?.email !== "string") throw new Error("json/showcase.json names no admin email");
1391
+ if (typeof admin?.email !== "string") throw new Error(`${showcaseFile(forkPath)} names no admin email`);
1381
1392
  return admin.email;
1382
1393
  }
1383
1394
  var shiftPort = (url) => {
@@ -1666,7 +1677,7 @@ async function withTestbed(forkPath, use, { fresh = false, destructive = false,
1666
1677
  }
1667
1678
  async function runTestbed(forkPath, options) {
1668
1679
  if (options.job === "stop") return stopTestbed(await resolveTestbed(forkPath));
1669
- if (options.showcase && !hasShowcase(forkPath)) throw new Error("this app keeps no json/showcase.json");
1680
+ if (options.showcase && !hasShowcase(forkPath)) throw new Error(`this app keeps no ${showcaseFile(forkPath)}`);
1670
1681
  const data = options.showcase ? "showcase" : "seed";
1671
1682
  if (options.command.length > 0) {
1672
1683
  return withTestbed(forkPath, (testbed2) => runCommand(forkPath, testbed2.env, options.command), { ...options, data });
@@ -1701,7 +1712,7 @@ var testbedCommand = {
1701
1712
  ],
1702
1713
  options: [
1703
1714
  ["--fresh", "start from an empty database: migrate and seed it again"],
1704
- ["--showcase", "hold the showcase of json/showcase.json, which the screenshots shoot, for a review loop"]
1715
+ ["--showcase", "hold the showcase seed, which the screenshots shoot, for a review loop"]
1705
1716
  ],
1706
1717
  passthrough: [
1707
1718
  "command",
@@ -1716,8 +1727,8 @@ var testbedCommand = {
1716
1727
  };
1717
1728
 
1718
1729
  // src/tools/a11y/audit.ts
1719
- import { mkdirSync as mkdirSync3, statSync, writeFileSync as writeFileSync4 } from "fs";
1720
- import { join as join9 } from "path";
1730
+ import { mkdirSync as mkdirSync3, statSync, writeFileSync as writeFileSync5 } from "fs";
1731
+ import { join as join10 } from "path";
1721
1732
 
1722
1733
  // src/tools/a11y/axe-scan.ts
1723
1734
  import axe from "axe-core";
@@ -2387,8 +2398,97 @@ function recordDecisions(forkPath, decisions) {
2387
2398
  }
2388
2399
 
2389
2400
  // src/tools/a11y/probes/page-structure.ts
2390
- import { readFileSync as readFileSync4 } from "fs";
2401
+ import { readFileSync as readFileSync5 } from "fs";
2402
+ import { join as join8 } from "path";
2403
+
2404
+ // src/tools/i18n/locales.ts
2405
+ import { existsSync as existsSync7, readdirSync, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
2391
2406
  import { join as join7 } from "path";
2407
+ var localesDir = (forkPath) => movedPath(forkPath, "shared/locales", "locales");
2408
+ var MANIFEST = "namespaces.json";
2409
+ var SOURCE_LANGUAGE = "en";
2410
+ var entryPattern = /^(\s+)("(?:[^"\\]|\\.)*")\s*:\s*("(?:[^"\\]|\\.)*"),?\s*$/;
2411
+ function parseLocaleFile(path, text2) {
2412
+ if (text2.trim() === "{}") return { path, indent: " ", entries: [] };
2413
+ const lines = text2.replace(/\n$/, "").split("\n");
2414
+ if (lines[0] !== "{" || lines.at(-1) !== "}") return "not one JSON object with one entry per line";
2415
+ const entries = [];
2416
+ let indent = " ";
2417
+ for (const line of lines.slice(1, -1)) {
2418
+ const match = entryPattern.exec(line);
2419
+ if (!match) return `cannot read the line ${JSON.stringify(line.trim())}`;
2420
+ indent = match[1];
2421
+ entries.push({ key: JSON.parse(match[2]), value: JSON.parse(match[3]), raw: [match[2], match[3]] });
2422
+ }
2423
+ return { path, indent, entries };
2424
+ }
2425
+ function serializeLocaleFile(file) {
2426
+ if (file.entries.length === 0) return "{}\n";
2427
+ return `{
2428
+ ${file.entries.map(({ raw }) => `${file.indent}${raw[0]}: ${raw[1]}`).join(",\n")}
2429
+ }
2430
+ `;
2431
+ }
2432
+ function writeLocaleFile(forkPath, file) {
2433
+ writeFileSync3(join7(forkPath, file.path), serializeLocaleFile(file));
2434
+ }
2435
+ var textsOf = (file) => Object.fromEntries((file?.entries ?? []).map(({ key, value }) => [key, value]));
2436
+ var readJsonObject = (path) => {
2437
+ const parsed2 = JSON.parse(readFileSync4(path, "utf8"));
2438
+ if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) throw new Error(`${path} is not a JSON object`);
2439
+ return Object.fromEntries(Object.entries(parsed2));
2440
+ };
2441
+ function readLocales(forkPath) {
2442
+ const localesPath = localesDir(forkPath);
2443
+ const dir = join7(forkPath, localesPath);
2444
+ if (!existsSync7(join7(dir, SOURCE_LANGUAGE))) throw new Error(`no ${localesPath}/${SOURCE_LANGUAGE}/ in ${forkPath}`);
2445
+ const languages = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name).sort((a, b) => a === SOURCE_LANGUAGE ? -1 : b === SOURCE_LANGUAGE ? 1 : a.localeCompare(b));
2446
+ const files2 = /* @__PURE__ */ new Map();
2447
+ for (const language2 of languages) {
2448
+ for (const name of readdirSync(join7(dir, language2)).filter((name2) => name2.endsWith(".json"))) {
2449
+ const path = `${localesPath}/${language2}/${name}`;
2450
+ const parsed2 = parseLocaleFile(path, readFileSync4(join7(forkPath, path), "utf8"));
2451
+ if (typeof parsed2 === "string") throw new Error(`${path}: ${parsed2}; pnpm lint:fix writes one entry per line`);
2452
+ files2.set(`${language2}/${name.slice(0, -".json".length)}`, parsed2);
2453
+ }
2454
+ }
2455
+ const manifestPath = join7(dir, MANIFEST);
2456
+ if (!existsSync7(manifestPath)) throw new Error(`no ${localesPath}/${MANIFEST}: upgrade the app from cella first`);
2457
+ const merged = readJsonObject(manifestPath);
2458
+ const namespaces = /* @__PURE__ */ new Map();
2459
+ for (const [namespace, sources] of Object.entries(merged)) {
2460
+ if (!Array.isArray(sources) || !sources.every((source) => typeof source === "string")) {
2461
+ throw new Error(`${localesPath}/${MANIFEST}: ${namespace} must list file names`);
2462
+ }
2463
+ namespaces.set(namespace, sources);
2464
+ }
2465
+ const mergedFiles = new Set([...namespaces.values()].flat());
2466
+ const ownFiles = [...new Set([...files2.keys()].map((key) => key.split("/")[1]))].filter(
2467
+ (file) => !mergedFiles.has(file)
2468
+ );
2469
+ for (const file of ownFiles.sort()) namespaces.set(file, [file]);
2470
+ return { dir: localesPath, languages, namespaces, files: files2 };
2471
+ }
2472
+ function namespaceTexts(locales, language2, namespace) {
2473
+ const sources = locales.namespaces.get(namespace) ?? [];
2474
+ return Object.assign({}, ...sources.map((file) => textsOf(locales.files.get(`${language2}/${file}`))));
2475
+ }
2476
+ var lineOf = (file, key) => file.entries.findIndex((entry) => entry.key === key) + 2;
2477
+ var pluralSuffix = /_(ordinal_)?(zero|one|two|few|many|other)$/;
2478
+ function pluralForm(key) {
2479
+ const match = pluralSuffix.exec(key);
2480
+ if (!match) return void 0;
2481
+ return { base: key.slice(0, match.index), category: match[2], ordinal: !!match[1] };
2482
+ }
2483
+ var withPluralForms = (key) => [
2484
+ key,
2485
+ ...["zero", "one", "two", "few", "many", "other"].flatMap((category) => [
2486
+ `${key}_${category}`,
2487
+ `${key}_ordinal_${category}`
2488
+ ])
2489
+ ];
2490
+
2491
+ // src/tools/a11y/probes/page-structure.ts
2392
2492
  var titles = { criteria: ["2.4.2"], check: "probe:titles", what: "A distinct, descriptive page title" };
2393
2493
  var language = {
2394
2494
  criteria: ["3.1.1"],
@@ -2422,7 +2522,7 @@ var pageStructure = async (page, state, { app }) => {
2422
2522
  var languageSwitch = async (page, state, { app, forkPath }) => {
2423
2523
  if (state.open) return [];
2424
2524
  const names = JSON.parse(
2425
- readFileSync4(join7(forkPath, "locales", app.defaultLanguage, "common.json"), "utf8")
2525
+ readFileSync5(join8(forkPath, localesDir(forkPath), app.defaultLanguage, "common.json"), "utf8")
2426
2526
  );
2427
2527
  const findings2 = [];
2428
2528
  for (const other of app.languages.filter((lng) => lng !== app.defaultLanguage)) {
@@ -2756,15 +2856,15 @@ var inputs = async (page, state) => {
2756
2856
  };
2757
2857
 
2758
2858
  // src/tools/a11y/review-packet.ts
2759
- import { mkdirSync as mkdirSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
2760
- import { join as join8 } from "path";
2761
- var packetDir = (run, stateId) => join8(run.resultsDir, "review", stateId);
2859
+ import { mkdirSync as mkdirSync2, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
2860
+ import { join as join9 } from "path";
2861
+ var packetDir = (run, stateId) => join9(run.resultsDir, "review", stateId);
2762
2862
  function writePacketFile(run, stateId, name, content) {
2763
2863
  mkdirSync2(packetDir(run, stateId), { recursive: true });
2764
- writeFileSync3(join8(packetDir(run, stateId), name), content);
2864
+ writeFileSync4(join9(packetDir(run, stateId), name), content);
2765
2865
  }
2766
2866
  function removePacketFile(run, stateId, name) {
2767
- rmSync2(join8(packetDir(run, stateId), name), { force: true });
2867
+ rmSync2(join9(packetDir(run, stateId), name), { force: true });
2768
2868
  }
2769
2869
  function pageFacts() {
2770
2870
  const text2 = (el) => (el?.textContent ?? "").trim().replace(/\s+/g, " ");
@@ -3225,7 +3325,7 @@ async function runAudit(run, scope, testbed, options) {
3225
3325
  const startedAt = Date.now();
3226
3326
  const email = options.email ?? testbed.env.ADMIN_EMAIL;
3227
3327
  if (!email) throw new Error("no audit user: pass --email, or set ADMIN_EMAIL in backend/.env");
3228
- const builtAt = statSync(join9(run.forkPath, "frontend/dist/index.html"), { throwIfNoEntry: false })?.mtime;
3328
+ const builtAt = statSync(join10(run.forkPath, "frontend/dist/index.html"), { throwIfNoEntry: false })?.mtime;
3229
3329
  printStep("auditing", `${testbed.frontendUrl}, frontend build of ${builtAt?.toLocaleString() ?? "unknown date"}`);
3230
3330
  const states = options.states.length > 0 ? scope.states.filter((state) => options.states.includes(state.id)) : scope.states;
3231
3331
  const session = await startAudit(run.forkPath, scope, { email, env: testbed.env });
@@ -3265,12 +3365,12 @@ async function runAudit(run, scope, testbed, options) {
3265
3365
  mkdirSync3(run.resultsDir, { recursive: true });
3266
3366
  const axe2 = summarizeAxe(scans);
3267
3367
  for (const [id, items] of axe2.evidence) for (const item of items) evidence.add([id], item);
3268
- writeFileSync4(join9(run.resultsDir, "axe.json"), `${JSON.stringify({ ...axe2.report, errors }, null, 2)}
3368
+ writeFileSync5(join10(run.resultsDir, "axe.json"), `${JSON.stringify({ ...axe2.report, errors }, null, 2)}
3269
3369
  `);
3270
3370
  evidence.addFindings(acrossStates(findings2));
3271
3371
  runCodeChecks(evidence, run, scope.sources);
3272
3372
  const skipped2 = Object.keys(errors).length;
3273
- const target = options.states.length > 0 || skipped2 ? join9(run.resultsDir, "ledger-partial.json") : join9(run.forkPath, ledgerFile);
3373
+ const target = options.states.length > 0 || skipped2 ? join10(run.resultsDir, "ledger-partial.json") : join10(run.forkPath, ledgerFile);
3274
3374
  const ledger = writeLedger(
3275
3375
  run.forkPath,
3276
3376
  evidence.byCriterion,
@@ -3294,10 +3394,10 @@ async function runAudit(run, scope, testbed, options) {
3294
3394
  }
3295
3395
 
3296
3396
  // src/tools/a11y/decide.ts
3297
- import { readFileSync as readFileSync5 } from "fs";
3397
+ import { readFileSync as readFileSync6 } from "fs";
3298
3398
  import { resolve as resolve4 } from "path";
3299
3399
  function runDecide(forkPath, { file, decision }) {
3300
- const input2 = file ? JSON.parse(readFileSync5(resolve4(file), "utf8")) : [decision];
3400
+ const input2 = file ? JSON.parse(readFileSync6(resolve4(file), "utf8")) : [decision];
3301
3401
  if (!Array.isArray(input2)) throw new Error(`${file} holds no list of decisions`);
3302
3402
  const decisions = input2.map(parseDecision);
3303
3403
  const ledger = recordDecisions(forkPath, decisions);
@@ -3311,8 +3411,8 @@ function runDecide(forkPath, { file, decision }) {
3311
3411
 
3312
3412
  // src/tools/a11y/report.ts
3313
3413
  import { spawnSync as spawnSync5 } from "child_process";
3314
- import { copyFileSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
3315
- import { dirname as dirname4, join as join10 } from "path";
3414
+ import { copyFileSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync6 } from "fs";
3415
+ import { dirname as dirname4, join as join11 } from "path";
3316
3416
  var statusLabel = {
3317
3417
  supports: "Supports",
3318
3418
  "partially-supports": "Partially Supports",
@@ -3431,7 +3531,7 @@ Not evaluated.
3431
3531
 
3432
3532
  This report describes the product as tested on the report date. Accessibility can change as the product changes.
3433
3533
  `;
3434
- const tracked = spawnSync5("git", ["ls-files", "frontend/src", "backend/src", "locales"], {
3534
+ const tracked = spawnSync5("git", ["ls-files", "frontend/src", "backend/src", "shared/locales", "locales"], {
3435
3535
  cwd: forkPath,
3436
3536
  encoding: "utf8"
3437
3537
  }).stdout.split("\n");
@@ -3458,7 +3558,7 @@ NVDA and Firefox on Windows, keyboard only, in light and dark mode.
3458
3558
 
3459
3559
  ## Confirm: decided by an agent (${byAgent.length})
3460
3560
 
3461
- The agent judged these from the review packets in \`${join10(resultsDir, "review")}/\`. Check its evidence, then record your own
3561
+ The agent judged these from the review packets in \`${join11(resultsDir, "review")}/\`. Check its evidence, then record your own
3462
3562
  decision; a wrong Supports here ends up in the published report.
3463
3563
 
3464
3564
  ${byAgent.map((r) => {
@@ -3502,11 +3602,11 @@ ${failing.map((r) => {
3502
3602
  }).join("\n")}
3503
3603
  `;
3504
3604
  mkdirSync4(resultsDir, { recursive: true });
3505
- writeFileSync5(join10(resultsDir, "vpat-draft.md"), vpat);
3506
- writeFileSync5(join10(resultsDir, "manual-pass.md"), checklist);
3605
+ writeFileSync6(join11(resultsDir, "vpat-draft.md"), vpat);
3606
+ writeFileSync6(join11(resultsDir, "manual-pass.md"), checklist);
3507
3607
  const html = toHtml(vpat, `${app.name} Accessibility Conformance Report`);
3508
- const pdfPath = join10(resultsDir, "conformance-report.pdf");
3509
- writeFileSync5(join10(resultsDir, "conformance-report.html"), html);
3608
+ const pdfPath = join11(resultsDir, "conformance-report.pdf");
3609
+ writeFileSync6(join11(resultsDir, "conformance-report.html"), html);
3510
3610
  const { chromium } = await loadPlaywright(forkPath);
3511
3611
  const browser = await chromium.launch();
3512
3612
  try {
@@ -3526,12 +3626,12 @@ ${failing.map((r) => {
3526
3626
  if (unconfirmed && !draft) {
3527
3627
  const ids = [...open, ...byAgent].map((r) => r.id).join(", ");
3528
3628
  throw new Error(
3529
- `not published: ${open.length} criteria are open and ${byAgent.length} are decided by an agent only (${ids}). See ${join10(resultsDir, "manual-pass.md")}, or add --draft to publish it as a draft`
3629
+ `not published: ${open.length} criteria are open and ${byAgent.length} are decided by an agent only (${ids}). See ${join11(resultsDir, "manual-pass.md")}, or add --draft to publish it as a draft`
3530
3630
  );
3531
3631
  }
3532
3632
  const publicPath = "frontend/public/static/common/accessibility-conformance-report.pdf";
3533
- mkdirSync4(dirname4(join10(forkPath, publicPath)), { recursive: true });
3534
- copyFileSync(pdfPath, join10(forkPath, publicPath));
3633
+ mkdirSync4(dirname4(join11(forkPath, publicPath)), { recursive: true });
3634
+ copyFileSync(pdfPath, join11(forkPath, publicPath));
3535
3635
  const count3 = (status) => ledger.criteria.filter((r) => r.status === status).length;
3536
3636
  const review = {
3537
3637
  standard: ledger.standard,
@@ -3638,8 +3738,8 @@ var a11yCommand = {
3638
3738
  };
3639
3739
 
3640
3740
  // src/tools/bench/command.ts
3641
- import { existsSync as existsSync7, readdirSync } from "fs";
3642
- import { basename, join as join11 } from "path";
3741
+ import { existsSync as existsSync8, readdirSync as readdirSync2 } from "fs";
3742
+ import { basename, join as join12 } from "path";
3643
3743
 
3644
3744
  // src/tools/bench/request.ts
3645
3745
  var BENCH_REQUEST_ENV = "CELLA_BENCH";
@@ -3648,11 +3748,11 @@ var BENCH_REQUEST_ENV = "CELLA_BENCH";
3648
3748
  var BENCH_DIR = "backend/bench";
3649
3749
  var ENTRY_COMMAND = ["pnpm", "exec", "tsx", "--no-experimental-webstorage", "bench/run.ts"];
3650
3750
  async function runBench(forkPath, options) {
3651
- if (!existsSync7(join11(forkPath, BENCH_DIR, "run.ts"))) {
3751
+ if (!existsSync8(join12(forkPath, BENCH_DIR, "run.ts"))) {
3652
3752
  throw new Error(`${BENCH_DIR}/run.ts is missing: upgrade the app from cella first`);
3653
3753
  }
3654
- const scenariosDir = join11(forkPath, BENCH_DIR, "scenarios");
3655
- const names = readdirSync(scenariosDir).filter((file) => file.endsWith(".ts")).map((file) => basename(file, ".ts")).sort();
3754
+ const scenariosDir = join12(forkPath, BENCH_DIR, "scenarios");
3755
+ const names = readdirSync2(scenariosDir).filter((file) => file.endsWith(".ts")).map((file) => basename(file, ".ts")).sort();
3656
3756
  if (options.scenario && !names.includes(options.scenario)) {
3657
3757
  throw new Error(`unknown scenario '${options.scenario}'. expected one of: ${names.join(", ")}`);
3658
3758
  }
@@ -3664,7 +3764,7 @@ async function runBench(forkPath, options) {
3664
3764
  scenariosDir,
3665
3765
  dataDir: toolDir(forkPath, "bench", "repository")
3666
3766
  };
3667
- const run = (env) => runCommand(join11(forkPath, "backend"), { ...env, [BENCH_REQUEST_ENV]: JSON.stringify(request) }, ENTRY_COMMAND);
3767
+ const run = (env) => runCommand(join12(forkPath, "backend"), { ...env, [BENCH_REQUEST_ENV]: JSON.stringify(request) }, ENTRY_COMMAND);
3668
3768
  return options.isolated ? withTestbed(forkPath, (testbed) => run(testbed.env)) : run(getEnvSnapshot());
3669
3769
  }
3670
3770
  var benchCommand = {
@@ -3703,98 +3803,12 @@ import { spawnSync as spawnSync6 } from "child_process";
3703
3803
  import { existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
3704
3804
  import { join as join14 } from "path";
3705
3805
 
3706
- // src/tools/i18n/locales.ts
3707
- import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
3708
- import { join as join12 } from "path";
3709
- var LOCALES_DIR = "locales";
3710
- var MANIFEST = "namespaces.json";
3711
- var SOURCE_LANGUAGE = "en";
3712
- var entryPattern = /^(\s+)("(?:[^"\\]|\\.)*")\s*:\s*("(?:[^"\\]|\\.)*"),?\s*$/;
3713
- function parseLocaleFile(path, text2) {
3714
- if (text2.trim() === "{}") return { path, indent: " ", entries: [] };
3715
- const lines = text2.replace(/\n$/, "").split("\n");
3716
- if (lines[0] !== "{" || lines.at(-1) !== "}") return "not one JSON object with one entry per line";
3717
- const entries = [];
3718
- let indent = " ";
3719
- for (const line of lines.slice(1, -1)) {
3720
- const match = entryPattern.exec(line);
3721
- if (!match) return `cannot read the line ${JSON.stringify(line.trim())}`;
3722
- indent = match[1];
3723
- entries.push({ key: JSON.parse(match[2]), value: JSON.parse(match[3]), raw: [match[2], match[3]] });
3724
- }
3725
- return { path, indent, entries };
3726
- }
3727
- function serializeLocaleFile(file) {
3728
- if (file.entries.length === 0) return "{}\n";
3729
- return `{
3730
- ${file.entries.map(({ raw }) => `${file.indent}${raw[0]}: ${raw[1]}`).join(",\n")}
3731
- }
3732
- `;
3733
- }
3734
- function writeLocaleFile(forkPath, file) {
3735
- writeFileSync6(join12(forkPath, file.path), serializeLocaleFile(file));
3736
- }
3737
- var textsOf = (file) => Object.fromEntries((file?.entries ?? []).map(({ key, value }) => [key, value]));
3738
- var readJsonObject = (path) => {
3739
- const parsed2 = JSON.parse(readFileSync6(path, "utf8"));
3740
- if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) throw new Error(`${path} is not a JSON object`);
3741
- return Object.fromEntries(Object.entries(parsed2));
3742
- };
3743
- function readLocales(forkPath) {
3744
- const dir = join12(forkPath, LOCALES_DIR);
3745
- if (!existsSync8(join12(dir, SOURCE_LANGUAGE))) throw new Error(`no ${LOCALES_DIR}/${SOURCE_LANGUAGE}/ in ${forkPath}`);
3746
- const languages = readdirSync2(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name).sort((a, b) => a === SOURCE_LANGUAGE ? -1 : b === SOURCE_LANGUAGE ? 1 : a.localeCompare(b));
3747
- const files2 = /* @__PURE__ */ new Map();
3748
- for (const language2 of languages) {
3749
- for (const name of readdirSync2(join12(dir, language2)).filter((name2) => name2.endsWith(".json"))) {
3750
- const path = `${LOCALES_DIR}/${language2}/${name}`;
3751
- const parsed2 = parseLocaleFile(path, readFileSync6(join12(forkPath, path), "utf8"));
3752
- if (typeof parsed2 === "string") throw new Error(`${path}: ${parsed2}; pnpm lint:fix writes one entry per line`);
3753
- files2.set(`${language2}/${name.slice(0, -".json".length)}`, parsed2);
3754
- }
3755
- }
3756
- const manifestPath = join12(dir, MANIFEST);
3757
- if (!existsSync8(manifestPath)) throw new Error(`no ${LOCALES_DIR}/${MANIFEST}: upgrade the app from cella first`);
3758
- const merged = readJsonObject(manifestPath);
3759
- const namespaces = /* @__PURE__ */ new Map();
3760
- for (const [namespace, sources] of Object.entries(merged)) {
3761
- if (!Array.isArray(sources) || !sources.every((source) => typeof source === "string")) {
3762
- throw new Error(`${LOCALES_DIR}/${MANIFEST}: ${namespace} must list file names`);
3763
- }
3764
- namespaces.set(namespace, sources);
3765
- }
3766
- const mergedFiles = new Set([...namespaces.values()].flat());
3767
- const ownFiles = [...new Set([...files2.keys()].map((key) => key.split("/")[1]))].filter(
3768
- (file) => !mergedFiles.has(file)
3769
- );
3770
- for (const file of ownFiles.sort()) namespaces.set(file, [file]);
3771
- return { languages, namespaces, files: files2 };
3772
- }
3773
- function namespaceTexts(locales, language2, namespace) {
3774
- const sources = locales.namespaces.get(namespace) ?? [];
3775
- return Object.assign({}, ...sources.map((file) => textsOf(locales.files.get(`${language2}/${file}`))));
3776
- }
3777
- var lineOf = (file, key) => file.entries.findIndex((entry) => entry.key === key) + 2;
3778
- var pluralSuffix = /_(ordinal_)?(zero|one|two|few|many|other)$/;
3779
- function pluralForm(key) {
3780
- const match = pluralSuffix.exec(key);
3781
- if (!match) return void 0;
3782
- return { base: key.slice(0, match.index), category: match[2], ordinal: !!match[1] };
3783
- }
3784
- var withPluralForms = (key) => [
3785
- key,
3786
- ...["zero", "one", "two", "few", "many", "other"].flatMap((category) => [
3787
- `${key}_${category}`,
3788
- `${key}_ordinal_${category}`
3789
- ])
3790
- ];
3791
-
3792
3806
  // src/tools/i18n/scan.ts
3793
3807
  import { execFileSync as execFileSync2 } from "child_process";
3794
3808
  import { readFileSync as readFileSync7, statSync as statSync2 } from "fs";
3795
3809
  import { dirname as dirname5, join as join13 } from "path";
3796
3810
  var codeFile = /\.(?:[cm]?[jt]sx?)$/;
3797
- var skipped = (path) => path.endsWith(".d.ts") || path.startsWith("locales/") || path.startsWith("cella/alignments/") || path.split("/").some((segment) => segment === "node_modules" || segment === "gen" || segment.includes(".gen."));
3811
+ var skipped = (path) => path.endsWith(".d.ts") || path.startsWith("locales/") || path.startsWith("shared/locales/") || path.startsWith("cella/alignments/") || path.split("/").some((segment) => segment === "node_modules" || segment === "gen" || segment.includes(".gen."));
3798
3812
  var MAX_FILE_BYTES = 1e6;
3799
3813
  var MAX_COMBINATIONS = 500;
3800
3814
  var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4211,10 +4225,10 @@ function printList(title, items) {
4211
4225
  console.info(` ${colors_default.dim(`${items.slice(0, LIST_MAX).join(", ")}${more}`)}`);
4212
4226
  }
4213
4227
  var describe = ({ key, missing, extra }) => [key, ...missing.map((name) => `-${name}`), ...extra.map((name) => `+${name}`)].join(" ");
4214
- function printStatus(report) {
4228
+ function printStatus(report, dir) {
4215
4229
  const label = ({ name, files: files2 }) => files2.length > 1 || files2[0] !== name ? `${name} (${files2.join(" + ")})` : name;
4216
4230
  console.info(
4217
- `${colors_default.bold(`${LOCALES_DIR}/`)} ${colors_default.dim(`${report.languages.join(", ")} \xB7 namespaces ${report.namespaces.map(label).join(", ")}`)}`
4231
+ `${colors_default.bold(`${dir}/`)} ${colors_default.dim(`${report.languages.join(", ")} \xB7 namespaces ${report.namespaces.map(label).join(", ")}`)}`
4218
4232
  );
4219
4233
  console.info();
4220
4234
  console.info(colors_default.dim(row2(columns.map(([title]) => title))));
@@ -4286,9 +4300,10 @@ function printStatus(report) {
4286
4300
  console.info(` ${colors_default.dim("missing and same-as-English keys: --json lists them \xB7 cella i18n unused")}`);
4287
4301
  }
4288
4302
  function runStatus(forkPath, json) {
4289
- const report = buildStatus(readLocales(forkPath));
4303
+ const locales = readLocales(forkPath);
4304
+ const report = buildStatus(locales);
4290
4305
  if (json) writeStdout(JSON.stringify(report, null, 2));
4291
- else printStatus(report);
4306
+ else printStatus(report, locales.dir);
4292
4307
  return defectCount(report) === 0 ? 0 : 1;
4293
4308
  }
4294
4309
 
@@ -5779,7 +5794,9 @@ var alignmentExempt = [
5779
5794
  "json/",
5780
5795
  "locales/",
5781
5796
  "sdk/gen",
5782
- "shared/config"
5797
+ "shared/config",
5798
+ "shared/json/",
5799
+ "shared/locales/"
5783
5800
  ];
5784
5801
  function ownedPaths(configSource) {
5785
5802
  const lists = [...configSource.matchAll(/\b(?:ignored|pinned): \[([\s\S]*?)\n {4}\]/g)].map((match) => match[1]);
@@ -7615,8 +7632,9 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
7615
7632
  const forkModifiedOld = forkOldHash !== baseOldHash;
7616
7633
  const oldPathPinned = predicates.isPinned(oldPath);
7617
7634
  let upstreamChanged2;
7618
- if (fileIsPinned || oldPathPinned || predicates.isIgnored(oldPath)) {
7635
+ if (fileIsPinned || fileIsIgnored || oldPathPinned || predicates.isIgnored(oldPath)) {
7619
7636
  status = "pinned";
7637
+ if (oldPathInFork) renamedFrom = oldPath;
7620
7638
  if (forkModifiedOld && upstreamHash !== baseOldHash) {
7621
7639
  upstreamChanged2 = true;
7622
7640
  renamedProtected.add(filePath);
@@ -7772,9 +7790,11 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
7772
7790
  );
7773
7791
  const batchRestorePaths = [];
7774
7792
  const batchRemovePaths = [];
7793
+ const movedProtected = [];
7775
7794
  for (const file of analyzedFiles) {
7776
7795
  if (file.status === "identical" || file.status === "ahead") continue;
7777
- if (file.isIgnored || file.isPinned && !file.renamedFrom) {
7796
+ if (file.status === "pinned" && file.renamedFrom) movedProtected.push({ from: file.renamedFrom, to: file.path });
7797
+ else if (file.isIgnored || file.isPinned) {
7778
7798
  (file.existsInFork ? batchRestorePaths : batchRemovePaths).push(file.path);
7779
7799
  }
7780
7800
  }
@@ -7791,6 +7811,10 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
7791
7811
  await removeFileFromWorktree(forkPath, filePath);
7792
7812
  }
7793
7813
  }
7814
+ for (const { from, to } of movedProtected) {
7815
+ onProgress?.(`\u2192 ${from} \u2192 ${to}: keeping your copy (moved in upstream)`);
7816
+ await moveFromRef(forkPath, "HEAD", from, to);
7817
+ }
7794
7818
  for (const file of analyzedFiles) {
7795
7819
  const { path: filePath, status, existsInFork } = file;
7796
7820
  if (status === "identical" || status === "ahead" || isProtectedFile(file)) continue;