@ultimat3/cli 5.0.1 → 7.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.
Files changed (60) hide show
  1. package/CLAUDE.md +75 -6
  2. package/README.md +2 -2
  3. package/package.json +28 -24
  4. package/src/affected.ts +320 -0
  5. package/src/browser-launcher.ts +109 -0
  6. package/src/ci-log.ts +0 -0
  7. package/src/ci-runs.ts +179 -0
  8. package/src/cmd-affected.ts +109 -0
  9. package/src/cmd-build.ts +36 -3
  10. package/src/cmd-ci.ts +273 -0
  11. package/src/cmd-dev.ts +35 -2
  12. package/src/cmd-generate.ts +16 -348
  13. package/src/cmd-i18n.ts +32 -16
  14. package/src/cmd-pr.ts +308 -0
  15. package/src/cmd-shot.ts +320 -0
  16. package/src/cmd-test.ts +96 -7
  17. package/src/cmd-verify.ts +10 -427
  18. package/src/compile-externals.ts +34 -0
  19. package/src/dev-lock.ts +275 -0
  20. package/src/dev-render.ts +7 -17
  21. package/src/error-codes.ts +18 -0
  22. package/src/generate-files.ts +127 -0
  23. package/src/generate-write.ts +229 -0
  24. package/src/gh-target.ts +118 -0
  25. package/src/gh.ts +204 -0
  26. package/src/i18n-audit.ts +39 -1
  27. package/src/i18n-registration.ts +130 -0
  28. package/src/index.ts +37 -0
  29. package/src/island-bundle.ts +68 -2
  30. package/src/island-solid-production.ts +129 -0
  31. package/src/island-styles.ts +41 -0
  32. package/src/mcp-errors.ts +11 -0
  33. package/src/messages.ts +67 -0
  34. package/src/pr-threads.ts +291 -0
  35. package/src/prerender.ts +52 -10
  36. package/src/registry.ts +8 -0
  37. package/src/shot-verdict.ts +337 -0
  38. package/src/solid-loader.ts +127 -0
  39. package/src/static-report.ts +219 -0
  40. package/src/templates/admin-page.ts +46 -5
  41. package/src/templates/index.ts +1 -0
  42. package/src/templates/island-fixture.ts +76 -0
  43. package/src/templates/island.ts +129 -18
  44. package/src/templates/resource-form-island.ts +279 -0
  45. package/src/templates/resource.ts +52 -43
  46. package/src/templates/route.ts +45 -6
  47. package/src/templates/scaffold-app.ts +70 -19
  48. package/src/templates/scaffold-container.ts +2 -2
  49. package/src/templates/scaffold-db-package.ts +88 -39
  50. package/src/templates/scaffold-docs.ts +18 -1
  51. package/src/templates/scaffold-i18n.ts +9 -2
  52. package/src/templates/scaffold-mcp-package.ts +35 -2
  53. package/src/templates/scaffold-package-shape.ts +7 -2
  54. package/src/templates/scaffold-repo.ts +2 -2
  55. package/src/test-shards.ts +19 -3
  56. package/src/verify-checks.ts +349 -0
  57. package/src/verify-run.ts +122 -0
  58. package/src/verify-step.ts +7 -0
  59. package/src/workspace-graph.ts +241 -0
  60. package/types/babel-modules.d.ts +31 -0
@@ -13,6 +13,37 @@ import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shap
13
13
 
14
14
  const DESCRIPTION = 'Entity re-exports and SQL migrations, no business logic';
15
15
 
