@ultimat3/cli 20.0.0 → 20.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "20.0.0",
3
+ "version": "20.1.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,34 +37,34 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "20.0.0",
41
- "@ultimat3/admin": "20.0.0",
42
- "@ultimat3/ai": "20.0.0",
43
- "@ultimat3/auth": "20.0.0",
44
- "@ultimat3/cache": "20.0.0",
45
- "@ultimat3/core": "20.0.0",
46
- "@ultimat3/db": "20.0.0",
47
- "@ultimat3/entity": "20.0.0",
48
- "@ultimat3/flags": "20.0.0",
49
- "@ultimat3/http": "20.0.0",
50
- "@ultimat3/i18n": "20.0.0",
51
- "@ultimat3/jobs": "20.0.0",
52
- "@ultimat3/mail": "20.0.0",
53
- "@ultimat3/manifest": "20.0.0",
54
- "@ultimat3/mcp": "20.0.0",
55
- "@ultimat3/money": "20.0.0",
56
- "@ultimat3/notify": "20.0.0",
57
- "@ultimat3/policy": "20.0.0",
58
- "@ultimat3/pwa": "20.0.0",
59
- "@ultimat3/query": "20.0.0",
60
- "@ultimat3/realtime": "20.0.0",
61
- "@ultimat3/render": "20.0.0",
62
- "@ultimat3/schema": "20.0.0",
63
- "@ultimat3/scraping": "20.0.0",
64
- "@ultimat3/seo": "20.0.0",
65
- "@ultimat3/storage": "20.0.0",
66
- "@ultimat3/testing": "20.0.0",
67
- "@ultimat3/time": "20.0.0",
40
+ "@ultimat3/action": "20.1.0",
41
+ "@ultimat3/admin": "20.1.0",
42
+ "@ultimat3/ai": "20.1.0",
43
+ "@ultimat3/auth": "20.1.0",
44
+ "@ultimat3/cache": "20.1.0",
45
+ "@ultimat3/core": "20.1.0",
46
+ "@ultimat3/db": "20.1.0",
47
+ "@ultimat3/entity": "20.1.0",
48
+ "@ultimat3/flags": "20.1.0",
49
+ "@ultimat3/http": "20.1.0",
50
+ "@ultimat3/i18n": "20.1.0",
51
+ "@ultimat3/jobs": "20.1.0",
52
+ "@ultimat3/mail": "20.1.0",
53
+ "@ultimat3/manifest": "20.1.0",
54
+ "@ultimat3/mcp": "20.1.0",
55
+ "@ultimat3/money": "20.1.0",
56
+ "@ultimat3/notify": "20.1.0",
57
+ "@ultimat3/policy": "20.1.0",
58
+ "@ultimat3/pwa": "20.1.0",
59
+ "@ultimat3/query": "20.1.0",
60
+ "@ultimat3/realtime": "20.1.0",
61
+ "@ultimat3/render": "20.1.0",
62
+ "@ultimat3/schema": "20.1.0",
63
+ "@ultimat3/scraping": "20.1.0",
64
+ "@ultimat3/seo": "20.1.0",
65
+ "@ultimat3/storage": "20.1.0",
66
+ "@ultimat3/testing": "20.1.0",
67
+ "@ultimat3/time": "20.1.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/budgets.ts CHANGED
@@ -13,6 +13,7 @@ import type { Manifest, RouteFact } from '@ultimat3/manifest';
13
13
  import { formatBytes, parseByteBudget } from '@ultimat3/render';
14
14
  import type { Finding } from './output';
15
15
  import type { UnmeasuredRoute } from './static-report';
16
+ import { SW_REGISTER_PATH } from './sw-artifacts';
16
17
 
17
18
  export const BUILD_STATS_FILE = join('.x', 'build-stats.json');
18
19
 
