@ultimat3/cli 23.0.0 → 25.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `x doctor`'s auth-storage checks: what an upgrade leaves in the database for an operator to
|
|
2
|
+
// finish — second-factor secrets written before they were sealed, and a framework table no
|
|
3
|
+
// release reads any more. Reaching the server is `authStorageProbe`, deciding is
|
|
4
|
+
// `authStorageFindings` — the split `doctor-sealed.ts` makes.
|
|
5
|
+
|
|
6
|
+
import { countUnsealedMfaSecrets, postgresAuthAdapter } from '@ultimat3/auth';
|
|
7
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
8
|
+
import { postgresClient, raw } from '@ultimat3/db';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Framework tables an earlier release created and none reads now, each with the command that
|
|
13
|
+
* removes it. The boot never drops one: a replica still on the previous release may be using it
|
|
14
|
+
* for the length of a rolling deploy. A whole literal per table — nothing is spliced into a fix.
|
|
15
|
+
*/
|
|
16
|
+
type RetiredTable = 'x_auth_failures';
|
|
17
|
+
|
|
18
|
+
// A Map, not an object: a table name is looked up by key, and an object answers `constructor`.
|
|
19
|
+
const RETIRED_TABLE_FIXES: ReadonlyMap<RetiredTable, string> = new Map([
|
|
20
|
+
[
|
|
21
|
+
'x_auth_failures',
|
|
22
|
+
`psql "$DATABASE_URL" -c 'drop table if exists x_auth_failures' # once every replica runs this release`,
|
|
23
|
+
],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const RETIRED_FRAMEWORK_TABLES: readonly RetiredTable[] = Object.freeze([
|
|
27
|
+
...RETIRED_TABLE_FIXES.keys(),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export interface AuthStorageFact {
|
|
31
|
+
/** `x_users` rows holding an `mfa_secret` that is not sealed. */
|
|
32
|
+
readonly unsealedMfaSecrets: number;
|
|
33
|
+
/** Which of `RETIRED_FRAMEWORK_TABLES` are still present. */
|
|
34
|
+
readonly retiredTables: readonly RetiredTable[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a database that was not asked — embedded, or unreachable — answers: nothing to report. */
|
|
38
|
+
export const NO_AUTH_STORAGE_FACT: AuthStorageFact = Object.freeze({
|
|
39
|
+
unsealedMfaSecrets: 0,
|
|
40
|
+
retiredTables: Object.freeze([]),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** The rule, pure. */
|
|
44
|
+
export function authStorageFindings(fact: AuthStorageFact): readonly Finding[] {
|
|
45
|
+
const findings: Finding[] = [];
|
|
46
|
+
if (fact.unsealedMfaSecrets > 0) {
|
|
47
|
+
findings.push({
|
|
48
|
+
code: 'X_MFA_SECRET_UNSEALED',
|
|
49
|
+
cause: `${fact.unsealedMfaSecrets} user(s) hold a second-factor secret that is not sealed; the framework never reads a plaintext secret, so each of them is refused at the second factor until it is sealed`,
|
|
50
|
+
fix: 'x auth seal-mfa --json',
|
|
51
|
+
docs: ERROR_DOCS_URL,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const table of fact.retiredTables) {
|
|
55
|
+
const fix = RETIRED_TABLE_FIXES.get(table);
|
|
56
|
+
if (fix === undefined) continue;
|
|
57
|
+
findings.push({
|
|
58
|
+
code: 'X_FRAMEWORK_TABLE_ORPHANED',
|
|
59
|
+
cause: `${table} is still in the database and no release reads it any more; the boot does not drop it, because a replica on the previous release may still be writing to it during a rolling deploy`,
|
|
60
|
+
fix,
|
|
61
|
+
docs: ERROR_DOCS_URL,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Asks an EXTERNAL database only — `probeDatabase`'s own rule: opening the embedded one boots
|
|
69
|
+
* PGlite and takes the single-writer lock the next command needs. A server that does not answer
|
|
70
|
+
* is `database()`'s finding to make, so every failure here is "nothing to report".
|
|
71
|
+
*/
|
|
72
|
+
export async function authStorageProbe(url: string | undefined): Promise<AuthStorageFact> {
|
|
73
|
+
if (url === undefined || url.trim() === '') return NO_AUTH_STORAGE_FACT;
|
|
74
|
+
const client = postgresClient({ url, applicationName: 'x-doctor' });
|
|
75
|
+
try {
|
|
76
|
+
const present = async (table: string): Promise<boolean> => {
|
|
77
|
+
const rows = await client.query<{ readonly present: boolean }>(
|
|
78
|
+
raw(`select to_regclass('public.${table}') is not null as present`),
|
|
79
|
+
);
|
|
80
|
+
return rows[0]?.present === true;
|
|
81
|
+
};
|
|
82
|
+
const retiredTables: RetiredTable[] = [];
|
|
83
|
+
for (const table of RETIRED_FRAMEWORK_TABLES) {
|
|
84
|
+
if (await present(table)) retiredTables.push(table);
|
|
85
|
+
}
|
|
86
|
+
const unsealedMfaSecrets = (await present('x_users'))
|
|
87
|
+
? await countUnsealedMfaSecrets({ adapter: postgresAuthAdapter(client) })
|
|
88
|
+
: 0;
|
|
89
|
+
return { unsealedMfaSecrets, retiredTables };
|
|
90
|
+
} catch {
|
|
91
|
+
return NO_AUTH_STORAGE_FACT;
|
|
92
|
+
} finally {
|
|
93
|
+
await client.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/doctor-offline.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// diagnostic over.
|
|
5
5
|
|
|
6
6
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import { describePages } from '@ultimat3/render';
|
|
8
8
|
import { loadApp } from './app-load';
|
|
9
9
|
import { APP_CONFIG_FILE } from './app-root';
|
|
10
10
|
import type { Finding } from './output';
|
|
11
11
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
12
|
+
import { quoteArg } from './shell-quote';
|
|
12
13
|
|
|
13
14
|
/** A route as this check reads one: the URL it answers, and the surface that answers it. */
|
|
14
15
|
export interface NavigableRoute {
|
|
@@ -46,7 +47,7 @@ export interface OfflineFallbackFact {
|
|
|
46
47
|
* below already refuses `--surface app`, so the check and the remedy disagreed about one code.
|
|
47
48
|
*
|
|
48
49
|
* Residual, and NOT closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
|
|
49
|
-
* either. `NavigableRoute` carries no render mode, and `
|
|
50
|
+
* either. `NavigableRoute` carries no render mode, and `describePages()` has one — the narrower
|
|
50
51
|
* check belongs with it.
|
|
51
52
|
*/
|
|
52
53
|
const NAVIGABLE: ReadonlySet<string> = new Set(['site']);
|
|
@@ -100,13 +101,13 @@ export function offlineFallbackFinding(fact: OfflineFallbackFact): Finding | und
|
|
|
100
101
|
const name = GENERATABLE.exec(fallback)?.[1];
|
|
101
102
|
return name === undefined
|
|
102
103
|
? finding(cause, `set pwa.offline.fallback in ${APP_CONFIG_FILE} to a path a route serves`)
|
|
103
|
-
: finding(cause, `x g route ${name} --surface site`);
|
|
104
|
+
: finding(cause, `x g route ${quoteArg(name)} --surface site`);
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
108
|
* The fact, read off a real app root. Both halves come from the framework's own answers — the
|
|
108
109
|
* config through `loadPwaArtifacts` (the one reader of that file) and the routes through
|
|
109
|
-
* `
|
|
110
|
+
* `describePages()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
|
|
110
111
|
* built from), so this check and the service worker cannot disagree about which routes exist.
|
|
111
112
|
*/
|
|
112
113
|
export async function offlineFallbackProbe(root: string): Promise<OfflineFallbackFact> {
|
|
@@ -117,6 +118,6 @@ export async function offlineFallbackProbe(root: string): Promise<OfflineFallbac
|
|
|
117
118
|
routes:
|
|
118
119
|
app.findings.length > 0
|
|
119
120
|
? undefined
|
|
120
|
-
:
|
|
121
|
+
: describePages().map((route) => ({ path: route.path, surface: route.surface })),
|
|
121
122
|
};
|
|
122
123
|
}
|
package/src/document-options.ts
CHANGED
|
@@ -32,6 +32,13 @@ export interface DocumentOptions {
|
|
|
32
32
|
* decided by `app.config.ts`, which the boot read and the renderer cannot.
|
|
33
33
|
*/
|
|
34
34
|
readonly themeHead?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The app's brand `<style>` from `theme-brand.ts`, or absent for an app with no
|
|
37
|
+
* `apps/web/shared/theme.ts`. Rendered AFTER the surface stylesheet `<link>`, never in the head
|
|
38
|
+
* block `themeHead` rides: the brand answers `theme.scss` at equal specificity, so only a later
|
|
39
|
+
* position in the cascade lets it win.
|
|
40
|
+
*/
|
|
41
|
+
readonly brandHead?: string;
|
|
35
42
|
/**
|
|
36
43
|
* The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
|
|
37
44
|
* document this process serves. Principal-free, so a shareable document carries it too; absent
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Single responsibility: which declared tables the drift step expects a migration to have made
|
|
2
|
+
// `REPLICA IDENTITY FULL` — the same set `x db gen` emits the ALTER for (`replicaIdentityTables`),
|
|
3
|
+
// or that command's own refusal as a finding. A tier-3 fact no entity carries, so no source hash
|
|
4
|
+
// moves when a channel with params is added over a table that already exists.
|
|
5
|
+
|
|
6
|
+
import { describeQueries } from '@ultimat3/query';
|
|
7
|
+
import { QuerySubscribesUnknownError, replicaIdentityTables } from './db-subscribes';
|
|
8
|
+
import { MIGRATIONS_DIR } from './migrations';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { findingFrom } from './output';
|
|
11
|
+
|
|
12
|
+
/** The tables needing FULL, given the tables the app's entities declare. Injected for tests. */
|
|
13
|
+
export type ReplicaIdentityWanted = (tables: ReadonlySet<string>) => readonly string[];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The app's own answer: every params channel's `records` tables, with its live queries'
|
|
17
|
+
* `subscribes:` validated but never granted (#518), read off the registries `loadApp` filled. One function with `db-generate.ts`, so the gate
|
|
18
|
+
* can never expect an identity the generator would not grant, or miss one it would.
|
|
19
|
+
*/
|
|
20
|
+
export const appReplicaIdentity: ReplicaIdentityWanted = (tables) =>
|
|
21
|
+
replicaIdentityTables(describeQueries(), tables);
|
|
22
|
+
|
|
23
|
+
export type WantedIdentity =
|
|
24
|
+
| { readonly tables: ReadonlySet<string> }
|
|
25
|
+
| { readonly finding: Finding };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `x db gen` refuses a `subscribes:` name no entity declares (`X_QUERY_SUBSCRIBES_UNKNOWN`), and the
|
|
29
|
+
* gate reports the same refusal rather than letting it escape the step: every other throw is a
|
|
30
|
+
* defect in this process and is rethrown as one.
|
|
31
|
+
*/
|
|
32
|
+
export function wantedReplicaIdentity(
|
|
33
|
+
wanted: ReplicaIdentityWanted,
|
|
34
|
+
tables: ReadonlySet<string>,
|
|
35
|
+
): WantedIdentity {
|
|
36
|
+
try {
|
|
37
|
+
return { tables: new Set(wanted(tables)) };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (!(error instanceof QuerySubscribesUnknownError)) throw error;
|
|
40
|
+
return { finding: { ...findingFrom(error), at: MIGRATIONS_DIR } };
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/drift.ts
CHANGED
|
@@ -53,16 +53,39 @@ async function declaredSchemaJson(root: string): Promise<string> {
|
|
|
53
53
|
*/
|
|
54
54
|
export async function schemaHash(root: string): Promise<string> {
|
|
55
55
|
const glob = new Bun.Glob(SCHEMA_GLOB);
|
|
56
|
-
const
|
|
56
|
+
const sources: SchemaSource[] = [];
|
|
57
57
|
for await (const path of glob.scan({ cwd: root, absolute: false })) {
|
|
58
|
-
if (
|
|
58
|
+
if (path.includes('.test.')) continue;
|
|
59
|
+
sources.push({ path, text: await Bun.file(join(root, path)).text() });
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
+
return hashSchemaSources(await declaredSchemaJson(root), sources);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One file under `SCHEMA_GLOB`: its path relative to the app root, as the OS spelled it. */
|
|
65
|
+
export interface SchemaSource {
|
|
66
|
+
readonly path: string;
|
|
67
|
+
readonly text: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The hash itself, pure. The sidecar is committed and compared on every OS, so what a checkout
|
|
72
|
+
* spells differently is folded away first: `\` separators (a Windows glob) become `/` BEFORE the
|
|
73
|
+
* sort — `seed2.ts` and `seed/index.ts` order differently under the two — and CRLF text (Git for
|
|
74
|
+
* Windows' `core.autocrlf=true`) becomes LF. An LF tree on POSIX is hashed exactly as before, so no
|
|
75
|
+
* sidecar already recorded moves.
|
|
76
|
+
*/
|
|
77
|
+
export function hashSchemaSources(schemaJson: string, sources: readonly SchemaSource[]): string {
|
|
78
|
+
const files = sources
|
|
79
|
+
.map(({ path, text }) => ({
|
|
80
|
+
path: path.replaceAll('\\', '/'),
|
|
81
|
+
text: text.replaceAll('\r\n', '\n'),
|
|
82
|
+
}))
|
|
83
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
61
84
|
const hasher = new Bun.CryptoHasher('sha256');
|
|
62
|
-
hasher.update(
|
|
63
|
-
for (const path of
|
|
85
|
+
hasher.update(schemaJson);
|
|
86
|
+
for (const { path, text } of files) {
|
|
64
87
|
hasher.update(path);
|
|
65
|
-
hasher.update(
|
|
88
|
+
hasher.update(text);
|
|
66
89
|
}
|
|
67
90
|
return hasher.digest('hex').slice(0, 16);
|
|
68
91
|
}
|
package/src/error-catalog.ts
CHANGED
|
@@ -46,21 +46,18 @@ export const CATALOG_PACKAGES = [
|
|
|
46
46
|
] as const;
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them — never one it does
|
|
50
|
+
* declare (`error-catalog.test.ts`), which is why `admin` is not here: `x dev` imports it and
|
|
51
|
+
* `package.json` depends on it. `ui` reaches the CLI only as `admin`'s own dependency, so an
|
|
52
|
+
* installer that keeps transitive packages private reports its codes `unavailable` rather than
|
|
53
|
+
* the CLI declaring a package no command imports. `scraping` is an app's own choice since 22.0.0: `x shot` drives Chrome over raw CDP and imports nothing from it, so its codes
|
|
53
54
|
* answer where the app installed it and are reported `unavailable` where it did not. Every other
|
|
54
55
|
* entry above is a real runtime import and must be a declared dependency — `error-catalog.test.ts`
|
|
55
56
|
* holds the list to exactly that, because an undeclared one resolves through workspace symlinks
|
|
56
57
|
* here and through nothing in an installed app, where `x errors explain X_FLAG_EXPIRED` then
|
|
57
58
|
* refuses a code the wiki promises.
|
|
58
59
|
*/
|
|
59
|
-
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = [
|
|
60
|
-
'@ultimat3/admin',
|
|
61
|
-
'@ultimat3/scraping',
|
|
62
|
-
'@ultimat3/ui',
|
|
63
|
-
];
|
|
60
|
+
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = ['@ultimat3/scraping', '@ultimat3/ui'];
|
|
64
61
|
|
|
65
62
|
export interface ErrorCatalog {
|
|
66
63
|
/** Packages whose codes are now registered. */
|
package/src/error-codes.ts
CHANGED
|
@@ -220,11 +220,22 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
220
220
|
'X_ADMIN_RESOURCE_UNWIRED',
|
|
221
221
|
'X_IMAGE_NOT_PREBUILT',
|
|
222
222
|
'X_ADMIN_UNSCANNED',
|
|
223
|
+
'X_FRAMEWORK_TABLE_ORPHANED',
|
|
224
|
+
'X_BUILD_OUT_UNSAFE',
|
|
225
|
+
'X_FRAMEWORK_SCHEMA_UNAPPLIED',
|
|
226
|
+
'X_APP_URL_PORT_MISMATCH',
|
|
227
|
+
'X_DEV_HOST_REFUSED',
|
|
228
|
+
'X_TRUSTED_PROXY_HOPS_INVALID',
|
|
229
|
+
'X_PERMISSION_BORROWED',
|
|
230
|
+
'X_ERROR_CODE_UNTHROWN_STALE',
|
|
231
|
+
'X_DEV_STATE_UNWRITABLE',
|
|
232
|
+
'X_PACKAGE_TEST_ONLY_SHIPPED',
|
|
233
|
+
'X_ISLAND_MODULE_DUPLICATED',
|
|
223
234
|
] as const;
|
|
224
235
|
|
|
225
236
|
/**
|
|
226
|
-
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s —
|
|
227
|
-
*
|
|
237
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — every planned command throws it through core's
|
|
238
|
+
* own `NotImplementedError`, and none of them may declare a title for it. The CLI is the process that imports every
|
|
228
239
|
* package (`error-catalog.ts`), so a title declared twice here is the one that would win by load
|
|
229
240
|
* order rather than by ownership.
|
|
230
241
|
*
|
|
@@ -285,7 +296,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
285
296
|
X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
|
|
286
297
|
X_ERROR_CODE_UNRESOLVED: 'an error code is written as a name this repository cannot resolve',
|
|
287
298
|
X_ERROR_CODE_UNTHROWN:
|
|
288
|
-
'a registered error code is constructed by nothing and
|
|
299
|
+
'a registered error code is constructed by nothing and UNTHROWN_CODES in packages/cli/src/unthrown-codes.ts does not list it',
|
|
289
300
|
X_FRAMEWORK_SCHEMA_FAILED: 'a framework table could not be created at boot',
|
|
290
301
|
X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
|
|
291
302
|
X_STORAGE_SECRET_DEV: 'upload grants would be signed with the shipped development key',
|
|
@@ -392,6 +403,21 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
392
403
|
'a container role built at boot what the image build should have: island chunks or stylesheets',
|
|
393
404
|
X_ADMIN_UNSCANNED:
|
|
394
405
|
'a defineAdmin() declaration sits outside the app scan, so the admin is never mounted',
|
|
406
|
+
X_FRAMEWORK_TABLE_ORPHANED:
|
|
407
|
+
'a framework table no release reads any more is still in the database',
|
|
408
|
+
X_BUILD_OUT_UNSAFE: 'the static export directory is not one a build may empty',
|
|
409
|
+
X_FRAMEWORK_SCHEMA_UNAPPLIED:
|
|
410
|
+
'a serving role booted over a database whose framework schema this build never applied',
|
|
411
|
+
X_APP_URL_PORT_MISMATCH: 'APP_URL names a loopback port x dev does not serve',
|
|
412
|
+
X_DEV_HOST_REFUSED: 'a /_x request named a host that is not this machine',
|
|
413
|
+
X_TRUSTED_PROXY_HOPS_INVALID:
|
|
414
|
+
'TRUSTED_PROXY_HOPS is not a whole number of proxies the boot accepts',
|
|
415
|
+
X_PERMISSION_BORROWED: 'an app rule requires a permission only an imported package declared',
|
|
416
|
+
X_ERROR_CODE_UNTHROWN_STALE:
|
|
417
|
+
'a code UNTHROWN_CODES lists as thrown by nothing is thrown again — delete it from UNTHROWN_CODES in packages/cli/src/unthrown-codes.ts',
|
|
418
|
+
X_DEV_STATE_UNWRITABLE: 'x dev cannot claim its state directory',
|
|
419
|
+
X_PACKAGE_TEST_ONLY_SHIPPED: 'a test-only module ships in the tarball',
|
|
420
|
+
X_ISLAND_MODULE_DUPLICATED: 'An island chunk bundles one module more than once',
|
|
395
421
|
};
|
|
396
422
|
|
|
397
423
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/error-page-csp.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// `apps/web/site/errors/<status>.html
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the page rendered unstyled; `x dev` sends the policy report-only, so the same page looked fine
|
|
6
|
-
// on every author's machine. Hashed at boot, like the hydration runtime: a hash is a function of
|
|
7
|
-
// the body, and these files do not change while a container runs.
|
|
1
|
+
// The inline `<style>` bodies of an app's own error pages, for `style-src` to admit. Such a page
|
|
2
|
+
// (`apps/web/site/errors/<status>.html`, served verbatim by `error-pages.ts`) carries its own
|
|
3
|
+
// `<style>` — no stylesheet bundle is linked from it — and under the container's enforced policy an
|
|
4
|
+
// unadmitted block is a page rendered unstyled; `x dev` is report-only, so it looked fine there.
|
|
8
5
|
|
|
9
6
|
// why: Bun exposes no path-join primitive, and the directory is app-root-relative — the same
|
|
10
7
|
// necessity `error-pages.ts` records.
|
|
11
8
|
import { join } from 'node:path';
|
|
12
|
-
import { cspHashSource } from '@ultimat3/http';
|
|
13
9
|
import { ERROR_PAGE_DIR } from './error-pages';
|
|
14
10
|
|
|
15
11
|
/**
|
|
@@ -26,18 +22,21 @@ export function inlineStyleBodies(html: string): readonly string[] {
|
|
|
26
22
|
}
|
|
27
23
|
|
|
28
24
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
25
|
+
* Every distinct `<style>` body across every error page the app ships, sorted — BODIES, because
|
|
26
|
+
* `startRoles({ inlineStyles })` hashes what it is given (`style-csp.ts`). This returned the hashes
|
|
27
|
+
* until 2026-10, and the boot hashed them again: the policy admitted the hash of a hash, and every
|
|
28
|
+
* app error page carrying a `<style>` rendered unstyled under the container's enforced CSP. Read
|
|
29
|
+
* once at boot: a page an author drops in while `x dev` runs is served (the reader is per request)
|
|
30
|
+
* but not yet admitted, which the report-only policy there turns into a console report.
|
|
32
31
|
*/
|
|
33
|
-
export async function
|
|
32
|
+
export async function errorPageStyleBodies(root: string): Promise<readonly string[]> {
|
|
34
33
|
const dir = join(root, ERROR_PAGE_DIR);
|
|
35
|
-
const
|
|
34
|
+
const bodies = new Set<string>();
|
|
36
35
|
for (const name of await htmlFilesIn(dir)) {
|
|
37
36
|
const html = await Bun.file(join(dir, name)).text();
|
|
38
|
-
for (const body of inlineStyleBodies(html))
|
|
37
|
+
for (const body of inlineStyleBodies(html)) bodies.add(body);
|
|
39
38
|
}
|
|
40
|
-
return [...
|
|
39
|
+
return [...bodies].sort();
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
/** `Bun.Glob.scan` throws `ENOENT` on a missing directory, and no error pages is the common case. */
|
package/src/error-unthrown.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// A registered code nothing constructs. The registry and the reference keep a shipped code alive
|
|
2
2
|
// forever — which is right — and that is exactly why a code can outlive its last thrower with no
|
|
3
3
|
// gate noticing: `X_RPC_FAILED` sat registered and documented as live after the transport change
|
|
4
|
-
// took its only throw site away.
|
|
4
|
+
// took its only throw site away. It has to be LISTED as unthrown (`UNTHROWN_CODES`), or this
|
|
5
|
+
// reports it.
|
|
5
6
|
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { ERROR_DOCS_URL, maskLiterals, stripComments } from '@ultimat3/core';
|
|
@@ -9,6 +10,7 @@ import { RESERVED_HEADING } from './error-contract';
|
|
|
9
10
|
import type { Finding } from './output';
|
|
10
11
|
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
11
12
|
import { isCodeRegistry } from './ts-scan';
|
|
13
|
+
import { UNTHROWN_CODES, UNTHROWN_CODES_FILE } from './unthrown-codes';
|
|
12
14
|
|
|
13
15
|
const CODE_LITERAL = /(['"`])(X_[A-Z0-9_]+)\1/g;
|
|
14
16
|
const TITLE_KEY = /^[\t ]*(X_[A-Z0-9_]+)\s*:/gm;
|
|
@@ -18,9 +20,6 @@ const LIST_LINE = /^[\t ]*(['"`])X_[A-Z0-9_]+\1\s*,?\s*$/;
|
|
|
18
20
|
const TABLE_ENTRY = /\b([a-z][A-Za-z0-9]*)\s*:\s*(['"`])(X_[A-Z0-9_]+)\2/g;
|
|
19
21
|
const MEMBER_READ = /\.([A-Za-z_$][\w$]*)/g;
|
|
20
22
|
|
|
21
|
-
/** Phrases a reference row uses to say, in words, that nothing throws the code any more. */
|
|
22
|
-
const DECLARED_UNTHROWN = /thrown by nothing|not thrown/i;
|
|
23
|
-
|
|
24
23
|
export interface CodeUse {
|
|
25
24
|
/** Every code a package registry names. */
|
|
26
25
|
readonly registered: ReadonlySet<string>;
|
|
@@ -78,6 +77,8 @@ export async function collectCodeUse(root: string): Promise<CodeUse> {
|
|
|
78
77
|
const members = new Set<string>();
|
|
79
78
|
for await (const source of eachSourceFile(root)) {
|
|
80
79
|
if (!/^packages\/[^/]+\/src\//.test(source) || isTest(source) || isGenerated(source)) continue;
|
|
80
|
+
// The list names every code it waives, and naming one is not throwing it.
|
|
81
|
+
if (source === UNTHROWN_CODES_FILE) continue;
|
|
81
82
|
const use = codeUseOf(await Bun.file(join(root, source)).text());
|
|
82
83
|
for (const code of use.registered) registered.add(code);
|
|
83
84
|
for (const code of use.used) used.add(code);
|
|
@@ -88,28 +89,36 @@ export async function collectCodeUse(root: string): Promise<CodeUse> {
|
|
|
88
89
|
return { registered, used };
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
/** Codes
|
|
92
|
+
/** Codes that sit under the reserved heading — declared unthrown by WHERE they are, not by words. */
|
|
92
93
|
export function declaredUnthrown(markdown: string): ReadonlySet<string> {
|
|
93
94
|
const out = new Set<string>();
|
|
94
95
|
let reserved = false;
|
|
95
96
|
for (const line of markdown.split('\n')) {
|
|
96
97
|
if (line.trim() === RESERVED_HEADING) reserved = true;
|
|
97
98
|
const row = /^\|\s*`(X_[A-Z0-9_]+)`\s*\|/.exec(line);
|
|
98
|
-
if (row !== null &&
|
|
99
|
+
if (row !== null && reserved) out.add(row[1] ?? '');
|
|
99
100
|
}
|
|
100
101
|
return out;
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
const unthrownFinding = (code: string, page: string): Finding => ({
|
|
104
105
|
code: 'X_ERROR_CODE_UNTHROWN',
|
|
105
|
-
cause: `${code} is registered and ${page} presents it as live, but no shipped source constructs it — a reader matching on it waits for an error that cannot arrive`,
|
|
106
|
+
cause: `${code} is registered and ${page} presents it as live, but no shipped source constructs it — a reader matching on it waits for an error that cannot arrive. Add it to UNTHROWN_CODES in ${UNTHROWN_CODES_FILE} and say so in its row, or throw it again where it belongs`,
|
|
106
107
|
// Never "delete the registration": a shipped code is stable forever, and an old log line must
|
|
107
108
|
// still explain. The row is what has to change.
|
|
108
|
-
fix:
|
|
109
|
+
fix: 'x errors explain X_ERROR_CODE_UNTHROWN --json',
|
|
109
110
|
docs: ERROR_DOCS_URL,
|
|
110
111
|
at: page,
|
|
111
112
|
});
|
|
112
113
|
|
|
114
|
+
const staleFinding = (code: string): Finding => ({
|
|
115
|
+
code: 'X_ERROR_CODE_UNTHROWN_STALE',
|
|
116
|
+
cause: `${code} is listed in UNTHROWN_CODES and shipped source constructs it again — the listing would excuse a code that is live, so delete it from UNTHROWN_CODES in ${UNTHROWN_CODES_FILE}`,
|
|
117
|
+
fix: 'x errors explain X_ERROR_CODE_UNTHROWN_STALE --json',
|
|
118
|
+
docs: ERROR_DOCS_URL,
|
|
119
|
+
at: UNTHROWN_CODES_FILE,
|
|
120
|
+
});
|
|
121
|
+
|
|
113
122
|
/**
|
|
114
123
|
* Registered, used by nothing, and not declared unthrown on the reference. A host check — the page
|
|
115
124
|
* is the host repo's to name, and only a monorepo's walk sees every package's source; in a
|
|
@@ -118,13 +127,22 @@ const unthrownFinding = (code: string, page: string): Finding => ({
|
|
|
118
127
|
export async function checkErrorCodesThrown(
|
|
119
128
|
root: string,
|
|
120
129
|
page: string,
|
|
130
|
+
listed: ReadonlySet<string> = UNTHROWN_CODES,
|
|
121
131
|
): Promise<readonly Finding[]> {
|
|
122
132
|
const reference = Bun.file(join(root, page));
|
|
123
133
|
if (!(await reference.exists())) return [];
|
|
124
|
-
const
|
|
134
|
+
const reserved = declaredUnthrown(await reference.text());
|
|
125
135
|
const { registered, used } = await collectCodeUse(root);
|
|
126
|
-
|
|
127
|
-
.filter((code) => !used.has(code) && !
|
|
136
|
+
const unthrown = [...registered]
|
|
137
|
+
.filter((code) => !used.has(code) && !listed.has(code) && !reserved.has(code))
|
|
128
138
|
.sort()
|
|
129
139
|
.map((code) => unthrownFinding(code, page));
|
|
140
|
+
// Only the THROWN-again direction: a listed name no registry holds waives nothing (the code it
|
|
141
|
+
// was meant to name is still reported), and a host repo's fixture registering none of this
|
|
142
|
+
// repo's codes would otherwise read every listing as stale.
|
|
143
|
+
const stale = [...listed]
|
|
144
|
+
.filter((code) => used.has(code))
|
|
145
|
+
.sort()
|
|
146
|
+
.map((code) => staleFinding(code));
|
|
147
|
+
return [...unthrown, ...stale];
|
|
130
148
|
}
|
package/src/errors.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// that resolves it — the codes themselves, their titles and their registration are `./error-codes`,
|
|
3
3
|
// so a package importing a class does not pull the table and vice versa.
|
|
4
4
|
import { UltimateError } from '@ultimat3/core';
|
|
5
|
+
import type { Finding } from './output';
|
|
5
6
|
import { quoteArg } from './shell-quote';
|
|
6
7
|
|
|
7
8
|
/** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
|
|
@@ -301,14 +302,59 @@ export class FixTargetUnknownError extends UltimateError {
|
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
305
|
+
/**
|
|
306
|
+
* A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
|
|
307
|
+
* entry point like any other, and the target's own logs are what says which line. The fix builds
|
|
308
|
+
* exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
|
|
309
|
+
*/
|
|
310
|
+
export class IslandBuildFailedError extends UltimateError {
|
|
311
|
+
constructor(input: { file: string; logs: string }) {
|
|
312
|
+
super({
|
|
313
|
+
code: 'X_BUILD_FAILED',
|
|
314
|
+
cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
|
|
315
|
+
fix: `bun build --target browser ${input.file}`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Which framework script a page ships: the sync worker, the page boot, or the client router. */
|
|
321
|
+
export type FrameworkScriptKind = 'sync worker' | 'page boot' | 'client router';
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* One of the page's framework scripts would not bundle. Same code as an island: "a browser entry
|
|
325
|
+
* the framework builds did not build" is one condition, and the entry here is framework code, not
|
|
326
|
+
* the app's — the cause names WHICH script, so the reader knows what the page is now missing.
|
|
327
|
+
*/
|
|
328
|
+
export class FrameworkScriptBuildFailedError extends UltimateError {
|
|
329
|
+
constructor(input: { what: FrameworkScriptKind; entry: string; logs: string }) {
|
|
330
|
+
super({
|
|
331
|
+
code: 'X_BUILD_FAILED',
|
|
332
|
+
cause: `the ${input.what} (${input.entry}) would not bundle for the browser: ${input.logs}`,
|
|
333
|
+
fix: `bun build --target browser --format iife ${quoteArg(input.entry)}`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A static build over an app whose modules did not all import. The route registry is filled BY the
|
|
340
|
+
* import, so a page whose module threw is not skipped but absent — and an export without it, with
|
|
341
|
+
* `x build` green, is a deploy that deletes a page. The `manifest` step owns load findings
|
|
342
|
+
* (`load-findings.ts`), so the fix is the command that lists every one with its own fix.
|
|
343
|
+
*/
|
|
344
|
+
export class PrerenderLoadFailedError extends UltimateError {
|
|
345
|
+
constructor(findings: readonly Finding[]) {
|
|
346
|
+
const listed = findings.map((finding) =>
|
|
347
|
+
finding.at === undefined
|
|
348
|
+
? `${finding.code} ${finding.cause}`
|
|
349
|
+
: `${finding.at}: ${finding.code} ${finding.cause}`,
|
|
350
|
+
);
|
|
351
|
+
super({
|
|
352
|
+
code: 'X_BUILD_FAILED',
|
|
353
|
+
cause: `the static export renders the routes the app's modules register, and ${findings.length} module load finding(s) left it short: ${listed.join('; ')}`,
|
|
354
|
+
fix: 'x verify --only manifest --json',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
312
358
|
|
|
313
359
|
/**
|
|
314
360
|
* `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
|
|
@@ -363,36 +409,6 @@ export class PortInvalidError extends UltimateError {
|
|
|
363
409
|
}
|
|
364
410
|
}
|
|
365
411
|
|
|
366
|
-
/**
|
|
367
|
-
* `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
|
|
368
|
-
* writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
|
|
369
|
-
* present", both read as a working environment declaration to whoever runs the command next.
|
|
370
|
-
*
|
|
371
|
-
* `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
|
|
372
|
-
* `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
|
|
373
|
-
* than minting a synonym.
|
|
374
|
-
*/
|
|
375
|
-
export class EnvSchemaMissingError extends UltimateError {
|
|
376
|
-
constructor(input: { subcommand: string }) {
|
|
377
|
-
super({
|
|
378
|
-
code: 'X_CONFIG_INVALID',
|
|
379
|
-
cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
|
|
380
|
-
fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/** An interface-complete command path whose remote/native half is not written yet. */
|
|
386
|
-
export class CliNotImplementedError extends UltimateError {
|
|
387
|
-
constructor(input: { feature: string; fix: string }) {
|
|
388
|
-
super({
|
|
389
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
390
|
-
cause: `${input.feature} is not implemented in this build`,
|
|
391
|
-
fix: input.fix,
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
412
|
/**
|
|
397
413
|
* The process could not obtain a storage disk to write to.
|
|
398
414
|
*
|
|
@@ -432,48 +448,3 @@ export class LocalDiskUnsafeError extends UltimateError {
|
|
|
432
448
|
});
|
|
433
449
|
}
|
|
434
450
|
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
|
|
438
|
-
* guessing one and opening a decrypted file in it is the last place a surprise belongs.
|
|
439
|
-
*/
|
|
440
|
-
export class SecretsEditorMissingError extends UltimateError {
|
|
441
|
-
constructor(input: { vars: readonly string[] }) {
|
|
442
|
-
super({
|
|
443
|
-
code: 'X_SECRETS_EDITOR_MISSING',
|
|
444
|
-
cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
|
|
445
|
-
fix: 'EDITOR=nano x secrets edit',
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
|
|
452
|
-
* and the committed file is left exactly as it was: resealing a buffer whose editor failed would
|
|
453
|
-
* commit whatever half-written state the crash left behind.
|
|
454
|
-
*/
|
|
455
|
-
export class SecretsEditFailedError extends UltimateError {
|
|
456
|
-
constructor(input: { editor: string; code: number }) {
|
|
457
|
-
super({
|
|
458
|
-
code: 'X_SECRETS_EDIT_FAILED',
|
|
459
|
-
cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
|
|
460
|
-
fix: 'x secrets edit',
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
|
|
467
|
-
* package's own code for exactly that, and a second name for "a generator would clobber something"
|
|
468
|
-
* is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
|
|
469
|
-
* the committed file it opens is then ciphertext nobody can read again.
|
|
470
|
-
*/
|
|
471
|
-
export class SecretsExistsError extends UltimateError {
|
|
472
|
-
constructor(input: { path: string; fix: string }) {
|
|
473
|
-
super({
|
|
474
|
-
code: 'X_GENERATE_CONFLICT',
|
|
475
|
-
cause: `${input.path} already exists, and x secrets init would replace it`,
|
|
476
|
-
fix: input.fix,
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
}
|