16
+ /**
17
+ * The example slice's entity lives in `apps/web/app/post/`, so `src/schema.ts` re-exports it from
18
+ * there — an edge this manifest has to declare or it exists only inside the root tsconfig's
19
+ * `paths`, where `bun --filter` and every change-detection tool are blind to it
20
+ * (`X_WORKSPACE_DEP_UNDECLARED`). Written here rather than through `workspacePackageJson` for the
21
+ * reason `scaffold-i18n.ts` states: that helper is the dependency-free shape.
22
+ *
23
+ * Under `--no-example` there is no entity and no import, so there is no dependency either: a pin
24
+ * for an edge the package does not have is the same lie in the other direction.
25
+ */
26
+ const dbPackage = (app: NameSet, example: boolean): string =>
27
+ example
28
+ ? `{
29
+ "name": "@${app.kebab}/db",
30
+ "version": "0.0.0",
31
+ "private": true,
32
+ "type": "module",
33
+ "description": "${DESCRIPTION}",
34
+ "exports": {
35
+ ".": "./src/index.ts"
36
+ },
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
39
+ },
40
+ "dependencies": {
41
+ "@${app.kebab}/web": "0.0.0"
42
+ }
43
+ }
44
+ `
45
+ : workspacePackageJson(app, 'db', DESCRIPTION);
46
+
16
47
  const dbIndex =
17
48
  (): string => `// Schema and migrations only — no business logic lives in this package. The client itself is
18
49
  // @ultimat3/db's: one connection pool, sized by ROLE, shared by every package in the app.
@@ -28,20 +59,6 @@ export * as schema from './schema';
28
59
  const SCHEMA_HEADER = `// Every entity the app declares, re-exported here. This list is what the migration generator
29
60
  // reads, so an entity that is not exported here does not exist as far as the database is concerned.`;
30
61
 
31
- /**
32
- * `bun run db:seed`'s entry point. Identical either way — only the rows differ. Interpolated, not
33
- * nested, so it carries exactly the escaping a single template literal needs.
34
- */
35
- const SEED_MAIN = `
36
-
37
- if (import.meta.main) {
38
- const count = await seed();
39
- // Bun's stdout, not process.stdout: one runtime, one API. Awaited because the write resolves
40
- // asynchronously, and this JSON line is the whole output of \`bun run db:seed\`.
41
- await Bun.stdout.write(\`\${JSON.stringify({ ok: true, seeded: count })}\\n\`);
42
- }
43
- `;
44
-
45
62
  const dbSchema = (app: NameSet, example: boolean): string =>
46
63
  example
47
64
  ? `${SCHEMA_HEADER}
@@ -52,38 +69,70 @@ export { post } from '@${app.kebab}/web/app/post/entity';
52
69
  export {};
53
70
  `;
54
71
 
72
+ /**
73
+ * The seed, as a `defineSeed()` — which is what `x db seed` discovers and what the framework has
74
+ * meant by "a seed" since 2.0.0.
75
+ *
76
+ * It used to be a plain `export async function seed()` with an `import.meta.main` block, run by a
77
+ * `bun run db:seed` npm script, and that shipped two defects at once. `x db seed` discovers
78
+ * every `seed*.ts` under a package's `src` and looks for an exported `Seed`, so the scaffold's
79
+ * own seed was invisible to its own command — `x db seed` on a fresh app answered "no seed
80
+ * matched".
81
+ * And `bun run db:seed` reaches the database through `@ultimat3/db`'s `db()`, which reads
82
+ * `DATABASE_URL` and speaks `postgres:` only, so on a clone with no Postgres it cannot see the
83
+ * embedded PGlite that `x db migrate` had just migrated in process. `bin/setup` therefore printed
84
+ * `✓ migrations applied` and then died on `X_DB_UNAVAILABLE`, whose `fix:` says "run `x dev` to use
85
+ * the embedded PGlite" — naming the mechanism that had just worked one line above.
86
+ *
87
+ * `x db seed` owns the connection, the tier and the per-seed transaction. One runner, one answer.
88
+ */
55
89
  const dbSeed = (app: NameSet, example: boolean): string =>
56
90
  example