@@ -24,7 +25,14 @@ export interface RouteStats {
24
25
  * prerenders many pages contributes ONE row, holding its heaviest.
25
26
  */
26
27
  readonly path: string;
28
+ /** The APP's JavaScript, and only the app's — see `FRAMEWORK_SCRIPTS`. */
27
29
  readonly jsBytes: number;
30
+ /**
31
+ * The framework's own injected runtime, in bytes: reported, never budgeted. Optional because a
32
+ * stats file written before 2026-09-11 has no such key, and absent is not zero — a row from an
33
+ * older build simply did not count it, and `checkBudgets` reads `jsBytes` either way.
34
+ */
35
+ readonly frameworkJsBytes?: number;
28
36
  /**
29
37
  * **Written by nothing, `As of 2026-08`.** `apps/web/prerender.ts` is the only producer of this
30
38
  * file and it emits static HTML — there is no browser in the build to observe a paint. So the
@@ -212,11 +220,39 @@ export interface MeasuredEntry {
212
220
  }
213
221
 
214
222
  export interface MeasuredJs {
223
+ /** The app's own executable bytes — what `budget.js` is a promise about. */
215
224
  readonly jsBytes: number;
216
- /** Every `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
225
+ /** The framework's injected runtime, counted separately so it is reported and never charged. */
226
+ readonly frameworkBytes: number;
227
+ /** Every APP `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
217
228
  readonly entries: readonly MeasuredEntry[];
218
229
  }
219
230
 
231
+ /**
232
+ * Scripts the FRAMEWORK injects into a document, which a route's `budget.js` does not answer for.
233
+ * `budget.js` is a promise about the APP's JavaScript: an author can delete an import, move one
234
+ * behind `hydrate: 'interaction'` or drop an island, and can do NOTHING about a file the build
235
+ * writes into every document it renders. Charging it made `js: '0kb'` — the budget `x new`
236
+ * scaffolds on `site/` — unreachable for any installable app, and the `fix:` it printed named an
237
+ * import chain of one entry the author never wrote.
238
+ *
239
+ * It was also not a stable number. `/x-sw-register.js` is written AFTER the documents that name
240
+ * it are weighed (`prerender.ts` emits the worker last, because its precache manifest is built
241
+ * from their content hashes), so a clean `.x/` measured a file that did not exist and recorded 0,
242
+ * and the next build measured the one before it and recorded 250. Same commit, green then red,
243
+ * decided by whether anything had cleaned the output directory.
244
+ *
245
+ * ENUMERATED, and it is one entry: `serviceWorkerHead` is the only `<script src>` the framework
246
+ * puts in a prerendered document — measured on a fresh scaffold plus `x g island` and
247
+ * `x g route`, where `<script src="/x-sw-register.js" defer>` is the only script tag across every
248
+ * emitted page. Two framework scripts are deliberately NOT here. `render/src/hydrate.ts`'s inline
249
+ * module runtime is charged, because it exists only when the page ships an island — it is the
250
+ * cost of the app's own interactivity, and a page with a `0kb` budget has none. `island-props.ts`'
251
+ * `<script type="application/json">` is already excluded as data, by `carriesJson`. A third one
252
+ * joins this set by a decision, here, with the same argument.
253
+ */
254
+ export const FRAMEWORK_SCRIPTS: ReadonlySet<string> = new Set([SW_REGISTER_PATH]);
255
+
220
256
  /**
221
257
  * What a rendered document actually makes the browser execute: the bytes of every inline script
222
258
  * the parser will run, the size of every file a `src` points at, and the size of every island
@@ -226,6 +262,7 @@ export interface MeasuredJs {
226
262
  */
227
263
  export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
228
264
  let jsBytes = 0;
265
+ let frameworkBytes = 0;
229
266
  const entries: MeasuredEntry[] = [];
230
267
  // Deduped ONCE, across both readers below, and the unit is the FETCH: a browser downloads a URL
231
268
  // once however many times the document names it, so `budget.js` — a byte budget — counts it
@@ -247,6 +284,14 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
247
284
  fetched.add(url);
248
285
  const file = Bun.file(join(out, url.slice(1)));
249
286
  const bytes = (await file.exists()) ? file.size : 0;
287
+ // Counted and set aside, not skipped: the bytes are real and a reader is owed the number.
288
+ // Kept out of `entries` as well as out of `jsBytes`, because `entries` is what a finding reads
289
+ // to name the heaviest import — and on a fresh scaffold every route's `heaviestChain` was
290
+ // `/x-sw-register.js`, a file the author cannot edit, delete or move.
291
+ if (FRAMEWORK_SCRIPTS.has(url)) {
292
+ frameworkBytes += bytes;
293
+ return;
294
+ }
250
295
  entries.push({ url, bytes });
251
296
  jsBytes += bytes;
252
297
  };
@@ -266,7 +311,7 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
266
311
  if (url === undefined) continue;
267
312
  await weigh(url);
268
313
  }
269
- return { jsBytes, entries };
314
+ return { jsBytes, frameworkBytes, entries };
270
315
  }
271
316
 
272
317
  /**
package/src/cmd-doctor.ts CHANGED
@@ -3,14 +3,20 @@
3
3
  // work back to the reader.
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
- import { join } from 'node:path';
6
+ import { dirname, join, resolve, sep } from 'node:path';
7
7
  import {
8
8
  ENV_EXAMPLE_PATH,
9
9
  ERROR_DOCS_URL,
10
10
  tryResolveEnvironment,
11
11
  usesDevCursorSecret,
12
12
  } from '@ultimat3/core';
13
- import { checkDb, createPostgresClient } from '@ultimat3/db';
13
+ import {
14
+ checkDb,
15
+ createPostgresClient,
16
+ PGLITE_FIX,
17
+ PGLITE_MISSING,
18
+ PGLITE_PACKAGE,
19
+ } from '@ultimat3/db';
14
20
  import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
15
21
  import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
16
22
  import type { CliCommand, CommandContext } from './command';
@@ -61,6 +67,16 @@ export interface DoctorProbe {
61
67
  * correctly answered `X_DB_UNAVAILABLE` (#F5).
62
68
  */
63
69
  database(): Promise<Finding | null>;
