@ultimat3/cli 23.0.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 +15 -7
- package/README.md +3 -3
- package/package.json +28 -28
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-manifest.ts +2 -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 +7 -17
- package/src/budgets.ts +4 -35
- 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.ts +64 -15
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +6 -5
- 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 +41 -14
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +79 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs.ts +18 -6
- 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-planned.ts +15 -8
- package/src/cmd-routes.ts +12 -2
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +4 -4
- 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/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +23 -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 +13 -2
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +33 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/i18n-audit.ts +4 -1
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +3 -15
- package/src/jobs-drain.ts +82 -15
- package/src/jobs-report.ts +4 -4
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +12 -3
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +2 -0
- package/src/metrics-endpoint.ts +25 -1
- package/src/output.ts +6 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +9 -4
- package/src/role-sync.ts +55 -20
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- 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 +31 -10
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +62 -35
- package/src/runtime-services.ts +20 -17
- package/src/runtime-storage.ts +10 -5
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +34 -6
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-web.ts +12 -7
- 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-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 -29
- package/src/templates/action.ts +3 -0
- package/src/templates/naming.ts +28 -0
- package/src/templates/route.ts +3 -6
- package/src/templates/scaffold-app.ts +6 -2
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +4 -2
- package/src/templates/scaffold-db-client.ts +4 -2
- 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-repo.ts +9 -1
- package/src/templates/scaffold-shell.ts +39 -36
- package/src/test-counts.ts +2 -6
- 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/trusted-hops-error.ts +17 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +17 -46
- package/src/verify-deadline.ts +130 -33
- package/src/verify-merge.ts +60 -27
- package/src/verify-part-step.ts +90 -0
- package/src/verify-run.ts +29 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-tests.ts +5 -2
- package/src/build-errors.ts +0 -54
- package/src/verify-errors.ts +0 -58
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
|
-
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The two ways a boot meets the framework schema (plan 101, s1-con #5): `applyLockedSchema` runs the
|
|
2
|
+
// DDL in ONE transaction behind the migration advisory lock with a bounded `lock_timeout`, then
|
|
3
|
+
// stamps the build; `verifySchema` runs no DDL and refuses a database this build was never applied
|
|
4
|
+
// to. Which boot takes which is `runtime-queue.ts`'s `SchemaMode`.
|
|
5
|
+
|
|
6
|
+
import { frameworkVersion, logger } from '@ultimat3/core';
|
|
7
|
+
import type { DbClient } from '@ultimat3/db';
|
|
8
|
+
import {
|
|
9
|
+
literal,
|
|
10
|
+
MIGRATION_LOCK_KEY,
|
|
11
|
+
MIGRATION_LOCK_WAIT_MS,
|
|
12
|
+
poolProfileFor,
|
|
13
|
+
raw,
|
|
14
|
+
withTransaction,
|
|
15
|
+
} from '@ultimat3/db';
|
|
16
|
+
import { applyFrameworkSchema } from './framework-schema';
|
|
17
|
+
import {
|
|
18
|
+
FrameworkSchemaUnappliedError,
|
|
19
|
+
frameworkSchemaHash,
|
|
20
|
+
nextStamp,
|
|
21
|
+
parseStamp,
|
|
22
|
+
STAMP_RELATION,
|
|
23
|
+
type StampEntry,
|
|
24
|
+
stampVerdict,
|
|
25
|
+
} from './framework-schema-stamp';
|
|
26
|
+
|
|
27
|
+
const thisBuild = (): StampEntry => ({
|
|
28
|
+
version: frameworkVersion(),
|
|
29
|
+
schema: frameworkSchemaHash(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** The stamp as the catalog holds it; `null` when the relation or its comment is absent. */
|
|
33
|
+
async function readStamp(client: DbClient): Promise<string | null> {
|
|
34
|
+
const rows = await client.query<{ readonly stamp: string | null }>(
|
|
35
|
+
raw(`select obj_description(to_regclass('${STAMP_RELATION}'), 'pg_class') as stamp`),
|
|
36
|
+
);
|
|
37
|
+
return rows[0]?.stamp ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The migration lock, not a lock of its own: a second migrator — another `ROLE=migrate` pod, an
|
|
42
|
+
* `x db migrate` — waits behind this one instead of racing its `create table`s into `23505`. A
|
|
43
|
+
* transaction-scoped lock (`pg_advisory_xact_lock`), so no pinned session: COMMIT releases it.
|
|
44
|
+
*
|
|
45
|
+
* Two `lock_timeout`s: the lock wait is the migrator's own (`MIGRATION_LOCK_WAIT_MS`), and every
|
|
46
|
+
* DDL statement after it is the migrate pool's (`poolProfileFor('migrate')`), so an `alter table`
|
|
47
|
+
* behind an open transaction fails in seconds rather than queueing every enqueue behind it.
|
|
48
|
+
*/
|
|
49
|
+
export async function applyLockedSchema(client: DbClient): Promise<void> {
|
|
50
|
+
await withTransaction(
|
|
51
|
+
async (tx) => {
|
|
52
|
+
await tx.execute(raw(`set local lock_timeout = ${Math.round(MIGRATION_LOCK_WAIT_MS)}`));
|
|
53
|
+
await tx.execute(raw(`select pg_advisory_xact_lock(${MIGRATION_LOCK_KEY})`));
|
|
54
|
+
const ddlTimeoutMs = Math.round(poolProfileFor('migrate').lockTimeoutMs);
|
|
55
|
+
await tx.execute(raw(`set local lock_timeout = ${ddlTimeoutMs}`));
|
|
56
|
+
await applyFrameworkSchema((statement) => tx.execute(raw(statement)));
|
|
57
|
+
const stamp = nextStamp(await readStamp(tx), thisBuild());
|
|
58
|
+
await tx.execute(raw(`comment on table ${STAMP_RELATION} is ${literal(stamp).text}`));
|
|
59
|
+
},
|
|
60
|
+
{ client },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One catalog read. A build absent from the stamp is refused with the command that applies it; a
|
|
66
|
+
* fleet straddling a major is reported once and served — refusing it would crash-loop the old pods
|
|
67
|
+
* of every rolling deploy across a breaking release.
|
|
68
|
+
*/
|
|
69
|
+
export async function verifySchema(client: DbClient): Promise<void> {
|
|
70
|
+
const mine = thisBuild();
|
|
71
|
+
const verdict = stampVerdict(parseStamp(await readStamp(client)), mine);
|
|
72
|
+
if (!verdict.applied) throw new FrameworkSchemaUnappliedError({ mine, newest: verdict.newest });
|
|
73
|
+
if (verdict.skew === undefined) return;
|
|
74
|
+
logger.warn('ultimate framework major skew', {
|
|
75
|
+
cause: `this process runs ${mine.version} and the database's framework schema was last applied by ${verdict.skew.version}: a fleet across a major boundary is mid-rollout or half rolled back`,
|
|
76
|
+
fix: 'kubectl rollout status deployment --selector app.kubernetes.io/component=web',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Which builds have applied the framework schema to this database: a stamp `ROLE=migrate` leaves
|
|
2
|
+
// in `x_jobs`' table comment, and the verdict a serving role reads from it instead of running DDL
|
|
3
|
+
// itself (plan 101, s1-con #5). Pure: the statements live in `framework-schema-apply.ts`.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { FRAMEWORK_SCHEMA } from './framework-schema';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The relation that carries the stamp. A comment, not a table: a new framework table moves every
|
|
10
|
+
* app's committed schema dump (`packages/db/schema/framework/`), and a comment is in none of them.
|
|
11
|
+
*/
|
|
12
|
+
export const STAMP_RELATION = 'x_jobs';
|
|
13
|
+
|
|
14
|
+
/** The first line of a stamp — what tells it apart from a comment someone else wrote. */
|
|
15
|
+
const STAMP_HEADER = 'ultimate-framework-schema';
|
|
16
|
+
|
|
17
|
+
/** Enough for a rolling deploy and its rollback, small enough to stay one readable comment. */
|
|
18
|
+
const STAMP_KEEP = 16;
|
|
19
|
+
|
|
20
|
+
export interface StampEntry {
|
|
21
|
+
readonly version: string;
|
|
22
|
+
readonly schema: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** This build's DDL, as one short hash: two builds with the same DDL are the same schema. */
|
|
26
|
+
export function frameworkSchemaHash(): string {
|
|
27
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
28
|
+
for (const entry of FRAMEWORK_SCHEMA) for (const ddl of entry.ddl) hasher.update(ddl);
|
|
29
|
+
return hasher.digest('hex').slice(0, 16);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every build a stamp records, newest first. Anything that is not a stamp records none. */
|
|
33
|
+
export function parseStamp(comment: string | null | undefined): readonly StampEntry[] {
|
|
34
|
+
if (typeof comment !== 'string') return [];
|
|
35
|
+
const [header, ...lines] = comment.split('\n');
|
|
36
|
+
if (header !== STAMP_HEADER) return [];
|
|
37
|
+
return lines.flatMap((line) => {
|
|
38
|
+
const [version, schema] = line.trim().split(' ');
|
|
39
|
+
return version !== undefined &&
|
|
40
|
+
/^[0-9A-Za-z.+-]{1,64}$/.test(version) &&
|
|
41
|
+
schema !== undefined &&
|
|
42
|
+
/^[0-9a-f]{16}$/.test(schema)
|
|
43
|
+
? [{ version, schema }]
|
|
44
|
+
: [];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The stamp after `mine` applied: it first, every other build kept, the oldest dropped. */
|
|
49
|
+
export function nextStamp(previous: string | null | undefined, mine: StampEntry): string {
|
|
50
|
+
const kept = parseStamp(previous).filter((entry) => entry.schema !== mine.schema);
|
|
51
|
+
const entries = [mine, ...kept].slice(0, STAMP_KEEP);
|
|
52
|
+
return [STAMP_HEADER, ...entries.map((entry) => `${entry.version} ${entry.schema}`)].join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const majorOf = (version: string): string => version.split('.')[0] ?? version;
|
|
56
|
+
|
|
57
|
+
export type StampVerdict =
|
|
58
|
+
| { readonly applied: true; readonly skew?: StampEntry }
|
|
59
|
+
| { readonly applied: false; readonly newest?: StampEntry };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Applied when this build's hash is anywhere in the stamp: an old pod restarting mid-rollout finds
|
|
63
|
+
* its own build there, behind the new one. `skew` is the newest build when its MAJOR differs from
|
|
64
|
+
* this process's — a fleet straddling a breaking release, which a boot reports and never refuses.
|
|
65
|
+
*/
|
|
66
|
+
export function stampVerdict(entries: readonly StampEntry[], mine: StampEntry): StampVerdict {
|
|
67
|
+
const newest = entries[0];
|
|
68
|
+
if (!entries.some((entry) => entry.schema === mine.schema)) {
|
|
69
|
+
return newest === undefined ? { applied: false } : { applied: false, newest };
|
|
70
|
+
}
|
|
71
|
+
return newest !== undefined && majorOf(newest.version) !== majorOf(mine.version)
|
|
72
|
+
? { applied: true, skew: newest }
|
|
73
|
+
: { applied: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A serving role over a database its build's framework tables were never applied to. */
|
|
77
|
+
export class FrameworkSchemaUnappliedError extends UltimateError {
|
|
78
|
+
constructor(input: { readonly mine: StampEntry; readonly newest: StampEntry | undefined }) {
|
|
79
|
+
super({
|
|
80
|
+
code: 'X_FRAMEWORK_SCHEMA_UNAPPLIED',
|
|
81
|
+
cause:
|
|
82
|
+
`this build (${input.mine.version}, framework schema ${input.mine.schema}) has not been applied ` +
|
|
83
|
+
`to this database — ${
|
|
84
|
+
input.newest === undefined
|
|
85
|
+
? `${STAMP_RELATION} carries no stamp, so no ROLE=migrate of this release ran`
|
|
86
|
+
: `the newest applied build is ${input.newest.version} (${input.newest.schema})`
|
|
87
|
+
}. A serving role verifies and never runs DDL: the migrate step must run first`,
|
|
88
|
+
fix: 'x db migrate',
|
|
89
|
+
meta: {
|
|
90
|
+
version: input.mine.version,
|
|
91
|
+
schema: input.mine.schema,
|
|
92
|
+
...(input.newest === undefined ? {} : { newest: { ...input.newest } }),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/framework-schema.ts
CHANGED
|
@@ -11,7 +11,11 @@ import { ADMIN_AUDIT_TABLE, SQL_ADMIN_AUDIT_TABLE } from '@ultimat3/admin/schema
|
|
|
11
11
|
import { AUTH_TABLE_NAMES, AUTH_TABLES, SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
|
|
12
12
|
import { SQL_RATE_LIMIT_TABLE } from '@ultimat3/http';
|
|
13
13
|
import { SQL_JOBS_TABLE } from '@ultimat3/jobs';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
SQL_NOTIFY_DELIVERIES_TABLE,
|
|
16
|
+
SQL_NOTIFY_DIGESTS_TABLE,
|
|
17
|
+
SQL_NOTIFY_INBOX_TABLE,
|
|
18
|
+
} from '@ultimat3/notify';
|
|
15
19
|
import { FrameworkSchemaFailedError } from './schema-errors';
|
|
16
20
|
|
|
17
21
|
export interface FrameworkSchema {
|
|
@@ -76,7 +80,7 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
|
|
|
76
80
|
}),
|
|
77
81
|
Object.freeze({
|
|
78
82
|
pkg: '@ultimat3/auth',
|
|
79
|
-
tables: Object.freeze(['
|
|
83
|
+
tables: Object.freeze(['x_auth_lockouts']),
|
|
80
84
|
ddl: Object.freeze([SQL_AUTH_LIMIT_TABLES]),
|
|
81
85
|
}),
|
|
82
86
|
/**
|
|
@@ -113,6 +117,13 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
|
|
|
113
117
|
tables: Object.freeze(['x_notify_inbox']),
|
|
114
118
|
ddl: Object.freeze([SQL_NOTIFY_INBOX_TABLE]),
|
|
115
119
|
}),
|
|
120
|
+
// `createPgDigestStore`'s windows. Installed whether or not the app picks that store, for the
|
|
121
|
+
// reason every notify row is: `setNotifyStores` is the app's boot line and runs after this one.
|
|
122
|
+
Object.freeze({
|
|
123
|
+
pkg: '@ultimat3/notify',
|
|
124
|
+
tables: Object.freeze(['x_notify_digests']),
|
|
125
|
+
ddl: Object.freeze([SQL_NOTIFY_DIGESTS_TABLE]),
|
|
126
|
+
}),
|
|
116
127
|
/**
|
|
117
128
|
* The admin's durable audit trail — what `postgresAuditLog()` writes and every detail page's
|
|
118
129
|
* history card reads. Without this row an app that chose the durable log met `42P01` on the
|
package/src/generate-files.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// `cmd-generate.ts` so a generator's output can be asserted on — by the generator tests, the
|
|
3
3
|
// scaffold fixture and `x new` — without a command line, an app root or a filesystem.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { NotImplementedError } from '@ultimat3/core';
|
|
6
|
+
import { BadFlagError } from './errors';
|
|
6
7
|
import type { Generator } from './generate-kinds';
|
|
7
8
|
import { assertLiveSupported, assertSurfaceSupported, GENERATORS } from './generate-kinds';
|
|
8
9
|
import { dedupe } from './generate-write';
|
|
@@ -233,8 +234,8 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
233
234
|
}),
|
|
234
235
|
);
|
|
235
236
|
default:
|
|
236
|
-
throw new
|
|
237
|
-
|
|
237
|
+
throw new NotImplementedError({
|
|
238
|
+
cause: `generator "${String(options.kind)}" is not implemented in this build`,
|
|
238
239
|
fix: `x g ${GENERATORS.join('|')}`,
|
|
239
240
|
});
|
|
240
241
|
}
|
package/src/generate-kinds.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from './errors';
|
|
12
12
|
import { quoteArg } from './shell-quote';
|
|
13
13
|
import type { Surface } from './templates';
|
|
14
|
-
import { camel, kebab } from './templates/naming';
|
|
14
|
+
import { camel, kebab, plural, singularOf } from './templates/naming';
|
|
15
15
|
|
|
16
16
|
export const GENERATORS = [
|
|
17
17
|
'resource',
|
|
@@ -137,10 +137,42 @@ export function readName(raw: string | undefined, kind: Generator): string {
|
|
|
137
137
|
// directory that serves it. Every other generator's name is one directory segment.
|
|
138
138
|
if (kind === 'route') return readRoutePath(raw);
|
|
139
139
|
refusePath(raw, 'name', kind);
|
|
140
|
+
refuseNonAscii(raw, kind);
|
|
140
141
|
refuseBadIdentifier(raw, kind);
|
|
142
|
+
refusePlural(raw, kind);
|
|
141
143
|
return raw;
|
|
142
144
|
}
|
|
143
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Every derived spelling keeps `[a-zA-Z0-9]` and drops the rest as a separator, so a letter
|
|
148
|
+
* outside ASCII vanished: `x g entity Über` wrote `ber`. Refused, with the name folded to ASCII.
|
|
149
|
+
*/
|
|
150
|
+
function refuseNonAscii(raw: string, kind: Generator): void {
|
|
151
|
+
if (!/[^\p{ASCII}]/u.test(raw)) return;
|
|
152
|
+
const folded = kebab(raw.normalize('NFKD').replace(/[^\p{ASCII}]/gu, ''));
|
|
153
|
+
throw new BadFlagError({
|
|
154
|
+
flag: 'name',
|
|
155
|
+
command: `g ${kind}`,
|
|
156
|
+
reason: `"${raw}" holds a letter outside ASCII, and every file, identifier and table name the generator derives from it keeps ASCII letters and digits only`,
|
|
157
|
+
fix: folded === '' ? exampleFor(kind) : ['x g', kind, folded].join(' '),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The generators that pluralise their name into a table and a page. */
|
|
162
|
+
const PLURALISED: ReadonlySet<Generator> = new Set<Generator>(['resource', 'entity']);
|
|
163
|
+
|
|
164
|
+
/** `x g resource posts` declared `entity('postses')`: a plural name is pluralised again. */
|
|
165
|
+
function refusePlural(raw: string, kind: Generator): void {
|
|
166
|
+
const singular = PLURALISED.has(kind) ? singularOf(raw) : undefined;
|
|
167
|
+
if (singular === undefined) return;
|
|
168
|
+
throw new BadFlagError({
|
|
169
|
+
flag: 'name',
|
|
170
|
+
command: `g ${kind}`,
|
|
171
|
+
reason: `"${raw}" is already plural, and the generator pluralises the name for its table and page — "${plural(camel(raw))}"`,
|
|
172
|
+
fix: ['x g', kind, singular].join(' '),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
144
176
|
/** A static segment, or `[param]` / `[...rest]` — the directory convention `x routes` reads. */
|
|
145
177
|
const ROUTE_SEGMENT = /^(?:[A-Za-z0-9][A-Za-z0-9_-]*|\[(?:\.\.\.)?[A-Za-z][A-Za-z0-9_-]*\])$/;
|
|
146
178
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A generator name whose type spelling the emitted code ALSO uses as a global or a local alias:
|
|
2
|
+
// `x g resource promise` imports `type { Promise }` beside `Promise<Row>`, and `row` writes
|
|
3
|
+
// `type Row = Row`. Decided on the planned files themselves, so a template that starts using
|
|
4
|
+
// another global is covered the day it does — and the names that clash nowhere stay allowed.
|
|
5
|
+
|
|
6
|
+
import { BadFlagError } from './errors';
|
|
7
|
+
import type { Generator } from './generate-kinds';
|
|
8
|
+
import type { GeneratedFile } from './templates';
|
|
9
|
+
import { kebab, names } from './templates/naming';
|
|
10
|
+
|
|
11
|
+
/** The emitted file that imports `type` from the slice AND uses the same name as its own. */
|
|
12
|
+
function shadowingFile(files: readonly GeneratedFile[], type: string): string | undefined {
|
|
13
|
+
// A pascal spelling is `[A-Za-z0-9]` only (`naming.ts`), so it splices into a pattern as is.
|
|
14
|
+
const imported = new RegExp(
|
|
15
|
+
`import\\s+(?:type\\s+)?\\{[^}]*\\b${type}\\b[^}]*\\}\\s+from\\s+'\\.`,
|
|
16
|
+
);
|
|
17
|
+
const reused = new RegExp(
|
|
18
|
+
`\\b${type}<|^\\s*(?:export\\s+)?(?:type\\s+${type}\\s*=|interface\\s+${type}\\b)`,
|
|
19
|
+
'm',
|
|
20
|
+
);
|
|
21
|
+
return files.find(
|
|
22
|
+
(file) =>
|
|
23
|
+
typeof file.contents === 'string' &&
|
|
24
|
+
/\.tsx?$/.test(file.path) &&
|
|
25
|
+
imported.test(file.contents) &&
|
|
26
|
+
reused.test(file.contents),
|
|
27
|
+
)?.path;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Refuses `name` when its type spelling would shadow a name the planned files rely on. */
|
|
31
|
+
export function refuseShadowedTypes(
|
|
32
|
+
files: readonly GeneratedFile[],
|
|
33
|
+
kind: Generator,
|
|
34
|
+
name: string,
|
|
35
|
+
): void {
|
|
36
|
+
const type = names(name).pascal;
|
|
37
|
+
const file = shadowingFile(files, type);
|
|
38
|
+
if (file === undefined) return;
|
|
39
|
+
const noun = kind.split(':').at(-1) ?? kind;
|
|
40
|
+
throw new BadFlagError({
|
|
41
|
+
flag: 'name',
|
|
42
|
+
command: `g ${kind}`,
|
|
43
|
+
reason: `"${name}" becomes the type ${type}, and ${file} imports it beside its own use of ${type} — the global or local type the template means is shadowed and the file does not compile`,
|
|
44
|
+
fix: ['x g', kind, `${kebab(name)}-${noun}`].join(' '),
|
|
45
|
+
});
|
|
46
|
+
}
|
package/src/generate-write.ts
CHANGED
|
@@ -102,7 +102,7 @@ export function containedPath(root: string, path: string): string {
|
|
|
102
102
|
dir: base,
|
|
103
103
|
// Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
|
|
104
104
|
// rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
|
|
105
|
-
fix: `x g resource
|
|
105
|
+
fix: `x g resource post --dry-run # name every file relative to the app root, no ".." segment`,
|
|
106
106
|
});
|
|
107
107
|
return target;
|
|
108
108
|
}
|
|
@@ -198,14 +198,56 @@ function planFile(
|
|
|
198
198
|
return { kind: 'write', file, absolute, contents: file.contents };
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/** Every file's plan, decided without touching the disk — the first of `writeFiles`' two passes. */
|
|
202
|
+
async function planAll(
|
|
203
|
+
root: string,
|
|
204
|
+
files: readonly GeneratedFile[],
|
|
205
|
+
force: boolean,
|
|
206
|
+
invocation: string,
|
|
207
|
+
): Promise<readonly WritePlan[]> {
|
|
208
|
+
const plans: WritePlan[] = [];
|
|
209
|
+
for (const file of files) {
|
|
210
|
+
const absolute = containedPath(root, file.path);
|
|
211
|
+
plans.push(
|
|
212
|
+
file.merge === 'json'
|
|
213
|
+
? await planJsonMerge(file, absolute)
|
|
214
|
+
: planFile(file, absolute, force, invocation),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return plans;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const conflictsOf = (plans: readonly WritePlan[]): readonly Finding[] =>
|
|
221
|
+
plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* What `writeFiles` WOULD do, and nothing else: the paths it would write and the conflicts that
|
|
225
|
+
* would stop it. `x g --dry-run` answers with this — the bare file list said "would write 33"
|
|
226
|
+
* where the real run was `X_GENERATE_CONFLICT`, and listed slice modules the run skips as writes.
|
|
227
|
+
*/
|
|
228
|
+
export async function planWrites(
|
|
229
|
+
root: string,
|
|
230
|
+
files: readonly GeneratedFile[],
|
|
231
|
+
force: boolean,
|
|
232
|
+
invocation = 'x g <kind> <name>',
|
|
233
|
+
): Promise<WriteReport> {
|
|
234
|
+
const plans = await planAll(root, files, force, invocation);
|
|
235
|
+
const conflicts = conflictsOf(plans);
|
|
236
|
+
if (conflicts.length > 0) return { written: [], conflicts };
|
|
237
|
+
return {
|
|
238
|
+
written: plans.flatMap((plan) => (plan.kind === 'write' ? [plan.file.path] : [])),
|
|
239
|
+
conflicts: [],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
201
243
|
/**
|
|
202
244
|
* Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
|
|
203
245
|
* twice; a generator that lands four of seven files and then reports a conflict is worse, because
|
|
204
246
|
* the next run conflicts on the files the failed one wrote.
|
|
205
247
|
*
|
|
206
248
|
* Two passes, and the split is the point: the first decides — containment, existence, whether a
|
|
207
|
-
* catalog can be merged into — and touches nothing
|
|
208
|
-
*
|
|
249
|
+
* catalog can be merged into — and touches nothing (`planWrites` is that pass alone), the second
|
|
250
|
+
* writes only when the first found no conflict at all.
|
|
209
251
|
*/
|
|
210
252
|
export async function writeFiles(
|
|
211
253
|
root: string,
|
|
@@ -218,16 +260,8 @@ export async function writeFiles(
|
|
|
218
260
|
*/
|
|
219
261
|
invocation = 'x g <kind> <name>',
|
|
220
262
|
): Promise<WriteReport> {
|
|
221
|
-
const plans
|
|
222
|
-
|
|
223
|
-
const absolute = containedPath(root, file.path);
|
|
224
|
-
plans.push(
|
|
225
|
-
file.merge === 'json'
|
|
226
|
-
? await planJsonMerge(file, absolute)
|
|
227
|
-
: planFile(file, absolute, force, invocation),
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
|
|
263
|
+
const plans = await planAll(root, files, force, invocation);
|
|
264
|
+
const conflicts = conflictsOf(plans);
|
|
231
265
|
if (conflicts.length > 0) return { written: [], conflicts };
|
|
232
266
|
|
|
233
267
|
const written: string[] = [];
|
package/src/gh-target.ts
CHANGED
|
@@ -76,18 +76,23 @@ const PR_VIEW = t.object({ number: t.number });
|
|
|
76
76
|
* The pull request for the current branch. gh exits non-zero when there is none, with a message
|
|
77
77
|
* that names the branch — so the refusal keeps gh's own sentence and adds the remedy gh has no
|
|
78
78
|
* opinion about: name the number, or open the PR.
|
|
79
|
+
*
|
|
80
|
+
* No `--repo`: gh refuses `pr view --repo <slug>` without a number, url or branch before any
|
|
81
|
+
* network call, so that argv could never succeed. The checkout-scoped form resolves the branch's
|
|
82
|
+
* tracking remote itself — the same resolution `gh repo view` gave `resolveRepo`. `repo` names the
|
|
83
|
+
* pull request in the refusal only.
|
|
79
84
|
*/
|
|
80
85
|
export async function resolvePrNumber(host: GhHost, repo: GhRepo): Promise<number> {
|
|
81
86
|
try {
|
|
82
|
-
const viewed = await ghJson(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
{ label: 'gh pr view', fix: 'x pr review --pr 241 --json' },
|
|
87
|
-
);
|
|
87
|
+
const viewed = await ghJson(host, ['pr', 'view', '--json', 'number'], PR_VIEW, {
|
|
88
|
+
label: 'gh pr view',
|
|
89
|
+
fix: 'x pr review --pr 241 --json',
|
|
90
|
+
});
|
|
88
91
|
return viewed.number;
|
|
89
92
|
} catch (error) {
|
|
90
|
-
if (error instanceof GhFailedError)
|
|
93
|
+
if (error instanceof GhFailedError) {
|
|
94
|
+
throw new PrNotFoundError({ detail: `${repo.slug}: ${error.cause}` });
|
|
95
|
+
}
|
|
91
96
|
throw error;
|
|
92
97
|
}
|
|
93
98
|
}
|
package/src/i18n-audit.ts
CHANGED
|
@@ -28,13 +28,16 @@ import { CATALOG_ROOT, catalogPath } from './templates/locales';
|
|
|
28
28
|
* glob set every text-scanning gate step already shares (`errors`, `filesize`) — a second glob
|
|
29
29
|
* here would mean this command and `x verify` disagree on what "the app's source" is. Test files
|
|
30
30
|
* are excluded: a fixture's `t('fixture.key')` is not a gap the shipped catalogs owe an answer to.
|
|
31
|
+
* So are `guards/`: gate tooling renders nothing, and its fix lines quote `t('…')` as an example.
|
|
31
32
|
* `extractFromFiles` reads by absolute path, so its `file` label is rewritten back to the same
|
|
32
33
|
* root-relative POSIX shape `app-load.ts` uses for every other CLI-reported path.
|
|
33
34
|
*/
|
|
35
|
+
const isGuard = (file: string): boolean => file.startsWith('guards/');
|
|
36
|
+
|
|
34
37
|
export async function scanSource(root: string): Promise<Extraction> {
|
|
35
38
|
const files: string[] = [];
|
|
36
39
|
for await (const file of eachSourceFile(root)) {
|
|
37
|
-
if (!isTest(file)) files.push(file);
|
|
40
|
+
if (!isTest(file) && !isGuard(file)) files.push(file);
|
|
38
41
|
}
|
|
39
42
|
const extraction = await extractFromFiles(files.map((file) => join(root, file)));
|
|
40
43
|
const toRelative = (file: string): string => relative(root, file).split(sep).join('/');
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export { appManifest, writeAppManifest } from './app-manifest';
|
|
|
21
21
|
export { requireAppRoot } from './app-root';
|
|
22
22
|
export type { RouteStats } from './budgets';
|
|
23
23
|
export { checkBudgets, FRAMEWORK_INLINE_SCRIPTS, FRAMEWORK_SCRIPTS } from './budgets';
|
|
24
|
-
export { BUILD_ENTRY, BUILD_TARGETS, readTarget } from './cmd-build';
|
|
24
|
+
export { BUILD_ENTRY, BUILD_TARGETS, BuildEntryMissingError, readTarget } from './cmd-build';
|
|
25
25
|
export { dbCommand } from './cmd-db';
|
|
26
26
|
export { planDeploy } from './cmd-deploy';
|
|
27
27
|
export { startDev } from './cmd-dev';
|
|
@@ -69,10 +69,8 @@ export {
|
|
|
69
69
|
export { checkErrorCodesThrown } from './error-unthrown';
|
|
70
70
|
export {
|
|
71
71
|
BadFlagError,
|
|
72
|
-
BuildEntryMissingError,
|
|
73
72
|
BunVersionError,
|
|
74
73
|
CatalogExistsError,
|
|
75
|
-
CliNotImplementedError,
|
|
76
74
|
DeclarationUnknownError,
|
|
77
75
|
ErrorCodeUnknownError,
|
|
78
76
|
FixTargetUnknownError,
|