@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
package/src/json-merge.ts
CHANGED
|
@@ -38,3 +38,49 @@ export function mergeJsonDeep(
|
|
|
38
38
|
}
|
|
39
39
|
return { merged, gained };
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/** Code units, never `localeCompare`: the bytes must not move with the machine's ICU. */
|
|
43
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
44
|
+
|
|
45
|
+
const isSorted = (keys: readonly string[]): boolean =>
|
|
46
|
+
keys.every((key, index) => index === 0 || byCodeUnit(keys[index - 1] ?? '', key) <= 0);
|
|
47
|
+
|
|
48
|
+
/** Every key of every level, sorted — the order a generator's own fresh output is written in. */
|
|
49
|
+
export function sortJsonDeep(value: unknown): unknown {
|
|
50
|
+
if (Array.isArray(value)) return value.map(sortJsonDeep);
|
|
51
|
+
if (!isObject(value)) return value;
|
|
52
|
+
return Object.fromEntries(
|
|
53
|
+
Object.keys(value)
|
|
54
|
+
.sort(byCodeUnit)
|
|
55
|
+
.map((key) => [key, sortJsonDeep(value[key])]),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `mergeJsonDeep`, for a file that is ALREADY ON DISK: the same union, written back in the order
|
|
61
|
+
* the file is in. A catalog a team keeps grouped by screen was re-sorted wholesale by a merge
|
|
62
|
+
* that sorted every level — 424 changed lines for 22 added keys — which buries the keys a run
|
|
63
|
+
* added and turns every generator run into a merge conflict.
|
|
64
|
+
*
|
|
65
|
+
* Per level: a held key stays where it is. A new key takes its SORTED place when that level is
|
|
66
|
+
* sorted already — which is every level a generator wrote, so two runs in either order still write
|
|
67
|
+
* the same bytes — and goes to the END, in sorted order among the new ones, when it is not. A
|
|
68
|
+
* wholly new subtree is the generator's own and arrives sorted.
|
|
69
|
+
*/
|
|
70
|
+
export function mergeJsonInPlace(held: JsonObject, incoming: JsonObject): JsonObject {
|
|
71
|
+
const heldKeys = Object.keys(held);
|
|
72
|
+
const added = Object.keys(incoming)
|
|
73
|
+
.filter((key) => !Object.hasOwn(held, key))
|
|
74
|
+
.sort(byCodeUnit);
|
|
75
|
+
const order = isSorted(heldKeys)
|
|
76
|
+
? [...heldKeys, ...added].sort(byCodeUnit)
|
|
77
|
+
: [...heldKeys, ...added];
|
|
78
|
+
return Object.fromEntries(
|
|
79
|
+
order.map((key) => {
|
|
80
|
+
if (!Object.hasOwn(held, key)) return [key, sortJsonDeep(incoming[key])];
|
|
81
|
+
const current = held[key];
|
|
82
|
+
const next = incoming[key];
|
|
83
|
+
return [key, isObject(current) && isObject(next) ? mergeJsonInPlace(current, next) : current];
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Whether a hostname is this machine: `localhost`, a `*.localhost` name (RFC 6761 reserves both
|
|
2
|
+
// to loopback) or a loopback address literal. One answer for `x dev`'s Host check and `x doctor`'s
|
|
3
|
+
// APP_URL rule, which ask the same question about two different headers.
|
|
4
|
+
|
|
5
|
+
import { classifyAddress } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** `hostname` as `URL` spells it — `[::1]` bracketed, no port. */
|
|
8
|
+
export const isLoopbackHostname = (hostname: string): boolean => {
|
|
9
|
+
const name = hostname.toLowerCase();
|
|
10
|
+
return (
|
|
11
|
+
name === 'localhost' || name.endsWith('.localhost') || classifyAddress(name) === 'loopback'
|
|
12
|
+
);
|
|
13
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Do the committed generated files still describe the code? ONE check, asked by `x manifest
|
|
2
|
+
// --check` and by the gate's `manifest` step through this function — two commands answering
|
|
3
|
+
// differently about one file is itself the drift these files exist to prevent.
|
|
4
|
+
|
|
5
|
+
// why: Bun has no synchronous existence probe, and `hasCommittedContract` guards an app load.
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
// why: Bun exposes no path-join primitive; Bun.file takes a path already joined.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
10
|
+
import type { Manifest } from '@ultimat3/manifest';
|
|
11
|
+
import { assertNoDrift, MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
12
|
+
import { OPENAPI_FILE, openApiStaleness } from './app-openapi';
|
|
13
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
14
|
+
import type { Finding } from './output';
|
|
15
|
+
import { findingFrom } from './output';
|
|
16
|
+
|
|
17
|
+
/** Whether there is any committed contract here to judge — the cheap question, asked first. */
|
|
18
|
+
export const hasCommittedContract = (root: string): boolean =>
|
|
19
|
+
existsSync(join(root, MANIFEST_FILENAME)) || existsSync(join(root, OPENAPI_FILE));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `x.manifest.json` was never written. An app root only: the framework monorepo emits
|
|
23
|
+
* `framework.manifest.json` and has no `x.manifest.json` to be missing. `AGENTS.md` tells an agent
|
|
24
|
+
* that facts live in this file, and after `x new` and every generator nothing had run the one
|
|
25
|
+
* command that writes it while the gate reported green (#F7).
|
|
26
|
+
*/
|
|
27
|
+
export function manifestMissingFindings(root: string): readonly Finding[] {
|
|
28
|
+
if (!existsSync(join(root, APP_CONFIG_FILE))) return [];
|
|
29
|
+
if (existsSync(join(root, MANIFEST_FILENAME))) return [];
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
code: 'X_MANIFEST_MISSING',
|
|
33
|
+
cause: `${MANIFEST_FILENAME} does not exist, so every fact an agent reads about this app — route table, action schemas, policies, error codes — is unavailable`,
|
|
34
|
+
fix: 'x manifest',
|
|
35
|
+
docs: ERROR_DOCS_URL,
|
|
36
|
+
at: MANIFEST_FILENAME,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every way the committed files disagree with `manifest`, the app as the code declares it now:
|
|
43
|
+
* `x.manifest.json` missing (`X_MANIFEST_MISSING`) or drifted (`X_MANIFEST_DRIFT` — a body that no
|
|
44
|
+
* longer hashes to its own `buildId` included), and `openapi.json` or a bearer mount's document
|
|
45
|
+
* stale (`X_MANIFEST_STALE`). The spec is judged whether or not the manifest is there: it is a
|
|
46
|
+
* published contract on its own, and the typed client is generated from it.
|
|
47
|
+
*/
|
|
48
|
+
export async function manifestStaleness(
|
|
49
|
+
root: string,
|
|
50
|
+
manifest: Manifest,
|
|
51
|
+
): Promise<readonly Finding[]> {
|
|
52
|
+
const path = join(root, MANIFEST_FILENAME);
|
|
53
|
+
const findings: Finding[] = [...manifestMissingFindings(root)];
|
|
54
|
+
if (existsSync(path)) {
|
|
55
|
+
try {
|
|
56
|
+
await assertNoDrift({ manifest, path });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
findings.push({ ...findingFrom(error), at: MANIFEST_FILENAME });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...findings, ...(await openApiStaleness(root, manifest))];
|
|
62
|
+
}
|
package/src/mcp-errors.ts
CHANGED
|
@@ -43,7 +43,17 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
43
43
|
X_VERIFY_MERGE_INCOMPLETE:
|
|
44
44
|
'x verify merge parts/*.json --json # after every CI job uploaded its part',
|
|
45
45
|
X_VERIFY_MERGE_INPUT: 'x verify --only unit --json > part.json # one document per part',
|
|
46
|
-
|
|
46
|
+
X_VERIFY_STEP_TIMEOUT:
|
|
47
|
+
'x verify --only unit --json # reproduce the step alone; one that needs longer states it under "stepTimeoutMs" in x.verify.json',
|
|
48
|
+
X_COVERAGE_BELOW_FLOOR:
|
|
49
|
+
'x verify --only unit --json # names the ten files losing the most lines; cover the worst with a test beside it',
|
|
50
|
+
X_COVERAGE_FLOOR_UNSTATED:
|
|
51
|
+
'x verify --only unit --json # the finding carries the measured numbers and the "coverage" line to add to x.verify.json',
|
|
52
|
+
X_COVERAGE_FLOOR_STALE:
|
|
53
|
+
'x verify --only unit --json # the finding carries the measured numbers; raise "coverage" in x.verify.json to them',
|
|
54
|
+
// Unthrown since `--worker` was deleted, and kept only because this record is typed over every
|
|
55
|
+
// registered code: `--shard i/n` is the one way to rerun a slice.
|
|
56
|
+
X_TEST_SHARD_FAILED: 'x verify --only unit --shard 1/2 --json',
|
|
47
57
|
X_SCAFFOLD_PATH_ESCAPE: 'x g route posts --json # a path with no ".." segment',
|
|
48
58
|
X_GENERATE_JSON_INVALID:
|
|
49
59
|
'bun test packages/cli/src/cmd-generate.test.ts # the error names the template to fix',
|
|
@@ -110,8 +120,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
110
120
|
'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
|
|
111
121
|
X_ERROR_CODE_UNRESOLVED:
|
|
112
122
|
'x verify --json # the finding names the file, the line and the name it could not resolve',
|
|
113
|
-
X_ERROR_CODE_UNTHROWN:
|
|
114
|
-
'x errors explain X_ERROR_CODE_UNTHROWN --json # then mark the row "registered, thrown by nothing since <version>"',
|
|
123
|
+
X_ERROR_CODE_UNTHROWN: 'x errors explain X_ERROR_CODE_UNTHROWN --json',
|
|
115
124
|
X_CLI_UNEXPECTED: 'x doctor --json',
|
|
116
125
|
// `-p .`: an app root has no `references`, so the gate's own step runs this form (#450).
|
|
117
126
|
X_TYPECHECK_FAILED: 'bunx tsc -p . --pretty false',
|
|
@@ -233,18 +242,30 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
233
242
|
X_GUARD_FAILED: 'x verify --json # the cause carries the throw the guard raised, verbatim',
|
|
234
243
|
X_GUARD_FINDING_INVALID:
|
|
235
244
|
'x verify --json # then give the finding an X_ code, a cause and a fix naming a command',
|
|
245
|
+
// The finding's own `fix:` is this command plus the edit — the line, and what replaces the call.
|
|
246
|
+
X_BROWSER_TRANSPORT_BYPASS:
|
|
247
|
+
'x verify --only boundaries --json # each finding names the line and the transport to use instead',
|
|
248
|
+
X_BROWSER_SERVER_BARREL:
|
|
249
|
+
'x verify --only boundaries --json # each finding names the import and the browser entry to import instead',
|
|
236
250
|
// `EDITOR=` inline rather than `export`: the variable is only needed for the one invocation, and
|
|
237
251
|
// an agent copying this line gets a working command instead of a shell it has to keep.
|
|
238
252
|
X_SECRETS_EDITOR_MISSING: 'EDITOR=nano x secrets edit',
|
|
239
253
|
X_SECRETS_EDIT_FAILED: 'x secrets show --json # then re-open the buffer: x secrets edit',
|
|
254
|
+
X_SEAL_RESEAL_PENDING:
|
|
255
|
+
'x doctor --json # names the sealed columns and the retired key; re-seal with a backfill(), then: x secrets rotate --drop <keyId>',
|
|
240
256
|
// Render's code, thrown here by the bundler half: the cause names the specifier and the file it
|
|
241
257
|
// resolved to, and `x g island` is what puts that file where the page already says it is.
|
|
242
258
|
X_ISLAND_INVALID: 'x routes --json # the cause names the src; then: x g island <name>',
|
|
243
259
|
// The four slice-11 codes: generated code that passed the gate and failed at runtime.
|
|
260
|
+
// The role map's file is the app's: the finding's `at` names it, so this line never guesses one.
|
|
244
261
|
X_PERMISSION_UNGRANTED:
|
|
245
|
-
'x verify --only policy --json # then add the permission to a role in
|
|
262
|
+
'x verify --only policy --json # then add the permission to a role in the file the finding names (at)',
|
|
246
263
|
X_JOB_UNREGISTERED:
|
|
247
264
|
'x manifest --json # after listing the module under jobs: [...] in apps/web/api/index.ts',
|
|
265
|
+
// The boot's twin of the line above: the worker or scheduler loaded only what the API index
|
|
266
|
+
// imports, and the manifest names a job or task that load did not register.
|
|
267
|
+
X_ROLE_LOAD_INCOMPLETE:
|
|
268
|
+
'x manifest --json # after importing the module the cause names from apps/web/api/index.ts',
|
|
248
269
|
X_FEATURE_UNKNOWN:
|
|
249
270
|
'x entities --json # then x g resource <feature> for the one the cause names',
|
|
250
271
|
X_BUDGET_PARAMS_UNDECLARED:
|
|
@@ -256,6 +277,22 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
256
277
|
X_DEV_RESTART_REQUIRED:
|
|
257
278
|
'x dev --json # a fresh process; the supervised x dev restarts itself on such a save',
|
|
258
279
|
X_DEV_ROOT_GONE: 'x dev --json # from the app root, once it exists again',
|
|
280
|
+
X_DB_HANDLE_UNREGISTERED:
|
|
281
|
+
'x g entity widget --dry-run --json # lists the files; the finding names the two lines to add to packages/db/src/client.ts',
|
|
282
|
+
X_ADMIN_RESOURCE_UNWIRED:
|
|
283
|
+
'x g resource widget --admin --dry-run --json # lists the files; the finding names the two lines to add to apps/admin/app/admin/admin.ts',
|
|
284
|
+
X_IMAGE_NOT_PREBUILT:
|
|
285
|
+
'edit docker/Dockerfile: add RUN bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt after COPY . . in the runtime stage, then rebuild the image',
|
|
286
|
+
X_ADMIN_UNSCANNED:
|
|
287
|
+
'git mv apps/admin/src/index.ts apps/admin/app/admin/admin.ts # then repoint its relative imports and x verify --only manifest --json',
|
|
288
|
+
X_FRAMEWORK_TABLE_ORPHANED: 'x doctor --json',
|
|
289
|
+
X_BUILD_OUT_UNSAFE: 'x build --target static --out .x/static --json',
|
|
290
|
+
X_FRAMEWORK_SCHEMA_UNAPPLIED: 'x db migrate --json',
|
|
291
|
+
X_APP_URL_PORT_MISMATCH: 'export APP_URL=http://localhost:3000',
|
|
292
|
+
X_DEV_HOST_REFUSED: "curl -sS -H 'accept: application/json' http://localhost:3000/_x",
|
|
293
|
+
X_TRUSTED_PROXY_HOPS_INVALID: 'TRUSTED_PROXY_HOPS=1 ROLE=web bun apps/web/server.ts',
|
|
294
|
+
X_PERMISSION_BORROWED: 'x verify --only policy --json',
|
|
295
|
+
X_ERROR_CODE_UNTHROWN_STALE: 'x errors explain X_ERROR_CODE_UNTHROWN_STALE --json',
|
|
259
296
|
};
|
|
260
297
|
|
|
261
298
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/mcp-host.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
// or prints inside a `fix:` an operator runs — the dev log, a per-role log, the committed
|
|
8
8
|
// manifest — and string concatenation would answer a different path on a Windows checkout.
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
agentActor,
|
|
12
|
+
isUltimateError,
|
|
13
|
+
NotImplementedError,
|
|
14
|
+
renderThrowable,
|
|
15
|
+
UltimateError,
|
|
16
|
+
} from '@ultimat3/core';
|
|
11
17
|
import type { DbClient } from '@ultimat3/db';
|
|
12
18
|
import {
|
|
13
19
|
ensureReadOnlyRole,
|
|
@@ -35,12 +41,11 @@ import { appManifest, policyFacts } from './app-manifest';
|
|
|
35
41
|
import { runVerify, VERIFY_STEPS } from './cmd-verify';
|
|
36
42
|
import { declareDevEnvironment } from './dev-environment';
|
|
37
43
|
import { loadCodeFixes } from './error-fixes';
|
|
38
|
-
import { BadFlagError
|
|
44
|
+
import { BadFlagError } from './errors';
|
|
39
45
|
import type { Runner } from './exec';
|
|
40
|
-
import { execOutput } from './exec';
|
|
41
46
|
import { databaseTarget } from './mcp-db-target';
|
|
42
47
|
import { explainErrorCode } from './mcp-errors';
|
|
43
|
-
import {
|
|
48
|
+
import { testRunOf } from './mcp-test-run';
|
|
44
49
|
import { type UiCapabilities, uiCapabilities } from './mcp-ui';
|
|
45
50
|
import { readMigrations } from './migrations';
|
|
46
51
|
import { retryMemo } from './retry-memo';
|
|
@@ -107,8 +112,9 @@ export function lazyServices(input: DevHostInput): LazyServices {
|
|
|
107
112
|
// A boot started after close() would hold the PGlite data directory for the life of the
|
|
108
113
|
// process with nobody left to stop it — the host is closed, so the call is the bug.
|
|
109
114
|
if (closed) {
|
|
110
|
-
throw new
|
|
111
|
-
|
|
115
|
+
throw new NotImplementedError({
|
|
116
|
+
cause:
|
|
117
|
+
'an MCP tool that needs the database after the host closed is not implemented in this build',
|
|
112
118
|
fix: 'x mcp serve --transport stdio # keep the host open for the whole session',
|
|
113
119
|
});
|
|
114
120
|
}
|
|
@@ -271,7 +277,8 @@ function capabilities(
|
|
|
271
277
|
cwd: root,
|
|
272
278
|
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
273
279
|
});
|
|
274
|
-
|
|
280
|
+
// The whole result, exit code included: `0 fail` beside `1 error` is a red run.
|
|
281
|
+
return testRunOf(result);
|
|
275
282
|
},
|
|
276
283
|
|
|
277
284
|
async tailLogs(lines: number, role: string | undefined) {
|
|
@@ -281,8 +288,8 @@ function capabilities(
|
|
|
281
288
|
if (!(await file.exists())) {
|
|
282
289
|
// `@ultimat3/core`'s `logger` is a module const with no sink seam, so there is nothing to
|
|
283
290
|
// intercept in-process — a log on disk is the only honest source, and the fix creates one.
|
|
284
|
-
throw new
|
|
285
|
-
|
|
291
|
+
throw new NotImplementedError({
|
|
292
|
+
cause: `logs.tail without ${path} is not implemented in this build`,
|
|
286
293
|
fix:
|
|
287
294
|
role === undefined
|
|
288
295
|
? `x dev > ${path} 2>&1`
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `test.run`'s answer from the whole `bun test` result, not from its pass/fail lines alone: bun
|
|
2
|
+
// counts an error raised OUTSIDE any test on a line of its own (`1 error`) beside `0 fail`, and
|
|
3
|
+
// says the run failed only through its exit code.
|
|
4
|
+
|
|
5
|
+
import type { TestRun } from '@ultimat3/mcp';
|
|
6
|
+
import type { ExecResult } from './exec';
|
|
7
|
+
import { execOutput } from './exec';
|
|
8
|
+
import { parseBunTest } from './mcp-test-output';
|
|
9
|
+
|
|
10
|
+
/** Bun's own `N error` / `N errors` summary line: the last one, as `parseBunTest` reads the rest. */
|
|
11
|
+
export const errorsIn = (output: string): number => {
|
|
12
|
+
const last = [...output.matchAll(/^\s*(\d+)\s+errors?\s*$/gm)].at(-1);
|
|
13
|
+
return last === undefined ? 0 : Number.parseInt(last[1] ?? '0', 10);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every error outside a test is one failure, carried with the run's own message; a non-zero exit
|
|
18
|
+
* with nothing else counted is one more, so a red process can never answer `failed: 0`.
|
|
19
|
+
*/
|
|
20
|
+
export function testRunOf(result: ExecResult): TestRun {
|
|
21
|
+
const output = execOutput(result);
|
|
22
|
+
const run = parseBunTest(output, result.durationMs);
|
|
23
|
+
const errors = errorsIn(output);
|
|
24
|
+
const outside = Array.from({ length: errors }, () => ({
|
|
25
|
+
test: 'bun test',
|
|
26
|
+
message: 'an error outside any test — a file that threw while loading, or a failed hook',
|
|
27
|
+
}));
|
|
28
|
+
const counted = run.failed + errors;
|
|
29
|
+
const exitOnly =
|
|
30
|
+
result.code !== 0 && counted === 0
|
|
31
|
+
? [{ test: 'bun test', message: `bun test exited ${String(result.code)} with no failure` }]
|
|
32
|
+
: [];
|
|
33
|
+
return {
|
|
34
|
+
...run,
|
|
35
|
+
failed: counted + exitOnly.length,
|
|
36
|
+
failures: [...run.failures, ...outside, ...exitOnly],
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/messages.ts
CHANGED
|
@@ -50,12 +50,21 @@ const CATALOG = {
|
|
|
50
50
|
'cli.db.branch.unknown': '-',
|
|
51
51
|
'cli.db.gen.failed': 'migration not generated',
|
|
52
52
|
'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
|
|
53
|
+
'cli.db.gen.blocked':
|
|
54
|
+
'nothing was compared — the app did not load ({count} finding(s)); fix them, then run x db gen again',
|
|
53
55
|
// A THIRD outcome, and it is neither of the other two: nothing to generate, but the sidecar the
|
|
54
56
|
// `drift` step reads did move — an edit under `packages/db/src` that implies no DDL. Rendering it
|
|
55
57
|
// as `written` would name a migration nobody can apply; as `unchanged`, it would hide a file this
|
|
56
58
|
// command wrote. `GeneratedFiles.outcome` is what `--json` carries the same distinction on.
|
|
57
59
|
'cli.db.gen.recorded': 'no migration needed — schema hash re-recorded in {file}',
|
|
60
|
+
// The fourth: no DDL and no sidecar moved, but `packages/db/schema/` did — a hand edit repaired,
|
|
61
|
+
// or a framework table that changed under an upgrade. `unchanged` would deny a write the lines
|
|
62
|
+
// below it list, and this command is `X_SCHEMA_DUMP_DRIFT`'s whole `fix:`.
|
|
63
|
+
'cli.db.gen.dumped': 'no migration needed — schema dump rewritten ({count} file(s))',
|
|
58
64
|
'cli.db.gen.written': 'migration {id} generated',
|
|
65
|
+
// Exit 1 and a migration on disk at once: the summary carries the half the exit code hides.
|
|
66
|
+
'cli.db.gen.writtenDumpFailed':
|
|
67
|
+
'migration {id} generated and written — the schema dump was not; do not generate it again',
|
|
59
68
|
'cli.db.migrate.applied': 'migrations applied',
|
|
60
69
|
'cli.db.migrate.failed': 'migration failed',
|
|
61
70
|
'cli.db.reset.done': 'database reset and migrated',
|
|
@@ -88,6 +97,11 @@ const CATALOG = {
|
|
|
88
97
|
'cli.deploy.plan': 'containers only: {images} image, roles {roles}',
|
|
89
98
|
'cli.doctor.clean': 'no findings — environment is shippable',
|
|
90
99
|
'cli.doctor.findings': '{count} finding(s)',
|
|
100
|
+
'cli.doctor.guards':
|
|
101
|
+
'{count} shipped guard(s) this app does not hold — each line adds one, then x verify reports what it finds:',
|
|
102
|
+
// `x doctor`'s other listing: what the coverage floor in x.verify.json does not see, and why.
|
|
103
|
+
'cli.doctor.coverageExclude':
|
|
104
|
+
'{count} path(s) x.verify.json excludes from the coverage floor — each with the reason it gives:',
|
|
91
105
|
'cli.docs.code': '{code} is an error code — x errors explain answers it',
|
|
92
106
|
'cli.docs.exports': 'exports: {list}',
|
|
93
107
|
'cli.docs.installed': 'installed: {list}',
|
|
@@ -110,6 +124,8 @@ const CATALOG = {
|
|
|
110
124
|
'cli.fix.plan':
|
|
111
125
|
'{count} boundary violation(s) involve {file} — {edits} edit(s) to make, nothing written',
|
|
112
126
|
'cli.generate.wrote': 'wrote {count} file(s) for {kind} {name}',
|
|
127
|
+
// The run that declared a table: `{next}` is commands, joined with `&&`, that run as printed.
|
|
128
|
+
'cli.generate.wroteNext': 'wrote {count} file(s) for {kind} {name} — next: {next}',
|
|
113
129
|
// A distinct key, not the same sentence with a flag beside it: `--dry-run` reported "wrote 4
|
|
114
130
|
// file(s)" while `data.dryRun` said nothing had landed, so an agent branching on `summary`
|
|
115
131
|
// believed the files were on disk.
|
|
@@ -131,7 +147,13 @@ const CATALOG = {
|
|
|
131
147
|
'cli.jobs.drainedPartial':
|
|
132
148
|
'drained {count} job(s) from {from} to {to} — {skipped} left on {from}',
|
|
133
149
|
'cli.jobs.listed': '{count} job(s)',
|
|
150
|
+
'cli.jobs.nextPage': 'more — next page: x jobs ls --after {cursor}',
|
|
134
151
|
'cli.jobs.noError': 'no error recorded',
|
|
152
|
+
'cli.jobs.paused': 'queue {queue} paused — no worker claims from it; enqueues still land',
|
|
153
|
+
'cli.jobs.pausedQueues': 'paused queue(s): {queues}',
|
|
154
|
+
'cli.jobs.promoted': 'job {id} promoted — due now',
|
|
155
|
+
'cli.jobs.removed': 'job {id} removed — it was {state}',
|
|
156
|
+
'cli.jobs.resumed': 'queue {queue} resumed',
|
|
135
157
|
'cli.jobs.retried': 'job {id} re-queued — {state}',
|
|
136
158
|
'cli.jobs.shown': 'job {id} — {state}, attempt {attempt} of {attempts}',
|
|
137
159
|
'cli.jobs.skipped': '{count} job(s) left on {from} — re-run the drain once each is claimable:',
|
|
@@ -175,6 +197,8 @@ const CATALOG = {
|
|
|
175
197
|
'cli.registry.described': '{kind} {name}',
|
|
176
198
|
'cli.routes.count': '{count} routes',
|
|
177
199
|
'cli.routes.empty': 'no routes in the manifest — run `x manifest` first',
|
|
200
|
+
'cli.auth.sealed':
|
|
201
|
+
'sealed {sealed} second-factor secret(s); {already} already sealed, {skipped} changed mid-run and left alone',
|
|
178
202
|
'cli.tasks.count': '{count} task(s)',
|
|
179
203
|
'cli.tasks.shown': '{name} — {cron} ({tz}), next {next}',
|
|
180
204
|
'cli.affected.count':
|
|
@@ -296,6 +320,12 @@ const CATALOG = {
|
|
|
296
320
|
// no skipped test to read a reason off, so the files the step selected hold no test.
|
|
297
321
|
'cli.verify.ranNothing': 'found no test to run in the file(s) it selected',
|
|
298
322
|
'cli.verify.merged': 'merged {parts} part(s) — {summary}',
|
|
323
|
+
// The `unit` step's own measurement, in `output`: what the suite covered of the app's whole
|
|
324
|
+
// source tree, beside the floor it was held to. A shard says it measured a slice instead.
|
|
325
|
+
'cli.verify.coverage':
|
|
326
|
+
'coverage: {lines}% of lines, {funcs}% of functions over {files} source file(s) — floor {floor}',
|
|
327
|
+
'cli.verify.coverageDeferred':
|
|
328
|
+
'coverage: this shard measured its own slice — x verify merge folds every shard and judges the floor',
|
|
299
329
|
'cli.verify.serial': 'serial',
|
|
300
330
|
'cli.verify.workers': '{workers} workers',
|
|
301
331
|
'cli.env.checked': '{count} declared variable(s), all present and valid',
|
|
@@ -314,6 +344,11 @@ const CATALOG = {
|
|
|
314
344
|
'cli.secrets.set': 'sealed {name} into {path} — {count} secret(s)',
|
|
315
345
|
'cli.secrets.rotated':
|
|
316
346
|
'rotated {path} from master key {from} to {to} — {count} secret(s) resealed',
|
|
347
|
+
'cli.secrets.retired':
|
|
348
|
+
' master key {kid} is retired, not gone: values it sealed still open — once they are re-sealed, x secrets rotate --drop {kid}',
|
|
349
|
+
'cli.secrets.dropped':
|
|
350
|
+
'dropped retired master key {kid} from {path} — {count} retired key(s) still declared',
|
|
351
|
+
'cli.secrets.ring': 'retired master key(s) that still open sealed values: {kids}',
|
|
317
352
|
} as const;
|
|
318
353
|
|
|
319
354
|
export type MessageKey = keyof typeof CATALOG;
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// Single responsibility: the scrape listener every role opens. `@ultimat3/core` declares the
|
|
2
2
|
// series and renders the body; this is the one place a process answers `METRICS_PATH` with it, so
|
|
3
|
-
// `docker/helm`'s HPAs read a number instead of `<unknown>`.
|
|
3
|
+
// `docker/helm`'s HPAs read a number instead of `<unknown>`. Opening it starts the process's own
|
|
4
|
+
// series (`process_*`): a role that can be scraped reports what it costs.
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
finiteCount,
|
|
8
|
+
type HealthPayload,
|
|
9
|
+
healthzPayload,
|
|
7
10
|
logger,
|
|
8
11
|
METRICS_CONTENT_TYPE,
|
|
9
12
|
METRICS_PATH,
|
|
10
13
|
markListening,
|
|
11
14
|
metricsText,
|
|
15
|
+
readyzPayload,
|
|
16
|
+
startProcessMetrics,
|
|
12
17
|
stringField,
|
|
13
18
|
UltimateError,
|
|
14
19
|
} from '@ultimat3/core';
|
|
@@ -64,6 +69,55 @@ export interface MetricsEndpointOptions {
|
|
|
64
69
|
readonly port?: number;
|
|
65
70
|
/** A container must bind every interface; a laptop must not. Same decision as the web role. */
|
|
66
71
|
readonly hostname?: string;
|
|
72
|
+
/**
|
|
73
|
+
* The `process_info{role}` label: `ROLE` in a container, the roles `x dev` runs joined by `+`.
|
|
74
|
+
* A host that opens the listener itself and names none is labelled `unknown`.
|
|
75
|
+
*/
|
|
76
|
+
readonly role?: string;
|
|
77
|
+
/**
|
|
78
|
+
* What a taken port means. `refuse` (the default, and every container's): `X_PORT_IN_USE` —
|
|
79
|
+
* Prometheus is configured against the declared port, so listening anywhere else is a scrape
|
|
80
|
+
* that silently finds nothing. `free`: bind whichever port the kernel hands out and say which —
|
|
81
|
+
* `x dev` with no `METRICS_PORT`, where two apps on one laptop both default to 9090.
|
|
82
|
+
*/
|
|
83
|
+
readonly whenTaken?: 'refuse' | 'free';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Which answer a boot gives. A laptop that named no port takes a free one when 9090 is another
|
|
88
|
+
* app's; a declared `METRICS_PORT` — and every container — is the port a scraper was told, so it
|
|
89
|
+
* refuses rather than listen where nothing will look.
|
|
90
|
+
*/
|
|
91
|
+
export const whenMetricsPortTaken = (
|
|
92
|
+
dev: boolean,
|
|
93
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
94
|
+
): 'refuse' | 'free' => {
|
|
95
|
+
const declared = env['METRICS_PORT'];
|
|
96
|
+
return dev && (declared === undefined || declared.trim().length === 0) ? 'free' : 'refuse';
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The bind and its one retry. `bind` is `Bun.serve` behind a seam: whether a kernel refuses a
|
|
101
|
+
* second bind is the OS's answer, and the rule here is testable without racing a socket for it.
|
|
102
|
+
* Port 0 is never retried — the kernel chose it, so a refusal there is not a collision.
|
|
103
|
+
*/
|
|
104
|
+
export function bindScrapePort<T>(
|
|
105
|
+
port: number,
|
|
106
|
+
whenTaken: 'refuse' | 'free',
|
|
107
|
+
bind: (port: number) => T,
|
|
108
|
+
): T {
|
|
109
|
+
try {
|
|
110
|
+
return bind(port);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!isAddressInUse(error)) throw error;
|
|
113
|
+
if (whenTaken === 'refuse' || port === 0) throw new MetricsPortInUseError({ port });
|
|
114
|
+
logger.warn('ultimate metrics port taken', {
|
|
115
|
+
port,
|
|
116
|
+
cause: `the metrics port ${String(port)} is already bound — another x dev, or a Prometheus — so this process listens on a free one`,
|
|
117
|
+
fix: `METRICS_PORT=${String(neighbouringPort(port))} x dev --json`,
|
|
118
|
+
});
|
|
119
|
+
return bind(0);
|
|
120
|
+
}
|
|
67
121
|
}
|
|
68
122
|
|
|
69
123
|
export interface MetricsEndpoint {
|
|
@@ -72,6 +126,18 @@ export interface MetricsEndpoint {
|
|
|
72
126
|
stop(): void;
|
|
73
127
|
}
|
|
74
128
|
|
|
129
|
+
/**
|
|
130
|
+
* `/healthz` and `/readyz` for the roles whose only socket this is — `worker`, `scheduler`,
|
|
131
|
+
* `replicator` — so the replicator's readiness check (`role-replicator.ts`) is read by a probe.
|
|
132
|
+
* The verdict only, never the report: this port is unauthenticated, and check names are topology.
|
|
133
|
+
*/
|
|
134
|
+
function healthResponse(payload: HealthPayload, role: string): Response {
|
|
135
|
+
return Response.json(
|
|
136
|
+
{ state: payload.body.state, ready: payload.body.ready, role },
|
|
137
|
+
{ status: payload.status, headers: { 'cache-control': 'no-store' } },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
75
141
|
/**
|
|
76
142
|
* Answers outside the request pipeline, exactly as `/healthz` and `/readyz` do in
|
|
77
143
|
* `@ultimat3/http`'s `server.ts`: no auth, no rate limit, no locale negotiation. A saturated or
|
|
@@ -86,29 +152,33 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
|
|
|
86
152
|
const port = finiteCount('startMetricsEndpoint', 'port', options.port ?? DEFAULT_METRICS_PORT);
|
|
87
153
|
// `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
|
|
88
154
|
// second `x dev` on one machine reported: no code, no fix, at the boot path this package owns.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
155
|
+
const server = bindScrapePort(port, options.whenTaken ?? 'refuse', (candidate) =>
|
|
156
|
+
Bun.serve({
|
|
157
|
+
port: candidate,
|
|
158
|
+
hostname: options.hostname ?? 'localhost',
|
|
159
|
+
fetch(request: Request): Response {
|
|
160
|
+
const url = new URL(request.url);
|
|
161
|
+
const role = options.role ?? 'unknown';
|
|
162
|
+
if (url.pathname === '/healthz') return healthResponse(healthzPayload(), role);
|
|
163
|
+
if (url.pathname === '/readyz') {
|
|
164
|
+
return healthResponse(
|
|
165
|
+
readyzPayload({ deep: url.searchParams.get('deep') === '1' }),
|
|
166
|
+
role,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (url.pathname !== METRICS_PATH) {
|
|
170
|
+
return new Response('not found', { status: 404 });
|
|
171
|
+
}
|
|
172
|
+
// `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
|
|
173
|
+
// steal each other's samples — but a cache would hand the second one a stale window.
|
|
174
|
+
return new Response(metricsText(), {
|
|
175
|
+
headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
// After the bind: a port that was taken starts no sampler nobody would stop.
|
|
181
|
+
const stopProcessMetrics = startProcessMetrics({ role: options.role ?? 'unknown' });
|
|
112
182
|
// Same rule as every other socket the framework opens: announce it, so a request back to it is
|
|
113
183
|
// recognisably this process calling itself rather than egress the test seal must refuse.
|
|
114
184
|
const stopListening = markListening(server.url.origin);
|
|
@@ -118,6 +188,7 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
|
|
|
118
188
|
stop(): void {
|
|
119
189
|
server.stop(true);
|
|
120
190
|
stopListening();
|
|
191
|
+
stopProcessMetrics();
|
|
121
192
|
},
|
|
122
193
|
};
|
|
123
194
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Which Postgres extensions an app's migrations create. One reader for the two embedded boots
|
|
2
|
+
// that must agree — `x dev`'s database (`runtime-queue.ts`) and the schema dump's scratch replay
|
|
3
|
+
// (`db-schema-dump.ts`): PGlite links an extension only when it is named at boot, and an app that
|
|
4
|
+
// replays in the gate and fails in `x dev` was the result of only one of them asking.
|
|
5
|
+
|
|
6
|
+
import type { Migration } from '@ultimat3/db';
|
|
7
|
+
import { readMigrations } from './migrations';
|
|
8
|
+
|
|
9
|
+
const CREATE_EXTENSION = /\bcreate\s+extension\s+(?:if\s+not\s+exists\s+)?"?([a-z][a-z0-9_-]*)"?/gi;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every extension the migrations create, sorted. Matched on the raw text, comments included, on
|
|
13
|
+
* purpose: over-reading costs one bundle that is never created, and a name nothing ships is
|
|
14
|
+
* refused later by `create extension` itself. Blanking quoted identifiers first would lose
|
|
15
|
+
* `"uuid-ossp"`, the one spelling that needs its quotes.
|
|
16
|
+
*/
|
|
17
|
+
export function migrationExtensions(migrations: readonly Migration[]): readonly string[] {
|
|
18
|
+
const names = new Set<string>();
|
|
19
|
+
for (const migration of migrations) {
|
|
20
|
+
for (const match of migration.up.matchAll(CREATE_EXTENSION)) {
|
|
21
|
+
if (match[1] !== undefined) names.add(match[1].toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return [...names].sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The same list for an app on disk — what an embedded boot hands `createPgliteClient`. */
|
|
28
|
+
export const appExtensions = async (root: string): Promise<readonly string[]> =>
|
|
29
|
+
migrationExtensions(await readMigrations(root));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The files one module's source imports, resolved the way Bun will resolve them. One function for
|
|
2
|
+
// the two graphs built over it: the reload graph (`app-reload-graph.ts`), which follows every file
|
|
3
|
+
// a module NAMES, and the document graph (`document-graph.ts`), which follows only what
|
|
4
|
+
// EVALUATING the module evaluates. Each caller keeps its own rule for which targets it walks.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API; an import specifier resolves against the importing file's directory.
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `referenced`: every file the module names, an `await import()` target included — a save to one
|
|
11
|
+
* must reload its importer. `evaluated`: what importing the module evaluates, so the lazy targets
|
|
12
|
+
* are left out. Each keeps the transpiler pass its graph was built on (`scanImports`, the fast
|
|
13
|
+
* one, and `scan`); measured on Bun 1.4.0 the two agree on every static shape — both drop
|
|
14
|
+
* `import type` and an inline `type` specifier, both keep a value import used only as a type.
|
|
15
|
+
*/
|
|
16
|
+
export type ImportEdges = 'referenced' | 'evaluated';
|
|
17
|
+
|
|
18
|
+
const transpilers = {
|
|
19
|
+
ts: new Bun.Transpiler({ loader: 'ts' }),
|
|
20
|
+
tsx: new Bun.Transpiler({ loader: 'tsx' }),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Absolute paths, in source order. A file that will not parse imports nothing yet, and a
|
|
25
|
+
* specifier that will not resolve contributes no edge: the module's own import reports both.
|
|
26
|
+
*/
|
|
27
|
+
export function resolvedImports(path: string, source: string, edges: ImportEdges): string[] {
|
|
28
|
+
const transpiler = path.endsWith('x') ? transpilers.tsx : transpilers.ts;
|
|
29
|
+
let specifiers: readonly { readonly path: string; readonly kind: string }[];
|
|
30
|
+
try {
|
|
31
|
+
specifiers =
|
|
32
|
+
edges === 'referenced'
|
|
33
|
+
? transpiler.scanImports(source)
|
|
34
|
+
: transpiler.scan(source).imports.filter((one) => one.kind !== 'dynamic-import');
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const found: string[] = [];
|
|
39
|
+
for (const { path: specifier } of specifiers) {
|
|
40
|
+
try {
|
|
41
|
+
found.push(Bun.resolveSync(specifier, dirname(path)));
|
|
42
|
+
} catch {
|
|
43
|
+
// Unresolvable: no edge.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|