70
+ /**
71
+ * Is the EMBEDDED database usable, and is it the one this environment would open? A FACT and not
72
+ * a finding, the split `offlineFallback` makes below: reaching a module resolver is IO, and what
73
+ * an unresolvable optional peer MEANS is a pure rule with a pure test.
74
+ *
75
+ * `database()` answers `null` the moment `DATABASE_URL` is unset — which is exactly a bare VM,
76
+ * the configuration `x dev` invents a database FOR — so until this existed `x doctor` was silent
77
+ * about the only database a fresh box has. `bin/setup` found out instead, at `x db migrate`.
78
+ */
79
+ embeddedDatabase(): Promise<EmbeddedDatabase>;
64
80
  drift(): Promise<readonly Finding[]>;
65
81
  /**
66
82
  * The other half of the migrations directory: a newest migration with no `.snapshot.json`, which
@@ -76,11 +92,40 @@ export interface DoctorProbe {
76
92
  offlineFallback(): Promise<OfflineFallbackFact>;
77
93
  }
78
94
 
95
+ /** What `x doctor` reads about the embedded database, without opening it. */
96
+ export interface EmbeddedDatabase {
97
+ /**
98
+ * True while `DATABASE_URL` is unset or blank — the condition that makes PGlite the app's
99
+ * database (`resolveServices`), and the same reading `probeDatabase` returns `null` on.
100
+ */
101
+ readonly selected: boolean;
102
+ /** Does `@electric-sql/pglite` resolve from the app root? */
103
+ readonly resolved: boolean;
104
+ }
105
+
79
106
  const finding = (code: string, cause: string, fix: string, at?: string): Finding =>
80
107
  at === undefined
81
108
  ? { code, cause, fix, docs: ERROR_DOCS_URL }
82
109
  : { code, cause, fix, docs: ERROR_DOCS_URL, at };
83
110
 
111
+ /**
112
+ * The rule, pure. Red only where the embedded database is the one that would be opened: an app
113
+ * pointed at a real Postgres never loads PGlite, and a finding about an absent optional peer there
114
+ * is noise the reader learns to skim.
115
+ *
116
+ * `@ultimat3/db`'s own refusal, not a CLI twin of it — same `X_DB_UNAVAILABLE`, same sentence,
117
+ * same runnable fix. The package already says this the moment a query arrives; `x doctor` is what
118
+ * says it before `bin/setup` gets that far.
119
+ */
120
+ export const embeddedDatabaseFinding = (fact: EmbeddedDatabase): Finding | undefined =>
121
+ fact.selected && !fact.resolved
122
+ ? finding(
123
+ 'X_DB_UNAVAILABLE',
124
+ `${PGLITE_MISSING} — and DATABASE_URL is unset or blank, so the embedded one is the database this app would open`,
125
+ PGLITE_FIX,
126
+ )
127
+ : undefined;
128
+
84
129
  /** The file `x doctor` reports missing, and the one the reader creates. */
85
130
  export const ENV_DEVELOPMENT = '.env.development';
86
131
 
@@ -229,6 +274,10 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
229
274
  if (offline !== undefined) findings.push(offline);
230
275
  const database = await probe.database();
231
276
  if (database !== null) findings.push(database);
277
+ // The other half of the same question, and the half a bare VM lands on: `database()` is silent
278
+ // where there is no `DATABASE_URL`, and that silence IS the bare-VM configuration.
279
+ const embedded = embeddedDatabaseFinding(await probe.embeddedDatabase());
280
+ if (embedded !== undefined) findings.push(embedded);
232
281
  findings.push(...(await probe.drift()));
233
282
  // Last, and it is why `X_CLI_UNEXPECTED`'s `fix: x doctor --json` is not a dead end on the path an
234
283
  // author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
@@ -249,6 +298,45 @@ export const doctorPort = (args: ParsedArgs): number =>
249
298
  DEFAULT_DOCTOR_PORT,
250
299
  );
251
300
 
301
+ /**
302
+ * `DATABASE_URL` as BOTH halves of the database question read it: unset and blank are one case.
303
+ * One seam, because the two halves are complementary — a second reading of the same variable is
304
+ * how a configuration ends up reported by neither probe, or by both.
305
+ */
306
+ const externalUrl = (raw: string | undefined): string | undefined =>
307
+ raw === undefined || raw.trim() === '' ? undefined : raw;
308
+
309
+ /**
310
+ * Is the optional peer INSTALLED for this app — a `node_modules` walk up from the app root.
311
+ *
312
+ * Never an IMPORT: loading PGlite boots 26 MB of WASM and takes the single-writer lock the next
313
+ * command needs, and a diagnostic must not be the reason `x dev` cannot open the database it just
314
+ * reported on.
315
+ *
316
+ * Never `Bun.resolveSync` either, which was this check's first draft. It falls back to Bun's
317
+ * machine-global install cache: measured 2026-09-11 against a freshly scaffolded app with no
318
+ * `node_modules` anywhere above it, `Bun.resolveSync('@electric-sql/pglite', <app>)` answered
319
+ * `~/.bun/install/cache/@electric-sql/pglite@0.5.8@@@1/dist/index.js` — a version the app does not
320
+ * depend on, in a directory `bun install` never wrote for it. A box that had downloaded the package
321
+ * ONCE, for anything, would have read as ready on every app after it, which is the exact opposite
322
+ * of the question. The walk asks what `bun add` answers, and nothing else.
323
+ *
324
+ * Up from the root and not at it, because an app checked out inside a larger workspace is installed
325
+ * by the hoisting one: `examples/dummy` in this repository has no `node_modules` of its own.
326
+ */
327
+ const installedAbove = (dir: string, specifier: string): boolean => {
328
+ let current = resolve(dir);
329
+ // Bounded by the path itself — one segment per step — rather than by `for (;;)` and the promise
330
+ // that `dirname('/')` is `/`. The loop still stops on that, one step earlier.
331
+ for (let remaining = current.split(sep).length; remaining > 0; remaining -= 1) {
332
+ if (existsSync(join(current, 'node_modules', specifier, 'package.json'))) return true;
333
+ const parent = dirname(current);
334
+ if (parent === current) return false;
335
+ current = parent;
336
+ }
337
+ return false;
338
+ };
339
+
252
340
  /**
253
341
  * A real `select 1` through the app's own driver, not a TCP connect: a running Postgres with the
254
342
  * wrong credentials or a database that does not exist accepts the socket and refuses the session,
@@ -258,7 +346,7 @@ export const doctorPort = (args: ParsedArgs): number =>
258
346
  * next `x db migrate` cannot have.
259
347
  */
