@ultimat3/cli 10.0.0 → 11.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/CLAUDE.md +98 -0
- package/package.json +28 -26
- package/src/budgets.ts +6 -0
- package/src/cmd-db-backfill.ts +14 -1
- package/src/cmd-dev.ts +3 -0
- package/src/cmd-new.ts +34 -4
- package/src/command.ts +12 -0
- package/src/dev-assets.ts +6 -0
- package/src/dev-hooks.ts +8 -0
- package/src/dev-purge.ts +8 -2
- package/src/dev-render.ts +5 -2
- package/src/dev-roles.ts +26 -2
- package/src/dispatch.ts +8 -0
- package/src/error-catalog.ts +11 -1
- package/src/error-codes.ts +15 -0
- package/src/error-contract.ts +61 -4
- package/src/error-pages.ts +79 -0
- package/src/errors.ts +25 -0
- package/src/favicon.ts +113 -0
- package/src/fix-path.ts +104 -0
- package/src/hold.ts +73 -7
- package/src/index.ts +24 -1
- package/src/live-routes.ts +181 -0
- package/src/mcp-errors.ts +7 -0
- package/src/messages.ts +6 -4
- package/src/prerender.ts +32 -3
- package/src/script-csp.ts +17 -0
- package/src/serve.ts +12 -1
- 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-mcp-package.ts +6 -3
- package/src/templates/scaffold-repo.ts +1 -0
- package/src/ts-scan.ts +117 -6
- package/src/verify-checks.ts +17 -3
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// One question only this package can ask: a route SUBSCRIBES to live rows, and does anything on
|
|
2
|
+
// that route ever run in a browser to receive them? `@ultimat3/realtime` cannot see a route and
|
|
3
|
+
// `@ultimat3/render` may not import realtime, so the two halves meet here — beside the island
|
|
4
|
+
// build, which is the other place the route table and the client graph are both in scope.
|
|
5
|
+
//
|
|
6
|
+
// The failure it closes is silent by construction (#271): with no island the page server-renders
|
|
7
|
+
// its `loading` branch, answers 200, and stays that way forever — nothing throws, nothing logs,
|
|
8
|
+
// and every suite passes.
|
|
9
|
+
|
|
10
|
+
// why: Bun exposes no path API, and a route file's imports are resolved against its own directory.
|
|
11
|
+
import { join, posix } from 'node:path';
|
|
12
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
13
|
+
import type { RouteEntry } from '@ultimat3/render';
|
|
14
|
+
import { ISLAND_EXTENSION, routeEntries } from '@ultimat3/render';
|
|
15
|
+
import type { Finding } from './output';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The exports that only work with a registered `LiveClient`. Each one either subscribes, mutates
|
|
19
|
+
* or reads the connection, so a module naming one is a module that needs a browser to have booted
|
|
20
|
+
* it — `hasLiveClient` and `LiveClient` itself are deliberately absent: the first IS the guard, and
|
|
21
|
+
* the second is what an island's `mount()` constructs.
|
|
22
|
+
*/
|
|
23
|
+
export const LIVE_HOOKS = [
|
|
24
|
+
'useLive',
|
|
25
|
+
'liveHookFor',
|
|
26
|
+
'useConnection',
|
|
27
|
+
'useMutation',
|
|
28
|
+
'useMutationQueue',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one escape hatch, and it is a call an author writes on purpose: a module that ASKS whether
|
|
33
|
+
* there is a client has already written what happens when there is none. `app/update-banner.tsx`
|
|
34
|
+
* in the reference app is the shape — imported by the layout, so by every page, and correct on all
|
|
35
|
+
* of them.
|
|
36
|
+
*/
|
|
37
|
+
const GUARD = 'hasLiveClient';
|
|
38
|
+
|
|
39
|
+
/** Value imports only: `import type` is erased, so it boots nothing and needs nothing. */
|
|
40
|
+
const REALTIME_IMPORT = /import\s+([^;]*?)from\s*['"]@ultimat3\/realtime(?:\/[\w-]+)?['"]/g;
|
|
41
|
+
|
|
42
|
+
const bindingsOf = (clause: string): readonly string[] =>
|
|
43
|
+
(/\{([^}]*)\}/.exec(clause)?.[1] ?? '')
|
|
44
|
+
.split(',')
|
|
45
|
+
.map((entry) => entry.split(/\bas\b/)[0]?.trim() ?? '')
|
|
46
|
+
.filter((name) => name.length > 0 && !name.startsWith('type '));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Which live hooks one module imports, or `[]` — including for a module that guards, which is a
|
|
50
|
+
* per-FILE verdict on purpose: the guard is written next to the read it protects.
|
|
51
|
+
*/
|
|
52
|
+
export function liveHooksIn(source: string): readonly string[] {
|
|
53
|
+
const hooks: string[] = [];
|
|
54
|
+
for (const match of source.matchAll(REALTIME_IMPORT)) {
|
|
55
|
+
const clause = match[1] ?? '';
|
|
56
|
+
if (clause.trimStart().startsWith('type ')) continue;
|
|
57
|
+
const names = bindingsOf(clause);
|
|
58
|
+
if (names.includes(GUARD)) return [];
|
|
59
|
+
for (const hook of LIVE_HOOKS) if (names.includes(hook)) hooks.push(hook);
|
|
60
|
+
}
|
|
61
|
+
return hooks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What a relative specifier can be on disk. The list `fix-imports.ts` already resolves against. */
|
|
65
|
+
const candidates = (base: string): readonly string[] => [
|
|
66
|
+
`${base}.ts`,
|
|
67
|
+
`${base}.tsx`,
|
|
68
|
+
`${base}/index.ts`,
|
|
69
|
+
`${base}/index.tsx`,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
async function readModule(
|
|
73
|
+
root: string,
|
|
74
|
+
file: string,
|
|
75
|
+
): Promise<{ path: string; source: string } | undefined> {
|
|
76
|
+
for (const path of file.endsWith('.ts') || file.endsWith('.tsx') ? [file] : candidates(file)) {
|
|
77
|
+
const handle = Bun.file(join(root, path));
|
|
78
|
+
if (await handle.exists()) return { path, source: await handle.text() };
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where a route's graph first reaches a live hook. */
|
|
84
|
+
export interface LiveReach {
|
|
85
|
+
/** App-root-relative module that imports it. */
|
|
86
|
+
readonly at: string;
|
|
87
|
+
readonly hook: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Walk the route module's own import graph and answer the first live hook in it.
|
|
92
|
+
*
|
|
93
|
+
* Relative specifiers only. A bare one resolves through `node_modules` or a workspace name, and
|
|
94
|
+
* following either would mean guessing which package a name came from — the limit `fix-imports.ts`
|
|
95
|
+
* records for the same walk. So this UNDER-reports rather than over-reports: a finding here is
|
|
96
|
+
* always a real one, which is what lets the rule ship with no pin table.
|
|
97
|
+
*/
|
|
98
|
+
export async function liveReachOf(root: string, file: string): Promise<LiveReach | undefined> {
|
|
99
|
+
const seen = new Set<string>();
|
|
100
|
+
const queue = [file];
|
|
101
|
+
while (queue.length > 0) {
|
|
102
|
+
const next = queue.shift();
|
|
103
|
+
if (next === undefined || seen.has(next)) continue;
|
|
104
|
+
seen.add(next);
|
|
105
|
+
const module = await readModule(root, next);
|
|
106
|
+
if (module === undefined) continue;
|
|
107
|
+
const hook = liveHooksIn(module.source)[0];
|
|
108
|
+
if (hook !== undefined) return { at: module.path, hook };
|
|
109
|
+
const loader = module.path.endsWith('x') ? 'tsx' : 'ts';
|
|
110
|
+
// Bun's transpiler is the parser, exactly as in `scripts/boundaries.ts`: it erases type-only
|
|
111
|
+
// imports and finds the dynamic ones, which no regex over this source could do.
|
|
112
|
+
for (const scanned of new Bun.Transpiler({ loader }).scanImports(module.source)) {
|
|
113
|
+
if (!scanned.path.startsWith('.')) continue;
|
|
114
|
+
queue.push(posix.normalize(posix.join(posix.dirname(module.path), scanned.path)));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface LiveRouteGap extends LiveReach {
|
|
121
|
+
readonly route: string;
|
|
122
|
+
readonly file: string;
|
|
123
|
+
/** What the route declares. `'never'` is the second way nothing boots. */
|
|
124
|
+
readonly hydrate: string;
|
|
125
|
+
readonly islands: readonly string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The `x g island` invocation that fixes it, built from this route's own file — never a placeholder. */
|
|
129
|
+
const generatorFor = (file: string): string => {
|
|
130
|
+
const dir = posix.dirname(file);
|
|
131
|
+
return `x g island ${posix.basename(dir)} --at ${dir}`;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Every route that reads live rows with nothing to receive them. Two shapes, one condition — no
|
|
136
|
+
* island at all, and an island the route declares `hydrate: 'never'` for. `X_ISLAND_NOT_HYDRATED`
|
|
137
|
+
* covers the second only at render time, and only for a render that reaches the island, so a route
|
|
138
|
+
* can hold the contradiction and never be asked.
|
|
139
|
+
*/
|
|
140
|
+
export async function liveRouteGaps(
|
|
141
|
+
root: string,
|
|
142
|
+
entries: readonly RouteEntry[],
|
|
143
|
+
): Promise<readonly LiveRouteGap[]> {
|
|
144
|
+
const gaps: LiveRouteGap[] = [];
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (entry.surface === 'api') continue;
|
|
147
|
+
if (entry.islands.length > 0 && entry.config.hydrate !== 'never') continue;
|
|
148
|
+
const reach = await liveReachOf(root, entry.file);
|
|
149
|
+
if (reach === undefined) continue;
|
|
150
|
+
gaps.push({
|
|
151
|
+
...reach,
|
|
152
|
+
route: entry.path,
|
|
153
|
+
file: entry.file,
|
|
154
|
+
hydrate: entry.config.hydrate,
|
|
155
|
+
islands: entry.islands,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return gaps;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const liveRouteFindingFor = (gap: LiveRouteGap): Finding => ({
|
|
162
|
+
code: 'X_LIVE_ROUTE_NO_ISLAND',
|
|
163
|
+
cause:
|
|
164
|
+
`${gap.route} reads ${gap.hook}() (${gap.at}) and ` +
|
|
165
|
+
(gap.islands.length === 0
|
|
166
|
+
? 'declares no island'
|
|
167
|
+
: `declares hydrate: 'never' beside ${gap.islands.join(', ')}`) +
|
|
168
|
+
', so no module of this route ever runs in a browser: its rows have nowhere to arrive and the page renders its loading branch forever, at 200',
|
|
169
|
+
fix:
|
|
170
|
+
`${generatorFor(gap.file)}, declare it with island({ src: './${posix.basename(posix.dirname(gap.file))}${ISLAND_EXTENSION}' }) above defineRoute in ${gap.file}, ` +
|
|
171
|
+
`and move the ${gap.hook}() read into its mount() — which is where setLiveClient() can be called`,
|
|
172
|
+
docs: ERROR_DOCS_URL,
|
|
173
|
+
at: gap.at,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* What this rule contributes to `x verify`'s `budgets` step — the step that already loaded the app
|
|
178
|
+
* and already asks what JavaScript a route's document boots.
|
|
179
|
+
*/
|
|
180
|
+
export const liveRouteFindings = async (root: string): Promise<readonly Finding[]> =>
|
|
181
|
+
(await liveRouteGaps(root, routeEntries())).map(liveRouteFindingFor);
|
package/src/mcp-errors.ts
CHANGED
|
@@ -48,6 +48,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
48
48
|
X_JOB_UNKNOWN: 'x jobs ls --json',
|
|
49
49
|
X_FIX_TARGET_UNKNOWN: 'x fix boundary apps/web/site/page.tsx --json',
|
|
50
50
|
X_ERROR_FIX_INVALID: 'x verify --json # the finding names the file, the line and the fix text',
|
|
51
|
+
X_ERROR_FIX_PATH_MISSING:
|
|
52
|
+
'x verify --json # the finding names the fix line and the path it cites',
|
|
51
53
|
X_WORKSPACE_DEP_UNDECLARED:
|
|
52
54
|
'x verify --json # the package-shape finding carries the dependency line to add',
|
|
53
55
|
X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
|
|
@@ -60,6 +62,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
60
62
|
X_ERROR_CODE_UNDOCUMENTED: 'x verify --json # the finding names the code and the missing page',
|
|
61
63
|
X_ERROR_CODE_UNREGISTERED:
|
|
62
64
|
'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
|
|
65
|
+
X_ERROR_CODE_UNRESOLVED:
|
|
66
|
+
'x verify --json # the finding names the file, the line and the name it could not resolve',
|
|
63
67
|
X_CLI_UNEXPECTED: 'x doctor --json',
|
|
64
68
|
X_TYPECHECK_FAILED: 'bunx tsc -b --pretty false',
|
|
65
69
|
X_LINT_FAILED: 'bunx biome check --write .',
|
|
@@ -89,6 +93,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
89
93
|
// this fix builds an image, writes no `.x/build-stats.json`, and the next `x verify` reports the
|
|
90
94
|
// same code. Byte-identical to `checkBudgets`'s own finding, which is the other half of the pair.
|
|
91
95
|
X_BUDGET_UNMEASURED: 'x build --target static --json && x verify --json',
|
|
96
|
+
// `x routes` first, because the finding is about a ROUTE and the table names its file and its
|
|
97
|
+
// islands; the generator that fixes it takes the directory that table just printed.
|
|
98
|
+
X_LIVE_ROUTE_NO_ISLAND: 'x routes --json # then: x g island <route-dir> --at <route-dir>',
|
|
92
99
|
X_BUILD_FAILED: 'x build --json # the finding names the failing step',
|
|
93
100
|
X_BUILD_ENTRY_MISSING:
|
|
94
101
|
'x new scratch-app --dry-run --json # the file list names every entry a build needs',
|
package/src/messages.ts
CHANGED
|
@@ -135,10 +135,12 @@ const CATALOG = {
|
|
|
135
135
|
'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
|
|
136
136
|
'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
|
|
137
137
|
'cli.mcp.scopes': ' scopes {scopes}',
|
|
138
|
-
// `
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
// `bin/setup` and nothing else: the scaffold ships it, `README.md` and `bin/check` both name it,
|
|
139
|
+
// and it is the only spelling that is right on a fresh clone — it installs, writes
|
|
140
|
+
// `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no migration, so the
|
|
141
|
+
// drift step is red until it has), migrates and seeds. The four-command line this replaced named
|
|
142
|
+
// `x dev` off a tree where nothing had installed the CLI yet, and skipped the seed entirely.
|
|
143
|
+
'cli.new.done': 'created {name} — next: cd {name} && bin/setup && x dev',
|
|
142
144
|
// The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
|
|
143
145
|
// one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
|
|
144
146
|
// command is a broken one — the same split `Finding.fix` already makes.
|
package/src/prerender.ts
CHANGED
|
@@ -13,6 +13,8 @@ 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
20
|
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
@@ -95,6 +97,9 @@ function heaviestSource(
|
|
|
95
97
|
|
|
96
98
|
export const DEFAULT_ORIGIN = 'https://localhost';
|
|
97
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
|
+
|
|
98
103
|
/**
|
|
99
104
|
* Prerendering and measuring are two questions, and conflating them made `X_BUDGET_UNMEASURED`
|
|
100
105
|
* unclosable by any invocation: only `static` was ever rendered, so a `budget:` on an ssr, isr,
|
|
@@ -127,6 +132,21 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
127
132
|
// behind it, so the artifact carries every byte the browser will ask for.
|
|
128
133
|
const islands = await buildIslands(options.root);
|
|
129
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
|
+
);
|
|
130
150
|
|
|
131
151
|
// Every render below goes through `routeDocument`, which is the function a REQUEST reaches — and
|
|
132
152
|
// a request arrives inside `runWithContext`, installed by the HTTP pipeline (`dev-render.ts`).
|
|
@@ -186,6 +206,13 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
186
206
|
skipped.push(skippedRoute(facts, 'no-prerender-paths'));
|
|
187
207
|
continue;
|
|
188
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;
|
|
189
216
|
for (const artifact of artifacts) {
|
|
190
217
|
const file = join(options.out, artifact.outputPath);
|
|
191
218
|
const bytes = await Bun.write(file, artifact.html);
|
|
@@ -200,12 +227,14 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
200
227
|
// declared budget against bytes that exist on disk rather than against a graph's estimate.
|
|
201
228
|
const measured = await measureDocumentJs(artifact.html, options.out);
|
|
202
229
|
const chain = heaviestSource(islands, measured.entries);
|
|
203
|
-
|
|
204
|
-
|
|
230
|
+
if (heaviest !== undefined && heaviest.jsBytes >= measured.jsBytes) continue;
|
|
231
|
+
heaviest = {
|
|
232
|
+
path: entry.path,
|
|
205
233
|
jsBytes: measured.jsBytes,
|
|
206
234
|
...(chain === undefined ? {} : { heaviestChain: chain }),
|
|
207
|
-
}
|
|
235
|
+
};
|
|
208
236
|
}
|
|
237
|
+
if (heaviest !== undefined) routes.push(heaviest);
|
|
209
238
|
}
|
|
210
239
|
const stats = await writeBuildStats(options.root, { routes });
|
|
211
240
|
// Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|