@ultimat3/cli 21.0.0 → 22.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 +84 -1650
- package/README.md +40 -2
- package/package.json +30 -30
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- 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 +11 -1
- 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 +111 -43
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +55 -36
- 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 +3 -3
- 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 +28 -30
- package/src/error-pages.ts +19 -7
- package/src/errors.ts +14 -0
- 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 -348
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +6 -1
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +15 -8
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +1 -1
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +28 -27
- 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 +23 -17
- 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/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/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} +22 -8
- 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} +39 -16
- 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} +18 -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-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 -347
- 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/sync-url.ts +21 -1
- 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 +49 -12
- package/src/templates/resource.ts +10 -4
- 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 +24 -159
- 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 +10 -7
- package/src/verify-run.ts +11 -1
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +1 -26
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -94
- package/src/cdp-connection.ts +0 -247
- package/src/cdp-e2e-page.ts +0 -180
- package/src/cdp-e2e-session.ts +0 -199
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -193
- package/src/cdp-offline-script.ts +0 -73
- package/src/cdp-pipe.ts +0 -77
- package/src/e2e-app.ts +0 -103
- package/src/e2e-browser-handle.ts +0 -55
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -117
- package/src/e2e-errors.ts +0 -117
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -153
- package/src/e2e-preload.ts +0 -64
- package/src/e2e-probe.ts +0 -23
- package/src/e2e-selection.ts +0 -182
- package/src/e2e-spawn.ts +0 -169
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
- /package/src/{dev-render.ts → runtime-render.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,9 +92,11 @@ 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;
|
|
98
|
+
/** Injected by a test: the settle wait, in place of `DEFAULT_SETTLE_MS` (a real sleep per call). */
|
|
99
|
+
readonly settleMs?: number | undefined;
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
export interface UiCapabilities {
|
|
@@ -122,11 +129,10 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
122
129
|
const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
|
|
123
130
|
const routes = input.routes ?? describeRoutes;
|
|
124
131
|
// One browser per call, sized to the call: the injected driver in a test, `appBrowser` otherwise.
|
|
125
|
-
const driverFor = async (viewport: UiShotInput['viewport']): Promise<
|
|
132
|
+
const driverFor = async (viewport: UiShotInput['viewport']): Promise<ShotDriver> => {
|
|
126
133
|
if (input.driver !== undefined) return input.driver(viewport);
|
|
127
134
|
const { cdpUrl, executablePath } = browser();
|
|
128
135
|
return appBrowser({
|
|
129
|
-
root,
|
|
130
136
|
viewport,
|
|
131
137
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
132
138
|
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
@@ -144,22 +150,22 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
144
150
|
},
|
|
145
151
|
|
|
146
152
|
async shotRoute(shot) {
|
|
147
|
-
assertBudgetedRoute(shot.route, routes());
|
|
153
|
+
const route = assertBudgetedRoute(shot.route, routes());
|
|
148
154
|
const driver = await driverFor(shot.viewport);
|
|
149
155
|
// One directory per (route, viewport, scheme), so two pictures of one route at two widths
|
|
150
156
|
// never overwrite each other and an agent can hold both.
|
|
151
157
|
const outDir = join(
|
|
152
158
|
root,
|
|
153
159
|
SHOT_DIR,
|
|
154
|
-
shotSlug(
|
|
160
|
+
shotSlug(route),
|
|
155
161
|
`${shot.viewport.width}x${shot.viewport.height}-${shot.colorScheme}`,
|
|
156
162
|
);
|
|
157
163
|
const artifacts = await runShot({
|
|
158
|
-
route
|
|
164
|
+
route,
|
|
159
165
|
outDir,
|
|
160
166
|
driver,
|
|
161
167
|
boot,
|
|
162
|
-
settleMs: DEFAULT_SETTLE_MS,
|
|
168
|
+
settleMs: input.settleMs ?? DEFAULT_SETTLE_MS,
|
|
163
169
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
164
170
|
fullPage: shot.fullPage,
|
|
165
171
|
colorScheme: shot.colorScheme,
|
|
@@ -175,13 +181,13 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
175
181
|
async inspectRoute(inspect) {
|
|
176
182
|
// The same gate as `ui.shot`, for the same reason: facts about a draft are facts about the
|
|
177
183
|
// wrong thing.
|
|
178
|
-
assertBudgetedRoute(inspect.route, routes());
|
|
179
|
-
return inspectRoute({ root, boot, driver: driverFor }, inspect);
|
|
184
|
+
const route = assertBudgetedRoute(inspect.route, routes());
|
|
185
|
+
return inspectRoute({ root, boot, driver: driverFor }, { ...inspect, route });
|
|
180
186
|
},
|
|
181
187
|
|
|
182
188
|
async interactRoute(interact) {
|
|
183
|
-
assertBudgetedRoute(interact.route, routes());
|
|
184
|
-
return interactRoute({ root, boot, driver: driverFor }, interact);
|
|
189
|
+
const route = assertBudgetedRoute(interact.route, routes());
|
|
190
|
+
return interactRoute({ root, boot, driver: driverFor }, { ...interact, route });
|
|
185
191
|
},
|
|
186
192
|
// No route gate and no boot: the captures were gated when they were taken, and a diff of two
|
|
187
193
|
// files needs neither a server nor a browser.
|
|
@@ -193,7 +199,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
193
199
|
root,
|
|
194
200
|
island: island.island,
|
|
195
201
|
...(island.state === undefined ? {} : { state: island.state }),
|
|
196
|
-
settleMs: DEFAULT_SETTLE_MS,
|
|
202
|
+
settleMs: input.settleMs ?? DEFAULT_SETTLE_MS,
|
|
197
203
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
198
204
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
199
205
|
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
@@ -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 {
|
|
@@ -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
|
+
}
|