260
348
  async function probeDatabase(url: string | undefined): Promise<Finding | null> {
261
- if (url === undefined || url.trim() === '') return null;
349
+ if (externalUrl(url) === undefined) return null;
262
350
  const client = createPostgresClient({ url, applicationName: 'x-doctor' });
263
351
  try {
264
352
  const report = await checkDb(client);
@@ -296,6 +384,12 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
296
384
  exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
297
385
  portFree,
298
386
  database: () => probeDatabase(process.env['DATABASE_URL']),
387
+ // `root ?? cwd` because the walk needs a directory that exists; outside an app `runDoctor`
388
+ // returns on `X_NOT_IN_APP` before this is ever asked, so the value only has to be honest.
389
+ embeddedDatabase: async () => ({
390
+ selected: externalUrl(process.env['DATABASE_URL']) === undefined,
391
+ resolved: installedAbove(root ?? cwd, PGLITE_PACKAGE),
392
+ }),
299
393
  drift: async () => (root === undefined ? [] : checkMigrationDrift(root)),
300
394
  snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
301
395
  // `routes: undefined` outside an app is "not judged", which is what the caller already is:
package/src/index.ts CHANGED
@@ -67,8 +67,14 @@ export type { DeployPlan } from './cmd-deploy';
67
67
  export { deployCommand, planDeploy } from './cmd-deploy';
68
68
  export type { DevServer, StartDevOptions } from './cmd-dev';
69
69
  export { devCommand, startDev } from './cmd-dev';
70
- export type { DoctorProbe } from './cmd-doctor';
71
- export { doctorCommand, ENV_DEVELOPMENT, probeFor, runDoctor } from './cmd-doctor';
70
+ export type { DoctorProbe, EmbeddedDatabase } from './cmd-doctor';
71
+ export {
72
+ doctorCommand,
73
+ ENV_DEVELOPMENT,
74
+ embeddedDatabaseFinding,
75
+ probeFor,
76
+ runDoctor,
77
+ } from './cmd-doctor';
72
78
  export { ERRORS_SUBCOMMANDS, errorsCommand } from './cmd-errors';
73
79
  export { FIX_SUBCOMMANDS, fixCommand } from './cmd-fix';
74
80
  export type { GenerateOptions, Generator } from './cmd-generate';
package/src/prerender.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  SW_REGISTER_PATH,
29
29
  serviceWorkerArtifacts,
30
30
  serviceWorkerHead,
31
+ serviceWorkerRegistration,
31
32
  } from './sw-artifacts';
32
33
 
33
34
  // Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
@@ -194,6 +195,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
194
195
  // header). `serviceWorkerHead` is the one predicate behind both, so a page can never name a
195
196
  // script the export does not carry.
196
197
  const swHead = pwa === undefined ? undefined : serviceWorkerHead(pwa);
198
+ // The registration's BYTES now too, and not beside `sw.js` at the end. Every document below
199
+ // names this file and `measureDocumentJs` weighs it off disk, so writing it last made the
200
+ // measurement read whatever happened to be there: nothing on a clean `out` (recorded as 0) and
201
+ // the PREVIOUS build's copy on a reused one. That is the run-order dependence the `jsBytes`
202
+ // split removed, and it came straight back in `frameworkJsBytes` because the field changed and
203
+ // the ORDER did not. `serviceWorkerRegistration()` depends on two constants and no document, so
204
+ // it has nothing to wait for; `sw.js` still comes last, because its precache manifest really is
205
+ // built from the hashes of pages that do not exist yet.
206
+ if (swHead !== undefined) {
207
+ await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorkerRegistration());
208
+ }
197
209
  if (pwa !== undefined) {
198
210
  await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
199
211
  // And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
@@ -252,6 +264,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
252
264
  routes.push({
253
265
  path: entry.path,
254
266
  jsBytes: measured.jsBytes,
267
+ frameworkJsBytes: measured.frameworkBytes,
255
268
  ...(chain === undefined ? {} : { heaviestChain: chain }),
256
269
  });
257
270
  } catch (error) {
@@ -314,6 +327,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
314
327
  heaviest = {
315
328
  path: entry.path,
316
329
  jsBytes: measured.jsBytes,
330
+ frameworkJsBytes: measured.frameworkBytes,
317
331
  ...(chain === undefined ? {} : { heaviestChain: chain }),
318
332
  };
319
333
  }
