@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The `policy` step's provenance question: every permission the app's OWN actions, queries and
|
|
2
|
+
// routes require is one the app ITSELF declares. `defineAdmin()` declares `<entity>:read|write|
|
|
3
|
+
// delete` for every mounted entity, so an app rule naming one passed in the web process and threw
|
|
4
|
+
// X_PERMISSION_UNKNOWN in any process that never mounted the admin — with nothing saying so.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API, and `relative` renders a frame's absolute path against the app root.
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
import { listActions } from '@ultimat3/action';
|
|
9
|
+
import { type Policy, permissionDeclarationSites, policyPermissions } from '@ultimat3/policy';
|
|
10
|
+
import { listQueries } from '@ultimat3/query';
|
|
11
|
+
import { routeEntries } from '@ultimat3/render';
|
|
12
|
+
import { frameFile, siteFile } from './app-permissions-site';
|
|
13
|
+
import type { Finding } from './output';
|
|
14
|
+
|
|
15
|
+
/** One permission one app declaration requires. */
|
|
16
|
+
export interface AppRule {
|
|
17
|
+
readonly permission: string;
|
|
18
|
+
/** `action createPost`, `query postList`, or the route file. */
|
|
19
|
+
readonly by: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An app rule whose permission no app module declares, and the file that did declare it. */
|
|
23
|
+
export interface BorrowedPermission extends AppRule {
|
|
24
|
+
/**
|
|
25
|
+
* Root-relative paths of EVERY declaring module (`node_modules/…` or `../…`), distinct and
|
|
26
|
+
* sorted: naming only the first told the reader one package declared it when two did.
|
|
27
|
+
*/
|
|
28
|
+
readonly from: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* EVERY permission in each tree, composites included — unlike the grant question, which judges
|
|
33
|
+
* only a bare `can()`: an `or()` branch is still a `can()` that asserted its name, so it needs the
|
|
34
|
+
* app's own declaration as much as a bare one does. A screen the admin MOUNTS is not here: those
|
|
35
|
+
* are the package's rules, and declaring their names is the package's job.
|
|
36
|
+
*/
|
|
37
|
+
export function appRules(): readonly AppRule[] {
|
|
38
|
+
const named = (by: string, policy: Policy<unknown, unknown>): readonly AppRule[] =>
|
|
39
|
+
policyPermissions(policy).map((permission) => ({ permission, by }));
|
|
40
|
+
return [
|
|
41
|
+
...listActions().flatMap((target) => named(`action ${target.name}`, target.policy)),
|
|
42
|
+
...listQueries().flatMap((target) => named(`query ${target.name}`, target.policy)),
|
|
43
|
+
...routeEntries().flatMap((entry) => {
|
|
44
|
+
const permission = entry.config.policy?.permission;
|
|
45
|
+
return permission === undefined ? [] : [{ permission, by: entry.file }];
|
|
46
|
+
}),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An app module's root-relative path, or `undefined` for a dependency's or an unreadable frame. */
|
|
51
|
+
const appFile = (root: string, site: string): string | undefined => {
|
|
52
|
+
const file = siteFile(root, site);
|
|
53
|
+
return file === undefined || file.split('/').includes('node_modules') ? undefined : file;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Provenance by the declaring module's PATH, which is where the call ran — never by the name's
|
|
58
|
+
* shape: `posts:read` is the same string whether `defineAdmin()` or the app declared it. A name
|
|
59
|
+
* with any unreadable site is one whose origin nobody can state, so it is not judged — and that
|
|
60
|
+
* covers an UNKNOWN name too, which has no site at all: it is X_PERMISSION_UNKNOWN's, asked first,
|
|
61
|
+
* and a second finding would be a second instruction for one fault.
|
|
62
|
+
*/
|
|
63
|
+
export function borrowedPermissions(
|
|
64
|
+
root: string,
|
|
65
|
+
rules: readonly AppRule[] = appRules(),
|
|
66
|
+
declaredAt: Readonly<Record<string, readonly string[]>> = permissionDeclarationSites(),
|
|
67
|
+
): readonly BorrowedPermission[] {
|
|
68
|
+
const found = new Map<string, BorrowedPermission>();
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
const sites = Object.hasOwn(declaredAt, rule.permission) ? declaredAt[rule.permission] : [];
|
|
71
|
+
const files = (sites ?? []).map(frameFile);
|
|
72
|
+
if (files.length === 0 || files.some((file) => file === undefined)) continue;
|
|
73
|
+
if ((sites ?? []).some((site) => appFile(root, site) !== undefined)) continue;
|
|
74
|
+
const from = [
|
|
75
|
+
...new Set(
|
|
76
|
+
files.flatMap((file) =>
|
|
77
|
+
file === undefined ? [] : [relative(root, file).replaceAll('\\', '/')],
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
].sort();
|
|
81
|
+
found.set(`${rule.permission}\u0000${rule.by}`, { ...rule, from });
|
|
82
|
+
}
|
|
83
|
+
return [...found.values()].sort((a, b) =>
|
|
84
|
+
a.permission === b.permission
|
|
85
|
+
? a.by.localeCompare(b.by)
|
|
86
|
+
: a.permission.localeCompare(b.permission),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The app file its permissions are declared in — where the missing name is added. Read off the
|
|
92
|
+
* recorded sites, never a scaffold path; none under the root is `undefined`, never a guess.
|
|
93
|
+
*/
|
|
94
|
+
export function permissionsFile(root: string): string | undefined {
|
|
95
|
+
for (const sites of Object.values(permissionDeclarationSites())) {
|
|
96
|
+
for (const site of sites) {
|
|
97
|
+
const file = appFile(root, site);
|
|
98
|
+
if (file !== undefined) return file;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A TS string literal for the name — quoted the house way unless the name itself needs escaping. */
|
|
105
|
+
const literal = (name: string): string =>
|
|
106
|
+
/^[\w:.*-]+$/.test(name) ? `'${name}'` : JSON.stringify(name);
|
|
107
|
+
|
|
108
|
+
export function borrowedFinding(
|
|
109
|
+
entry: BorrowedPermission,
|
|
110
|
+
declaredIn: string | undefined,
|
|
111
|
+
): Finding {
|
|
112
|
+
const [only, ...more] = entry.from;
|
|
113
|
+
const declarers =
|
|
114
|
+
more.length === 0 ? `only ${only} declares it` : `only ${entry.from.join(', ')} declare it`;
|
|
115
|
+
const where =
|
|
116
|
+
declaredIn === undefined
|
|
117
|
+
? 'paste this call into an app module'
|
|
118
|
+
: `paste this call into ${declaredIn} (definePermissions() merges, so a second call is fine)`;
|
|
119
|
+
const finding: Finding = {
|
|
120
|
+
code: 'X_PERMISSION_BORROWED',
|
|
121
|
+
cause:
|
|
122
|
+
`${entry.by} requires ${literal(entry.permission)}, and ${declarers} — no app module does, ` +
|
|
123
|
+
"so the rule holds only in a process that ran that package's declaration (defineAdmin()) " +
|
|
124
|
+
`first; ${where}, then x verify --only policy`,
|
|
125
|
+
// A call to paste, never prose: `add …` read as a command answered `add: command not found`.
|
|
126
|
+
fix: `definePermissions([${literal(entry.permission)}])`,
|
|
127
|
+
};
|
|
128
|
+
return declaredIn === undefined ? finding : { ...finding, at: declaredIn };
|
|
129
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: a declaration site — a stack FRAME `@ultimat3/policy` recorded for a
|
|
2
|
+
// `defineRoles()` or `definePermissions()` call — reduced to the file it names, absolute or
|
|
3
|
+
// app-root-relative. Both `policy` step questions key their findings by it.
|
|
4
|
+
|
|
5
|
+
// why: Bun ships no path API, and `relative` is what turns a stack frame's absolute path into
|
|
6
|
+
// the app-root-relative one every finding is keyed by.
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/** The absolute source path a frame (`at foo (/abs/path.ts:3:1)`) names, or `undefined`. */
|
|
10
|
+
export function frameFile(site: string): string | undefined {
|
|
11
|
+
return /\(?(\/[^():]+\.[cm]?tsx?)(?::\d+)?(?::\d+)?\)?/.exec(site)?.[1];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The frame's file relative to the app root, or `undefined` outside it: a locator nobody can act on
|
|
16
|
+
* is worse than none. The frame is the only place the declaring module's name exists by the time
|
|
17
|
+
* the declaration runs.
|
|
18
|
+
*/
|
|
19
|
+
export function siteFile(root: string, site: string): string | undefined {
|
|
20
|
+
const absolute = frameFile(site);
|
|
21
|
+
if (absolute === undefined) return undefined;
|
|
22
|
+
const rel = relative(root, absolute).replaceAll('\\', '/');
|
|
23
|
+
return rel.startsWith('..') || rel.length === 0 ? undefined : rel;
|
|
24
|
+
}
|
package/src/app-permissions.ts
CHANGED
|
@@ -7,9 +7,6 @@
|
|
|
7
7
|
// required it on `/dashboard`, declared it nowhere, and served HTTP 500 on two of its three routes
|
|
8
8
|
// with `x verify` green (#F1). This is the question nothing asked.
|
|
9
9
|
|
|
10
|
-
// why: Bun ships no path API, and `relative` is what turns a stack frame's absolute path into
|
|
11
|
-
// the app-root-relative one every finding is keyed by.
|
|
12
|
-
import { relative } from 'node:path';
|
|
13
10
|
import {
|
|
14
11
|
grantMatches,
|
|
15
12
|
isKnownPermission,
|
|
@@ -21,6 +18,8 @@ import {
|
|
|
21
18
|
import { routeEntries } from '@ultimat3/render';
|
|
22
19
|
import { definedStorage } from '@ultimat3/storage';
|
|
23
20
|
import { loadApp } from './app-load';
|
|
21
|
+
import { borrowedFinding, borrowedPermissions, permissionsFile } from './app-permissions-borrowed';
|
|
22
|
+
import { siteFile } from './app-permissions-site';
|
|
24
23
|
import type { DuplicateProbe } from './duplicate-packages';
|
|
25
24
|
import { duplicateFinding, findDuplicateInstalls } from './duplicate-packages';
|
|
26
25
|
import type { Finding } from './output';
|
|
@@ -45,19 +44,6 @@ export interface PermissionReference {
|
|
|
45
44
|
/** What this check needs of a boot — the seam `i18n-registration.ts` already established. */
|
|
46
45
|
export type AppLoader = (root: string) => Promise<{ readonly findings: readonly Finding[] }>;
|
|
47
46
|
|
|
48
|
-
/**
|
|
49
|
-
* A role's declaration site is a stack FRAME (`at foo (/abs/path.ts:3:1)`), which is the only
|
|
50
|
-
* place the declaring module's name exists by the time `defineRoles` runs. Reduced to the
|
|
51
|
-
* app-relative path an agent opens, or dropped: a locator nobody can act on is worse than none.
|
|
52
|
-
*/
|
|
53
|
-
export function siteFile(root: string, site: string): string | undefined {
|
|
54
|
-
const match = /\(?(\/[^():]+\.[cm]?tsx?)(?::\d+)?(?::\d+)?\)?/.exec(site);
|
|
55
|
-
const absolute = match?.[1];
|
|
56
|
-
if (absolute === undefined) return undefined;
|
|
57
|
-
const rel = relative(root, absolute).replaceAll('\\', '/');
|
|
58
|
-
return rel.startsWith('..') || rel.length === 0 ? undefined : rel;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
47
|
/** Every permission a registered role grants, with where that role was declared. */
|
|
62
48
|
export function grantedReferences(root: string): readonly PermissionReference[] {
|
|
63
49
|
const sites = roleDeclarationSites();
|
|
@@ -198,7 +184,11 @@ export async function policyFindings(
|
|
|
198
184
|
unknown.length === 0
|
|
199
185
|
? ungrantedRequirements().map((rule) => ungrantedFinding(rule, rolesAt))
|
|
200
186
|
: [];
|
|
201
|
-
|
|
187
|
+
// Asked of KNOWN names only (`borrowedPermissions` skips the rest), so it rides beside the
|
|
188
|
+
// unknown half rather than behind it: one fault, one finding.
|
|
189
|
+
const declaredIn = permissionsFile(root);
|
|
190
|
+
const borrowed = borrowedPermissions(root).map((entry) => borrowedFinding(entry, declaredIn));
|
|
191
|
+
const findings = [...duplicated, ...unknown, ...borrowed, ...ungranted];
|
|
202
192
|
return findings.length === 0 ? [] : [...findings, ...app.findings];
|
|
203
193
|
}
|
|
204
194
|
|
package/src/budgets.ts
CHANGED
|
@@ -39,15 +39,6 @@ export interface RouteStats {
|
|
|
39
39
|
* older build simply did not count it, and `checkBudgets` reads `jsBytes` either way.
|
|
40
40
|
*/
|
|
41
41
|
readonly frameworkJsBytes?: number;
|
|
42
|
-
/**
|
|
43
|
-
* **Written by nothing, `As of 2026-08`.** `apps/web/prerender.ts` is the only producer of this
|
|
44
|
-
* file and it emits static HTML — there is no browser in the build to observe a paint. So the
|
|
45
|
-
* comparison in `checkBudgets` below is reachable only for an app that writes its own stats, and
|
|
46
|
-
* `x new` no longer scaffolds an `lcp` budget for exactly that reason: a budget the build cannot
|
|
47
|
-
* weigh passes silently the moment a stats row exists, which is the false green this file's
|
|
48
|
-
* header is about. `RouteBudget.lcp` still accepts one — that key is `@ultimat3/render`'s.
|
|
49
|
-
*/
|
|
50
|
-
readonly lcpMs?: number;
|
|
51
42
|
/** Import chain that pulled the heaviest module into this route. */
|
|
52
43
|
readonly heaviestChain?: readonly string[];
|
|
53
44
|
}
|
|
@@ -67,8 +58,9 @@ export interface BuildStats {
|
|
|
67
58
|
* `examples/dummy` was charged 250 B for `/x-sw-register.js` by a file written before
|
|
68
59
|
* `FRAMEWORK_SCRIPTS` exempted it. `2`: that exemption and the page-boot decision (ledger #28).
|
|
69
60
|
* `3`: an island entry's imported chunks are charged, each once per document (shared chunks).
|
|
61
|
+
* `4`: the exempt theme script's bytes moved — it also stamps `data-theme-default` (plan 101, 09).
|
|
70
62
|
*/
|
|
71
|
-
export const BUILD_STATS_RULES =
|
|
63
|
+
export const BUILD_STATS_RULES = 4;
|
|
72
64
|
|
|
73
65
|
const chainOf = (stats: RouteStats): string =>
|
|
74
66
|
stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
|
|
@@ -94,14 +86,6 @@ function exceededJsFix(url: string, file: string | undefined, bytes: number): st
|
|
|
94
86
|
return `edit ${where} — set budget: { js: '${String(kb)}kb' } with // measured: ${String(bytes)} B (x build --target static) — why: <the function it buys> directly above it; or x routes --json for the chain and move the heavy import behind hydrate: 'interaction'`;
|
|
95
87
|
}
|
|
96
88
|
|
|
97
|
-
/** Which budgets the route declared, for a cause line that names what went unmeasured. */
|
|
98
|
-
function declaredBudgets(js: number | null, lcp: number | undefined): string {
|
|
99
|
-
const labels: string[] = [];
|
|
100
|
-
if (js !== null) labels.push('JS');
|
|
101
|
-
if (lcp !== undefined) labels.push('LCP');
|
|
102
|
-
return labels.join(' and ');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
89
|
/**
|
|
106
90
|
* Compare declared budgets against measured stats. A declared budget with no measurement is a
|
|
107
91
|
* finding, never a pass: a route that clears the gate without ever being weighed is exactly the
|
|
@@ -210,17 +194,11 @@ export function checkBudgets(
|
|
|
210
194
|
if (unweighable.has(route.url)) continue;
|
|
211
195
|
const measured = byPath.get(route.url);
|
|
212
196
|
const js = jsBudgetOf(route);
|
|
213
|
-
const lcp = route.budget?.lcp;
|
|
214
197
|
if (measured === undefined) {
|
|
215
|
-
if (js !== null
|
|
198
|
+
if (js !== null) {
|
|
216
199
|
findings.push(
|
|
217
200
|
ownCodeFinding(route.url, unmeasured) ??
|
|
218
|
-
unmeasuredFinding(
|
|
219
|
-
route.url,
|
|
220
|
-
declaredBudgets(js, lcp),
|
|
221
|
-
stats !== undefined,
|
|
222
|
-
stats?.stale === true,
|
|
223
|
-
),
|
|
201
|
+
unmeasuredFinding(route.url, 'JS', stats !== undefined, stats?.stale === true),
|
|
224
202
|
);
|
|
225
203
|
}
|
|
226
204
|
continue;
|
|
@@ -234,15 +212,6 @@ export function checkBudgets(
|
|
|
234
212
|
at: route.url,
|
|
235
213
|
});
|
|
236
214
|
}
|
|
237
|
-
if (lcp !== undefined && measured.lcpMs !== undefined && measured.lcpMs > lcp) {
|
|
238
|
-
findings.push({
|
|
239
|
-
code: 'X_BUDGET_EXCEEDED',
|
|
240
|
-
cause: `${route.url} LCP ${measured.lcpMs}ms over the ${lcp}ms budget`,
|
|
241
|
-
fix: `raise the budget in defineRoute, or switch render to 'isr' to serve it prebuilt`,
|
|
242
|
-
docs: ERROR_DOCS_URL,
|
|
243
|
-
at: route.url,
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
215
|
}
|
|
247
216
|
return findings;
|
|
248
217
|
}
|
package/src/cdp-shot-driver.ts
CHANGED
|
@@ -91,7 +91,9 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
|
|
|
91
91
|
throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
92
92
|
}
|
|
93
93
|
const launched = await launch(options.executablePath, timeoutMs);
|
|
94
|
-
|
|
94
|
+
// Awaited by `close` below: `x shot` exits right after its last picture, and a reap still
|
|
95
|
+
// running then is a Chrome child left behind and a profile directory it re-creates.
|
|
96
|
+
return { connection: launched.connection, end: () => launched.close() };
|
|
95
97
|
};
|
|
96
98
|
|
|
97
99
|
return {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// `x auth`'s declaration, apart from its body: the parser, `x help` and the `errors` step
|
|
2
|
+
// read it without loading `cmd-auth.ts`, which `registry.ts` imports only when the command runs.
|
|
3
|
+
|
|
4
|
+
import type { CommandSpec } from './parse';
|
|
5
|
+
|
|
6
|
+
export const AUTH_SUBCOMMANDS = ['seal-mfa'] as const;
|
|
7
|
+
|
|
8
|
+
export const authSpec: CommandSpec = {
|
|
9
|
+
name: 'auth',
|
|
10
|
+
summary: 'one-shot maintenance of the auth tables: seal every plaintext second-factor secret',
|
|
11
|
+
usage: 'x auth seal-mfa [--json]',
|
|
12
|
+
requiresApp: true,
|
|
13
|
+
// No default: the one subcommand WRITES, and a bare `x auth` must not.
|
|
14
|
+
subcommands: [...AUTH_SUBCOMMANDS],
|
|
15
|
+
flags: [],
|
|
16
|
+
};
|
package/src/cmd-auth.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `x auth seal-mfa` — seal every `x_users.mfa_secret` still stored in the clear, in place, under
|
|
2
|
+
// the app's master key. The one-shot an upgrade onto sealed second-factor secrets owes: readers
|
|
3
|
+
// refuse a plaintext value (`X_MFA_SECRET_UNSEALED`), and this is the command that refusal names.
|
|
4
|
+
// Idempotent — a sealed value is counted and left alone — so an interrupted run is finished by
|
|
5
|
+
// running it again.
|
|
6
|
+
|
|
7
|
+
import type { MfaSecretStore } from '@ultimat3/auth';
|
|
8
|
+
import { BuiltinAdapter, sealMfaSecrets } from '@ultimat3/auth';
|
|
9
|
+
import { requireAppRoot } from './app-root';
|
|
10
|
+
import { authSpec } from './cmd-auth-spec';
|
|
11
|
+
import type { CliCommand, CommandContext } from './command';
|
|
12
|
+
import { ok } from './command';
|
|
13
|
+
import { msg } from './messages';
|
|
14
|
+
import type { CommandResult } from './output';
|
|
15
|
+
import { resolveServices } from './runtime-bindings';
|
|
16
|
+
import { startQueue } from './runtime-queue';
|
|
17
|
+
|
|
18
|
+
export interface OpenAuthStore extends MfaSecretStore {
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The app's own database, opened the way every other command opens it (`startQueue`: the
|
|
24
|
+
* framework's tables are applied first, so `x_users` exists) and ALWAYS released — a CLI that
|
|
25
|
+
* exits holding the PGlite lock breaks the next command run against this app.
|
|
26
|
+
*/
|
|
27
|
+
async function openAppAuthStore(root: string, ctx: CommandContext): Promise<OpenAuthStore> {
|
|
28
|
+
const queue = await startQueue(resolveServices(root, ctx.env), undefined, ctx.env);
|
|
29
|
+
return { adapter: new BuiltinAdapter(queue.db), close: () => queue.stop() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `open` is the seam a test supplies a store through; the shipped command opens the app's. */
|
|
33
|
+
export const authCommandOver = (
|
|
34
|
+
open: (root: string, ctx: CommandContext) => Promise<OpenAuthStore>,
|
|
35
|
+
): CliCommand => ({
|
|
36
|
+
spec: authSpec,
|
|
37
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
38
|
+
const root = requireAppRoot('auth', ctx.cwd).dir;
|
|
39
|
+
const store = await open(root, ctx);
|
|
40
|
+
try {
|
|
41
|
+
// The key is read from the command's own environment and the app's `.secrets.key`: with
|
|
42
|
+
// neither, the first seal refuses (`X_SEAL_KEY_MISSING`) before any row is written.
|
|
43
|
+
const report = await sealMfaSecrets(store, { root, env: ctx.env });
|
|
44
|
+
return ok(
|
|
45
|
+
'auth',
|
|
46
|
+
msg('cli.auth.sealed', {
|
|
47
|
+
sealed: report.sealed,
|
|
48
|
+
already: report.alreadySealed,
|
|
49
|
+
skipped: report.skipped,
|
|
50
|
+
}),
|
|
51
|
+
{
|
|
52
|
+
data: {
|
|
53
|
+
sealed: report.sealed,
|
|
54
|
+
alreadySealed: report.alreadySealed,
|
|
55
|
+
skipped: report.skipped,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
} finally {
|
|
60
|
+
await store.close();
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const authCommand: CliCommand = authCommandOver(openAppAuthStore);
|
package/src/cmd-build.ts
CHANGED
|
@@ -4,19 +4,21 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join, resolve } from 'node:path';
|
|
7
|
-
import { ERROR_DOCS_URL, frameworkVersion, VERSION_DEFINE } from '@ultimat3/core';
|
|
7
|
+
import { ERROR_DOCS_URL, frameworkVersion, UltimateError, VERSION_DEFINE } from '@ultimat3/core';
|
|
8
8
|
import { requireAppRoot } from './app-root';
|
|
9
9
|
import { buildSpec } from './cmd-build-spec';
|
|
10
10
|
import { runVerify } from './cmd-verify';
|
|
11
11
|
import type { CliCommand, CommandContext } from './command';
|
|
12
12
|
import { externalArgs } from './compile-externals';
|
|
13
|
-
import { BadFlagError,
|
|
13
|
+
import { BadFlagError, UnknownCommandError } from './errors';
|
|
14
14
|
import type { ExecResult } from './exec';
|
|
15
15
|
import { execOutput } from './exec';
|
|
16
16
|
import { msg } from './messages';
|
|
17
17
|
import type { CommandResult } from './output';
|
|
18
|
+
import type { ParsedArgs } from './parse';
|
|
18
19
|
import { flagString } from './parse';
|
|
19
20
|
import { PREBUILT_DIR } from './serve-prebuilt-paths';
|
|
21
|
+
import { quoteArg } from './shell-quote';
|
|
20
22
|
import type { StaticReport } from './static-report';
|
|
21
23
|
import {
|
|
22
24
|
readStaticReport,
|
|
@@ -24,6 +26,22 @@ import {
|
|
|
24
26
|
renderStaticReport,
|
|
25
27
|
staticReportData,
|
|
26
28
|
} from './static-report';
|
|
29
|
+
import type { VerifyStepName } from './verify-step';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A build target names an entry file the app does not have. `x build` refuses before it spawns the
|
|
33
|
+
* builder: `bun build`'s own "module not found" says nothing about which file an Ultimate app is
|
|
34
|
+
* supposed to own, and `docker build`'s says nothing about which target wanted it.
|
|
35
|
+
*/
|
|
36
|
+
export class BuildEntryMissingError extends UltimateError {
|
|
37
|
+
constructor(input: { target: string; entry: string }) {
|
|
38
|
+
super({
|
|
39
|
+
code: 'X_BUILD_ENTRY_MISSING',
|
|
40
|
+
cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
|
|
41
|
+
fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
27
45
|
|
|
28
46
|
export const BUILD_TARGETS = ['docker', 'binary', 'static', 'prebuilt'] as const;
|
|
29
47
|
|
|
@@ -190,6 +208,46 @@ export function buildResult(input: {
|
|
|
190
208
|
};
|
|
191
209
|
}
|
|
192
210
|
|
|
211
|
+
/** The flags only some targets read. */
|
|
212
|
+
const TARGET_SCOPED_FLAGS = ['tag', 'out', 'preflight'] as const;
|
|
213
|
+
|
|
214
|
+
type TargetScopedFlag = (typeof TARGET_SCOPED_FLAGS)[number];
|
|
215
|
+
|
|
216
|
+
/** The optional flags each target READS. One table, so a flag cannot be read by one and ignored. */
|
|
217
|
+
const TARGET_FLAGS: ReadonlyMap<BuildTarget, readonly TargetScopedFlag[]> = new Map([
|
|
218
|
+
['docker', ['tag', 'preflight']],
|
|
219
|
+
['binary', ['out', 'preflight']],
|
|
220
|
+
['static', ['out', 'preflight']],
|
|
221
|
+
// The boot reads ONE place, and the image holds no devDependencies to run a gate with.
|
|
222
|
+
['prebuilt', []],
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
/** Why a target takes no `--<flag>`, in the words its refusal prints. */
|
|
226
|
+
const UNREAD_BECAUSE: Readonly<Record<TargetScopedFlag, string>> = {
|
|
227
|
+
tag: 'only the docker target tags an image',
|
|
228
|
+
out: 'the docker target writes an image, not a path',
|
|
229
|
+
preflight: 'the prebuilt target runs no gate: `x verify` ran before `docker build` was called',
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A flag the chosen target never reads is refused before the gate runs. Accepted and dropped, it
|
|
234
|
+
* printed a green build: `--out dist` on `docker` and `--tag` on `binary` read as if they landed.
|
|
235
|
+
*/
|
|
236
|
+
function refuseUnreadFlags(args: ParsedArgs, target: BuildTarget): void {
|
|
237
|
+
for (const flag of TARGET_SCOPED_FLAGS) {
|
|
238
|
+
if (!args.flags.has(flag) || TARGET_FLAGS.get(target)?.includes(flag) === true) continue;
|
|
239
|
+
throw new BadFlagError({
|
|
240
|
+
flag,
|
|
241
|
+
command: 'build',
|
|
242
|
+
reason:
|
|
243
|
+
target === 'prebuilt' && flag !== 'preflight'
|
|
244
|
+
? `the prebuilt target writes ${PREBUILT_DIR}, the one place a container boots from, and takes no --${flag}`
|
|
245
|
+
: `--target ${target} never reads it: ${UNREAD_BECAUSE[flag]}`,
|
|
246
|
+
fix: `x build --target ${quoteArg(target)}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
193
251
|
/**
|
|
194
252
|
* `x build --target prebuilt`: the island chunks and compiled stylesheets, written by the process
|
|
195
253
|
* `docker build` runs after the source is copied in. No gate and no subprocess — the image holds
|
|
@@ -198,17 +256,7 @@ export function buildResult(input: {
|
|
|
198
256
|
* A module that would not import fails the image build rather than warning into a build log
|
|
199
257
|
* nobody reads: its stylesheets are missing from the store, and every pod would compile them.
|
|
200
258
|
*/
|
|
201
|
-
async function buildPrebuilt(
|
|
202
|
-
// The boot reads ONE place; a flag that seemed to move it would write a store no pod opens.
|
|
203
|
-
for (const flag of ['tag', 'out']) {
|
|
204
|
-
if (flagString(ctx.args, flag) === undefined) continue;
|
|
205
|
-
throw new BadFlagError({
|
|
206
|
-
flag,
|
|
207
|
-
command: 'build',
|
|
208
|
-
reason: `the prebuilt target writes ${PREBUILT_DIR}, the one place a container boots from, and takes no --${flag}`,
|
|
209
|
-
fix: 'x build --target prebuilt',
|
|
210
|
-
});
|
|
211
|
-
}
|
|
259
|
+
async function buildPrebuilt(root: string): Promise<CommandResult> {
|
|
212
260
|
const started = Bun.nanoseconds();
|
|
213
261
|
const { prebuildImage } = await import('./image-prepare');
|
|
214
262
|
const built = await prebuildImage(root);
|
|
@@ -230,7 +278,7 @@ async function buildPrebuilt(ctx: CommandContext, root: string): Promise<Command
|
|
|
230
278
|
}
|
|
231
279
|
|
|
232
280
|
/** The static steps `x build` runs before it builds, and `--no-preflight` leaves to the gate. */
|
|
233
|
-
export const PREFLIGHT_STEPS: readonly
|
|
281
|
+
export const PREFLIGHT_STEPS: readonly VerifyStepName[] = [
|
|
234
282
|
'typecheck',
|
|
235
283
|
'lint',
|
|
236
284
|
'boundaries',
|
|
@@ -248,7 +296,8 @@ export const buildCommand: CliCommand = {
|
|
|
248
296
|
// typecheck, and eight seconds of `tsc` ahead of "that file does not exist" is eight seconds
|
|
249
297
|
// an agent spends on the wrong question.
|
|
250
298
|
requireEntry(root, target);
|
|
251
|
-
|
|
299
|
+
refuseUnreadFlags(ctx.args, target);
|
|
300
|
+
if (target === 'prebuilt') return buildPrebuilt(root);
|
|
252
301
|
|
|
253
302
|
// Run static verify steps before building — unless the caller is a gate that runs the same
|
|
254
303
|
// six steps itself right after (`bin/check`: `x build --no-preflight && x verify`), where the
|
package/src/cmd-db-backfill.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { BadFlagError } from './errors';
|
|
|
23
23
|
import { withJobDriver } from './jobs-driver';
|
|
24
24
|
import { backfillToJson } from './jobs-json';
|
|
25
25
|
import { msg } from './messages';
|
|
26
|
-
import type { CommandResult } from './output';
|
|
26
|
+
import type { CommandResult, Finding } from './output';
|
|
27
27
|
import { findingFrom } from './output';
|
|
28
28
|
import type { ParsedArgs } from './parse';
|
|
29
29
|
import { flagBool, flagString } from './parse';
|
|
@@ -171,15 +171,22 @@ async function runBackfillList(ctx: CommandContext, root: string): Promise<Comma
|
|
|
171
171
|
* The alarm the framework did not have. Non-zero when anything is unswept, so a cron or a deploy
|
|
172
172
|
* check can read the exit code — a `--json` nobody has to parse to know something is wrong.
|
|
173
173
|
* `loadApp` first: importing the app's modules IS the declaration, and a diff run without it
|
|
174
|
-
* would report a clean database against an empty declaration list.
|
|
174
|
+
* would report a clean database against an empty declaration list. Its findings fail the run for
|
|
175
|
+
* the same reason: a module that would not import may be the one declaring the unswept backfill.
|
|
175
176
|
*/
|
|
176
177
|
async function runBackfillPending(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
177
|
-
await loadApp(root);
|
|
178
|
+
const { findings: loadFindings } = await loadApp(root);
|
|
178
179
|
const environment = resolveEnvironment({ env: ctx.env });
|
|
179
180
|
return withJobDriver(root, ctx, async (driver) => {
|
|
180
181
|
const report = await pendingReport(driver, environment);
|
|
182
|
+
const findings = [
|
|
183
|
+
...report.pending.map((row) =>
|
|
184
|
+
findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
|
|
185
|
+
),
|
|
186
|
+
...loadFindings,
|
|
187
|
+
];
|
|
181
188
|
return {
|
|
182
|
-
ok:
|
|
189
|
+
ok: findings.length === 0,
|
|
183
190
|
command: 'db',
|
|
184
191
|
summary:
|
|
185
192
|
report.pending.length === 0
|
|
@@ -188,9 +195,7 @@ async function runBackfillPending(ctx: CommandContext, root: string): Promise<Co
|
|
|
188
195
|
count: report.pending.length,
|
|
189
196
|
declared: report.rows.length,
|
|
190
197
|
}),
|
|
191
|
-
findings
|
|
192
|
-
findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
|
|
193
|
-
),
|
|
198
|
+
findings,
|
|
194
199
|
lines: report.rows.length === 0 ? [] : renderPendingTable(report).map((line) => ` ${line}`),
|
|
195
200
|
data: pendingToJson(report),
|
|
196
201
|
};
|
|
@@ -208,7 +213,7 @@ async function runBackfillPass(
|
|
|
208
213
|
root: string,
|
|
209
214
|
names: readonly string[] | 'all',
|
|
210
215
|
): Promise<CommandResult> {
|
|
211
|
-
await loadApp(root);
|
|
216
|
+
const { findings: loadFindings } = await loadApp(root);
|
|
212
217
|
const environment = resolveEnvironment({ env: ctx.env });
|
|
213
218
|
const write = flagBool(ctx.args, 'write');
|
|
214
219
|
return withJobDriver(root, ctx, async (driver) => {
|
|
@@ -220,16 +225,24 @@ async function runBackfillPass(
|
|
|
220
225
|
environment,
|
|
221
226
|
appliedMigrations: await readAppliedMigrations(),
|
|
222
227
|
});
|
|
223
|
-
return backfillPassResult(rows, write);
|
|
228
|
+
return backfillPassResult(rows, write, loadFindings);
|
|
224
229
|
});
|
|
225
230
|
}
|
|
226
231
|
|
|
227
232
|
/**
|
|
228
233
|
* A blocked or deduped name is a finding and a non-zero exit, and every OTHER name still ran —
|
|
229
|
-
* that isolation is what stops one wedged cleanup blocking every later one forever.
|
|
234
|
+
* that isolation is what stops one wedged cleanup blocking every later one forever. A module that
|
|
235
|
+
* would not import fails the pass too: `--all` over it plans every backfill but the unloaded ones.
|
|
230
236
|
*/
|
|
231
|
-
function backfillPassResult(
|
|
232
|
-
|
|
237
|
+
function backfillPassResult(
|
|
238
|
+
rows: readonly BackfillPlanRow[],
|
|
239
|
+
write: boolean,
|
|
240
|
+
loadFindings: readonly Finding[],
|
|
241
|
+
): CommandResult {
|
|
242
|
+
const findings = [
|
|
243
|
+
...rows.flatMap((row) => (row.finding === null ? [] : [row.finding])),
|
|
244
|
+
...loadFindings,
|
|
245
|
+
];
|
|
233
246
|
// Counted per action, never derived from the total: a deduped pass is neither enqueued nor
|
|
234
247
|
// blocked, and `rows.length - enqueued` reported it as blocked while `--json` reported it as
|
|
235
248
|
// deduped. `planToJson` is the same list, so the two renders now add up to the same run.
|
package/src/cmd-db.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// directory tree. `node:path` for `join` — Bun exposes no path joiner.
|
|
12
12
|
import { rm } from 'node:fs/promises';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
-
import { resolveEnvironment } from '@ultimat3/core';
|
|
14
|
+
import { NotImplementedError, resolveEnvironment } from '@ultimat3/core';
|
|
15
15
|
import { type DriftReport, driftError, withTransaction } from '@ultimat3/db';
|
|
16
16
|
import { postgresDriver } from '@ultimat3/entity';
|
|
17
17
|
import { requireAppRoot } from './app-root';
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
selectSeeds,
|
|
41
41
|
} from './db-seed';
|
|
42
42
|
import { DevAlreadyRunningError, liveDevLock } from './dev-lock';
|
|
43
|
-
import {
|
|
43
|
+
import { MissingSubcommandError, UnknownCommandError } from './errors';
|
|
44
44
|
import { withJobDriver } from './jobs-driver';
|
|
45
45
|
import { msg } from './messages';
|
|
46
46
|
import type { CommandResult, Finding } from './output';
|
|
@@ -254,8 +254,8 @@ async function runMigrate(
|
|
|
254
254
|
async function runReset(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
255
255
|
const services = resolveServices(root, ctx.env);
|
|
256
256
|
if (services.db.mode === 'external') {
|
|
257
|
-
throw new
|
|
258
|
-
|
|
257
|
+
throw new NotImplementedError({
|
|
258
|
+
cause: 'x db reset against an external Postgres is not implemented in this build',
|
|
259
259
|
fix: 'drop and recreate the database yourself, then run: x db migrate',
|
|
260
260
|
});
|
|
261
261
|
}
|
|
@@ -301,7 +301,8 @@ async function runSeed(ctx: CommandContext, root: string): Promise<CommandResult
|
|
|
301
301
|
});
|
|
302
302
|
if (chosen.length === 0) {
|
|
303
303
|
return {
|
|
304
|
-
|
|
304
|
+
// A seed module that would not import may hold the seed that was asked for.
|
|
305
|
+
ok: discovery.findings.length === 0,
|
|
305
306
|
command: 'db',
|
|
306
307
|
summary: msg('cli.db.seed.none'),
|
|
307
308
|
findings: discovery.findings,
|
package/src/cmd-deploy-helm.ts
CHANGED
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
// why: Bun exposes no path-join primitive; the chart path is handed to helm as one joined string.
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { UltimateError } from '@ultimat3/core';
|
|
11
|
-
import {
|
|
12
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
11
|
+
import { loadAppConfig } from './app-config-load';
|
|
13
12
|
import { BadFlagError } from './errors';
|
|
14
13
|
|
|
15
14
|
/** Long enough for a migration a pre-upgrade hook must finish; helm's own 5m default is not. */
|
|
@@ -67,16 +66,18 @@ export function readLabel(flag: 'namespace' | 'release', raw: string): string {
|
|
|
67
66
|
*/
|
|
68
67
|
export async function readReleaseName(root: string, flag: string | undefined): Promise<string> {
|
|
69
68
|
if (flag !== undefined) return readLabel('release', flag);
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
const name =
|
|
69
|
+
const config = await loadAppConfig(root);
|
|
70
|
+
// `unknown`, not the declared `string`: core reads an absent name as `"undefined"` and lets it by.
|
|
71
|
+
const name: unknown = config?.name;
|
|
73
72
|
if (typeof name === 'string' && name.length <= RELEASE_MAX && LABEL.test(name)) return name;
|
|
74
73
|
throw new UltimateError({
|
|
75
74
|
code: 'X_CONFIG_INVALID',
|
|
76
75
|
cause:
|
|
77
76
|
typeof name === 'string'
|
|
78
77
|
? `app.config.ts names the app "${name.slice(0, 80)}", which is not a helm release name (a DNS-1123 label of at most ${RELEASE_MAX} characters)`
|
|
79
|
-
:
|
|
78
|
+
: config === undefined
|
|
79
|
+
? `${root} has no app.config.ts, and x deploy --method helm names the release after its config.name`
|
|
80
|
+
: 'app.config.ts exports no config.name, and x deploy --method helm names the release after it',
|
|
80
81
|
fix: 'name the release explicitly: x deploy --method helm --release my-app --json',
|
|
81
82
|
});
|
|
82
83
|
}
|