@ultimat3/cli 5.0.1 → 6.0.0

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/CLAUDE.md CHANGED
@@ -12,7 +12,7 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
12
12
  | Bare subcommands | `CommandSpec.defaultSubcommand`, **declared**. The parser answered `subcommands[0]` until 1.2.0, so `x db` ran `gen` — the migration GENERATOR — because it sorted first, and `x mcp` started a server. A command with no defensible default declares none and `MissingSubcommandError` refuses the bare form; `parse.test.ts` pins the set at exactly `db` and `mcp`. Its fix is `x help <command>`, never `x <command> --help`: the subcommand is resolved *after* the flag loop, so the latter throws the same error again — a fix line that reproduced its own failure |
13
13
  | Closed flag values | a flag whose values are a closed set is READ through a function that refuses the rest — `cmd-build.ts`'s `readTarget`, `cmd-deploy.ts`'s `readMethod`, `cmd-routes.ts`'s `readSurfaceFilter`, `cmd-mcp.ts`'s `isTransport`. `=== 'helm' ? 'helm' : 'compose'` made `x deploy --method helmm` a COMPOSE deploy reporting `method: "compose"`, and `--surface App` reported `0 routes` and exit 0 — a typo and an empty table rendering identically. The set is the framework's own where one exists (`SURFACES` from `@ultimat3/render`), never a list restated here |
14
14
  | App root | `CommandSpec.requiresApp`, **enforced by `dispatch.ts`** before `target.run` — the field's doc said so for 17 commands and nothing read it, so the promise was kept only by each command remembering to call `requireAppRoot` itself. Those 17 calls stay (they hand the command its root, and name subcommands the dispatcher cannot see), but the DECLARATION is what decides, ahead of any check a command makes about its own arguments; `--help` is exempt, because `target` is the help command by then |