@@ -334,9 +348,10 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
334
348
  styles,
335
349
  documents,
336
350
  });
351
+ // `sw.js` only: `serviceWorker.register` IS `serviceWorkerRegistration()`, already on disk above
352
+ // and identical by construction. A second writer of one path is how the two could ever disagree.
337
353
  if (serviceWorker !== undefined) {
338
354
  await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
339
- await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
340
355
  }
341
356
  const stats = await writeBuildStats(options.root, { routes });
342
357
  // Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
@@ -129,8 +129,15 @@ const staticAssets = (islands: IslandBundle, styles: StyleBundle): readonly Prec
129
129
  * silently controls a subdirectory. The `catch` because a registration that throws in a browser
130
130
  * with service workers disabled — an incognito profile, an enterprise policy — must not take an
131
131
  * otherwise working page down with it.
132
+ *
133
+ * EXPORTED because a static export has to put these bytes on disk BEFORE it renders, not after.
134
+ * The file is named by every document and weighed by `measureDocumentJs`, and it used to be
135
+ * written last, beside `sw.js` — so the measurement read a file that did not exist yet on a clean
136
+ * output directory, and the PREVIOUS build's copy on a reused one. `sw.js` still comes last, for
137
+ * the reason its own comment gives (its precache manifest is built from the rendered documents'
138
+ * content hashes); this half depends on nothing but two constants, so it can and must come first.
132
139
  */
133
- const registerSource = (): string =>
140
+ export const serviceWorkerRegistration = (): string =>
134
141
  `if ('serviceWorker' in navigator) {
135
142
  addEventListener('load', function () {
136
143
  navigator.serviceWorker
@@ -199,7 +206,7 @@ export function serviceWorkerArtifacts(
199
206
  );
200
207
  return {
201
208
  source: output.source,
202
- register: registerSource(),
209
+ register: serviceWorkerRegistration(),
203
210
  head,
204
211
  precache: output.precache,
205
212
  // `output.warnings` IS `output.precache.warnings` — the generator returns the manifest's list
@@ -0,0 +1,74 @@
1
+ // The CI half of what `x new` writes: one workflow that runs the app's OWN two commands.
2
+ //
3
+ // Nothing under `templates/` emitted `.github` until this file, so every scaffolded app started
4
+ // life with a gate that ran on exactly one machine — the author's. The two commands are `bin/setup`
5
+ // and `bin/check` and NOT a restatement of their steps: a workflow that spelled out `bun install`,
6
+ // `x db migrate`, `x build` and `x verify` is a second definition of the gate, free to drift from
7
+ // the scripts a human runs, and axiom 1 has one way to do each thing. `bin/check` is also not a
8
+ // synonym for `x verify` — it BUILDS first, and the build is what makes the gate's `budgets` step
9
+ // measurable rather than X_BUDGET_UNMEASURED.
10
+
11
+ import { REQUIRED_BUN } from '../../app-root';
12
+ import type { GeneratedFile, NameSet } from '../naming';
13
+
14
+ /** Where the workflow lands. GitHub reads this path and no other. */
15
+ export const CI_WORKFLOW_PATH = '.github/workflows/ci.yml';
16
+
17
+ /**
18
+ * Pinned by commit SHA with the version in the trailing comment, because this step runs before any
19
+ * code of yours does and `@v2` is a tag its owner can move. `actions/*` follows its major tag
20
+ * instead: GitHub owns the runner and the tag together. Same rule the framework's own
21
+ * `.github/actions/setup/action.yml` states, and the same SHA.
22
+ */
23
+ const SETUP_BUN = 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0';
24
+
25
+ const ci = (app: NameSet): string => `name: ci
26
+
27
+ # The gate for ${app.kebab}: the same two commands \`README.md\` tells a human to run, in the same
28
+ # order, on a machine that has never seen this repository. A check that exists only in CI is one
29
+ # nobody can reproduce locally, and a CI file that restates the gate's steps is a second gate.
30
+ #
31
+ # \`bin/check\` is \`x build --target static\` and THEN \`x verify\`. The build is not a convenience:
32
+ # the gate's \`budgets\` step compares declared limits against measured bytes in
33
+ # \`.x/build-stats.json\`, so a run with no build reports X_BUDGET_UNMEASURED. Run \`x verify\` here
34
+ # instead and this workflow is red for a reason that has nothing to do with the commit.
35
+ #
36
+ # No \`services:\` block, deliberately — \`bin/setup\` brings up embedded Postgres in-process, so
37
+ # there is nothing to provision and nothing to wait on. Add one the day this app needs a real
38
+ # server, beside the \`DATABASE_URL\` that selects it.
39
+
40
+ # EVERY push, not just the default branch's. \`x new\` runs a plain \`git init\` and takes whatever
41
+ # \`init.defaultBranch\` this machine already agreed on, so a \`branches: [main]\` filter silently
42
+ # runs nothing on a repository whose branch is called something else — and \`CLAUDE.md\` promises a
43
+ # run on every push, which a filter would make untrue for the app that read it.
44
+ on:
45
+ push:
46
+ pull_request:
47
+
48
+ permissions:
49
+ contents: read
50
+
51
+ jobs:
52
+ check:
53
+ runs-on: ubuntu-latest
54
+ # A bound on a HANG, not on cost: a step that never returns holds a runner until GitHub's
55
+ # six-hour default expires, and the failure is invisible for all six of them.
56
+ timeout-minutes: 20
57
+ steps:
58
+ - uses: actions/checkout@v7
59
+ - uses: ${SETUP_BUN}
60
+ with:
61
+ # \`package.json\`'s \`engines.bun\` floor, exactly — the OLDEST runtime this app declares
62
+ # it supports, so a call that needs a newer Bun fails here rather than in the install of
63
+ # whoever took the app at its word. Never \`latest\`: a Bun minor landing unannounced is a
64
+ # runtime change nobody chose.
65
+ bun-version: '${REQUIRED_BUN}'
66
+ # Two steps rather than one \`&&\`, so the log names which half failed and times each.
67
+ - run: bin/setup
68
+ - run: bin/check
69
+ `;
70
+
71
+ /** The CI a new app is born with, in the order a reader meets it. */
72
+ export function githubFiles(app: NameSet): readonly GeneratedFile[] {
73
+ return [{ path: CI_WORKFLOW_PATH, contents: ci(app) }];
74
+ }
@@ -6,7 +6,7 @@
6
6
  import type { GeneratedFile, NameSet } from './naming';
