@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/island-bundle.ts
CHANGED
|
@@ -22,8 +22,7 @@
|
|
|
22
22
|
// POSIX by construction), `join`/`basename` the filesystem side.
|
|
23
23
|
import { basename, join, posix, relative, sep } from 'node:path';
|
|
24
24
|
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
25
|
-
import {
|
|
26
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
25
|
+
import { loadAppConfig } from './app-config-load';
|
|
27
26
|
import { IslandBuildFailedError } from './errors';
|
|
28
27
|
import { describeBuildError, sourcesContentOf, stableChunk, stripDebugId } from './island-identity';
|
|
29
28
|
import type { BuiltOutput, LinkedFile } from './island-link';
|
|
@@ -357,20 +356,9 @@ export interface BuildIslandsOptions {
|
|
|
357
356
|
readonly sharedChunks?: boolean;
|
|
358
357
|
}
|
|
359
358
|
|
|
360
|
-
/**
|
|
361
|
-
* `islands.sharedChunks` as `defineConfig` validated it — `false` for an app with no config file or
|
|
362
|
-
* none of it. Structural, never `instanceof`, for `loadNavigation`'s reason.
|
|
363
|
-
*/
|
|
359
|
+
/** `islands.sharedChunks` off the one loader (`app-config-load.ts`) — `false` with no config file. */
|
|
364
360
|
export async function loadSharedChunks(root: string): Promise<boolean> {
|
|
365
|
-
|
|
366
|
-
if (!(await Bun.file(configPath).exists())) return false;
|
|
367
|
-
const config: unknown = ((await import(configPath)) as Record<string, unknown>)[
|
|
368
|
-
APP_CONFIG_EXPORT
|
|
369
|
-
];
|
|
370
|
-
if (typeof config !== 'object' || config === null) return false;
|
|
371
|
-
const islands: unknown = (config as Record<string, unknown>)['islands'];
|
|
372
|
-
if (typeof islands !== 'object' || islands === null) return false;
|
|
373
|
-
return (islands as Record<string, unknown>)['sharedChunks'] === true;
|
|
361
|
+
return (await loadAppConfig(root))?.islands.sharedChunks ?? false;
|
|
374
362
|
}
|
|
375
363
|
|
|
376
364
|
/** Build every island in the app. An app with none returns an empty bundle and costs one glob. */
|
package/src/jobs-drain.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// it is the only command here that WRITES to two drivers at once, and the ordering rule that makes
|
|
3
3
|
// that safe — lease, copy steps, enqueue, then ack — has to be readable in one screen.
|
|
4
4
|
|
|
5
|
-
import { uuid } from '@ultimat3/core';
|
|
5
|
+
import { logger, uuid } from '@ultimat3/core';
|
|
6
6
|
import type { ClaimedJob, JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
|
|
7
|
-
import { claimOf, inspectJobList } from '@ultimat3/jobs';
|
|
7
|
+
import { claimOf, inspectJobList, jobCursor, MAX_JOB_PAGE } from '@ultimat3/jobs';
|
|
8
8
|
import type { Finding } from './output';
|
|
9
9
|
import { findingFrom } from './output';
|
|
10
10
|
|
|
@@ -12,10 +12,10 @@ import { findingFrom } from './output';
|
|
|
12
12
|
const PENDING_STATES: readonly JobState[] = ['ready', 'delayed', 'suspended'];
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* The lease has to outlive the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* The lease has to outlive the transfer of the whole BATCH it covers: a batch is claimed up front,
|
|
16
|
+
* and a lease expiring mid-batch would hand a half-transferred job back to a source worker. A
|
|
17
|
+
* batch is at most `MAX_JOB_PAGE` rows, so this is a ceiling over a couple of hundred sequential
|
|
18
|
+
* enqueues rather than a guess about however many the queue holds.
|
|
19
19
|
*/
|
|
20
20
|
const DRAIN_LEASE_MS = 300_000;
|
|
21
21
|
|
|
@@ -54,19 +54,56 @@ export interface DrainOutcome {
|
|
|
54
54
|
* left alone. `queues` is always explicit because the pg driver reads an empty list as
|
|
55
55
|
* `['default']` rather than as "every queue".
|
|
56
56
|
*/
|
|
57
|
-
function
|
|
57
|
+
function leaseBatch(
|
|
58
58
|
source: JobDriver,
|
|
59
59
|
candidates: readonly JobRecord[],
|
|
60
|
+
limit: number,
|
|
60
61
|
): Promise<readonly ClaimedJob[]> {
|
|
61
|
-
if (candidates.length === 0) return Promise.resolve([]);
|
|
62
|
+
if (candidates.length === 0 || limit <= 0) return Promise.resolve([]);
|
|
62
63
|
return source.claim({
|
|
63
64
|
queues: [...new Set(candidates.map((record) => record.queue))],
|
|
64
|
-
limit
|
|
65
|
+
limit,
|
|
65
66
|
visibilityTimeoutMs: DRAIN_LEASE_MS,
|
|
66
67
|
workerId: `x-jobs-drain:${uuid()}`,
|
|
68
|
+
// A claim BURIES a row whose lease lapsed on its final attempt, whoever makes it — this one
|
|
69
|
+
// too. The drain is an operator's tool with no worker context, so it runs no `onSettled`
|
|
70
|
+
// (at most once across a crash, as documented) and names no `dropExhausted`: said out loud
|
|
71
|
+
// here, because an unlistened burial is a row that went `dead` with nothing anywhere saying so.
|
|
72
|
+
onExhausted: (dead) => {
|
|
73
|
+
for (const row of dead) {
|
|
74
|
+
logger.error('jobs.claim.exhausted', {
|
|
75
|
+
workerId: 'x-jobs-drain',
|
|
76
|
+
job: row.name,
|
|
77
|
+
jobId: row.id,
|
|
78
|
+
attempt: row.attempt,
|
|
79
|
+
maxAttempts: row.maxAttempts,
|
|
80
|
+
error: row.lastError ?? '',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
},
|
|
67
84
|
});
|
|
68
85
|
}
|
|
69
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Every row in `state`, page by page on the keyset cursor until a short page: one page was the
|
|
89
|
+
* drivers' default 100, so a drain of 350 ready jobs moved 100 and left 250 on the source.
|
|
90
|
+
*/
|
|
91
|
+
async function everyRow(source: JobDriver, state: JobState): Promise<readonly JobRecord[]> {
|
|
92
|
+
const rows: JobRecord[] = [];
|
|
93
|
+
let after: string | undefined;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const page = await inspectJobList(source, {
|
|
96
|
+
state,
|
|
97
|
+
limit: MAX_JOB_PAGE,
|
|
98
|
+
...(after === undefined ? {} : { after }),
|
|
99
|
+
});
|
|
100
|
+
rows.push(...page);
|
|
101
|
+
const last = page.at(-1);
|
|
102
|
+
if (page.length < MAX_JOB_PAGE || last === undefined) return rows;
|
|
103
|
+
after = jobCursor(last);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
/** Steps carry their own `runId`, so a copy lands under the same key the target job resumes on. */
|
|
71
108
|
async function copySteps(source: JobDriver, target: JobDriver, runId: string): Promise<void> {
|
|
72
109
|
for (const record of await source.steps.list(runId)) await target.steps.put(record);
|
|
@@ -114,18 +151,49 @@ export async function drainJobs(
|
|
|
114
151
|
target: JobDriver,
|
|
115
152
|
dryRun: boolean,
|
|
116
153
|
): Promise<DrainOutcome> {
|
|
117
|
-
const lists = await Promise.all(PENDING_STATES.map((state) =>
|
|
154
|
+
const lists = await Promise.all(PENDING_STATES.map((state) => everyRow(source, state)));
|
|
118
155
|
const candidates = lists.flat();
|
|
119
156
|
const base = { from: source.name, to: target.name, candidates };
|
|
120
157
|
if (dryRun) return { ...base, dryRun: true, moved: [], skipped: [], failures: [] };
|
|
121
158
|
|
|
122
|
-
const leased = await leaseCandidates(source, candidates);
|
|
123
|
-
const held = new Set(leased.map((record) => record.id));
|
|
124
159
|
const found = new Map(candidates.map((record) => [record.id, record]));
|
|
125
|
-
const
|
|
126
|
-
|
|
160
|
+
const held = new Set<string>();
|
|
127
161
|
const moved: JobRecord[] = [];
|
|
128
162
|
const failures: DrainFailure[] = [];
|
|
163
|
+
// Batch by batch, each under its own lease, until the claims stop or the snapshot's count is
|
|
164
|
+
// spent — a claim may lease a row enqueued after the snapshot, and that row spends the budget
|
|
165
|
+
// too, so a queue that keeps filling cannot keep the drain running.
|
|
166
|
+
let budget = candidates.length;
|
|
167
|
+
while (budget > 0) {
|
|
168
|
+
const leased = await leaseBatch(source, candidates, Math.min(budget, MAX_JOB_PAGE));
|
|
169
|
+
if (leased.length === 0) break;
|
|
170
|
+
// A row the target already refused is tried once per drain: handed straight back, spending
|
|
171
|
+
// no budget and adding no second failure for the same id.
|
|
172
|
+
const refused = new Set(failures.map((failure) => failure.id));
|
|
173
|
+
const fresh = leased.filter((record) => !refused.has(record.id));
|
|
174
|
+
for (const record of leased) if (refused.has(record.id)) await releaseLease(source, record);
|
|
175
|
+
if (fresh.length === 0) break;
|
|
176
|
+
budget -= fresh.length;
|
|
177
|
+
for (const record of fresh) held.add(record.id);
|
|
178
|
+
const before = moved.length;
|
|
179
|
+
await transfer(source, target, fresh, found, moved, failures);
|
|
180
|
+
// A batch that moved nothing handed its leases back, and the next claim would take the same
|
|
181
|
+
// rows to the same refusing target: stop, and let the failures already counted say why.
|
|
182
|
+
if (moved.length === before) break;
|
|
183
|
+
}
|
|
184
|
+
const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
|
|
185
|
+
return { ...base, dryRun: false, moved, skipped, failures };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One leased batch onto the target: steps, then the job, then the ack — per record. */
|
|
189
|
+
async function transfer(
|
|
190
|
+
source: JobDriver,
|
|
191
|
+
target: JobDriver,
|
|
192
|
+
leased: readonly ClaimedJob[],
|
|
193
|
+
found: ReadonlyMap<string, JobRecord>,
|
|
194
|
+
moved: JobRecord[],
|
|
195
|
+
failures: DrainFailure[],
|
|
196
|
+
): Promise<void> {
|
|
129
197
|
for (const record of leased) {
|
|
130
198
|
let enqueued = false;
|
|
131
199
|
try {
|
|
@@ -159,5 +227,4 @@ export async function drainJobs(
|
|
|
159
227
|
failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
|
|
160
228
|
}
|
|
161
229
|
}
|
|
162
|
-
return { ...base, dryRun: false, moved, skipped, failures };
|
|
163
230
|
}
|
package/src/jobs-report.ts
CHANGED
|
@@ -127,10 +127,10 @@ export async function showJob(driver: JobDriver, id: string): Promise<JobTrace>
|
|
|
127
127
|
// ── retry ─────────────────────────────────────────────────────────────────
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
* Existence is checked up front so an unknown id always surfaces as `X_JOB_UNKNOWN`:
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
130
|
+
* Existence is checked up front so an unknown id always surfaces as `X_JOB_UNKNOWN`: both
|
|
131
|
+
* drivers answer `X_JOB_NOT_FOUND` from inside `requeue()` for a missing row, and that code is
|
|
132
|
+
* the driver's contract, not this command's — `retryFromStep`'s documented `undefined` return
|
|
133
|
+
* is, and the driver never reaches it once the row is already known absent.
|
|
134
134
|
*/
|
|
135
135
|
export async function retryJob(
|
|
136
136
|
driver: JobDriver,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Whether a hostname is this machine: `localhost`, a `*.localhost` name (RFC 6761 reserves both
|
|
2
|
+
// to loopback) or a loopback address literal. One answer for `x dev`'s Host check and `x doctor`'s
|
|
3
|
+
// APP_URL rule, which ask the same question about two different headers.
|
|
4
|
+
|
|
5
|
+
import { classifyAddress } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** `hostname` as `URL` spells it — `[::1]` bracketed, no port. */
|
|
8
|
+
export const isLoopbackHostname = (hostname: string): boolean => {
|
|
9
|
+
const name = hostname.toLowerCase();
|
|
10
|
+
return (
|
|
11
|
+
name === 'localhost' || name.endsWith('.localhost') || classifyAddress(name) === 'loopback'
|
|
12
|
+
);
|
|
13
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Do the committed generated files still describe the code? ONE check, asked by `x manifest
|
|
2
|
+
// --check` and by the gate's `manifest` step through this function — two commands answering
|
|
3
|
+
// differently about one file is itself the drift these files exist to prevent.
|
|
4
|
+
|
|
5
|
+
// why: Bun has no synchronous existence probe, and `hasCommittedContract` guards an app load.
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
// why: Bun exposes no path-join primitive; Bun.file takes a path already joined.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
10
|
+
import type { Manifest } from '@ultimat3/manifest';
|
|
11
|
+
import { assertNoDrift, MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
12
|
+
import { OPENAPI_FILE, openApiStaleness } from './app-openapi';
|
|
13
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
14
|
+
import type { Finding } from './output';
|
|
15
|
+
import { findingFrom } from './output';
|
|
16
|
+
|
|
17
|
+
/** Whether there is any committed contract here to judge — the cheap question, asked first. */
|
|
18
|
+
export const hasCommittedContract = (root: string): boolean =>
|
|
19
|
+
existsSync(join(root, MANIFEST_FILENAME)) || existsSync(join(root, OPENAPI_FILE));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `x.manifest.json` was never written. An app root only: the framework monorepo emits
|
|
23
|
+
* `framework.manifest.json` and has no `x.manifest.json` to be missing. `AGENTS.md` tells an agent
|
|
24
|
+
* that facts live in this file, and after `x new` and every generator nothing had run the one
|
|
25
|
+
* command that writes it while the gate reported green (#F7).
|
|
26
|
+
*/
|
|
27
|
+
export function manifestMissingFindings(root: string): readonly Finding[] {
|
|
28
|
+
if (!existsSync(join(root, APP_CONFIG_FILE))) return [];
|
|
29
|
+
if (existsSync(join(root, MANIFEST_FILENAME))) return [];
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
code: 'X_MANIFEST_MISSING',
|
|
33
|
+
cause: `${MANIFEST_FILENAME} does not exist, so every fact an agent reads about this app — route table, action schemas, policies, error codes — is unavailable`,
|
|
34
|
+
fix: 'x manifest',
|
|
35
|
+
docs: ERROR_DOCS_URL,
|
|
36
|
+
at: MANIFEST_FILENAME,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every way the committed files disagree with `manifest`, the app as the code declares it now:
|
|
43
|
+
* `x.manifest.json` missing (`X_MANIFEST_MISSING`) or drifted (`X_MANIFEST_DRIFT` — a body that no
|
|
44
|
+
* longer hashes to its own `buildId` included), and `openapi.json` or a bearer mount's document
|
|
45
|
+
* stale (`X_MANIFEST_STALE`). The spec is judged whether or not the manifest is there: it is a
|
|
46
|
+
* published contract on its own, and the typed client is generated from it.
|
|
47
|
+
*/
|
|
48
|
+
export async function manifestStaleness(
|
|
49
|
+
root: string,
|
|
50
|
+
manifest: Manifest,
|
|
51
|
+
): Promise<readonly Finding[]> {
|
|
52
|
+
const path = join(root, MANIFEST_FILENAME);
|
|
53
|
+
const findings: Finding[] = [...manifestMissingFindings(root)];
|
|
54
|
+
if (existsSync(path)) {
|
|
55
|
+
try {
|
|
56
|
+
await assertNoDrift({ manifest, path });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
findings.push({ ...findingFrom(error), at: MANIFEST_FILENAME });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...findings, ...(await openApiStaleness(root, manifest))];
|
|
62
|
+
}
|
package/src/mcp-errors.ts
CHANGED
|
@@ -51,7 +51,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
51
51
|
'x verify --only unit --json # the finding carries the measured numbers and the "coverage" line to add to x.verify.json',
|
|
52
52
|
X_COVERAGE_FLOOR_STALE:
|
|
53
53
|
'x verify --only unit --json # the finding carries the measured numbers; raise "coverage" in x.verify.json to them',
|
|
54
|
-
|
|
54
|
+
// Unthrown since `--worker` was deleted, and kept only because this record is typed over every
|
|
55
|
+
// registered code: `--shard i/n` is the one way to rerun a slice.
|
|
56
|
+
X_TEST_SHARD_FAILED: 'x verify --only unit --shard 1/2 --json',
|
|
55
57
|
X_SCAFFOLD_PATH_ESCAPE: 'x g route posts --json # a path with no ".." segment',
|
|
56
58
|
X_GENERATE_JSON_INVALID:
|
|
57
59
|
'bun test packages/cli/src/cmd-generate.test.ts # the error names the template to fix',
|
|
@@ -118,8 +120,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
118
120
|
'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
|
|
119
121
|
X_ERROR_CODE_UNRESOLVED:
|
|
120
122
|
'x verify --json # the finding names the file, the line and the name it could not resolve',
|
|
121
|
-
X_ERROR_CODE_UNTHROWN:
|
|
122
|
-
'x errors explain X_ERROR_CODE_UNTHROWN --json # then mark the row "registered, thrown by nothing since <version>"',
|
|
123
|
+
X_ERROR_CODE_UNTHROWN: 'x errors explain X_ERROR_CODE_UNTHROWN --json',
|
|
123
124
|
X_CLI_UNEXPECTED: 'x doctor --json',
|
|
124
125
|
// `-p .`: an app root has no `references`, so the gate's own step runs this form (#450).
|
|
125
126
|
X_TYPECHECK_FAILED: 'bunx tsc -p . --pretty false',
|
|
@@ -284,6 +285,14 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
284
285
|
'edit docker/Dockerfile: add RUN bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt after COPY . . in the runtime stage, then rebuild the image',
|
|
285
286
|
X_ADMIN_UNSCANNED:
|
|
286
287
|
'git mv apps/admin/src/index.ts apps/admin/app/admin/admin.ts # then repoint its relative imports and x verify --only manifest --json',
|
|
288
|
+
X_FRAMEWORK_TABLE_ORPHANED: 'x doctor --json',
|
|
289
|
+
X_BUILD_OUT_UNSAFE: 'x build --target static --out .x/static --json',
|
|
290
|
+
X_FRAMEWORK_SCHEMA_UNAPPLIED: 'x db migrate --json',
|
|
291
|
+
X_APP_URL_PORT_MISMATCH: 'export APP_URL=http://localhost:3000',
|
|
292
|
+
X_DEV_HOST_REFUSED: "curl -sS -H 'accept: application/json' http://localhost:3000/_x",
|
|
293
|
+
X_TRUSTED_PROXY_HOPS_INVALID: 'TRUSTED_PROXY_HOPS=1 ROLE=web bun apps/web/server.ts',
|
|
294
|
+
X_PERMISSION_BORROWED: 'x verify --only policy --json',
|
|
295
|
+
X_ERROR_CODE_UNTHROWN_STALE: 'x errors explain X_ERROR_CODE_UNTHROWN_STALE --json',
|
|
287
296
|
};
|
|
288
297
|
|
|
289
298
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/mcp-host.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
// or prints inside a `fix:` an operator runs — the dev log, a per-role log, the committed
|
|
8
8
|
// manifest — and string concatenation would answer a different path on a Windows checkout.
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
agentActor,
|
|
12
|
+
isUltimateError,
|
|
13
|
+
NotImplementedError,
|
|
14
|
+
renderThrowable,
|
|
15
|
+
UltimateError,
|
|
16
|
+
} from '@ultimat3/core';
|
|
11
17
|
import type { DbClient } from '@ultimat3/db';
|
|
12
18
|
import {
|
|
13
19
|
ensureReadOnlyRole,
|
|
@@ -35,12 +41,11 @@ import { appManifest, policyFacts } from './app-manifest';
|
|
|
35
41
|
import { runVerify, VERIFY_STEPS } from './cmd-verify';
|
|
36
42
|
import { declareDevEnvironment } from './dev-environment';
|
|
37
43
|
import { loadCodeFixes } from './error-fixes';
|
|
38
|
-
import { BadFlagError
|
|
44
|
+
import { BadFlagError } from './errors';
|
|
39
45
|
import type { Runner } from './exec';
|
|
40
|
-
import { execOutput } from './exec';
|
|
41
46
|
import { databaseTarget } from './mcp-db-target';
|
|
42
47
|
import { explainErrorCode } from './mcp-errors';
|
|
43
|
-
import {
|
|
48
|
+
import { testRunOf } from './mcp-test-run';
|
|
44
49
|
import { type UiCapabilities, uiCapabilities } from './mcp-ui';
|
|
45
50
|
import { readMigrations } from './migrations';
|
|
46
51
|
import { retryMemo } from './retry-memo';
|
|
@@ -107,8 +112,9 @@ export function lazyServices(input: DevHostInput): LazyServices {
|
|
|
107
112
|
// A boot started after close() would hold the PGlite data directory for the life of the
|
|
108
113
|
// process with nobody left to stop it — the host is closed, so the call is the bug.
|
|
109
114
|
if (closed) {
|
|
110
|
-
throw new
|
|
111
|
-
|
|
115
|
+
throw new NotImplementedError({
|
|
116
|
+
cause:
|
|
117
|
+
'an MCP tool that needs the database after the host closed is not implemented in this build',
|
|
112
118
|
fix: 'x mcp serve --transport stdio # keep the host open for the whole session',
|
|
113
119
|
});
|
|
114
120
|
}
|
|
@@ -271,7 +277,8 @@ function capabilities(
|
|
|
271
277
|
cwd: root,
|
|
272
278
|
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
273
279
|
});
|
|
274
|
-
|
|
280
|
+
// The whole result, exit code included: `0 fail` beside `1 error` is a red run.
|
|
281
|
+
return testRunOf(result);
|
|
275
282
|
},
|
|
276
283
|
|
|
277
284
|
async tailLogs(lines: number, role: string | undefined) {
|
|
@@ -281,8 +288,8 @@ function capabilities(
|
|
|
281
288
|
if (!(await file.exists())) {
|
|
282
289
|
// `@ultimat3/core`'s `logger` is a module const with no sink seam, so there is nothing to
|
|
283
290
|
// intercept in-process — a log on disk is the only honest source, and the fix creates one.
|
|
284
|
-
throw new
|
|
285
|
-
|
|
291
|
+
throw new NotImplementedError({
|
|
292
|
+
cause: `logs.tail without ${path} is not implemented in this build`,
|
|
286
293
|
fix:
|
|
287
294
|
role === undefined
|
|
288
295
|
? `x dev > ${path} 2>&1`
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `test.run`'s answer from the whole `bun test` result, not from its pass/fail lines alone: bun
|
|
2
|
+
// counts an error raised OUTSIDE any test on a line of its own (`1 error`) beside `0 fail`, and
|
|
3
|
+
// says the run failed only through its exit code.
|
|
4
|
+
|
|
5
|
+
import type { TestRun } from '@ultimat3/mcp';
|
|
6
|
+
import type { ExecResult } from './exec';
|
|
7
|
+
import { execOutput } from './exec';
|
|
8
|
+
import { parseBunTest } from './mcp-test-output';
|
|
9
|
+
|
|
10
|
+
/** Bun's own `N error` / `N errors` summary line: the last one, as `parseBunTest` reads the rest. */
|
|
11
|
+
export const errorsIn = (output: string): number => {
|
|
12
|
+
const last = [...output.matchAll(/^\s*(\d+)\s+errors?\s*$/gm)].at(-1);
|
|
13
|
+
return last === undefined ? 0 : Number.parseInt(last[1] ?? '0', 10);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every error outside a test is one failure, carried with the run's own message; a non-zero exit
|
|
18
|
+
* with nothing else counted is one more, so a red process can never answer `failed: 0`.
|
|
19
|
+
*/
|
|
20
|
+
export function testRunOf(result: ExecResult): TestRun {
|
|
21
|
+
const output = execOutput(result);
|
|
22
|
+
const run = parseBunTest(output, result.durationMs);
|
|
23
|
+
const errors = errorsIn(output);
|
|
24
|
+
const outside = Array.from({ length: errors }, () => ({
|
|
25
|
+
test: 'bun test',
|
|
26
|
+
message: 'an error outside any test — a file that threw while loading, or a failed hook',
|
|
27
|
+
}));
|
|
28
|
+
const counted = run.failed + errors;
|
|
29
|
+
const exitOnly =
|
|
30
|
+
result.code !== 0 && counted === 0
|
|
31
|
+
? [{ test: 'bun test', message: `bun test exited ${String(result.code)} with no failure` }]
|
|
32
|
+
: [];
|
|
33
|
+
return {
|
|
34
|
+
...run,
|
|
35
|
+
failed: counted + exitOnly.length,
|
|
36
|
+
failures: [...run.failures, ...outside, ...exitOnly],
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/messages.ts
CHANGED
|
@@ -197,6 +197,8 @@ const CATALOG = {
|
|
|
197
197
|
'cli.registry.described': '{kind} {name}',
|
|
198
198
|
'cli.routes.count': '{count} routes',
|
|
199
199
|
'cli.routes.empty': 'no routes in the manifest — run `x manifest` first',
|
|
200
|
+
'cli.auth.sealed':
|
|
201
|
+
'sealed {sealed} second-factor secret(s); {already} already sealed, {skipped} changed mid-run and left alone',
|
|
200
202
|
'cli.tasks.count': '{count} task(s)',
|
|
201
203
|
'cli.tasks.shown': '{name} — {cron} ({tz}), next {next}',
|
|
202
204
|
'cli.affected.count':
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
finiteCount,
|
|
8
|
+
type HealthPayload,
|
|
9
|
+
healthzPayload,
|
|
8
10
|
logger,
|
|
9
11
|
METRICS_CONTENT_TYPE,
|
|
10
12
|
METRICS_PATH,
|
|
11
13
|
markListening,
|
|
12
14
|
metricsText,
|
|
15
|
+
readyzPayload,
|
|
13
16
|
startProcessMetrics,
|
|
14
17
|
stringField,
|
|
15
18
|
UltimateError,
|
|
@@ -123,6 +126,18 @@ export interface MetricsEndpoint {
|
|
|
123
126
|
stop(): void;
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
/**
|
|
130
|
+
* `/healthz` and `/readyz` for the roles whose only socket this is — `worker`, `scheduler`,
|
|
131
|
+
* `replicator` — so the replicator's readiness check (`role-replicator.ts`) is read by a probe.
|
|
132
|
+
* The verdict only, never the report: this port is unauthenticated, and check names are topology.
|
|
133
|
+
*/
|
|
134
|
+
function healthResponse(payload: HealthPayload, role: string): Response {
|
|
135
|
+
return Response.json(
|
|
136
|
+
{ state: payload.body.state, ready: payload.body.ready, role },
|
|
137
|
+
{ status: payload.status, headers: { 'cache-control': 'no-store' } },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
/**
|
|
127
142
|
* Answers outside the request pipeline, exactly as `/healthz` and `/readyz` do in
|
|
128
143
|
* `@ultimat3/http`'s `server.ts`: no auth, no rate limit, no locale negotiation. A saturated or
|
|
@@ -142,7 +157,16 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
|
|
|
142
157
|
port: candidate,
|
|
143
158
|
hostname: options.hostname ?? 'localhost',
|
|
144
159
|
fetch(request: Request): Response {
|
|
145
|
-
|
|
160
|
+
const url = new URL(request.url);
|
|
161
|
+
const role = options.role ?? 'unknown';
|
|
162
|
+
if (url.pathname === '/healthz') return healthResponse(healthzPayload(), role);
|
|
163
|
+
if (url.pathname === '/readyz') {
|
|
164
|
+
return healthResponse(
|
|
165
|
+
readyzPayload({ deep: url.searchParams.get('deep') === '1' }),
|
|
166
|
+
role,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (url.pathname !== METRICS_PATH) {
|
|
146
170
|
return new Response('not found', { status: 404 });
|
|
147
171
|
}
|
|
148
172
|
// `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
|
package/src/output.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface Finding {
|
|
|
29
29
|
* and must never be run as this CLI's own instruction.
|
|
30
30
|
*/
|
|
31
31
|
readonly source?: 'ci-log';
|
|
32
|
+
/**
|
|
33
|
+
* Structured facts behind `cause`, for a `--json` reader that would otherwise parse the sentence:
|
|
34
|
+
* `X_VERIFY_STEP_TIMEOUT` carries the processes it killed and the files still in flight. Never
|
|
35
|
+
* the only home of a fact — the human render prints `cause` and `fix`, and both already say it.
|
|
36
|
+
*/
|
|
37
|
+
readonly meta?: { readonly [key: string]: JsonValue };
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface StepResult {
|
package/src/page-navigation.ts
CHANGED
|
@@ -6,12 +6,9 @@
|
|
|
6
6
|
// check a soft visit gets before `load`. No surface opted in: nothing is built, served or named — a
|
|
7
7
|
// `site/` page that did not ask stays 0kb (axiom 6).
|
|
8
8
|
|
|
9
|
-
// why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
|
|
10
|
-
// necessity `theme-boot.ts` records.
|
|
11
|
-
import { join } from 'node:path';
|
|
12
9
|
import { clientScopeOf } from '@ultimat3/auth';
|
|
13
10
|
import type { Ctx, NavigationSurface } from '@ultimat3/core';
|
|
14
|
-
import { CLIENT_NAVIGATION_SCOPE_HEADER
|
|
11
|
+
import { CLIENT_NAVIGATION_SCOPE_HEADER } from '@ultimat3/core';
|
|
15
12
|
import type { Route, RouteNavigation, UltimateRequest } from '@ultimat3/http';
|
|
16
13
|
import { locationFor, navigationPurpose, relocate } from '@ultimat3/http';
|
|
17
14
|
import type { ClientNavigationHead, HeadTag, RouteEntry } from '@ultimat3/render';
|
|
@@ -22,8 +19,7 @@ import {
|
|
|
22
19
|
routeEntries,
|
|
23
20
|
} from '@ultimat3/render';
|
|
24
21
|
import { ssrHeaders } from '@ultimat3/render/server';
|
|
25
|
-
import {
|
|
26
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
22
|
+
import { loadAppConfig } from './app-config-load';
|
|
27
23
|
import { FrameworkScriptBuildFailedError } from './errors';
|
|
28
24
|
import {
|
|
29
25
|
buildNavigationScript,
|
|
@@ -56,26 +52,14 @@ export interface NavigationDeclaration {
|
|
|
56
52
|
readonly surfaces: readonly NavigationSurface[];
|
|
57
53
|
}
|
|
58
54
|
|
|
59
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
60
|
-
typeof value === 'object' && value !== null;
|
|
61
|
-
|
|
62
|
-
const isSurface = (value: unknown): value is NavigationSurface =>
|
|
63
|
-
NAVIGATION_SURFACES.some((surface) => surface === value);
|
|
64
|
-
|
|
65
55
|
/**
|
|
66
|
-
* `navigation.client` and `name`,
|
|
67
|
-
*
|
|
56
|
+
* `navigation.client` and `name`, off the one loader (`app-config-load.ts`) — no surfaces, and the
|
|
57
|
+
* app called `app`, for a root with no config file.
|
|
68
58
|
*/
|
|
69
59
|
export async function loadNavigation(root: string): Promise<NavigationDeclaration> {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
74
|
-
if (!isRecord(config)) return { app: 'app', surfaces: [] };
|
|
75
|
-
const app = typeof config['name'] === 'string' ? config['name'] : 'app';
|
|
76
|
-
const navigation = config['navigation'];
|
|
77
|
-
const client = isRecord(navigation) ? navigation['client'] : undefined;
|
|
78
|
-
return { app, surfaces: Array.isArray(client) ? client.filter(isSurface) : [] };
|
|
60
|
+
const config = await loadAppConfig(root);
|
|
61
|
+
if (config === undefined) return { app: 'app', surfaces: [] };
|
|
62
|
+
return { app: config.name, surfaces: config.navigation.client };
|
|
79
63
|
}
|
|
80
64
|
|
|
81
65
|
/**
|
package/src/page-speculation.ts
CHANGED
|
@@ -5,11 +5,8 @@
|
|
|
5
5
|
// the next page's document on pointer rest, so a full-page navigation between prerendered pages
|
|
6
6
|
// paints from memory — and the page still ships 0kb of JavaScript (axiom 6).
|
|
7
7
|
|
|
8
|
-
// why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
|
|
9
|
-
// necessity `theme-boot.ts` records.
|
|
10
|
-
import { join } from 'node:path';
|
|
11
8
|
import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
|
|
12
|
-
import { DEFAULT_SPECULATION, localeSegment
|
|
9
|
+
import { DEFAULT_SPECULATION, localeSegment } from '@ultimat3/core';
|
|
13
10
|
import { cspHashSource } from '@ultimat3/http';
|
|
14
11
|
import { localeConfig, routedLocales } from '@ultimat3/i18n';
|
|
15
12
|
import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
|
|
@@ -21,26 +18,14 @@ import {
|
|
|
21
18
|
speculationRulesBody,
|
|
22
19
|
speculationRulesTag,
|
|
23
20
|
} from '@ultimat3/render';
|
|
24
|
-
import {
|
|
25
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
26
|
-
|
|
27
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
28
|
-
typeof value === 'object' && value !== null;
|
|
21
|
+
import { loadAppConfig } from './app-config-load';
|
|
29
22
|
|
|
30
23
|
/**
|
|
31
|
-
* `navigation.speculation`
|
|
32
|
-
*
|
|
33
|
-
* validated: what it finds goes through core's `resolveSpeculation`, the one validator, which
|
|
34
|
-
* REFUSES (`X_CONFIG_INVALID`) a value `defineConfig` would refuse. Never coerced: an `'eager'`
|
|
35
|
-
* quietly served as `'moderate'` is a rule the app did not write.
|
|
24
|
+
* `navigation.speculation` off the one loader (`app-config-load.ts`), which holds it to core's
|
|
25
|
+
* `resolveSpeculation` — the default (on, `'moderate'`) for a root with no config file.
|
|
36
26
|
*/
|
|
37
27
|
export async function loadSpeculation(root: string): Promise<SpeculationConfig> {
|
|
38
|
-
|
|
39
|
-
if (!(await Bun.file(configPath).exists())) return DEFAULT_SPECULATION;
|
|
40
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
41
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
42
|
-
const navigation = isRecord(config) ? config['navigation'] : undefined;
|
|
43
|
-
return resolveSpeculation(isRecord(navigation) ? navigation['speculation'] : undefined);
|
|
28
|
+
return (await loadAppConfig(root))?.navigation.speculation ?? DEFAULT_SPECULATION;
|
|
44
29
|
}
|
|
45
30
|
|
|
46
31
|
/**
|
package/src/prerender-out.ts
CHANGED
|
@@ -1,25 +1,52 @@
|
|
|
1
1
|
// The export directory is the build's own, and emptied before every build: `prerenderSite` only
|
|
2
2
|
// ever ADDED files, so a route deleted from the app kept shipping its last HTML from `.x/static`.
|
|
3
|
-
//
|
|
3
|
+
// Emptied only when it is provably the build's: absent, empty, or marked by an earlier build —
|
|
4
|
+
// `--out apps` deleted the app's pages and `--out .x` its embedded dev database.
|
|
4
5
|
|
|
5
|
-
import { rm } from 'node:fs/promises'; // why: Bun has no recursive
|
|
6
|
-
import { isAbsolute, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
|
|
6
|
+
import { lstat, readdir, rm } from 'node:fs/promises'; // why: Bun has no recursive delete or readdir.
|
|
7
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
|
|
7
8
|
import { UltimateError } from '@ultimat3/core';
|
|
8
9
|
|
|
10
|
+
/** Written into every export directory a build empties: the proof the next build may empty it. */
|
|
11
|
+
export const EXPORT_MARKER = '.x-export';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The export directory `x build --target static` chooses itself. Exempt from the marker: a build
|
|
15
|
+
* from before the marker existed left it full and unmarked, and the build owns this path by name.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_EXPORT = join('.x', 'static');
|
|
18
|
+
|
|
19
|
+
const MARKER_TEXT =
|
|
20
|
+
'Written by x build --target static. This directory is emptied before every static build.\n';
|
|
21
|
+
|
|
9
22
|
/** True when `dir` is `root` itself or one of its ancestors. */
|
|
10
23
|
const containsRoot = (dir: string, root: string): boolean => {
|
|
11
24
|
const up = relative(resolve(dir), resolve(root));
|
|
12
25
|
return up === '' || (up !== '..' && !up.startsWith(`..${sep}`) && !isAbsolute(up));
|
|
13
26
|
};
|
|
14
27
|
|
|
15
|
-
/**
|
|
28
|
+
/** Why `out` is not the build's to empty, or `undefined` when it is. */
|
|
29
|
+
async function unsafeReason(out: string, root: string): Promise<string | undefined> {
|
|
30
|
+
if (containsRoot(out, root)) return 'is the app root or holds it';
|
|
31
|
+
const stat = await lstat(out).catch(() => undefined);
|
|
32
|
+
if (stat === undefined) return undefined;
|
|
33
|
+
if (!stat.isDirectory()) return 'is not a directory';
|
|
34
|
+
const entries = await readdir(out);
|
|
35
|
+
if (entries.length === 0 || entries.includes(EXPORT_MARKER)) return undefined;
|
|
36
|
+
if (resolve(out) === resolve(root, DEFAULT_EXPORT)) return undefined;
|
|
37
|
+
return `already holds ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} and no ${EXPORT_MARKER} a previous build wrote`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Empties `out` for a fresh export and marks it, or refuses a directory the build did not write. */
|
|
16
41
|
export async function clearPrerenderOut(out: string, root: string): Promise<void> {
|
|
17
|
-
|
|
42
|
+
const reason = await unsafeReason(out, root);
|
|
43
|
+
if (reason !== undefined) {
|
|
18
44
|
throw new UltimateError({
|
|
19
|
-
code: '
|
|
20
|
-
cause: `the static export directory ${resolve(out)}
|
|
45
|
+
code: 'X_BUILD_OUT_UNSAFE',
|
|
46
|
+
cause: `the static export directory ${resolve(out)} ${reason}, and every build empties its export directory first`,
|
|
21
47
|
fix: 'x build --target static --out .x/static --json',
|
|
22
48
|
});
|
|
23
49
|
}
|
|
24
50
|
await rm(out, { recursive: true, force: true });
|
|
51
|
+
await Bun.write(join(out, EXPORT_MARKER), MARKER_TEXT);
|
|
25
52
|
}
|