@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.
- package/CLAUDE.md +71 -0
- package/package.json +28 -26
- package/src/affected.ts +0 -3
- package/src/app-boundaries.ts +4 -5
- package/src/app-env.ts +7 -2
- package/src/browser-launcher.ts +0 -2
- package/src/budgets.ts +10 -3
- package/src/cmd-build.ts +2 -2
- package/src/cmd-db-backfill.ts +14 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-deploy.ts +14 -3
- package/src/cmd-dev.ts +3 -0
- package/src/cmd-docs.ts +2 -1
- package/src/cmd-doctor.ts +3 -5
- package/src/cmd-env.ts +2 -2
- package/src/cmd-fix.ts +2 -4
- package/src/cmd-new.ts +36 -6
- package/src/cmd-shot.ts +22 -2
- package/src/command.ts +12 -0
- package/src/db-finding.ts +2 -2
- package/src/db-seed.ts +0 -3
- package/src/dev-assets.ts +6 -0
- package/src/dev-cache.ts +12 -5
- package/src/dev-hooks.ts +8 -0
- package/src/dev-lock.ts +8 -7
- package/src/dev-purge.ts +8 -2
- package/src/dev-render.ts +5 -2
- package/src/dev-roles.ts +26 -2
- package/src/dev-runtime.ts +11 -3
- package/src/dev-storage.ts +8 -2
- package/src/dev-sync.ts +37 -2
- package/src/dispatch.ts +8 -0
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +3 -2
- package/src/error-catalog.ts +11 -1
- package/src/error-codes.ts +19 -2
- package/src/error-contract.ts +30 -7
- package/src/error-pages.ts +79 -0
- package/src/errors.ts +26 -29
- package/src/favicon.ts +113 -0
- package/src/fix-path.ts +104 -0
- package/src/flag-reads.ts +2 -2
- package/src/generate-write.ts +3 -2
- package/src/guards.ts +4 -4
- package/src/hold.ts +73 -7
- package/src/index.ts +16 -6
- package/src/island-bundle.ts +32 -4
- package/src/island-routes.ts +7 -1
- package/src/live-routes.ts +181 -0
- package/src/mcp-errors.ts +7 -2
- package/src/messages.ts +6 -4
- package/src/metrics-endpoint.ts +0 -2
- package/src/output.ts +2 -2
- package/src/prerender.ts +64 -22
- package/src/script-csp.ts +17 -0
- package/src/serve.ts +12 -1
- package/src/static-report.ts +41 -3
- package/src/templates/admin-page.ts +11 -7
- package/src/templates/imports.ts +26 -0
- package/src/templates/route.ts +2 -2
- package/src/templates/scaffold-app.ts +18 -6
- package/src/templates/scaffold-auth.ts +151 -0
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-docs.ts +1 -1
- package/src/templates/scaffold-domain-package.ts +3 -1
- package/src/templates/scaffold-mcp-package.ts +6 -3
- package/src/templates/scaffold-repo.ts +17 -8
- package/src/templates/slice-foundation.ts +3 -5
- package/src/test-shards.ts +2 -2
- package/src/tsconfig-references.ts +2 -2
- package/src/verify-checks.ts +10 -3
- package/src/verify-floor.ts +8 -6
- package/src/verify-run.ts +2 -2
- package/src/verify-step.ts +2 -1
- package/src/verify-test-run.ts +2 -2
- package/src/workspace-checks.ts +10 -12
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +7 -1
package/src/metrics-endpoint.ts
CHANGED
|
@@ -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:
|
|
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
|
|
145
|
-
entry,
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/static-report.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
51
|
-
* every generated admin page a lint error in
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
[
|
|
57
|
-
|
|
58
|
-
|
|
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');
|
package/src/templates/route.ts
CHANGED
|
@@ -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
|
-
|
|
51
|
-
import {
|
|
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
|
-
|
|
130
|
-
import {
|
|
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
|
-
|
|
327
|
-
import {
|
|
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
|
|
61
|
+
Built with [Ultimate](https://github.com/developerz-ai/ultimate). Bun-only, Postgres, SolidJS.
|
|
62
62
|
|
|
63
63
|
## 🚀 Start
|
|
64
64
|
|