7
7
 
8
8
  const feature = (app: NameSet): string => `---
9
- description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`x verify\`.
9
+ description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`bin/check\`.
10
10
  argument-hint: <what you want built or fixed, plain language>
11
11
  allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
12
12
  ---
@@ -16,7 +16,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
16
16
  You are a senior engineer on **${app.kebab}**, an Ultimate app. Read \`AGENTS.md\` before designing
17
17
  anything — it is the short form of every rule below, and it wins where the two disagree.
18
18
 
19
- **Done means \`x verify\` green.** A passing unit test is not done. A working \`x dev\` is not done.
19
+ **Done means \`bin/check\` green.** A passing unit test is not done. A working \`x dev\` is not done.
20
20
  Report what you actually ran, never what you assume passed.
21
21
 
22
22
  ## Request
@@ -77,10 +77,16 @@ the filename never is. One interactive control on a 0kb page is an island: \`x g
77
77
  ## 4. Gate it
78
78
 
79
79
  \`\`\`sh
80
- x verify # the gate. green = shippable
81
- x verify --json # the same steps, machine-readable
80
+ bin/check # the gate. green = shippable
81
+ bin/check --json # the same, machine-readable
82
82
  \`\`\`
83
83
 
84
+ \`bin/check\` is \`x build --target static\` and THEN \`x verify\`, and the order is the whole point:
85
+ the \`budgets\` step measures \`.x/build-stats.json\`, which only a build writes, so \`x verify\` on
86
+ its own reports X_BUDGET_UNMEASURED on a tree that is fine. \`--json\` is forwarded to both halves;
87
+ a machine consumer takes the last line. CI runs this same script — \`.github/workflows/ci.yml\` is
88
+ \`bin/setup\` and then \`bin/check\`, nothing else.
89
+
84
90
  Red is instructions, not a verdict: **every finding carries an executable \`fix:\` — run it verbatim
85
91
  before improvising**, and \`x errors explain <CODE>\` expands any code it names. Never narrow the gate
86
92
  to make it pass: there is no \`--only\` and no \`--skip\`, on purpose, and disabling a lint rule or
@@ -112,7 +118,7 @@ ISO code, never a float. Bun only.
112
118
  Primitive: <which of the eight> Slice: <dir>
113
119
  Generated: <the x g invocations you ran>
114
120
  Changed: <files>
115
- Gate: x verify ✓ | ✗ <failing steps>
121
+ Gate: bin/check ✓ | ✗ <failing steps>
116
122
  Deferred: <what you did not do, and why> [never omit this line]
117
123
  \`\`\`
118
124
  `;