57
- ? `// Deterministic seed: same rows every time, so a test and a demo see the same database.
58
- import { db, sql } from '@ultimat3/db';
59
-
60
- const ORG = '00000000-0000-0000-0000-000000000002';
91
+ ? `// Deterministic fixtures: the same rows every time, so a test, a demo and a branch database
92
+ // all see the same content.
93
+ //
94
+ // \`x db seed\` is the runner — it discovers every exported \`defineSeed()\` in a package's
95
+ // \`src/seed*.ts\`, opens the database exactly as \`x db migrate\` does (embedded PGlite
96
+ // included), and wraps each seed in its own transaction. Never a plain \`bun run\` script: that
97
+ // reaches the database through \`db()\`, which needs a \`postgres:\` \`DATABASE_URL\` and so cannot
98
+ // see the embedded database at all.
99
+ import { defineSeed } from '@ultimat3/entity';
100
+ import { post } from './schema';
61
101
 
62
- export async function seed(): Promise<number> {
63
- const rows = [
64
- { id: '00000000-0000-0000-0000-000000000101', title: 'Hello ${app.pascal}', minor: 0 },
65
- { id: '00000000-0000-0000-0000-000000000102', title: 'Second post', minor: 1900 },
66
- ];
67
- for (const row of rows) {
68
- // Idempotent by primary key, so re-seeding a branch database is a no-op rather than a crash.
69
- await db().execute(sql\`
70
- insert into posts (id, org_id, title, price_minor, price_currency)
71
- values (\${row.id}, \${ORG}, \${row.title}, \${row.minor}, 'USD')
72
- on conflict (id) do nothing\`);
73
- }
74
- return rows.length;
75
- }${SEED_MAIN}`
76
- : `// Deterministic seed: same rows every time, so a test and a demo see the same database.
77
- // No entity is declared yet, so there is nothing to insert — the shape stays, so the first
78
- // \`x g entity\` has one obvious place to seed from.
102
+ /** Stable across runs: \`id('post:hello')\` is a UUID v5 of the label, not a random one. */
103
+ export const ${app.camel}Seed = defineSeed('${app.kebab}', async ({ insert, id }) => {
104
+ await insert(post, [
105
+ {
106
+ id: id('post:hello'),
107
+ orgId: id('org:demo'),
108
+ title: 'Hello ${app.pascal}',
109
+ price: { minor: 0, currency: 'USD' },
110
+ },
111
+ {
112
+ id: id('post:second'),
113
+ orgId: id('org:demo'),
114
+ title: 'Second post',
115
+ price: { minor: 1900, currency: 'USD' },
116
+ },
117
+ ]);
118
+ });
119
+ `
120
+ : `// Deterministic fixtures, run by \`x db seed\`. No entity is declared yet, so there is nothing
121
+ // to insert — the shape stays so the first \`x g entity\` has one obvious place to seed from.
122
+ //
123
+ // \`x db seed\` discovers every exported \`defineSeed()\` in a package's \`src/seed*.ts\` and opens
124
+ // the database the way \`x db migrate\` does, embedded PGlite included. Never a plain \`bun run\`
125
+ // script: that needs a \`postgres:\` \`DATABASE_URL\` and cannot see the embedded database.
126
+ import { defineSeed } from '@ultimat3/entity';
79
127
 
80
- export async function seed(): Promise<number> {
81
- return 0;
82
- }${SEED_MAIN}`;
128
+ export const ${app.camel}Seed = defineSeed('${app.kebab}', async () => {
129
+ // \`await insert(<entity>, [...])\` once an entity exists.
130
+ });
131
+ `;
83
132
 
84
133
  /** Every file the `packages/db` workspace ships, in the order `x new` writes them. */
85
134
  export const dbPackageFiles = (app: NameSet, example: boolean): readonly GeneratedFile[] => [
86
- { path: 'packages/db/package.json', contents: workspacePackageJson(app, 'db', DESCRIPTION) },
135
+ { path: 'packages/db/package.json', contents: dbPackage(app, example) },
87
136
  ...packageShapeFiles(app, 'db', DESCRIPTION),
88
137
  { path: 'packages/db/src/index.ts', contents: dbIndex() },
89
138
  { path: 'packages/db/src/schema.ts', contents: dbSchema(app, example) },
@@ -88,7 +88,11 @@ bun install
88
88
  # this script is documented idempotent, and the guard is what makes that true here.
89
89
  ls packages/db/migrations/*.sql >/dev/null 2>&1 || bunx x db gen "initial"
90
90
  bunx x db migrate "$@"
91
- bun run db:seed
91
+ # \`x db seed\`, never \`bun run\`: the CLI owns the connection, so this reaches the same embedded
92
+ # PGlite the migration above just wrote to. A plain script goes through \`db()\`, which needs a
93
+ # \`postgres:\` DATABASE_URL and so dies on a clone with no Postgres — one line after reporting a
94
+ # successful migration.
95
+ bunx x db seed
92
96
  echo "setup complete — next: x dev"
93
97
  `;
94
98
 
@@ -103,6 +107,19 @@ const binCheck = (): string => `#!/usr/bin/env bash
103
107
  # The gate. Same steps as CI, because a check that lives only in CI cannot be run locally.
104
108
  set -euo pipefail
105
109
  cd "$(dirname "$0")/.."
110
+ # The build FIRST, and not as a convenience: \`x verify\`'s budgets step compares declared limits
111
+ # against measured bytes in .x/build-stats.json, so with no build it reports X_BUDGET_UNMEASURED and
112
+ # the very first gate anyone runs on a brand-new app is red for a reason that has nothing to do with
113
+ # their code. Cheap on a warm tree, and it makes "green" reachable from a fresh clone.
114
+ #
115
+ # \`--json\` is forwarded to BOTH, or the contract breaks: \`bin/check --json\` would otherwise print
116
+ # the build's human renderer to stdout and then the gate's JSON, and a machine consumer reading one
117
+ # document off stdout gets neither. Both commands emit one object; a reader takes the last line.
118
+ build_flags=""
119
+ for arg in "$@"; do
120
+ case "$arg" in --json|-j) build_flags="--json" ;; esac
121
+ done
122
+ bunx x build --target static $build_flags
106
123
  exec bunx x verify "$@"
107
124
  `;
108
125
 
@@ -87,8 +87,15 @@ export type AppCatalog = typeof en;
87
87
  export type TranslationKey = KeyOf<AppCatalog>;
88
88
 
89
89
  /**
90
- * Use this, never \`useI18n()\` directly — the type parameter is what makes an unknown key a
91
- * compile error instead of a \`⟦key⟧\` someone notices in production.
90
+ * The app's ONE way to read a string. Never \`useI18n()\` directly and never \`t\` from
91
+ * \`@ultimat3/i18n\`, for two independent reasons:
92
+ *
93
+ * 1. the type parameter makes an unknown key a compile error instead of a \`⟦key⟧\` someone
94
+ * notices in production;
95
+ * 2. importing THIS module is what registers the catalogs — \`defineCatalogs()\` above runs on
96
+ * import and nowhere else. A page that reached past it rendered every string as \`⟦key⟧\`
97
+ * with \`x verify\` green, because nothing in the app depended on the module that registers
98
+ * (issue #249). \`x i18n check\` now refuses that app; this import is why it never happens.
92
99
  */
93
100
  export const useT = (): Translator<AppCatalog> => useI18n<AppCatalog>();
94
101
  `;
@@ -3,10 +3,43 @@
3
3
  // itself.
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
- import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shape';
6
+ import { packageShapeFiles } from './scaffold-package-shape';
7
7
 
8
8
  const DESCRIPTION = "The app's own MCP tools";
9
9
 
10
+ /**
11
+ * Writes its own manifest, for the reason `scaffold-i18n.ts` does: `workspacePackageJson` is the
12
+ * dependency-free shape, and this package has a real dependency — `src/index.ts` below imports the
13
+ * app's actions out of `apps/web/api`, because `registerActions` has to see them.
14
+ *
15
+ * The edge points AT the app, which is unusual and correct. `apps/web` is a workspace like any
16
+ * other; reversing it would put the tool catalog upstream of the actions it projects. Declared
17
+ * rather than left to the root tsconfig's `paths`: an undeclared edge resolves for `tsc` and for
18
+ * nothing else — not for `bun --filter` ordering, not for any tool asking what a change affects
19
+ * (`X_WORKSPACE_DEP_UNDECLARED`).
20
+ *
21
+ * `"0.0.0"` and not `workspace:*`: it is the version `apps/web` really carries, which is what
22
+ * `checkLockstep` compares a sibling pin against, and it is the one spelling every other manifest
23
+ * `x new` writes already uses.
24
+ */
25
+ const mcpPackage = (app: NameSet): string => `{
26
+ "name": "@${app.kebab}/mcp",
27
+ "version": "0.0.0",
28
+ "private": true,
29
+ "type": "module",
30
+ "description": "${DESCRIPTION}",
31
+ "exports": {
32
+ ".": "./src/index.ts"
33
+ },
34
+ "scripts": {
35
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
36
+ },
37
+ "dependencies": {
38
+ "@${app.kebab}/web": "0.0.0"
39
+ }
40
+ }
41
+ `;
42
+
10
43
  const mcpIndex = (
11
44
  app: NameSet,
12
45
  ): string => `// The app's own MCP tools. Every action with mcp.expose is already a tool; add app-specific
@@ -42,7 +75,7 @@ unitTest('the app exposes its actions as MCP tools', () => {
42
75
 
43
76
  /** Every file the `packages/mcp` workspace ships, in the order `x new` writes them. */
44
77
  export const mcpPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
45
- { path: 'packages/mcp/package.json', contents: workspacePackageJson(app, 'mcp', DESCRIPTION) },
78
+ { path: 'packages/mcp/package.json', contents: mcpPackage(app) },
46
79
  ...packageShapeFiles(app, 'mcp', DESCRIPTION),
47
80
  { path: 'packages/mcp/src/index.ts', contents: mcpIndex(app) },
48
81
  { path: 'packages/mcp/src/index.test.ts', contents: mcpTest() },
@@ -50,8 +50,13 @@ export const packageShapeFiles = (
50
50
 
51
51
  /**
52
52
  * The manifest every scaffolded `packages/*` carries. Private, versionless-by-convention and
53
- * dependency-free: these packages only re-export a framework package's types, so the one that does
54
- * name a dependency writes its own (`scaffold-i18n.ts`, and it says why). Lives beside
53
+ * dependency-free: these packages only re-export a framework package's types. Three now DO name a
54
+ * dependency and each writes its own manifest — `scaffold-i18n.ts`, `scaffold-mcp-package.ts` and
55
+ * `scaffold-db-package.ts` (the last only under `--example`, which is the branch that emits the
56
+ * import). Each says why at its own site. They write their own rather than taking a `dependencies`
57
+ * parameter here because the edge is a fact about the SOURCE that template emits, and a parameter
58
+ * would let a caller declare an edge its generated code does not have — which is the drift
59
+ * `X_WORKSPACE_DEP_UNDECLARED` exists to catch, pointed the other way. Lives beside
55
60
  * `packageShapeFiles` because every caller of one calls the other.
56
61
  */
57
62
  export const workspacePackageJson = (app: NameSet, name: string, description: string): string => `{
@@ -45,12 +45,12 @@ const rootPackage = (app: NameSet, version: string): string => `{
45
45
  "lint": "biome check .",
46
46
  "test": "bun test",
47
47
  "db:migrate": "x db migrate",
48
- "db:seed": "bun run packages/db/src/seed.ts"
48
+ "db:seed": "x db seed"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@biomejs/biome": "${BIOME_VERSION}",
52
52
  "@electric-sql/pglite": "^0.5.4",
53
- "@types/bun": "^1.3.14",
53
+ "@types/bun": "^1.4.0",
54
54
  "@ultimat3/testing": "^${version}",
55
55
  "typescript": "^6.0.3"
56
56
  },
@@ -3,6 +3,7 @@
3
3
  // cmd-test.ts because a printed reproduction is only true if it carries every input to the split —
4
4
  // that rule is this file's, and argv parsing is that one's.
5
5
 
6
+ import type { AffectedSelection } from './affected';
6
7
  import { docsFor } from './error-codes';
7
8
  import type { Runner } from './exec';
8
9
  import { execOutput } from './exec';
@@ -72,12 +73,18 @@ export interface ReproduceOptions {
72
73
  readonly type?: TestType;
73
74
  /** Files `--sample` kept, so the rerun samples the same corpus instead of the whole type. */
74
75
  readonly sample?: number;
76
+ /**
77
+ * The `--affected` narrowing, when there was one. The fourth input to the split and the one most
78
+ * easily forgotten: `--affected` decides which files exist to shard at all, so a rerun without it
79
+ * re-splits the WHOLE corpus and its shard 2 is a different shard 2.
80
+ */
81
+ readonly affected?: AffectedSelection;
75
82
  }
76
83
 
77
84
  /**
78
- * Every input to the split, printed back. The type and `--filter` decide which files exist to
79
- * shard, `--sample` decides how many of them survive, `--workers` decides the bins — drop any one
80
- * and the command still runs, over a different file set, which reproduces nothing.
85
+ * Every input to the split, printed back. The type, `--filter` and `--affected` decide which files
86
+ * exist to shard, `--sample` decides how many of them survive, `--workers` decides the bins — drop
87
+ * any one and the command still runs, over a different file set, which reproduces nothing.
81
88
  */
82
89
  export function reproduceFor(shard: Shard, options: ReproduceOptions): string {
83
90
  return [
@@ -85,6 +92,12 @@ export function reproduceFor(shard: Shard, options: ReproduceOptions): string {
85
92
  ...(options.type === undefined ? [] : [quoteArg(options.type)]),
86
93
  ...(options.filter === undefined ? [] : ['--filter', quoteArg(options.filter)]),
87
94
  ...(options.sample === undefined ? [] : ['--sample', String(options.sample)]),
95
+ // `--base` is emitted always rather than only when non-default: the default is `main`, and a
96
+ // rerun days later against a moved `main` is a different diff wearing the same flag.
97
+ ...(options.affected === undefined
98
+ ? []
99
+ : ['--affected', '--base', quoteArg(options.affected.base)]),
100
+ ...(options.affected?.dirty === true ? ['--dirty'] : []),
88
101
  '--workers',
89
102
  String(options.workers),
90
103
  '--worker',
@@ -107,6 +120,8 @@ export interface RunShardsOptions {
107
120
  * shard of the sample and would otherwise report that shard's size as the corpus.
108
121
  */
109
122
  readonly sample?: { readonly kept: number; readonly total: number };
123
+ /** Passed straight to `reproduceFor`: see `ReproduceOptions.affected`. */
124
+ readonly affected?: AffectedSelection;
110
125
  }
111
126
 
112
127
  /** The reproduction's inputs, resolved once: `workers` is the split's real width, not the ask. */
@@ -115,6 +130,7 @@ const planOf = (options: RunShardsOptions, workers: number): ReproduceOptions =>
115
130
  ...(options.filter === undefined ? {} : { filter: options.filter }),
116
131
  ...(options.type === undefined ? {} : { type: options.type }),
117
132
  ...(options.sample === undefined ? {} : { sample: options.sample.kept }),
133
+ ...(options.affected === undefined ? {} : { affected: options.affected }),
118
134
  });
119
135
 
120
136
  const failureOf = (shard: Shard, code: number, plan: ReproduceOptions): Finding => ({