@ultimat3/cli 20.2.1 → 22.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 +84 -1582
- package/README.md +40 -2
- package/package.json +31 -31
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-env.ts +2 -2
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +56 -13
- package/src/build-errors.ts +54 -0
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +117 -42
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +78 -122
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +119 -0
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +33 -28
- package/src/error-pages.ts +19 -7
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +22 -29
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -334
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +38 -11
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +98 -0
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +2 -2
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/live-routes.ts +82 -42
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +30 -26
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +19 -15
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/page-sync.ts +54 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +23 -11
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-render.ts → runtime-render.ts} +119 -20
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -338
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +76 -30
- package/src/templates/resource.ts +10 -4
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +25 -145
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +41 -0
- package/src/verify-run.ts +115 -50
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +22 -30
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/worker-bundle.ts +192 -0
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -100
- package/src/cdp-connection.ts +0 -211
- package/src/cdp-e2e-page.ts +0 -209
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -139
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -97
- package/src/e2e-errors.ts +0 -103
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -150
- package/src/e2e-selection.ts +0 -182
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
package/src/mcp-ui.ts
CHANGED
|
@@ -24,17 +24,17 @@ import type {
|
|
|
24
24
|
UiShotResult,
|
|
25
25
|
} from '@ultimat3/mcp';
|
|
26
26
|
import { describeRoutes } from '@ultimat3/render';
|
|
27
|
-
import type { ScrapeDriver } from '@ultimat3/scraping';
|
|
28
|
-
import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
|
|
29
27
|
import { appBrowser } from './browser-launcher';
|
|
30
|
-
import {
|
|
28
|
+
import type { ShotDriver } from './browser-launcher-port';
|
|
29
|
+
import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
|
|
30
|
+
import { DEFAULT_SETTLE_MS, readRoute, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
31
31
|
import { islandShot } from './cmd-shot-island';
|
|
32
|
-
import type { Env } from './dev-services';
|
|
33
32
|
import { islandVerdictJson } from './island-verdict';
|
|
34
33
|
import { diffShots } from './mcp-ui-diff';
|
|
35
34
|
import { inspectRoute } from './mcp-ui-inspect';
|
|
36
35
|
import { interactRoute } from './mcp-ui-interact';
|
|
37
36
|
import { retryMemo } from './retry-memo';
|
|
37
|
+
import type { Env } from './runtime-bindings';
|
|
38
38
|
import { shotBrowserChoice } from './shot-browser';
|
|
39
39
|
import { devServerFor, type ShotServer } from './shot-server';
|
|
40
40
|
import { verdictJson } from './shot-verdict';
|
|
@@ -54,7 +54,11 @@ export interface DeclaredRoute {
|
|
|
54
54
|
readonly budgetJs: string | null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function assertBudgetedRoute(
|
|
57
|
+
export function assertBudgetedRoute(raw: string, declared: readonly DeclaredRoute[]): string {
|
|
58
|
+
// `readRoute` FIRST, for every `ui.*` caller: `/\\localhost:9200` matches `/:slug` and a
|
|
59
|
+
// browser reads it as `//localhost:9200`, another service on the box. `x shot` ran it; the three
|
|
60
|
+
// MCP tools skipped it, and this is the one function all four pass through.
|
|
61
|
+
const route = readRoute(raw);
|
|
58
62
|
const path = route.split('?')[0] ?? route;
|
|
59
63
|
const hit = declared.find((entry) => matches(entry.path, path));
|
|
60
64
|
if (hit === undefined) {
|
|
@@ -71,6 +75,7 @@ export function assertBudgetedRoute(route: string, declared: readonly DeclaredRo
|
|
|
71
75
|
fix: `declare budget: { js: '<n>kb' } in ${hit.file}, then: x build --target static --json && x verify --only budgets --json`,
|
|
72
76
|
});
|
|
73
77
|
}
|
|
78
|
+
return route;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/** `/links/:slug` matches `/links/abc123`; one segment per `:param`, no globbing. */
|
|
@@ -87,7 +92,7 @@ export interface UiHostInput {
|
|
|
87
92
|
/** Injected by a test: the server a picture is of, in place of `devServerFor`. */
|
|
88
93
|
readonly boot?: (() => Promise<ShotServer>) | undefined;
|
|
89
94
|
/** Injected by a test: the browser, in place of `appBrowser` (which needs Chrome). */
|
|
90
|
-
readonly driver?: ((viewport: UiShotInput['viewport']) => Promise<
|
|
95
|
+
readonly driver?: ((viewport: UiShotInput['viewport']) => Promise<ShotDriver>) | undefined;
|
|
91
96
|
/** Injected by a test: the route table, in place of the registry. */
|
|
92
97
|
readonly routes?: (() => readonly DeclaredRoute[]) | undefined;
|
|
93
98
|
}
|
|
@@ -122,11 +127,10 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
122
127
|
const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
|
|
123
128
|
const routes = input.routes ?? describeRoutes;
|
|
124
129
|
// One browser per call, sized to the call: the injected driver in a test, `appBrowser` otherwise.
|
|
125
|
-
const driverFor = async (viewport: UiShotInput['viewport']): Promise<
|
|
130
|
+
const driverFor = async (viewport: UiShotInput['viewport']): Promise<ShotDriver> => {
|
|
126
131
|
if (input.driver !== undefined) return input.driver(viewport);
|
|
127
132
|
const { cdpUrl, executablePath } = browser();
|
|
128
133
|
return appBrowser({
|
|
129
|
-
root,
|
|
130
134
|
viewport,
|
|
131
135
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
132
136
|
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
@@ -144,18 +148,18 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
144
148
|
},
|
|
145
149
|
|
|
146
150
|
async shotRoute(shot) {
|
|
147
|
-
assertBudgetedRoute(shot.route, routes());
|
|
151
|
+
const route = assertBudgetedRoute(shot.route, routes());
|
|
148
152
|
const driver = await driverFor(shot.viewport);
|
|
149
153
|
// One directory per (route, viewport, scheme), so two pictures of one route at two widths
|
|
150
154
|
// never overwrite each other and an agent can hold both.
|
|
151
155
|
const outDir = join(
|
|
152
156
|
root,
|
|
153
157
|
SHOT_DIR,
|
|
154
|
-
shotSlug(
|
|
158
|
+
shotSlug(route),
|
|
155
159
|
`${shot.viewport.width}x${shot.viewport.height}-${shot.colorScheme}`,
|
|
156
160
|
);
|
|
157
161
|
const artifacts = await runShot({
|
|
158
|
-
route
|
|
162
|
+
route,
|
|
159
163
|
outDir,
|
|
160
164
|
driver,
|
|
161
165
|
boot,
|
|
@@ -175,13 +179,13 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
175
179
|
async inspectRoute(inspect) {
|
|
176
180
|
// The same gate as `ui.shot`, for the same reason: facts about a draft are facts about the
|
|
177
181
|
// wrong thing.
|
|
178
|
-
assertBudgetedRoute(inspect.route, routes());
|
|
179
|
-
return inspectRoute({ root, boot, driver: driverFor }, inspect);
|
|
182
|
+
const route = assertBudgetedRoute(inspect.route, routes());
|
|
183
|
+
return inspectRoute({ root, boot, driver: driverFor }, { ...inspect, route });
|
|
180
184
|
},
|
|
181
185
|
|
|
182
186
|
async interactRoute(interact) {
|
|
183
|
-
assertBudgetedRoute(interact.route, routes());
|
|
184
|
-
return interactRoute({ root, boot, driver: driverFor }, interact);
|
|
187
|
+
const route = assertBudgetedRoute(interact.route, routes());
|
|
188
|
+
return interactRoute({ root, boot, driver: driverFor }, { ...interact, route });
|
|
185
189
|
},
|
|
186
190
|
// No route gate and no boot: the captures were gated when they were taken, and a diff of two
|
|
187
191
|
// files needs neither a server nor a browser.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The database a measurement render reads, when the build has none. A page's `load` reads rows,
|
|
2
|
+
// and a build runs with no `DATABASE_URL`, so every data-reading route was `X_DB_UNAVAILABLE` and
|
|
3
|
+
// never weighed. The answer is `x dev`'s own embedded PGlite on a THROWAWAY state directory, with
|
|
4
|
+
// the app's migrations applied and its `dev`-tier seeds run: `defineSeed` rows are keyed by
|
|
5
|
+
// `seedId(label)`, so the database is the same on every machine — and a dynamic route whose
|
|
6
|
+
// `prerender()` lists paths from its own rows has paths to be weighed at (plan 101 slice 11 m).
|
|
7
|
+
|
|
8
|
+
// why: Bun has no mkdtemp and no recursive remove; the state directory must not outlive the build.
|
|
9
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
10
|
+
// why: Bun exposes no tmpdir(); the throwaway directory lives in the platform temp root.
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
// why: Bun ships no path-join primitive.
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { migrate, withTransaction } from '@ultimat3/db';
|
|
15
|
+
import { postgresDriver } from '@ultimat3/entity';
|
|
16
|
+
import { discoverSeeds, runSeeds, selectSeeds } from './db-seed';
|
|
17
|
+
import { readMigrations } from './migrations';
|
|
18
|
+
import { resolveServices } from './runtime-bindings';
|
|
19
|
+
import { startQueue } from './runtime-queue';
|
|
20
|
+
|
|
21
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
22
|
+
|
|
23
|
+
/** One build's database: started on first use, released once. */
|
|
24
|
+
export interface MeasureDatabase {
|
|
25
|
+
ready(): Promise<void>;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `x dev`'s embedded database, empty and migrated, for an env with no `DATABASE_URL`. An env that
|
|
31
|
+
* names one keeps it: `db()` already reaches that server, and a build pointed at a database was
|
|
32
|
+
* pointed there on purpose.
|
|
33
|
+
*/
|
|
34
|
+
export function measureDatabase(root: string, env: Env = process.env): MeasureDatabase {
|
|
35
|
+
let started: Promise<() => Promise<void>> | undefined;
|
|
36
|
+
const start = async (): Promise<() => Promise<void>> => {
|
|
37
|
+
if ((env['DATABASE_URL'] ?? '') !== '') return async () => undefined;
|
|
38
|
+
// An app with no migration has no table to read, so it pays for no database: a PGlite boot
|
|
39
|
+
// is seconds, and every build of such an app (and every prerender test) paid it for nothing.
|
|
40
|
+
const migrations = await readMigrations(root);
|
|
41
|
+
if (migrations.length === 0) return async () => undefined;
|
|
42
|
+
const state = await mkdtemp(join(tmpdir(), 'x-measure-'));
|
|
43
|
+
const scoped = { ...env, ULTIMATE_STATE_DIR: state };
|
|
44
|
+
const queue = await startQueue(resolveServices(root, scoped), undefined, scoped);
|
|
45
|
+
await migrate({ migrations });
|
|
46
|
+
// The seeds `x db seed` runs in development. A failed seed is a row in its own result and
|
|
47
|
+
// never a thrown build: a route it starves is reported by the measurement that needed it.
|
|
48
|
+
const discovered = (await discoverSeeds(root)).seeds;
|
|
49
|
+
await runSeeds({
|
|
50
|
+
seeds: selectSeeds({ discovered, environment: 'development' }),
|
|
51
|
+
driver: postgresDriver(),
|
|
52
|
+
dryRun: false,
|
|
53
|
+
env: scoped,
|
|
54
|
+
transaction: (work) => withTransaction(() => work()),
|
|
55
|
+
});
|
|
56
|
+
return async () => {
|
|
57
|
+
await queue.stop();
|
|
58
|
+
await rm(state, { recursive: true, force: true });
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
ready: async () => {
|
|
63
|
+
started ??= start();
|
|
64
|
+
await started;
|
|
65
|
+
},
|
|
66
|
+
close: async () => {
|
|
67
|
+
if (started === undefined) return;
|
|
68
|
+
const stop = await started;
|
|
69
|
+
started = undefined;
|
|
70
|
+
await stop();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Which URLs a route rendered only to WEIGH is rendered at. A route with no params is its own
|
|
2
|
+
// path. A route with params is rendered at the paths its own `prerender()` lists — the same list a
|
|
3
|
+
// static route writes files for — and one that lists none is not rendered at all: rendering
|
|
4
|
+
// `/blog/:slug` with an empty `slug` failed the page's own input schema (`X_INPUT_INVALID`), and
|
|
5
|
+
// that was filed as "could not be weighed", a failure of the app dressed as a gap in the build.
|
|
6
|
+
|
|
7
|
+
import type { RouteEntry } from '@ultimat3/render';
|
|
8
|
+
import { enumeratePrerender } from '@ultimat3/render/server';
|
|
9
|
+
import { quoteArg } from './shell-quote';
|
|
10
|
+
import type { UnmeasuredRoute } from './static-report';
|
|
11
|
+
|
|
12
|
+
/** One render the measuring pass performs: the URL path and the params it carries. */
|
|
13
|
+
export interface MeasurePath {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly params: Readonly<Record<string, string>>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `pattern` with each `:name` / `*name` segment filled from `params`. Values are URI-encoded per
|
|
20
|
+
* segment, except a catch-all's, whose slashes are its own.
|
|
21
|
+
*/
|
|
22
|
+
export function fillPath(pattern: string, params: Readonly<Record<string, string>>): string {
|
|
23
|
+
const segments = pattern.split('/').map((segment) => {
|
|
24
|
+
const name = segment.slice(1);
|
|
25
|
+
const value = Object.hasOwn(params, name) ? (params[name] ?? '') : '';
|
|
26
|
+
if (segment.startsWith(':')) return encodeURIComponent(value);
|
|
27
|
+
if (segment.startsWith('*')) return value.split('/').map(encodeURIComponent).join('/');
|
|
28
|
+
return segment;
|
|
29
|
+
});
|
|
30
|
+
return segments.join('/') || '/';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The distinct finding for a dynamic route that gives the build nothing to render it at. */
|
|
34
|
+
export function paramsUndeclared(entry: RouteEntry, listed: boolean): UnmeasuredRoute {
|
|
35
|
+
const keys = entry.pattern.keys;
|
|
36
|
+
const example = keys.map((key) => `${key}: 'a-real-${key}'`).join(', ');
|
|
37
|
+
const cause = listed
|
|
38
|
+
? `${entry.path} has dynamic params and its prerender() listed no path, so the build had nothing to render it at and weighed nothing`
|
|
39
|
+
: `${entry.path} has dynamic params and declares no prerender(), so the build has no path to render it at and weighed nothing`;
|
|
40
|
+
return {
|
|
41
|
+
path: entry.path,
|
|
42
|
+
reason: cause,
|
|
43
|
+
code: 'X_BUDGET_PARAMS_UNDECLARED',
|
|
44
|
+
cause,
|
|
45
|
+
fix: `add prerender: () => [{ ${example} }] to defineRoute in ${quoteArg(entry.file)}, then x build --target static`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The paths to render `entry` at, or the finding that says why there are none. */
|
|
50
|
+
export type MeasurePlan =
|
|
51
|
+
| { readonly paths: readonly MeasurePath[] }
|
|
52
|
+
| { readonly unmeasured: UnmeasuredRoute };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A `render: 'ssr'` route with params: it may not declare `prerender()` (`X_ROUTE_MODE_INVALID`),
|
|
56
|
+
* so no build can hold a value to render it at. Reported as unweighable, never as a finding — there
|
|
57
|
+
* is no edit to the app that a build could then measure.
|
|
58
|
+
*/
|
|
59
|
+
export const perRequestParams = (entry: RouteEntry): UnmeasuredRoute => ({
|
|
60
|
+
path: entry.path,
|
|
61
|
+
reason: `${entry.path} renders per request with params (${entry.pattern.keys.join(', ')}) and render: 'ssr' may not declare prerender(), so no build has a value to render it at — its budget is not enforced by x verify`,
|
|
62
|
+
weighable: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export async function measurePaths(entry: RouteEntry): Promise<MeasurePlan> {
|
|
66
|
+
if (entry.pattern.keys.length === 0) return { paths: [{ path: entry.path, params: {} }] };
|
|
67
|
+
if (entry.config.render === 'ssr') return { unmeasured: perRequestParams(entry) };
|
|
68
|
+
if (entry.config.prerender === undefined) return { unmeasured: paramsUndeclared(entry, false) };
|
|
69
|
+
const sets = await enumeratePrerender(entry);
|
|
70
|
+
if (sets.length === 0) return { unmeasured: paramsUndeclared(entry, true) };
|
|
71
|
+
return {
|
|
72
|
+
paths: sets.map((params) => ({ path: fillPath(entry.pattern.source, params), params })),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The request a measurement render runs inside. `x build` renders every app/ route it cannot
|
|
2
|
+
// prerender only to WEIGH it, and that render reads its data the way a real one does: a `load`
|
|
3
|
+
// calls the app's typed client, over `APP_URL`, as the request's own member. So it needs a request
|
|
4
|
+
// context (`useRequestHeader('cookie')` throws X_NO_REQUEST outside one), an actor the app chose
|
|
5
|
+
// (`defineMeasurementActor()` in `app.config.ts`, core's `measurementActor()` otherwise), and a
|
|
6
|
+
// server to answer — the app's own API pipeline, dispatched IN PROCESS through core's
|
|
7
|
+
// `withInProcessFetch`, because no server is listening during a build (plan 101 slice 11 m).
|
|
8
|
+
|
|
9
|
+
import type { Actor, Ctx } from '@ultimat3/core';
|
|
10
|
+
import { measurementActor, runWithContext, withInProcessFetch } from '@ultimat3/core';
|
|
11
|
+
import {
|
|
12
|
+
createPipeline,
|
|
13
|
+
createRequestContext,
|
|
14
|
+
createRouter,
|
|
15
|
+
defineHttpConfig,
|
|
16
|
+
} from '@ultimat3/http';
|
|
17
|
+
import { apiRoutes } from './api-routes';
|
|
18
|
+
|
|
19
|
+
/** One build's measurement scope: its actor, and a runner that renders inside it. */
|
|
20
|
+
export interface MeasureScope {
|
|
21
|
+
readonly actor: Actor;
|
|
22
|
+
run<T>(fn: () => T): T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The scope for `origin`. The pipeline is the app's API table and nothing else: a page's data
|
|
27
|
+
* comes from its actions and queries, and a route the render reaches that is not one of them
|
|
28
|
+
* answers 404 — which the render reports, and the route is filed unmeasured with that reason.
|
|
29
|
+
*/
|
|
30
|
+
export async function measureScope(input: {
|
|
31
|
+
readonly origin: string;
|
|
32
|
+
readonly buildId: string;
|
|
33
|
+
}): Promise<MeasureScope> {
|
|
34
|
+
const actor = await measurementActor();
|
|
35
|
+
// No rate limit: every request here is this build's own render, and a limiter would need a
|
|
36
|
+
// deployment scope a build does not have (`X_RATE_LIMIT_SCOPE_UNSET`).
|
|
37
|
+
const config = defineHttpConfig({ buildId: input.buildId, rateLimit: { enabled: false } });
|
|
38
|
+
const ctx = createRequestContext({
|
|
39
|
+
url: new URL(input.origin),
|
|
40
|
+
method: 'GET',
|
|
41
|
+
role: 'web',
|
|
42
|
+
config,
|
|
43
|
+
});
|
|
44
|
+
ctx.actor = actor;
|
|
45
|
+
const pipeline = createPipeline({
|
|
46
|
+
table: createRouter(apiRoutes()),
|
|
47
|
+
config,
|
|
48
|
+
hooks: { authenticate: () => actor },
|
|
49
|
+
});
|
|
50
|
+
const dispatch = (url: string, init: RequestInit): Promise<Response> =>
|
|
51
|
+
pipeline.handle(new Request(url, init), { role: 'web' });
|
|
52
|
+
const request: Ctx = ctx;
|
|
53
|
+
return {
|
|
54
|
+
actor,
|
|
55
|
+
run: <T>(fn: () => T): T => withInProcessFetch(dispatch, () => runWithContext(request, fn)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `APP_URL` for the length of `fn`, when the process has none: an app's typed client reads it to
|
|
61
|
+
* name the origin it posts to, and `withInProcessFetch` answers that origin in process. A value
|
|
62
|
+
* the process already carries is left alone — it is the app's.
|
|
63
|
+
*/
|
|
64
|
+
export async function withAppUrl<T>(origin: string, fn: () => Promise<T>): Promise<T> {
|
|
65
|
+
const before = process.env['APP_URL'];
|
|
66
|
+
if (before !== undefined && before !== '') return fn();
|
|
67
|
+
process.env['APP_URL'] = origin;
|
|
68
|
+
try {
|
|
69
|
+
return await fn();
|
|
70
|
+
} finally {
|
|
71
|
+
if (before === undefined) delete process.env['APP_URL'];
|
|
72
|
+
else process.env['APP_URL'] = before;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/messages.ts
CHANGED
|
@@ -69,7 +69,7 @@ const CATALOG = {
|
|
|
69
69
|
'cli.dev.ready': 'dev ready on {url} — /_x mounted ({panels} panels), {services}',
|
|
70
70
|
// The mail and CDN halves of that boot line. Rendered text, so it lives here — while
|
|
71
71
|
// `describeMail`/`describeCdn` keep the same wording as the fixed vocabulary `x dev --json`
|
|
72
|
-
// carries, and `
|
|
72
|
+
// carries, and `runtime-services.test.ts` pins the two together so neither can drift alone.
|
|
73
73
|
'cli.dev.cdn.external': 'cdn=external({driver} via {detail})',
|
|
74
74
|
'cli.dev.cdn.none': 'cdn=none',
|
|
75
75
|
'cli.dev.mail.embedded': 'mail=embedded',
|
|
@@ -204,8 +204,6 @@ const CATALOG = {
|
|
|
204
204
|
'cli.shot.pageError': ' threw {message} {at}',
|
|
205
205
|
'cli.shot.picture': ' picture {path}',
|
|
206
206
|
'cli.shot.verdict': ' verdict {path}',
|
|
207
|
-
'cli.shot.blind.status':
|
|
208
|
-
'HTTP response status is not observed — the port records requests, never responses',
|
|
209
207
|
// `--island`. A component capture reports per STATE, so the summary counts pictures and the
|
|
210
208
|
// lines name one state each; nothing here restates a fact `--json` does not carry.
|
|
211
209
|
'cli.shot.island.ok':
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -81,7 +81,7 @@ export interface MetricsEndpoint {
|
|
|
81
81
|
export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): MetricsEndpoint {
|
|
82
82
|
// Screened here rather than left to `Bun.serve`, which refuses a `NaN` with a bare `RangeError`
|
|
83
83
|
// — no code, no `fix:` — at exactly the boot path the refusal below exists to stop reporting
|
|
84
|
-
// that way. Floor 0, because 0 asks the kernel for a free port and `
|
|
84
|
+
// that way. Floor 0, because 0 asks the kernel for a free port and `role-start.ts` passes it for
|
|
85
85
|
// an ephemeral boot; the ceiling stays Bun's, which names the range it refuses.
|
|
86
86
|
const port = finiteCount('startMetricsEndpoint', 'port', options.port ?? DEFAULT_METRICS_PORT);
|
|
87
87
|
// `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
|
package/src/otlp-export.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
startMetricExport,
|
|
16
16
|
tryOtlpEndpoint,
|
|
17
17
|
} from '@ultimat3/core';
|
|
18
|
-
import type { Env } from './
|
|
18
|
+
import type { Env } from './runtime-bindings';
|
|
19
19
|
|
|
20
20
|
/** How often counters are pushed. Core's own default; named here because the boot chose it. */
|
|
21
21
|
export const METRIC_EXPORT_INTERVAL_MS = 60_000;
|
package/src/output.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface Finding {
|
|
|
13
13
|
readonly docs?: string;
|
|
14
14
|
/** Optional locator: a file, route, or table the finding is about. */
|
|
15
15
|
readonly at?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Where the finding's TEXT came from, when not from this process. `'ci-log'` is `x ci` reading
|
|
18
|
+
* a finding back out of a CI log that anyone who can push a branch writes: its `fix` is fenced
|
|
19
|
+
* and must never be run as this CLI's own instruction.
|
|
20
|
+
*/
|
|
21
|
+
readonly source?: 'ci-log';
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export interface StepResult {
|
package/src/page-sync.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What a page needs to reach the sync node, composed ONCE for `x dev` and the container alike: the
|
|
2
|
+
// worker bundle and the page boot with their routes, the head a document carries (`ultimate-sync`,
|
|
3
|
+
// `ultimate-build`, `ultimate-sync-worker`, the boot script), and the persisted record types a
|
|
4
|
+
// private document names. One call from both boots, so the two cannot serve different targets.
|
|
5
|
+
|
|
6
|
+
import { persistedRecordTypes } from '@ultimat3/entity';
|
|
7
|
+
import type { Route } from '@ultimat3/http';
|
|
8
|
+
import type { ClientSyncHead } from '@ultimat3/render';
|
|
9
|
+
import { syncUrlFrom } from './sync-url';
|
|
10
|
+
import {
|
|
11
|
+
buildPageBoot,
|
|
12
|
+
buildSyncWorker,
|
|
13
|
+
type FrameworkScript,
|
|
14
|
+
pageBootRoutes,
|
|
15
|
+
syncWorkerRoutes,
|
|
16
|
+
} from './worker-bundle';
|
|
17
|
+
|
|
18
|
+
export interface PageSync {
|
|
19
|
+
readonly routes: readonly Route[];
|
|
20
|
+
/** The scripts those routes serve, for the service worker to precache beside the islands. */
|
|
21
|
+
readonly scripts: readonly FrameworkScript[];
|
|
22
|
+
readonly head: ClientSyncHead;
|
|
23
|
+
/**
|
|
24
|
+
* The record types the app persists, read per render off the entity registry — the app's modules
|
|
25
|
+
* register their entities during boot, so a value captured here could predate them.
|
|
26
|
+
*/
|
|
27
|
+
readonly persisted: () => readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Built at boot and never on the watcher tick: the worker is framework code, not the app's, and a
|
|
32
|
+
* worker that changes under tabs already running it is exactly what a source-addressed URL exists
|
|
33
|
+
* to keep from happening mid-session.
|
|
34
|
+
*/
|
|
35
|
+
export async function pageSync(
|
|
36
|
+
root: string,
|
|
37
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
38
|
+
buildId: string,
|
|
39
|
+
): Promise<PageSync> {
|
|
40
|
+
const syncUrl = syncUrlFrom(env);
|
|
41
|
+
const worker = await buildSyncWorker(root);
|
|
42
|
+
const boot = await buildPageBoot(root);
|
|
43
|
+
return {
|
|
44
|
+
routes: [...syncWorkerRoutes(() => worker), ...pageBootRoutes(() => boot)],
|
|
45
|
+
scripts: [worker, boot].filter((script): script is FrameworkScript => script !== undefined),
|
|
46
|
+
head: {
|
|
47
|
+
syncUrl,
|
|
48
|
+
buildId,
|
|
49
|
+
...(worker === undefined ? {} : { workerUrl: worker.url }),
|
|
50
|
+
...(boot === undefined ? {} : { bootUrl: boot.url }),
|
|
51
|
+
},
|
|
52
|
+
persisted: persistedRecordTypes,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The `policy` step's second question: every permission an action, a query or a route REQUIRES is
|
|
2
|
+
// one some role GRANTS. `x g resource customer` declared `customer:read`/`customer:write`, the
|
|
3
|
+
// actions required them, no role held them, and `x verify` was green over an app whose every
|
|
4
|
+
// generated endpoint answered 403 to the dev actor (plan 101 slice 11 a).
|
|
5
|
+
|
|
6
|
+
import { listActions } from '@ultimat3/action';
|
|
7
|
+
import type { Policy } from '@ultimat3/policy';
|
|
8
|
+
import { roleDefinitions, rolesGranting } from '@ultimat3/policy';
|
|
9
|
+
import { listQueries } from '@ultimat3/query';
|
|
10
|
+
import { routeEntries } from '@ultimat3/render';
|
|
11
|
+
import type { Finding } from './output';
|
|
12
|
+
import { quoteArg } from './shell-quote';
|
|
13
|
+
|
|
14
|
+
/** One rule that cannot pass for any actor the role map mints. */
|
|
15
|
+
export interface UngrantedRequirement {
|
|
16
|
+
readonly permission: string;
|
|
17
|
+
/** `action createCustomer`, `query customerList`, or the route file. */
|
|
18
|
+
readonly by: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A rule that REQUIRES exactly one permission: a bare `can()`. A composite is not judged — in an
|
|
23
|
+
* `or(...)` one branch may be granted and the other deliberately not, and a `not(...)` names a
|
|
24
|
+
* permission the actor must LACK — so a finding there would be an argument, not a fact.
|
|
25
|
+
*/
|
|
26
|
+
const required = (policy: Policy<unknown, unknown>): string | undefined =>
|
|
27
|
+
policy.kind === 'permission' && policy.permissions.length === 1
|
|
28
|
+
? policy.permissions[0]
|
|
29
|
+
: undefined;
|
|
30
|
+
|
|
31
|
+
/** Every single-permission rule the app's actions, queries and routes declare. */
|
|
32
|
+
export function requirements(): readonly UngrantedRequirement[] {
|
|
33
|
+
const rules: UngrantedRequirement[] = [];
|
|
34
|
+
for (const target of listActions()) {
|
|
35
|
+
const permission = required(target.policy);
|
|
36
|
+
if (permission !== undefined) rules.push({ permission, by: `action ${target.name}` });
|
|
37
|
+
}
|
|
38
|
+
for (const target of listQueries()) {
|
|
39
|
+
const permission = required(target.policy);
|
|
40
|
+
if (permission !== undefined) rules.push({ permission, by: `query ${target.name}` });
|
|
41
|
+
}
|
|
42
|
+
for (const entry of routeEntries()) {
|
|
43
|
+
const permission = entry.config.policy?.permission;
|
|
44
|
+
if (permission !== undefined) rules.push({ permission, by: entry.file });
|
|
45
|
+
}
|
|
46
|
+
return rules;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The requirements no role grants. `rolesGranting` is `@ultimat3/policy`'s own matcher, wildcards
|
|
51
|
+
* included, so the gate and `can()` agree on what a grant covers. An app that declares no role at
|
|
52
|
+
* all is not judged: it grants through `Actor.permissions` directly (an API key, a service actor),
|
|
53
|
+
* and there is no role map for a grant to be missing from.
|
|
54
|
+
*/
|
|
55
|
+
export function ungrantedRequirements(
|
|
56
|
+
rules: readonly UngrantedRequirement[] = requirements(),
|
|
57
|
+
): readonly UngrantedRequirement[] {
|
|
58
|
+
const roles = roleDefinitions();
|
|
59
|
+
if (Object.keys(roles).length === 0) return [];
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
return rules
|
|
62
|
+
.filter((rule) => rolesGranting(rule.permission, roles).length === 0)
|
|
63
|
+
.filter((rule) => {
|
|
64
|
+
const key = `${rule.permission} ${rule.by}`;
|
|
65
|
+
if (seen.has(key)) return false;
|
|
66
|
+
seen.add(key);
|
|
67
|
+
return true;
|
|
68
|
+
})
|
|
69
|
+
.sort((a, b) =>
|
|
70
|
+
a.permission === b.permission
|
|
71
|
+
? a.by.localeCompare(b.by)
|
|
72
|
+
: a.permission.localeCompare(b.permission),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Where the scaffold keeps the role map — the file `x g policy` and `x g resource` edit. */
|
|
77
|
+
export const ROLES_FILE = 'apps/web/shared/roles.ts';
|
|
78
|
+
|
|
79
|
+
export function ungrantedFinding(rule: UngrantedRequirement): Finding {
|
|
80
|
+
return {
|
|
81
|
+
code: 'X_PERMISSION_UNGRANTED',
|
|
82
|
+
cause: `${rule.by} requires ${quoteArg(rule.permission)}, and no role in the app's role map grants it — no actor a role mints can ever pass`,
|
|
83
|
+
fix: `add '${rule.permission}' to the grants of a role in ${ROLES_FILE}, then x verify --only policy`,
|
|
84
|
+
at: ROLES_FILE,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The export directory is the build's own, and emptied before every build: `prerenderSite` only
|
|
2
|
+
// ever ADDED files, so a route deleted from the app kept shipping its last HTML from `.x/static`.
|
|
3
|
+
// Refused when `out` is the app root or above it — an `rm -rf` there deletes the app.
|
|
4
|
+
|
|
5
|
+
import { rm } from 'node:fs/promises'; // why: Bun has no recursive directory delete.
|
|
6
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
|
|
7
|
+
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/** True when `dir` is `root` itself or one of its ancestors. */
|
|
10
|
+
const containsRoot = (dir: string, root: string): boolean => {
|
|
11
|
+
const up = relative(resolve(dir), resolve(root));
|
|
12
|
+
return up === '' || (up !== '..' && !up.startsWith(`..${sep}`) && !isAbsolute(up));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Empties `out` for a fresh export, or refuses a directory that holds the app. */
|
|
16
|
+
export async function clearPrerenderOut(out: string, root: string): Promise<void> {
|
|
17
|
+
if (containsRoot(out, root)) {
|
|
18
|
+
throw new UltimateError({
|
|
19
|
+
code: 'X_CLI_BAD_FLAG',
|
|
20
|
+
cause: `the static export directory ${resolve(out)} is the app root or holds it, and every build empties its export directory first`,
|
|
21
|
+
fix: 'x build --target static --out .x/static --json',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
await rm(out, { recursive: true, force: true });
|
|
25
|
+
}
|