@@ -166,7 +172,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
166
172
  - What to add, next to the source as \`<file>.test.ts\`. Command to run it.
167
173
 
168
174
  ## Done when
169
- - Acceptance criteria, ending in \`x verify\` green.
175
+ - Acceptance criteria, ending in \`bin/check\` green.
170
176
 
171
177
  ## Risks
172
178
  - Anything the executor must decide, and every claim in the ask the code disproves.
@@ -179,7 +185,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
179
185
  - No checkboxes. The plan is a map, not a tracker.
180
186
  - The plan must obey the app's own rules — one way to do each thing, generators over hand-written
181
187
  files, imports that never cross a surface boundary, a stable error code with a runnable \`fix:\` for
182
- every new failure, and \`x verify\` green as the last line of *Done when*.
188
+ every new failure, and \`bin/check\` green as the last line of *Done when*.
183
189
 
184
190
  ## Output
185
191
 
@@ -196,13 +202,18 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash
196
202
 
197
203
  # /verify
198
204
 
199
- Run \`x verify\`.
205
+ Run \`bin/check\`.
206
+
207
+ It is \`x build --target static\` and then \`x verify\`, and it is THE gate — the one CI runs
208
+ (\`.github/workflows/ci.yml\`) and the one \`README.md\` names. \`x verify\` alone is half of it:
209
+ the \`budgets\` step measures \`.x/build-stats.json\`, which only the build writes, so a bare
210
+ \`x verify\` reports X_BUDGET_UNMEASURED on a tree with nothing wrong with it.
200
211
 
201
212
  Green: say so and stop.
202
213
 
203
214
  Red: fix every finding, then re-run until green. Each finding carries a stable code, a cause and an
204
215
  executable \`fix:\` — **run the \`fix:\` verbatim before improvising**, and use \`x errors explain <CODE>\`
205
- when the cause is not enough. \`x verify --json\` gives the same steps machine-readably; \`x doctor\`
216
+ when the cause is not enough. \`bin/check --json\` gives the same steps machine-readably; \`x doctor\`
206
217
  covers the case where the environment, not the code, is what is broken.
207
218
 
208
219
  Do not narrow the gate to make it pass. There is no \`--only\` and no \`--skip\`; disabling a lint rule,
@@ -2,9 +2,11 @@
2
2
  // the bin/ shims and the optional dev compose. Separated from the config half so neither file has
3
3
  // to be scrolled to find the other — one file, one job applies to templates too. The image, its
4
4
  // ignore file, the production topology and the deploy page are `scaffold-container.ts`; the
5
- // `.claude/` harness that reads AGENTS.md is `scaffold-claude.ts`.
5
+ // `.claude/` harness that reads AGENTS.md is `scaffold-claude.ts`; the one workflow is
6
+ // `github/ci.yml.ts`.
6
7
 
7
8
  import { LINE_CEILING } from '../workspace-checks';
9
+ import { githubFiles } from './github/ci.yml';
8
10
  import type { GeneratedFile, NameSet } from './naming';
9
11
  import { claudeFiles } from './scaffold-claude';
10
12
  import { containerFiles } from './scaffold-container';
@@ -19,7 +21,7 @@ not exist — five of these had an empty column and were each measured green on
19
21
 
20
22
  | Rule | Detail | Refused by |
21
23
  |---|---|---|
22
- | One gate | \`x verify\` — green means shippable. Never merge red. | the gate itself |
24
+ | One gate | \`bin/check\` — \`x build\` and then \`x verify\`. Green means shippable; never merge red. | the gate itself, and \`.github/workflows/ci.yml\` on every push and pull request |
23
25
  | One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` | review |
24
26
  | Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf | \`X_BOUNDARY_SITE_TO_APP\` |
25
27
  | Data | routes call actions and queries; only \`repo.ts\` touches the database | \`X_BOUNDARY_ROUTE_TO_DB\` |
@@ -54,7 +56,19 @@ see, and the type already fires — measured, \`price: 19.99\` in a seed is
54
56
  \`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
55
57
  it would be worse than the type that really does.
56
58
 
57
- Commands: \`x dev\`, \`x verify\`, \`x g <primitive>\`, \`x g guard <name>\`, \`x db branch create <name>\`, \`x doctor\`.
59
+ \`bin/check\` is the gate and \`x verify\` is only its second half: the first is
60
+ \`x build --target static\`, and the build is what writes the \`.x/build-stats.json\` the
61
+ \`budgets\` step measures. Run \`x verify\` on a tree nobody has built and \`budgets\` is red with
62
+ X_BUDGET_UNMEASURED — the gate reporting on a file that does not exist, not on your code.
63
+
64
+ The platform's \`.dz/\` is ADDITIVE, in both directions. developerz.ai keeps its own files under
65
+ \`.dz/maintainer/\` and \`.dz/pipeline/\`; nothing \`x new\` writes lands under \`.dz/\` and nothing
66
+ here is generated from it. So the scaffold never clobbers a maintainer policy, the platform never
67
+ clobbers \`AGENTS.md\`, \`bin/\`, \`.claude/\` or \`.github/\`, and deleting either side leaves the
68
+ other exactly as it was.
69
+
70
+ Commands: \`bin/setup\`, \`bin/dev\`, \`bin/check\`, \`x g <primitive>\`, \`x g guard <name>\`,
71
+ \`x db branch create <name>\`, \`x doctor\`.
58
72
 
59
73
  Project notes for ${app.kebab}: replace this line with the conventions a newcomer could not guess.
60
74
  `;
