@ultimat3/cli 9.0.0 → 11.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 (78) hide show
  1. package/CLAUDE.md +71 -0
  2. package/package.json +28 -26
  3. package/src/affected.ts +0 -3
  4. package/src/app-boundaries.ts +4 -5
  5. package/src/app-env.ts +7 -2
  6. package/src/browser-launcher.ts +0 -2
  7. package/src/budgets.ts +10 -3
  8. package/src/cmd-build.ts +2 -2
  9. package/src/cmd-db-backfill.ts +14 -1
  10. package/src/cmd-db-branch.ts +2 -2
  11. package/src/cmd-deploy.ts +14 -3
  12. package/src/cmd-dev.ts +3 -0
  13. package/src/cmd-docs.ts +2 -1
  14. package/src/cmd-doctor.ts +3 -5
  15. package/src/cmd-env.ts +2 -2
  16. package/src/cmd-fix.ts +2 -4
  17. package/src/cmd-new.ts +36 -6
  18. package/src/cmd-shot.ts +22 -2
  19. package/src/command.ts +12 -0
  20. package/src/db-finding.ts +2 -2
  21. package/src/db-seed.ts +0 -3
  22. package/src/dev-assets.ts +6 -0
  23. package/src/dev-cache.ts +12 -5
  24. package/src/dev-hooks.ts +8 -0
  25. package/src/dev-lock.ts +8 -7
  26. package/src/dev-purge.ts +8 -2
  27. package/src/dev-render.ts +5 -2
  28. package/src/dev-roles.ts +26 -2
  29. package/src/dev-runtime.ts +11 -3
  30. package/src/dev-storage.ts +8 -2
  31. package/src/dev-sync.ts +37 -2
  32. package/src/dispatch.ts +8 -0
  33. package/src/document-styles.ts +2 -1
  34. package/src/drift.ts +3 -2
  35. package/src/error-catalog.ts +11 -1
  36. package/src/error-codes.ts +19 -2
  37. package/src/error-contract.ts +30 -7
  38. package/src/error-pages.ts +79 -0
  39. package/src/errors.ts +26 -29
  40. package/src/favicon.ts +113 -0
  41. package/src/fix-path.ts +104 -0
  42. package/src/flag-reads.ts +2 -2
  43. package/src/generate-write.ts +3 -2
  44. package/src/guards.ts +4 -4
  45. package/src/hold.ts +73 -7
  46. package/src/index.ts +16 -6
  47. package/src/island-bundle.ts +32 -4
  48. package/src/island-routes.ts +7 -1
  49. package/src/live-routes.ts +181 -0
  50. package/src/mcp-errors.ts +7 -2
  51. package/src/messages.ts +6 -4
  52. package/src/metrics-endpoint.ts +0 -2
  53. package/src/output.ts +2 -2
  54. package/src/prerender.ts +64 -22
  55. package/src/script-csp.ts +17 -0
  56. package/src/serve.ts +12 -1
  57. package/src/static-report.ts +41 -3
  58. package/src/templates/admin-page.ts +11 -7
  59. package/src/templates/imports.ts +26 -0
  60. package/src/templates/route.ts +2 -2
  61. package/src/templates/scaffold-app.ts +18 -6
  62. package/src/templates/scaffold-auth.ts +151 -0
  63. package/src/templates/scaffold-container.ts +12 -0
  64. package/src/templates/scaffold-docs.ts +1 -1
  65. package/src/templates/scaffold-domain-package.ts +3 -1
  66. package/src/templates/scaffold-mcp-package.ts +6 -3
  67. package/src/templates/scaffold-repo.ts +17 -8
  68. package/src/templates/slice-foundation.ts +3 -5
  69. package/src/test-shards.ts +2 -2
  70. package/src/tsconfig-references.ts +2 -2
  71. package/src/verify-checks.ts +10 -3
  72. package/src/verify-floor.ts +8 -6
  73. package/src/verify-run.ts +2 -2
  74. package/src/verify-step.ts +2 -1
  75. package/src/verify-test-run.ts +2 -2
  76. package/src/workspace-checks.ts +10 -12
  77. package/src/workspace-graph.ts +3 -2
  78. package/src/write-line.ts +7 -1
@@ -11,7 +11,6 @@ import {
11
11
  stringField,
12
12
  UltimateError,
13
13
  } from '@ultimat3/core';
14
- import { docsFor } from './error-codes';
15
14
  import { neighbouringPort } from './flag-number';
16
15
 