15
- | Result helpers | `command.ts`'s `ok()` / `failed()` write `ok` **after** the `extra` spread: the function's name is the verdict and nothing a caller passes can overturn it. Spread last, `failed('verify', '1 of 17 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
15
+ | Result helpers | `command.ts`'s `ok()` / `failed()` write `ok` **after** the `extra` spread: the function's name is the verdict and nothing a caller passes can overturn it. Spread last, `failed('verify', '1 of 19 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
16
16
  | I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
17
17
  | Staying up | a command still listening when `run` resolves returns `hold` (`hold.ts`), or `bin.ts` exits out from under it |
18
18
  | `--json` | every command, no exceptions — same data as the human render |
@@ -93,7 +93,7 @@ them is answered by this table rather than by a second convention:
93
93
  | `x jobs` | `cmd-jobs.ts`, `jobs-{driver,report,drain,json,table}.ts` | `@ultimat3/jobs`' own introspection |
94
94
  | `x tasks` | `cmd-tasks.ts`, `tasks-facts.ts` | `registeredTasks()` + `@ultimat3/time`'s cron resolution |
95
95
  | `x policy` | `cmd-policy.ts`, `policy-facts.ts` | `@ultimat3/policy`'s `policyMatrix()` over the app's own `Policy` objects |
96
- | `x i18n` | `cmd-i18n.ts`, `i18n-audit.ts` | `@ultimat3/i18n`'s `extractFromFiles` + `auditCatalogs` |
96
+ | `x i18n` | `cmd-i18n.ts`, `i18n-audit.ts`, `i18n-registration.ts` | `@ultimat3/i18n`'s `extractFromFiles` + `auditCatalogs`, then the live catalog registry |
97
97
 
98
98
  Each pairs a `cmd-*.ts` of CLI wiring with a facts module that takes plain inputs and returns plain
99
99
  data, so the projection is testable without a `ParsedArgs` — the `cmd-jobs.ts` / `jobs-report.ts`
@@ -108,6 +108,29 @@ mode `cmd-planned.ts` closes for planned commands and these close for real ones.
108
108
  forbid: a `t()` call is not a primitive and no registry holds it. It uses `source-files.ts`, the
109
109
  same walk `errors` and `filesize` use, so the three cannot disagree on what the app's source is.
110
110
 
111
+ **And then it asks the question the scan cannot answer.** A catalog complete on disk, its keys used
112
+ everywhere in source, and an audit of one against the other were all green for an app that rendered
113
+ `⟦key⟧` on every page — registration is a side effect of importing the module that calls
114
+ `defineCatalogs()`, and nothing imported it (issue #249). `i18n-registration.ts` loads the app
115
+ through `loadApp` — the same call `serveApp` makes at boot, so it is the boot's own answer and not a
116
+ simulation of one — and compares the catalogs on disk against the live registry, per locale
117
+ (`X_CATALOG_UNREGISTERED`). Two conditions, one code: a shipped catalog no module registered, and
118
+ no catalog anywhere while source calls `t()` — the second is the vacuous green an app with an
119
+ `app.config.ts` and no `packages/i18n/catalogs/` used to get. `loadApp`'s own findings ride along
120
+ ONLY when something is unregistered, because "packages/i18n/src/index.ts: SyntaxError" is the
121
+ evidence for the gap above it and noise on a pass.
122
+
123
+ `catalogFindings(root)` is the one composition both callers report: `x i18n check` renders it as a
124
+ table with a `registered` column, `x verify`'s **`i18n` step** returns it as findings. One
125
+ implementation, so the command and the gate can never disagree about an app.
126
+
127
+ **The generators emit `useT()` from the app's own catalog module, never `t` from `@ultimat3/i18n`.**
128
+ The specifier is `resolveCatalogModule(root)` — `packages/i18n/package.json`'s `name`, read off
129
+ disk, because a template is a pure string function and only a package name resolves as an import.
130
+ An app with no such package keeps the framework import: emitting one that cannot resolve trades a
131
+ wrong idiom for a file that does not compile. This is where the reported bug's idiom came from —
132
+ every generated page imported `t` directly, so no page depended on the module that registers.
133
+
111
134
  **A catalog is authored nested and read flat.** `Catalog` (`{ 'nav.home': 'Home' }`) is the
112
135
  translator's form; the file on disk holds `{ nav: { home: 'Home' } }`, and `parseNestedCatalog`
113
136
  refuses a dot inside a key — so anything writing a catalog goes through `nestCatalog`
package/README.md CHANGED
@@ -11,7 +11,7 @@ Commands and the `x verify` step count, `As of 2026-08`:
11
11
  | `x new <name>` | scaffolds the monorepo | interactive-free; auth, seeded DB, example route |
12
12
  | `x dev` | every role in one process | embedded Postgres/events/storage, `/_x` mounted |
13
13
  | `x build --target docker\|binary\|static` | one artifact | `ROLE` selects behaviour at start |
14
- | `x verify` | **the gate** | 17 named steps, each with pass/fail + duration |
14
+ | `x verify` | **the gate** | 19 named steps, each with pass/fail + duration |
15
15
  | `x g <primitive> <name>` | scaffolds a primitive **with a passing test** | never a TODO stub |
16
16
  | `x db gen\|migrate\|reset\|branch\|backfill` | everything DB | `branch` = copy-on-write clone + preview URL; `backfill` dry-runs unless `--write`. `x db studio` is **planned** — it parses, and exits `X_NOT_IMPLEMENTED` naming `/_x`'s db panel |
17
17
  | `x mcp serve` | `@ultimat3/mcp`'s 13 dev tools, over stdio or HTTP | one catalog, one scope set, both transports |
@@ -47,7 +47,7 @@ X_DB_DRIFT: schema differs from migrations
47
47
 
48
48
  ```sh
49
49
  x verify --json
50
- # {"ok":false,"command":"verify","summary":"1 of 17 steps failed","steps":[...]}
50
+ # {"ok":false,"command":"verify","summary":"1 of 19 steps failed","steps":[...]}
51
51
  ```
52
52
 
53
53
  ## `x verify` steps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "5.0.1",
3
+ "version": "6.0.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,6 +22,7 @@
22
22
  "files": [
23
23
  "src",
24
24
  "!src/**/*.test.ts",
25
+ "types",
25
26
  "CLAUDE.md",
26
27
  "README.md",
27
28
  "LICENSE"
@@ -35,28 +36,30 @@
35
36
  "dev": "bun run src/bin.ts dev"
36
37
  },
37
38
  "dependencies": {
38
- "@ultimat3/action": "5.0.1",
39
- "@ultimat3/admin": "5.0.1",
40
- "@ultimat3/ai": "5.0.1",
41
- "@ultimat3/cache": "5.0.1",
42
- "@ultimat3/core": "5.0.1",
43
- "@ultimat3/db": "5.0.1",
44
- "@ultimat3/entity": "5.0.1",
45
- "@ultimat3/http": "5.0.1",
46
- "@ultimat3/i18n": "5.0.1",
47
- "@ultimat3/jobs": "5.0.1",
48
- "@ultimat3/mail": "5.0.1",
49
- "@ultimat3/manifest": "5.0.1",
50
- "@ultimat3/mcp": "5.0.1",
51
- "@ultimat3/policy": "5.0.1",
52
- "@ultimat3/pwa": "5.0.1",
53
- "@ultimat3/query": "5.0.1",
54
- "@ultimat3/realtime": "5.0.1",
55
- "@ultimat3/render": "5.0.1",
56
- "@ultimat3/schema": "5.0.1",
57
- "@ultimat3/seo": "5.0.1",
58
- "@ultimat3/storage": "5.0.1",
59
- "@ultimat3/testing": "5.0.1",
60
- "@ultimat3/time": "5.0.1"
39
+ "@babel/core": "^7.28.4",
40
+ "@ultimat3/action": "6.0.0",
41
+ "@ultimat3/admin": "6.0.0",
42
+ "@ultimat3/ai": "6.0.0",
43
+ "@ultimat3/cache": "6.0.0",
44
+ "@ultimat3/core": "6.0.0",
45
+ "@ultimat3/db": "6.0.0",
46
+ "@ultimat3/entity": "6.0.0",
47
+ "@ultimat3/http": "6.0.0",
48
+ "@ultimat3/i18n": "6.0.0",
49
+ "@ultimat3/jobs": "6.0.0",
50
+ "@ultimat3/mail": "6.0.0",
51
+ "@ultimat3/manifest": "6.0.0",
52
+ "@ultimat3/mcp": "6.0.0",
53
+ "@ultimat3/policy": "6.0.0",
54
+ "@ultimat3/pwa": "6.0.0",
55
+ "@ultimat3/query": "6.0.0",
56
+ "@ultimat3/realtime": "6.0.0",
57
+ "@ultimat3/render": "6.0.0",
58
+ "@ultimat3/schema": "6.0.0",
59
+ "@ultimat3/seo": "6.0.0",
60
+ "@ultimat3/storage": "6.0.0",
61
+ "@ultimat3/testing": "6.0.0",
62
+ "@ultimat3/time": "6.0.0",
63
+ "babel-preset-solid": "^1.9.15"
61
64
  }
62
65
  }
package/src/cmd-build.ts CHANGED
@@ -7,6 +7,7 @@ import { frameworkVersion, VERSION_DEFINE } from '@ultimat3/core';
7
7
  import { requireAppRoot } from './app-root';
8
8
  import { runVerify } from './cmd-verify';
9
9
  import type { CliCommand, CommandContext } from './command';
10
+ import { externalArgs } from './compile-externals';
10
11
  import { BuildEntryMissingError, UnknownCommandError } from './errors';
11
12
  import type { ExecResult } from './exec';
12
13
  import { execOutput } from './exec';
@@ -58,6 +59,11 @@ export function dockerArgs(root: string, tag: string): readonly string[] {
58
59
  * `frameworkVersion()` has nothing to read and throws — which is exactly how this target came to
59
60
  * compile an artifact that could never boot. The value is this CLI's own `@ultimat3/core`, which is
60
61
  * the app's too: the packages release in lockstep and `x new` pins them together.
62
+ *
63
+ * `externalArgs()` is not optional either, and for the opposite reason: it names the one specifier
64
+ * this graph must NOT resolve. `apps/web/server.ts` reaches `serve.ts`, which reaches the island
65
+ * builder, which reaches `@babel/core` — whose `.cts`-config loader requires a package we
66
+ * deliberately do not install. Bun 1.3 fails the compile on it. See `compile-externals.ts`.
61
67
  */
62
68
  export function binaryArgs(root: string, out: string): readonly string[] {
63
69
  return [
@@ -67,6 +73,7 @@ export function binaryArgs(root: string, out: string): readonly string[] {
67
73
  '--minify',
68
74
  '--define',
69
75
  `${VERSION_DEFINE}=${JSON.stringify(frameworkVersion())}`,
76
+ ...externalArgs(),
70
77
  join(root, BUILD_ENTRY.binary),
71
78
  '--outfile',
72
79
  out,
package/src/cmd-dev.ts CHANGED
@@ -23,10 +23,11 @@ import type { CliCommand, CommandContext } from './command';
23
23
  import { assetRoutes } from './dev-assets';
24
24
  import type { DevDashboardInput, DevStatus } from './dev-dashboard';
25
25
  import { devDashboardRoutes, devPanels } from './dev-dashboard';
26
+ import { clearLock, preflight, writeLock } from './dev-lock';
26
27
  import { createStatementLedger } from './dev-n-plus-one';
27
28
  import { appRoutes } from './dev-render';
28
29
  import type { RunningRoles } from './dev-roles';
29
- import { DEV_ROLES, selectRoles, startRoles } from './dev-roles';
30
+ import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
30
31
  import type { RunningServices } from './dev-runtime';
31
32
  import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
32
33
  import type { DevServices } from './dev-services';
@@ -295,6 +296,20 @@ export const devCommand: CliCommand = {
295
296
  DEFAULT_PORT,
296
297
  );
297
298
  const roles = selectRoles(flagString(ctx.args, 'role'));
299
+ // BEFORE anything boots. Both failures this catches were reachable and both reported the wrong
300
+ // thing: a taken port surfaced as X_CLI_UNEXPECTED wrapping "Is port 3000 in use?" with a `fix:`
301
+ // naming `x doctor`, and a second `x dev` on one checkout died later on X_DB_UNAVAILABLE whose
302
+ // `fix:` named `x dev`. Neither is discoverable from the message; both are trivial once the
303
+ // preflight has the state directory and the port in front of it.
304
+ const services = resolveServices(root, ctx.env);
305
+ const { clearedStale } = await preflight({
306
+ stateDir: services.stateDir,
307
+ port,
308
+ // The address the web role will actually bind, never a wider one: probing `0.0.0.0` would
309
+ // refuse a boot that a neighbour on one LAN interface does not actually block.
310
+ hostname: DEV_BINDING.hostname,
311
+ embeddedDb: services.db.mode === 'embedded',
312
+ });
298
313
  const server = await startDev({
299
314
  root,
300
315
  port,
@@ -344,13 +359,23 @@ export const devCommand: CliCommand = {
344
359
  panels: [...server.panels],
345
360
  },
346
361
  lines: [
362
+ // A hard kill leaves the lock behind; clearing it is normal and worth one line, never a
363
+ // finding. First, because it happened before anything else this run reports.
364
+ ...(clearedStale ? [msg('cli.dev.staleLock')] : []),
347
365
  msg('cli.dev.roles', { roles: server.roles.join(', ') }),
348
366
  msg('cli.dev.panels', { panels: server.panels.join(', ') }),
349
367
  msg('cli.dev.manifest', { path: join(root, MANIFEST_FILENAME) }),
350
368
  msg('cli.dev.introspect', { url: `${server.url}/_x` }),
351
369
  ],
352
370
  };
371
+ await writeLock(services.stateDir, {
372
+ pid: process.pid,
373
+ port,
374
+ url: server.url,
375
+ startedAt: new Date().toISOString(),
376
+ });
353
377
  if (ctx.args.flags.get('once') === true) {
378
+ clearLock(services.stateDir);
354
379
  await server.stop();
355
380
  return result;
356
381
  }
@@ -358,6 +383,14 @@ export const devCommand: CliCommand = {
358
383
  // `/_x` stays reachable. Ctrl-C drains the web role through core's phases first and releases
359
384
  // the embedded Postgres, the worker and the watcher after — a hard kill leaves the PGlite
360
385
  // directory locked by a process that no longer exists.
361
- return { ...result, hold: holdUntilShutdown('dev', () => server.stop()) };
386
+ return {
387
+ ...result,
388
+ hold: holdUntilShutdown('dev', async () => {
389
+ // The lock first: a stop() that throws must not leave a file claiming this pid still owns
390
+ // the directory, because the next boot would then refuse for a process that is gone.
391
+ clearLock(services.stateDir);
392
+ await server.stop();
393
+ }),
394
+ };
362
395
  },
363
396
  };
@@ -2,365 +2,29 @@
2
2
  // emits a TODO has moved the work, not done it; every file this writes typechecks, and every
3
3
  // primitive arrives with the test that pins its distant invariants (policy, idempotency, budget).
4
4
 
5
- // `resolve`/`sep` and not `join`: only resolving the assembled path can prove it stayed inside the
6
- // app root, and `node:path` is the only API that resolves one. `node:fs` for the exists check.
7
5
  import { existsSync } from 'node:fs';
8
- import { resolve, sep } from 'node:path';
9
6
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
10
7
  import { appManifest, writeAppManifest } from './app-manifest';
11
8
  import { requireAppRoot } from './app-root';
12
9
  import type { CliCommand, CommandContext } from './command';
13
- import {
14
- CliNotImplementedError,
15
- GenerateJsonInvalidError,
16
- ScaffoldPathEscapeError,
17
- } from './errors';
18
- // Re-exported below: `GENERATORS` and `Generator` are imported from this module by the tests, the
19
- // scaffold fixture and `src/index.ts`, and moving where they are declared must not move where they
20
- // are read from.
21
- import type { Generator } from './generate-kinds';
22
- import {
23
- assertSurfaceSupported,
24
- GENERATORS,
25
- readKind,
26
- readName,
27
- readSurface,
28
- } from './generate-kinds';
29
- import { mergeJsonDeep } from './json-merge';
10
+ import { generate } from './generate-files';
11
+ import { GENERATORS, readKind, readName, readSurface } from './generate-kinds';
12
+ import { containedPath, writeFiles } from './generate-write';
13
+ import { resolveCatalogModule } from './i18n-audit';
30
14
  import { msg } from './messages';
31
15
  import type { CommandResult, Finding } from './output';
32
16
  import { flagBool, flagList, flagString } from './parse';
33
- import type { GeneratedFile, Surface } from './templates';
34
- import {
35
- actionFiles,
36
- adminPageFiles,
37
- backfillFiles,
38
- CATALOG_ROOT,
39
- entityFiles,
40
- guardFiles,
41
- i18nIndex,
42
- islandFiles,
43
- jobFiles,
44
- kebab,
45
- policyFiles,
46
- queryFiles,
47
- resolveLocales,
48
- resourceFiles,
49
- routeFiles,
50
- taskFiles,
51
- } from './templates';
17
+ import { CATALOG_ROOT, i18nIndex, resolveLocales } from './templates';
52
18
 
19
+ // One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
20
+ // fixture reach the kinds, the pure file list and the writer through this module, and a second path
21
+ // to any of them would be the ambiguity axiom 1 forbids.
22
+ export type { GenerateOptions } from './generate-files';
23
+ export { generate } from './generate-files';
53
24
  export type { Generator } from './generate-kinds';
54
25
  export { GENERATORS } from './generate-kinds';
55
-
56
- export interface GenerateOptions {
57
- readonly kind: Generator;
58
- readonly name: string;
59
- readonly feature?: string;
60
- readonly surface?: Surface;
61
- readonly live?: boolean;
62
- /** `resource` only: also emit the per-entity admin override. */
63
- readonly admin?: boolean;
64
- /** Every locale a generated i18n catalog entry ships for. Defaults to `['en']`. */
65
- readonly locales?: readonly string[];
66
- /**
67
- * `island` and `admin:page`: the directory the generated files land in. Named rather than
68
- * derived, because neither destination is derivable — `X_ISLAND_INVALID`'s cause already holds
69
- * the path a page's `src` resolved to, and an app's admin is wherever its `defineAdmin` is.
70
- */
71
- readonly at?: string;
72
- /** `admin:page` only: the permission the page's own work needs, on top of `admin:read`. */
73
- readonly permission?: string;
74
- }
75
-
76
- const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
77
- site: 'apps/web/site',
78
- app: 'apps/web/app',
79
- };
80
-
81
- /** `undefined` when `text` does not parse as a JSON object — the one shape every catalog, whether
82
- * generated or hand-edited on disk, must hold. */
83
- function parseJsonObject(text: string): Record<string, unknown> | undefined {
84
- let parsed: unknown;
85
- try {
86
- parsed = JSON.parse(text);
87
- } catch {
88
- return undefined;
89
- }
90
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
91
- ? (parsed as Record<string, unknown>)
92
- : undefined;
93
- }
94
-
95
- /** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
96
- * the keys a run actually changed, never a reordering. */
97
- function prettyJson(value: Record<string, unknown>): string {
98
- const sorted = Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
99
- return `${JSON.stringify(sorted, null, 2)}\n`;
100
- }
101
-
102
- /**
103
- * Two generators can legitimately produce the same shared file. A plain file (errors.ts) keeps
104
- * first-write-wins; a `merge: 'json'` catalog instead merges every contributor's keys into one
105
- * file — `resourceFiles` and `routeFiles` both target the same locale's catalog, and a plain
106
- * overwrite would drop whichever generator ran first. Later entries fill keys the earlier one
107
- * lacks; the first occurrence wins a clash, the same rule `writeFiles` applies against the copy
108
- * already on disk. Exported so `x new` (`cmd-new.ts`) and the scaffold fixture resolve a shared
109
- * catalog the identical way — one merge rule, not three hand-copied ones.
110
- *
111
- * A `merge: 'json'` file's `contents` are the generator's own output, not user data — one that
112
- * fails to parse as a JSON object is a bug in the template that produced it, so it throws here
113
- * rather than being silently treated as `{}` and merged into (or written as) a catalog with
114
- * attribution to nobody. `writeFiles`/`mergeJsonFile` never see a malformed *generated* payload in
115
- * practice: every production caller (`generate()` below, `cmd-new.ts`'s `planNewApp()`, the
116
- * scaffold fixture) runs its file list through this function first.
117
- */
118
- export function dedupe(files: readonly GeneratedFile[]): readonly GeneratedFile[] {
119
- const seen = new Map<string, GeneratedFile>();
120
- for (const file of files) {
121
- if (file.merge === 'json' && parseJsonObject(file.contents) === undefined) {
122
- throw new GenerateJsonInvalidError({ path: file.path });
123
- }
124
- const prior = seen.get(file.path);
125
- if (prior === undefined) {
126
- seen.set(file.path, file);
127
- } else if (prior.merge === 'json' && file.merge === 'json') {
128
- // Both sides already proved parseable above — the fallback only guards a future change to
129
- // that invariant, it never fires today. Deep: two generators contributing to one nested
130
- // catalog share top-level keys (`app`, `admin`), and a shallow spread drops one of them.
131
- const later = parseJsonObject(file.contents) ?? {};
132
- const earlier = parseJsonObject(prior.contents) ?? {};
133
- const { merged } = mergeJsonDeep(earlier, later);
134
- seen.set(file.path, { ...prior, contents: prettyJson(merged) });
135
- }
136
- // else: not mergeable — first write wins, exactly as it always has.
137
- }
138
- return [...seen.values()];
139
- }
140
-
141
- /**
142
- * Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
143
- * on them, and nothing has to run a filesystem to review what a generator produces.
144
- */
145
- export function generate(options: GenerateOptions): readonly GeneratedFile[] {
146
- const surface: Surface = options.surface ?? 'app';
147
- assertSurfaceSupported(options.kind, surface, options.name);
148
- const surfaceDir = DEFAULT_SURFACE_DIR[surface];
149
- const feature = options.feature ?? options.name;
150
- const target = { surfaceDir, feature };
151
- switch (options.kind) {
152
- case 'resource':
153
- return dedupe(
154
- resourceFiles(options.name, {
155
- ...target,
156
- admin: options.admin === true,
157
- ...(options.locales === undefined ? {} : { locales: options.locales }),
158
- }),
159
- );
160
- case 'action':
161
- return dedupe(actionFiles(options.name, target));
162
- case 'mutator':
163
- return dedupe(actionFiles(options.name, { ...target, mutator: true }));
164
- case 'backfill':
165
- return dedupe(backfillFiles(options.name, target));
166
- case 'entity':
167
- return dedupe(entityFiles(options.name, target));
168
- case 'policy':
169
- return dedupe(policyFiles(options.name, target));
170
- case 'query':
171
- return dedupe(queryFiles(options.name, { ...target, live: options.live === true }));
172
- case 'job':
173
- return dedupe(jobFiles(options.name, target));
174
- case 'task':
175
- return dedupe(taskFiles(options.name, target));
176
- case 'island':
177
- return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
178
- // No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
179
- // guard that lived anywhere else would need an app-side registration to be found.
180
- case 'guard':
181
- return dedupe(guardFiles(options.name));
182
- case 'admin:page':
183
- // A default permission, never none: an empty list is `X_ADMIN_PAGE_UNGUARDED` on sight.
184
- return dedupe(
185
- adminPageFiles(options.name, {
186
- permission: options.permission ?? `${kebab(options.name)}:read`,
187
- // The same `--at` `island` takes: an app's admin is wherever its `defineAdmin` is.
188
- ...(options.at === undefined ? {} : { dir: options.at }),
189
- ...(options.locales === undefined ? {} : { locales: options.locales }),
190
- }),
191
- );
192
- case 'route':
193
- // `--locales` reaches the route generator too: its catalog entry is the route's title and
194
- // description, and a locale asked for on the command line is a locale that gets a file.
195
- return dedupe(
196
- routeFiles(options.name, {
197
- surface,
198
- ...(options.locales === undefined ? {} : { locales: options.locales }),
199
- }),
200
- );
201
- default:
202
- throw new CliNotImplementedError({
203
- feature: `generator "${String(options.kind)}"`,
204
- fix: `x g ${GENERATORS.join('|')}`,
205
- });
206
- }
207
- }
208
-
209
- export interface WriteReport {
210
- readonly written: readonly string[];
211
- readonly conflicts: readonly Finding[];
212
- }
213
-
214
- /**
215
- * `GeneratedFile.path` is documented as relative-POSIX, not enforced as it: `join` would happily
216
- * walk out of the app on a `..` segment or ignore the root entirely on an absolute path. Proven
217
- * before the write, once per file, because after the write there is nothing left to prove.
218
- */
219
- export function containedPath(root: string, path: string): string {
220
- const base = resolve(root);
221
- const target = resolve(base, path);
222
- if (target !== base && !target.startsWith(`${base}${sep}`))
223
- // The default `fix` names the scaffold gate's own test, which repairs nothing for someone
224
- // running `x g`: the fix here is the generate command, re-run as a dry run.
225
- throw new ScaffoldPathEscapeError({
226
- path,
227
- dir: base,
228
- // Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
229
- // rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
230
- fix: `x g resource posts --dry-run # name every file relative to the app root, no ".." segment`,
231
- });
232
- return target;
233
- }
234
-
235
- /**
236
- * A `merge: 'json'` catalog is never a conflict on existence and never subject to `--force`: an
237
- * existing key on disk always wins, because it may hold a human translation, and only genuinely
238
- * new keys are added — so a second, third… generator run keeps growing the same file instead of
239
- * fighting over it. A file that exists but does not parse as a JSON object cannot be merged into
240
- * without risking silent data loss, so that alone is reported rather than clobbered or thrown past.
241
- *
242
- * Typed to the `merge: 'json'` variant alone, not the general `GeneratedFile` union: a
243
- * byte-carrying file has no `contents: string` to merge, and this is what stops one from ever
244
- * reaching `parseJsonObject` even if a future caller forgets the `file.merge === 'json'` guard
245
- * its one call site already applies.
246
- */
247
- async function planJsonMerge(
248
- file: Extract<GeneratedFile, { merge: 'json' }>,
249
- absolute: string,
250
- ): Promise<WritePlan> {
251
- const generated = parseJsonObject(file.contents) ?? {};
252
- if (!existsSync(absolute))
253
- return { kind: 'write', file, absolute, contents: prettyJson(generated) };
254
- const existing = parseJsonObject(await Bun.file(absolute).text());
255
- if (existing === undefined) {
256
- return {
257
- kind: 'conflict',
258
- finding: {
259
- code: 'X_GENERATE_CONFLICT',
260
- cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
261
- fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
262
- docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
263
- at: file.path,
264
- },
265
- };
266
- }
267
- // An existing key wins because it may hold a human translation; only the new keys are added.
268
- // Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
269
- const { merged, gained } = mergeJsonDeep(existing, generated);
270
- // Every key the generator wants is already there — leave the file untouched and unclaimed.
271
- if (!gained) return { kind: 'skip' };
272
- return { kind: 'write', file, absolute, contents: prettyJson(merged) };
273
- }
274
-
275
- /**
276
- * What one generated file would do, decided without doing it. The merge case computes its own
277
- * bytes here rather than at the write, so the two passes below cannot disagree about a file.
278
- */
279
- type WritePlan =
280
- | {
281
- readonly kind: 'write';
282
- readonly file: GeneratedFile;
283
- readonly absolute: string;
284
- readonly contents: string | Uint8Array;
285
- }
286
- | { readonly kind: 'skip' }
287
- | { readonly kind: 'conflict'; readonly finding: Finding };
288
-
289
- function planFile(
290
- file: GeneratedFile,
291
- absolute: string,
292
- force: boolean,
293
- invocation: string,
294
- ): WritePlan {
295
- // A foundation file belongs to the slice, not to the generator that needs it: several generators
296
- // emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
297
- // overwritten, `--force` included. `--force` is about the primitive the author named; clobbering
298
- // `policy.ts` to regenerate one action would delete every rule they wrote. Regenerating a slice
299
- // module is `x g entity|policy`.
300
- if (file.merge === 'if-absent') {
301
- return existsSync(absolute)
302
- ? { kind: 'skip' }
303
- : { kind: 'write', file, absolute, contents: file.contents };
304
- }
305
- if (!force && existsSync(absolute)) {
306
- return {
307
- kind: 'conflict',
308
- finding: {
309
- code: 'X_GENERATE_CONFLICT',
310
- cause: `${file.path} already exists`,
311
- // The caller's own invocation, not `x g <kind>`: `x g --force` is X_CLI_UNKNOWN_COMMAND
312
- // when run, and a `fix:` is copied and pasted verbatim. Same construction as
313
- // `generate-kinds.ts`'s `assertSurfaceSupported`.
314
- fix: `${invocation} --force # overwrites ${file.path}, or pass a different name`,
315
- docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
316
- at: file.path,
317
- },
318
- };
319
- }
320
- return { kind: 'write', file, absolute, contents: file.contents };
321
- }
322
-
323
- /**
324
- * Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
325
- * twice; a generator that lands four of seven files and then reports a conflict is worse, because
326
- * the next run conflicts on the files the failed one wrote.
327
- *
328
- * Two passes, and the split is the point: the first decides — containment, existence, whether a
329
- * catalog can be merged into — and touches nothing, the second writes only when the first found
330
- * no conflict at all. Containment was already proven up front and the rest was not.
331
- */
332
- export async function writeFiles(
333
- root: string,
334
- files: readonly GeneratedFile[],
335
- force: boolean,
336
- /**
337
- * The command line that produced these files, so a conflict's `fix:` can hand it back with
338
- * `--force` on the end. Optional for a caller assembling files itself; the fallback is the
339
- * shape, not a runnable line, and every generator path supplies the real one.
340
- */
341
- invocation = 'x g <kind> <name>',
342
- ): Promise<WriteReport> {
343
- const plans: WritePlan[] = [];
344
- for (const file of files) {
345
- const absolute = containedPath(root, file.path);
346
- plans.push(
347
- file.merge === 'json'
348
- ? await planJsonMerge(file, absolute)
349
- : planFile(file, absolute, force, invocation),
350
- );
351
- }
352
- const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
353
- if (conflicts.length > 0) return { written: [], conflicts };
354
-
355
- const written: string[] = [];
356
- for (const plan of plans) {
357
- if (plan.kind !== 'write') continue;
358
- // Bun.write creates missing parent directories, so a generator never needs an mkdir step.
359
- await Bun.write(plan.absolute, plan.contents);
360
- written.push(plan.file.path);
361
- }
362
- return { written, conflicts: [] };
363
- }
26
+ export type { WriteReport } from './generate-write';
27
+ export { dedupe, writeFiles } from './generate-write';
364
28
 
365
29
  const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
366
30
 
@@ -425,6 +89,9 @@ export const generateCommand: CliCommand = {
425
89
  const locales = resolveLocales(flagList(ctx.args, 'locales'));
426
90
  const at = flagString(ctx.args, 'at');
427
91
  const permission = flagString(ctx.args, 'permission');
92
+ // Read before a file is planned, like the flags above: which module a generated component
93
+ // imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
94
+ const catalogModule = await resolveCatalogModule(root);
428
95
  const files = generate({
429
96
  kind,
430
97
  name,
@@ -435,6 +102,7 @@ export const generateCommand: CliCommand = {
435
102
  live: flagBool(ctx.args, 'live'),
436
103
  admin: flagBool(ctx.args, 'admin'),
437
104
  locales,
105
+ ...(catalogModule === undefined ? {} : { catalogModule }),
438
106
  });
439
107
  if (flagBool(ctx.args, 'dry-run')) {
440
108
  return {