@@ -63,7 +77,12 @@ const claude = (app: NameSet): string => `# CLAUDE.md
63
77
 
64
78
  ${app.kebab} — Ultimate app. Read AGENTS.md first; it is the same content in the same order.
65
79
 
66
- - Gate: \`x verify\` (add \`--json\` for machine output).
80
+ - Gate: \`bin/check\` (add \`--json\` for machine output — it reaches both halves). It is
81
+ \`x build --target static\` and then \`x verify\`; \`x verify\` alone leaves \`budgets\` with
82
+ nothing to measure. \`.github/workflows/ci.yml\` runs \`bin/setup && bin/check\` on every push
83
+ and pull request, so CI and your terminal run the same two commands.
84
+ - \`.dz/\` belongs to the developerz.ai platform and is additive both ways: the scaffold writes
85
+ nothing there, and nothing there is generated from this repo. Neither side clobbers the other.
67
86
  - Scaffold, do not hand-write: \`x g <kind> <name>\` — \`x g --help\` lists every kind, and is the
68
87
  only place that list is stated.
69
88
  - Destructive DB work goes in a branch: \`x db branch create <name>\`, never the shared dev DB.
@@ -83,7 +102,7 @@ Built with [Ultimate](https://github.com/developerz-ai/ultimate). Bun-only, Post
83
102
  \`\`\`sh
84
103
  bin/setup # prerequisites, deps, env, the first migration, migrate, seed, the manifest
85
104
  bin/dev # all roles in one process, embedded Postgres, /_x mounted
86
- bin/check # the gate: typecheck, lint, boundaries, tests, drift, budgets
105
+ bin/check # the gate: a static build, then typecheck, lint, boundaries, tests, drift, budgets
87
106
  \`\`\`
88
107
 
89
108
  \`packages/db/migrations\` starts empty and \`x db gen\` is its only writer — \`bin/setup\` runs
@@ -102,6 +121,7 @@ with \`X_DB_DRIFT\`, and that is the fix it names.
102
121
  | \`packages/*\` | domain, db, i18n, ui, mcp |
103
122
  | \`app.config.ts\` | the one config file |
104
123
  | \`x.manifest.json\` | generated facts: routes, actions, jobs, policies |
124
+ | \`.github/workflows/ci.yml\` | \`bin/setup\` then \`bin/check\`, on push and pull request |
105
125
  `;
106
126
 
107
127
  const binSetup = (): string => `#!/usr/bin/env bash
@@ -204,6 +224,10 @@ export function docsFiles(app: NameSet): readonly GeneratedFile[] {
204
224
  // The harness half of the same job AGENTS.md does. It lands in the app's own repo rather than
205
225
  // in a global config, so it is visible in the scaffold's diff and deletable in one line.
206
226
  ...claudeFiles(app),
227
+ // The same two commands `README.md` opens with, run by a machine that has never seen this
228
+ // repository. Registered here and not in `scaffold-repo.ts` because it is documentation of the
229
+ // gate in executable form, which is the job this file has.
230
+ ...githubFiles(app),
207
231
  ...containerFiles(app),
208
232
  ];
209
233
  }
@@ -5,6 +5,7 @@
5
5
  // container files scaffold-container.ts.
6
6
 
7
7
  import { ENV_EXAMPLE_PATH } from '@ultimat3/core';
8
+ import { REQUIRED_BUN } from '../app-root';
8
9
  import { VERIFY_FLOOR_FILE } from '../verify-floor';
9
10
  import type { VerifyStepName } from '../verify-step';
10
11
  import type { GeneratedFile, NameSet } from './naming';
@@ -37,6 +38,14 @@ const BIOME_VERSION = '2.5.8';
37
38
  */
38
39
  const TYPESCRIPT_VERSION = '^7.0.2';
39
40
 
41
+ // `engines.bun` is `REQUIRED_BUN`, the floor the SHIPPED `x` enforces
42
+ // (`packages/cli/src/app-root.ts`), and not a second literal. It was one: `>=1.3.0`, a whole minor
43
+ // BELOW the CLI the app then runs, so on a box with Bun 1.3.x `bun install` succeeded and the very
44
+ // next line of `bin/setup` died `X_BUN_VERSION: Bun 1.3.14 is older than the required 1.4.0` — a
45
+ // floor admitting a runtime the app's own first command refuses. Interpolated, the two cannot
46
+ // disagree; `scripts/bun-pin.test.ts` reads the emitted string as one more pin site, and
47
+ // `github/ci.yml.ts` pins the workflow's Bun to the same constant.
48
+ //
40
49
  // `version` is not decoration: the manifest's app version IS the contract's compatibility gate,
41
50
  // and the manifest never fabricates one — so an app scaffolded without it failed `x manifest`,
42
51
  // the `manifest` verify step and every production boot with X_APP_PACKAGE_INVALID.
@@ -89,7 +98,7 @@ const rootPackage = (app: NameSet, version: string): string => `{
89
98
  "solid-js": "1.9.14"
90
99
  },
91
100
  "engines": {
92
- "bun": ">=1.3.0"
101
+ "bun": ">=${REQUIRED_BUN}"
93
102
  }
94
103
  }
95
104
  `;