17
16
  /**
@@ -44,7 +43,6 @@ export class MetricsPortInUseError extends UltimateError {
44
43
  code: 'X_PORT_IN_USE',
45
44
  cause: `the metrics port ${input.port} is already bound, so no role could open its scrape listener`,
46
45
  fix: `METRICS_PORT=${neighbouringPort(input.port)} x dev --json`,
47
- docs: docsFor('X_PORT_IN_USE'),
48
46
  });
49
47
  }
50
48
  }
package/src/output.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // and the JSON renderer are projections of it, so `--json` can never drift from the terminal
3
3
  // output (axiom 4). The human renderer owns the canonical 3-line error format.
4
4
 
5
- import { renderThrowable, singleLine, stringField } from '@ultimat3/core';
5
+ import { ERROR_DOCS_URL, renderThrowable, singleLine, stringField } from '@ultimat3/core';
6
6
  import { msg } from './messages';
7
7
 
8
8
  export interface Finding {
@@ -107,7 +107,7 @@ export function findingFrom(value: unknown): Finding {
107
107
  code: 'X_CLI_UNEXPECTED',
108
108
  cause: renderThrowable(value),
109
109
  fix: 'x doctor --json',
110
- docs: 'https://ultimate.dev/errors/X_CLI_UNEXPECTED',
110
+ docs: ERROR_DOCS_URL,
111
111
  };
112
112
  }
113
113
 
package/src/prerender.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // only which routes qualify and where the bytes land.
5
5
 
6
6
  import { join } from 'node:path';
7
- import { renderThrowable } from '@ultimat3/core';
7
+ import { createContext, renderThrowable, runWithContext } from '@ultimat3/core';
8
8
  import type { RouteEntry } from '@ultimat3/render';
9
9
  import { routeEntries } from '@ultimat3/render';
10
10
  import { renderStatic } from '@ultimat3/render/server';
@@ -13,11 +13,17 @@ import { appManifest } from './app-manifest';
13
13
  import type { RouteStats } from './budgets';
14
14
  import { measureDocumentJs, writeBuildStats } from './budgets';
15
15
  import { routeDocument } from './dev-render';
16
+ import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
17
+ import { FAVICON_PATH, faviconBytes } from './favicon';
16
18
  import type { IslandBundle } from './island-bundle';
17
19
  import { buildIslands, writeIslands } from './island-bundle';
18
- import type { SkippedRoute } from './static-report';
20
+ import type { SkippedRoute, UnmeasuredRoute } from './static-report';
19
21
  import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
20
22
 
23
+ // Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
24
+ // carries it, and this file already imports that module.
25
+ export type { UnmeasuredRoute };
26
+
21
27
  /**
22
28
  * `static` only. `isr` revalidates and `ssr`/`stream` need a process, so writing any of them to
23
29
  * disk would publish a page whose staleness nothing can correct — and the route already declared
@@ -46,12 +52,6 @@ export interface PrerenderedPage {
46
52
  readonly bytes: number;
47
53
  }
48
54
 
49
- /** A route that declared a budget and could not be rendered here, and what stopped it. */
50
- export interface UnmeasuredRoute {
51
- readonly path: string;
52
- readonly reason: string;
53
- }
54
-
55
55
  export interface PrerenderReport {
56
56
  readonly out: string;
57
57
  readonly buildId: string;
@@ -97,6 +97,9 @@ function heaviestSource(
97
97
 
98
98
  export const DEFAULT_ORIGIN = 'https://localhost';
99
99
 
100
+ /** The one status a static export can answer for itself: a path that matches no file. */
101
+ const NOT_FOUND_STATUS = 404;
102
+
100
103
  /**
101
104
  * Prerendering and measuring are two questions, and conflating them made `X_BUDGET_UNMEASURED`
102
105
  * unclosable by any invocation: only `static` was ever rendered, so a `budget:` on an ssr, isr,
@@ -129,6 +132,37 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
129
132
  // behind it, so the artifact carries every byte the browser will ask for.
130
133
  const islands = await buildIslands(options.root);
131
134
  await writeIslands(islands, options.out);
135
+ // Same rule, one asset further: a browser asks for `/favicon.ico` on the first page it loads,
136
+ // and a static export has no route to answer it — so the bytes the served surfaces would have
137
+ // returned go into the artifact instead of leaving a 404 in every visitor's console.
138
+ await Bun.write(
139
+ join(options.out, FAVICON_PATH.slice(1)),
140
+ (await faviconBytes(options.root)).bytes,
141
+ );
142
+ // And the one error page a static host serves ITSELF: `404.html` at the export root is what S3,
143
+ // Cloudflare Pages, Netlify and nginx all reach for when a path matches no file. The app's own
144
+ // file if it wrote one, the framework's page otherwise — the same two rungs the served process
145
+ // answers a 404 with, so the artifact and the server cannot disagree about one document.
146
+ await Bun.write(
147
+ join(options.out, STATIC_ERROR_PAGE),
148
+ await errorPageDocument(options.root, NOT_FOUND_STATUS),
149
+ );
150
+
151
+ // Every render below goes through `routeDocument`, which is the function a REQUEST reaches — and
152
+ // a request arrives inside `runWithContext`, installed by the HTTP pipeline (`dev-render.ts`).
153
+ // Called bare, any route whose component, `load` or `meta` reads `useContext()` threw
154
+ // `X_NO_CONTEXT`: measured against `examples/dummy`, `/posts/new` and `/settings` were filed
155
+ // unmeasured for that reason alone, and a `render: 'static'` route reading it failed the whole
156
+ // build. One context for the build, `role: 'web'` because that is the role serving these
157
+ // documents, and this build's own id so a component reading `ctx.buildId` stamps the artifact
158
+ // with the id the report and the stats carry.
159
+ const ctx = createContext({ role: 'web', buildId });
160
+ const document = (entry: RouteEntry, data: { url: string; params: Record<string, string> }) =>
161
+ runWithContext(ctx, () =>
162
+ routeDocument(entry, data, {
163
+ resolveIsland: (file: string) => islands.resolverFor(file),
164
+ }),
165
+ );
132
166
 
133
167
  for (const entry of routeEntries()) {
134
168
  const facts = { surface: entry.surface, render: entry.config.render, route: entry.path };
@@ -141,11 +175,10 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
141
175
  // routes it never used to touch would be a worse regression than the gap it closes. A route
142
176
  // that will not render here is reported, gets no stats entry, and stays `X_BUDGET_UNMEASURED`.
143
177
  try {
144
- const html = await routeDocument(
145
- entry,
146
- { url: new URL(entry.path, origin).href, params: {} },
147
- { resolveIsland: (file: string) => islands.resolverFor(file) },
148
- );
178
+ const html = await document(entry, {
179
+ url: new URL(entry.path, origin).href,
180
+ params: {},
181
+ });
149
182
  const measured = await measureDocumentJs(html, options.out);
150
183
  const chain = heaviestSource(islands, measured.entries);
151
184
  routes.push({
@@ -162,12 +195,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
162
195
  }
163
196
  const artifacts = await renderStatic(
164
197
  entry,
165
- ({ path, params }) =>
166
- routeDocument(
167
- entry,
168
- { url: new URL(path, origin).href, params },
169
- { resolveIsland: (file: string) => islands.resolverFor(file) },
170
- ),
198
+ ({ path, params }) => document(entry, { url: new URL(path, origin).href, params }),
171
199
  { buildId },
172
200
  );
173
201
  // `enumeratePrerender` answers `[]` for a dynamic route with no `prerender()`, so a
@@ -178,6 +206,13 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
178
206
  skipped.push(skippedRoute(facts, 'no-prerender-paths'));
179
207
  continue;
180
208
  }
209
+ // One stats row per ROUTE, holding its heaviest page. `checkBudgets` looks a route up by
210
+ // `route.url`, which is the manifest's DECLARED pattern (`/blog/:slug`), and this pushed the
211
+ // FILLED path (`/blog/hello`) — so no dynamic static route has ever been weighed: every one
212
+ // was `X_BUDGET_UNMEASURED` and `X_BUDGET_EXCEEDED` could not fire for the whole class. The
213
+ // heaviest page and not the first, because a budget is a ceiling: the page that breaks it is
214
+ // the one the route has to answer for. `pages` below still names every filled path.
215
+ let heaviest: RouteStats | undefined;
181
216
  for (const artifact of artifacts) {
182
217
  const file = join(options.out, artifact.outputPath);
183
218
  const bytes = await Bun.write(file, artifact.html);
@@ -192,12 +227,14 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
192
227
  // declared budget against bytes that exist on disk rather than against a graph's estimate.
193
228
  const measured = await measureDocumentJs(artifact.html, options.out);
194
229
  const chain = heaviestSource(islands, measured.entries);
195
- routes.push({
196
- path: artifact.path,
230
+ if (heaviest !== undefined && heaviest.jsBytes >= measured.jsBytes) continue;
231
+ heaviest = {
232
+ path: entry.path,
197
233
  jsBytes: measured.jsBytes,
198
234
  ...(chain === undefined ? {} : { heaviestChain: chain }),
199
- });
235
+ };
200
236
  }
237
+ if (heaviest !== undefined) routes.push(heaviest);
201
238
  }
202
239
  const stats = await writeBuildStats(options.root, { routes });
203
240
  // Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
@@ -209,6 +246,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
209
246
  buildId,
210
247
  emitted: pages.map((page) => ({ route: page.route, path: page.path, file: page.file })),
211
248
  skipped,
249
+ // The list `X_BUDGET_UNMEASURED`'s `fix:` cites by name. It rode home on the in-process
250
+ // `PrerenderReport` and nowhere else, and `cmd-build.ts` discards a successful subprocess's
251
+ // stdout — so the one command the finding tells an author to run printed no `unmeasured` key
252
+ // and no reason. Written into the report is what makes the instruction true.
253
+ unmeasured,
212
254
  });
213
255
  return {
214
256
  out: options.out,
@@ -0,0 +1,17 @@
1
+ // Every inline `<script>` body a served process can put in a document, as the `script-src` sources
2
+ // that admit it. The mirror of `style-csp.ts`, and needed for the same reason: `script-src` was
3
+ // `'self' 'wasm-unsafe-eval'` while every document carrying an island shipped the hydration runtime
4
+ // INLINE, so under the enforced policy a container serves (`dev: false`) no island ever booted —
5
+ // invisible in `x dev`, where the policy is report-only.
6
+
7
+ import { cspHashSource } from '@ultimat3/http';
8
+ import { HYDRATE_RUNTIME_BODIES } from '@ultimat3/render';
9
+
10
+ /**
11
+ * Hashes, never a nonce: a `render: 'static'` page is a file on disk, so no per-response value can
12
+ * reach it. Read from `@ultimat3/render`'s own enumeration rather than restated here — the body
13
+ * the document carries and the body the policy hashes have to be one string.
14
+ */
15
+ export function inlineScriptSources(): readonly string[] {
16
+ return [...new Set(HYDRATE_RUNTIME_BODIES.map(cspHashSource))].sort();
17
+ }
package/src/serve.ts CHANGED
@@ -328,6 +328,9 @@ async function bootRoles(boot: {
328
328
  // Same declaration `x dev` reads. Without it a container answers a browser that opened a
329
329
  // guarded page with the problem document, rendered as raw JSON in the viewport.
330
330
  signInPath: await loadSignInPath(options.root),
331
+ // The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
332
+ // process and `x dev` cannot answer a browser differently.
333
+ root: options.root,
331
334
  http: CONTAINER_BINDING,
332
335
  ...(options.runtime === undefined ? {} : { overrides: options.runtime }),
333
336
  });
@@ -367,6 +370,14 @@ export async function runRole(options: ServeOptions): Promise<StartedApp> {
367
370
  }
368
371
  const app = await serveApp({ ...options, role });
369
372
  logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
370
- await holdUntilShutdown('serve', () => app.stop())();
373
+ // `exit` because this is the one entry point with nothing above it: `bin.ts` ends in
374
+ // `process.exit(code)` and `apps/web/server.ts` — which is what awaits this — does not. One
375
+ // non-unref'd interval anywhere in the app then holds an event loop that has nothing left to do,
376
+ // until `terminationGracePeriodSeconds` runs out and the kubelet SIGKILLs a drained process.
377
+ await holdUntilShutdown('serve', () => app.stop(), {
378
+ exit: (code) => {
379
+ process.exit(code);
380
+ },
381
+ })();
371
382
  return app;
372
383
  }
@@ -48,6 +48,19 @@ export type SkippedRoute = RouteFacts & {
48
48
  readonly why: string;
49
49
  };
50
50
 
51
+ /**
52
+ * A route that declared a budget and could not be rendered here, and what stopped it.
53
+ *
54
+ * Declared HERE and not in `prerender.ts`, though `prerenderSite` is what fills it: this file is
55
+ * the report's shape and `prerender.ts` imports it, so the other direction is a cycle. `prerender`
56
+ * re-exports the name for its own callers.
57
+ */
58
+ export type UnmeasuredRoute = {
59
+ /** The DECLARED path, as `X_BUDGET_UNMEASURED`'s `at:` spells it, so the two rows join. */
60
+ readonly path: string;
61
+ readonly reason: string;
62
+ };
63
+
51
64
  /** One HTML file in the artifact, and the declared route that produced it. */
52
65
  export type EmittedPage = {
53
66
  readonly route: string;
@@ -62,6 +75,15 @@ export type StaticReport = {
62
75
  readonly buildId: string;
63
76
  readonly emitted: readonly EmittedPage[];
64
77
  readonly skipped: readonly SkippedRoute[];
78
+ /**
79
+ * Every budgeted route this build could not weigh, with the reason. `X_BUDGET_UNMEASURED`'s own
80
+ * `fix:` cites this list by name — `x build --target static --json # its "unmeasured" list says
81
+ * why` — and until it was written here the list existed only on the in-process `PrerenderReport`
82
+ * and reached no `x` command's output at all: `cmd-build.ts` discards a successful subprocess's
83
+ * stdout. A `fix:` naming a key nothing prints is axiom 4 inverted at the step meant to unblock
84
+ * the reader.
85
+ */
86
+ readonly unmeasured: readonly UnmeasuredRoute[];
65
87
  };
66
88
 
67
89
  /**
@@ -139,6 +161,9 @@ const isSkipped = (value: unknown): value is SkippedRoute =>
139
161
  typeof value['why'] === 'string' &&
140
162
  inDomain(SKIP_REASONS, value['reason']);
141
163
 
164
+ const isUnmeasured = (value: unknown): value is UnmeasuredRoute =>
165
+ isRecord(value) && typeof value['path'] === 'string' && typeof value['reason'] === 'string';
166
+
142
167
  const isEmitted = (value: unknown): value is EmittedPage =>
143
168
  isRecord(value) &&
144
169
  typeof value['route'] === 'string' &&
@@ -152,13 +177,20 @@ const isEmitted = (value: unknown): value is EmittedPage =>
152
177
  */
153
178
  export function parseStaticReport(value: unknown): StaticReport | undefined {
154
179
  if (!isRecord(value)) return undefined;
155
- const { target, out, buildId, emitted, skipped } = value;
180
+ const { target, out, buildId, emitted, skipped, unmeasured } = value;
156
181
  if (target !== 'static' || typeof out !== 'string' || typeof buildId !== 'string') {
157
182
  return undefined;
158
183
  }
159
184
  if (!Array.isArray(emitted) || !emitted.every(isEmitted)) return undefined;
160
185
  if (!Array.isArray(skipped) || !skipped.every(isSkipped)) return undefined;
161
- return { target, out, buildId, emitted, skipped };
186
+ // OPTIONAL on the way in, total on the way out. `.x/` survives a checkout of an older commit,
187
+ // and a report written before this field existed has to read as "nothing unmeasured" — refusing
188
+ // it would answer `X_BUDGET_UNMEASURED` for every route on a build that had weighed them all.
189
+ // Present and malformed is still no report, exactly as a malformed skip row is.
190
+ if (unmeasured !== undefined && (!Array.isArray(unmeasured) || !unmeasured.every(isUnmeasured))) {
191
+ return undefined;
192
+ }
193
+ return { target, out, buildId, emitted, skipped, unmeasured: unmeasured ?? [] };
162
194
  }
163
195
 
164
196
  export async function writeStaticReport(root: string, report: StaticReport): Promise<string> {
@@ -198,7 +230,9 @@ export async function removeStaticReport(root: string): Promise<void> {
198
230
  export function staticReportData(report: StaticReport | undefined): Record<string, JsonValue> {
199
231
  // Never `{ ...report }`: `out` is an absolute build path and `buildId` is this run's, neither of
200
232
  // which the inventory is about — `data` already carries `artifact` and the build's own id.
201
- return report === undefined ? {} : { emitted: report.emitted, skipped: report.skipped };
233
+ return report === undefined
234
+ ? {}
235
+ : { emitted: report.emitted, skipped: report.skipped, unmeasured: report.unmeasured };
202
236
  }
203
237
 
204
238
  /**
@@ -209,6 +243,10 @@ export function renderStaticReport(report: StaticReport): readonly string[] {
209
243
  const rows: readonly (readonly string[])[] = [
210
244
  ...report.emitted.map((page) => ['emitted', page.route, page.file]),
211
245
  ...report.skipped.map((route) => ['skipped', route.route, route.why]),
246
+ // The same three columns, so the terminal and `--json` carry the same three lists. A route
247
+ // whose budget could not be weighed is invisible in `emitted` and, when it also rendered, in
248
+ // `skipped` too — and it is the row `X_BUDGET_UNMEASURED` sends its reader here to read.
249
+ ...report.unmeasured.map((route) => ['unmeasured', route.path, route.reason]),
212
250
  ];
213
251
  const widths = [0, 1].map((index) =>
214
252
  Math.max(...rows.map((row) => (row[index] ?? '').length), 0),
@@ -5,6 +5,7 @@
5
5
  // declaration here would hand back the unguarded second way in that seam exists to close.
6
6
 
7
7
  import { catalogJson } from './catalog-json';
8
+ import { sortedImports } from './imports';
8
9
  import { catalogPath, resolveLocales } from './locales';
9
10
  import type { GeneratedFile } from './naming';
10
11
  import { camel, kebab, pascal } from './naming';
@@ -47,15 +48,18 @@ const catalogImport = (module: string | undefined): string =>
47
48
  /**
48
49
  * The two imports, in the order biome's organize-imports wants — which DEPENDS on the app's scope
49
50
  * and cannot be hardcoded either way. An app catalog (`@myapp/i18n`) sorts BEFORE
50
- * `@ultimat3/admin`; the fallback `@ultimat3/i18n` sorts AFTER it. Emitting one fixed order makes
51
- * every generated admin page a lint error in exactly one of the two cases, and each case is
52
- * covered by a different job — the fallback by `templates`' own linter test, the app-scoped one
53
- * only by `scaffold-smoke`, which runs the generators against a real scaffold.
51
+ * `@ultimat3/admin`; the fallback `@ultimat3/i18n` sorts AFTER it, and `@zebra/i18n` after that.
52
+ * Emitting one fixed order makes every generated admin page a lint error in one of those cases.
53
+ *
54
+ * `sortedImports` is this sort, moved to `templates/imports.ts` so the four scaffold sites that
55
+ * had the same mix and none of the fix share it — `x new zebra` was four `organizeImports` errors
56
+ * on its first `x verify`.
54
57
  */
55
58
  const pageImports = (module: string | undefined): string =>
56
- [`import type { AdminCustomPage, AdminPageProps } from '@ultimat3/admin';`, catalogImport(module)]
57
- .sort((a, b) => (a.slice(a.indexOf("'")) < b.slice(b.indexOf("'")) ? -1 : 1))
58
- .join('\n');
59
+ sortedImports([
60
+ `import type { AdminCustomPage, AdminPageProps } from '@ultimat3/admin';`,
61
+ catalogImport(module),
62
+ ]);
59
63
 
60
64
  /** `useT()` is per render, so the component binds it in its own body. */
61
65
  const translatorBinding = (module: string | undefined): string =>
@@ -0,0 +1,26 @@
1
+ // The order Biome's `organizeImports` wants for a generated file — COMPUTED, because it depends on
2
+ // the app's own scope and no fixed order can be right for every app.
3
+ //
4
+ // `import { useT } from '@myapp/i18n'` sorts BEFORE `@ultimat3/render`; `@zebra/i18n` sorts after
5
+ // it. Every template wrote one fixed order, so `x new zebra` scaffolded four files Biome refuses
6
+ // (`assist/source/organizeImports`, measured: `apps/web/site/page.tsx`,
7
+ // `apps/web/app/dashboard/page.tsx`, `apps/admin/app/admin/page.tsx`, `packages/mcp/src/index.ts`)
8
+ // and the app's very first `x verify` was red on its `lint` step. `x new alpha` was clean, which is
9
+ // why nothing caught it: both CI fixtures — `demoapp` and `bareapp` — sort before `ultimat3`.
10
+ //
11
+ // `templates/admin-page.ts` already did this by hand for its two lines; this is that sort, in one
12
+ // place, for every generator that mixes an app specifier with a framework one.
13
+
14
+ /** The quoted specifier a line imports from — the only thing Biome orders these lines by. */
15
+ const specifierOf = (line: string): string => line.slice(line.indexOf("'"));
16
+
17
+ /**
18
+ * One import block, sorted the way Biome would sort it.
19
+ *
20
+ * BARE specifiers only (`@myapp/i18n`, `@ultimat3/render`, `solid-js`). A relative specifier
21
+ * (`./page.module.scss`) belongs to a LATER group and sorts before every `@` by plain string
22
+ * compare, so passing one here would emit the block Biome then moves — the exact defect this
23
+ * exists to end. Templates write those lines after the block, where they already are.
24
+ */
25
+ export const sortedImports = (lines: readonly string[]): string =>
26
+ [...lines].sort((a, b) => (specifierOf(a) < specifierOf(b) ? -1 : 1)).join('\n');
@@ -5,6 +5,7 @@
5
5
  // fallback is a blank screen on a train.
6
6
 
7
7
  import { catalogJson } from './catalog-json';
8
+ import { sortedImports } from './imports';
8
9
  import { catalogPath, resolveLocales } from './locales';
9
10
  import type { GeneratedFile } from './naming';
10
11
  import { kebab, pascal, titleKey } from './naming';
@@ -112,8 +113,7 @@ const pageSource = (surface: Surface, path: string, module: string | undefined):
112
113
  return `// Route: /${path} on the ${surface} surface. Config first: render mode, offline
113
114
  // strategy and budget are declarations, not runtime choices.
114
115
 
115
- ${catalogImport(module)}
116
- import { defineRoute } from '@ultimat3/render';
116
+ ${sortedImports([catalogImport(module), `import { defineRoute } from '@ultimat3/render';`])}
117
117
  import styles from './page.module.scss';
118
118
 
119
119
  export const config = defineRoute({
@@ -2,8 +2,10 @@
2
2
  // already speaks MCP, and the mobile/desktop placeholders that exist so adding them later is not
3
3
  // a restructure. Every file here is real, typed and covered — no placeholder that fails to boot.
4
4
 
5
+ import { sortedImports } from './imports';
5
6
  import type { GeneratedFile, NameSet } from './naming';
6
7
  import { apiFiles } from './scaffold-api';
8
+ import { authFiles } from './scaffold-auth';
7
9
  import { entryFiles } from './scaffold-entries';
8
10
  import { icon } from './scaffold-icon';
9
11
  import { rolesFiles } from './scaffold-roles';
@@ -47,8 +49,10 @@ const sitePage = (
47
49
  // \`t\` in @ultimat3/i18n. That import is what puts the module holding \`defineCatalogs()\` in
48
50
  // this page's graph, so rendering a string is what registers the catalogs. A page that reached
49
51
  // past it shipped every string as \`\u27e6key\u27e7\` with \`x verify\` green (issue #249).
50
- import { useT } from '@${app.kebab}/i18n';
51
- import { defineRoute } from '@ultimat3/render';
52
+ ${sortedImports([
53
+ `import { useT } from '@${app.kebab}/i18n';`,
54
+ `import { defineRoute } from '@ultimat3/render';`,
55
+ ])}
52
56
  import styles from './page.module.scss';
53
57
 
54
58
  export const config = defineRoute({
@@ -126,8 +130,10 @@ const dashboardPage = (
126
130
  // as their data resolves.
127
131
 
128
132
  // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
129
- import { useT } from '@${app.kebab}/i18n';
130
- import { defineRoute } from '@ultimat3/render';
133
+ ${sortedImports([
134
+ `import { useT } from '@${app.kebab}/i18n';`,
135
+ `import { defineRoute } from '@ultimat3/render';`,
136
+ ])}
131
137
  import styles from './page.module.scss';
132
138
 
133
139
  export const config = defineRoute({
@@ -323,8 +329,10 @@ const adminPage = (
323
329
  // user's agents can drive the user's product with the user's permissions.
324
330
 
325
331
  // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
326
- import { useT } from '@${app.kebab}/i18n';
327
- import { defineRoute } from '@ultimat3/render';
332
+ ${sortedImports([
333
+ `import { useT } from '@${app.kebab}/i18n';`,
334
+ `import { defineRoute } from '@ultimat3/render';`,
335
+ ])}
328
336
 
329
337
  export const config = defineRoute({
330
338
  render: 'ssr',
@@ -377,6 +385,10 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
377
385
  { path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage(app) },
378
386
  { path: 'apps/web/app/dashboard/page.module.scss', contents: dashboardStyle() },
379
387
  { path: 'apps/web/app/dashboard/page.test.ts', contents: dashboardTest() },
388
+ // The third piece of the authz story the scaffold already tells twice: the routes declare a
389
+ // policy and `shared/roles.ts` declares the grants, and until this file existed nothing
390
+ // answered "who is this?" — so every one of those routes refused every request.
391
+ ...authFiles(app),
380
392
  { path: 'apps/web/app/offline.tsx', contents: offlineFallback(app) },
381
393
  { path: 'apps/web/app/offline.module.scss', contents: offlineStyle() },
382
394
  // The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
@@ -0,0 +1,151 @@
1
+ // The scaffold's answer to "who is this?", which it did not have.
2
+ //
3
+ // `hooks.authenticate` is the ONLY place an actor can come from, and nothing in a generated app
4
+ // called `configureAuthenticator()` — so a fresh `x new` booted with
5
+ // `X_CONFIG_INVALID: 7 route(s) declare auth: 'required' and no authenticator is configured` on
6
+ // every start, and its own `/dashboard` answered 401 on the first click. The scaffold declares the
7
+ // routes and the roles; this is the missing third piece, and it is deliberately the smallest one
8
+ // that can be honest: a viewer named by a cookie, installed in `development` and nowhere else.
9
+ //
10
+ // The alternative — dropping `policy:` from the scaffolded routes — was refused: a dashboard that
11
+ // declares no policy is registered `auth: 'public'`, which also skips `render-ssr`'s gated branch,
12
+ // so the document ships with no `vary: cookie` and a shared cache may hand one visitor's page to
13
+ // the next. The scaffold would teach the wrong shape to every app that starts from it.
14
+
15
+ import type { GeneratedFile, NameSet } from './naming';
16
+
17
+ const devActor = (
18
+ app: NameSet,
19
+ ): string => `// Who a browser is until this app issues sessions of its own.
20
+ //
21
+ // \`hooks.authenticate\` is the one place an actor can come from. Without it every request is
22
+ // anonymous, so each route declaring a \`policy:\` answers 401 and the boot warns
23
+ // \`X_CONFIG_INVALID\` — which is what a scaffolded app did on its very first \`x dev\`.
24
+ //
25
+ // DEVELOPMENT ONLY, and the guard is the point: a viewer that followed this to staging would sign
26
+ // every visitor in as an admin. \`bun test\` sets \`NODE_ENV=test\`, so it does not install there
27
+ // either — a fixture mints its own actor, and a second one arriving from a cookie would decide
28
+ // which actor a test is about.
29
+ //
30
+ // REPLACE IT with the real thing: resolve a session cookie to a row, and return that actor.
31
+ // Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
32
+ import { type Actor, logger, tryResolveEnvironment } from '@ultimat3/core';
33
+ import { configureAuthenticator, readCookie } from '@ultimat3/http';
34
+
35
+ /** Set it to a role from \`apps/web/shared/roles.ts\` to browse as that role. */
36
+ export const DEV_ROLE_COOKIE = '${app.kebab}_dev_role';
37
+
38
+ /** The roles \`shared/roles.ts\` declares. A cookie naming anything else falls back. */
39
+ export const DEV_ROLES = ['member', 'admin'] as const;
40
+
41
+ export type DevRole = (typeof DEV_ROLES)[number];
42
+
43
+ /** The one that can open every scaffolded route, including \`/admin\`. */
44
+ export const DEFAULT_DEV_ROLE: DevRole = 'admin';
45
+
46
+ const isDevRole = (value: string | null): value is DevRole =>
47
+ value !== null && (DEV_ROLES as readonly string[]).includes(value);
48
+
49
+ /**
50
+ * An unknown cookie value falls back rather than refusing: the cookie is a viewing convenience,
51
+ * and a typo that resolved nobody would reproduce the 401 this module exists to remove.
52
+ */
53
+ export const devRoleFrom = (cookieHeader: string | null): DevRole => {
54
+ const named = readCookie(cookieHeader, DEV_ROLE_COOKIE);
55
+ return isDevRole(named) ? named : DEFAULT_DEV_ROLE;
56
+ };
57
+
58
+ /**
59
+ * \`roles\`, never a permission list: \`can()\` expands the role map at decision time, so a grant
60
+ * moved between roles reaches this actor without an edit here.
61
+ */
62
+ export const devActorFor = (role: DevRole): Actor => ({
63
+ kind: 'user',
64
+ id: 'dev-actor',
65
+ orgId: 'dev-org',
66
+ roles: [role],
67
+ // Both required, and both deliberately empty: \`scopes\` is the framework's own escape hatch
68
+ // (\`tenancy:cross\`) and \`permissions\` is a DIRECT grant that bypasses the role map — a
69
+ // development viewer holds exactly what its role holds, and nothing a rule cannot explain.
70
+ scopes: [],
71
+ permissions: [],
72
+ });
73
+
74
+ /**
75
+ * Installs it, and says so — loudly, because a silent stand-in for authentication is the one thing
76
+ * worse than none. Returns whether it installed, so the test can assert both halves.
77
+ */
78
+ export function installDevAuthenticator(
79
+ env: Readonly<Record<string, string | undefined>> = process.env,
80
+ ): boolean {
81
+ if (tryResolveEnvironment({ env }) !== 'development') return false;
82
+ configureAuthenticator((request) => devActorFor(devRoleFrom(request.header('cookie'))));
83
+ logger.warn('every request is answered as a development viewer', {
84
+ role: DEFAULT_DEV_ROLE,
85
+ cause:
86
+ 'apps/web/app/auth/dev-actor.ts installs a viewer in development only, because this app issues no session yet',
87
+ fix: \`browse as someone else: document.cookie = '\${DEV_ROLE_COOKIE}=member'\`,
88
+ });
89
+ return true;
90
+ }
91
+
92
+ // Module scope, which IS the wiring: the boot scan imports every module under \`apps/*\` before a
93
+ // listener binds, and \`x dev\` and the container both read the configured value back at start.
94
+ installDevAuthenticator();
95
+ `;
96
+
97
+ const devActorTest =
98
+ (): string => `// The two halves that make a development-only stand-in safe: it resolves the cookie, and it does
99
+ // not install itself anywhere but development.
100
+ import { configuredAuthenticator, resetAuthenticator } from '@ultimat3/http';
101
+ import { actorHas } from '@ultimat3/policy';
102
+ import { expect, unitTest } from '@ultimat3/testing';
103
+ import { roles } from '../../shared/roles';
104
+ import {
105
+ DEFAULT_DEV_ROLE,
106
+ DEV_ROLE_COOKIE,
107
+ devActorFor,
108
+ devRoleFrom,
109
+ installDevAuthenticator,
110
+ } from './dev-actor';
111
+
112
+ unitTest('the cookie names the role, and anything else falls back', () => {
113
+ expect(devRoleFrom(\`\${DEV_ROLE_COOKIE}=member\`)).toBe('member');
114
+ expect(devRoleFrom(\`\${DEV_ROLE_COOKIE}=nobody\`)).toBe(DEFAULT_DEV_ROLE);
115
+ expect(devRoleFrom(null)).toBe(DEFAULT_DEV_ROLE);
116
+ });
117
+
118
+ unitTest('the actor it mints holds what the role map grants it, and nothing else', () => {
119
+ // \`actorHas\` and not \`holds\`: this is the function \`can()\` itself calls, so the assertion is
120
+ // the pipeline's own decision rather than a second implementation of it. The map is passed
121
+ // explicitly — a test that depended on which module imported first would pass alone and fail
122
+ // inside a suite.
123
+ expect(actorHas(devActorFor('admin'), 'admin:read', roles)).toBe(true);
124
+ expect(actorHas(devActorFor('member'), 'dashboard:read', roles)).toBe(true);
125
+ // The whole reason this is development-only: a member is not an admin, and neither is a deploy.
126
+ expect(actorHas(devActorFor('member'), 'admin:read', roles)).toBe(false);
127
+ });
128
+
129
+ unitTest('it installs in development and in no other environment', () => {
130
+ // This process is \`test\`, so the module-scope call at the bottom of dev-actor.ts installed
131
+ // nothing — which is what keeps a fixture's own actor the only one a test can be about.
132
+ expect(configuredAuthenticator()).toBeUndefined();
133
+
134
+ expect(installDevAuthenticator({ ULTIMATE_ENV: 'production' })).toBe(false);
135
+ expect(installDevAuthenticator({ ULTIMATE_ENV: 'staging' })).toBe(false);
136
+ expect(configuredAuthenticator()).toBeUndefined();
137
+
138
+ expect(installDevAuthenticator({ ULTIMATE_ENV: 'development' })).toBe(true);
139
+ expect(configuredAuthenticator()).toBeDefined();
140
+ // Process-global, so the case that installed one takes it back out.
141
+ resetAuthenticator();
142
+ });
143
+ `;
144
+
145
+ /** The app's development viewer, beside the roles it names. */
146
+ export function authFiles(app: NameSet): readonly GeneratedFile[] {
147
+ return [
148
+ { path: 'apps/web/app/auth/dev-actor.ts', contents: devActor(app) },
149
+ { path: 'apps/web/app/auth/dev-actor.test.ts', contents: devActorTest() },
150
+ ];
151
+ }
@@ -197,6 +197,18 @@ services:
197
197
  deploy: { replicas: 1 } # scales on concurrent websockets, no sticky sessions — pinned by the port
198
198
  # The sync role binds PORT + 1. PORT is unset here, so it is 3000 and this listens on 3001.
199
199
  ports: ['3001:3001']
200
+ # ...which is why the image's own HEALTHCHECK cannot be inherited here. It fetches $PORT —
201
+ # 3000 — and this role never binds it, so the container reports \`unhealthy\` from
202
+ # \`start_period\` onward and never recovers, and anything gated on \`sync: service_healthy\`
203
+ # would never start. Literal 3001 rather than an expression, for the same reason \`ports:\`
204
+ # above is literal: PORT is unset in this file, and two ways of saying one number drift.
205
+ # \`docker/helm\` states the same rule as \`PORT = .port - 1\`.
206
+ healthcheck:
207
+ test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:3001/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
208
+ interval: 10s
209
+ timeout: 3s
210
+ start_period: 30s
211
+ retries: 3
200
212
 
201
213
  worker:
202
214
  <<: *image
@@ -58,7 +58,7 @@ file in it is deletable.
58
58
 
59
59
  const readme = (app: NameSet): string => `# ${app.pascal}
60
60
 
61
- Built with [Ultimate](https://ultimate.dev). Bun-only, Postgres, SolidJS.
61
+ Built with [Ultimate](https://github.com/developerz-ai/ultimate). Bun-only, Postgres, SolidJS.
62
62
 
63
63
  ## 🚀 Start
64
64