@ultimat3/cli 19.1.3 → 19.3.1
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 +125 -8
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/app-openapi.ts +13 -5
- package/src/app-permissions.ts +0 -0
- package/src/browser-launcher.ts +53 -4
- package/src/budgets.ts +60 -7
- package/src/cmd-dev.ts +49 -39
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +5 -2
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-shot.ts +3 -1
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-render.ts +28 -7
- package/src/dev-roles.ts +9 -8
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +17 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +75 -0
- package/src/doctor-offline.ts +122 -0
- package/src/duplicate-packages.ts +278 -0
- package/src/error-catalog.ts +4 -5
- package/src/error-codes.ts +6 -0
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/i18n-registration.ts +34 -5
- package/src/index.ts +3 -1
- package/src/island-bundle.ts +123 -10
- package/src/island-harness.ts +11 -4
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-errors.ts +2 -0
- package/src/mcp-host.ts +21 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +68 -16
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +17 -2
- package/src/shot-browser.ts +23 -4
- package/src/source-files.ts +3 -1
- package/src/static-report.ts +21 -1
- package/src/style-bundle.ts +124 -0
- package/src/style-csp.ts +14 -12
- package/src/style-routes.ts +56 -0
- package/src/sw-artifacts.ts +84 -12
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/resource-form-island.ts +13 -3
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +13 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +13 -7
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +33 -8
- package/src/web-binding.ts +22 -0
package/src/cmd-dev.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// alongside it — mounted, never re-implemented — so an agent can introspect the running app.
|
|
5
5
|
// No Docker, no env setup: an unset variable means the embedded default.
|
|
6
6
|
|
|
7
|
-
import { watch } from 'node:fs';
|
|
8
7
|
import { join } from 'node:path';
|
|
9
8
|
import { devShellStyle } from '@ultimat3/admin/dev';
|
|
10
9
|
import type { Role } from '@ultimat3/core';
|
|
@@ -29,6 +28,7 @@ import { devDashboardRoutes, devPanels } from './dev-dashboard';
|
|
|
29
28
|
import { liveFeedLabel } from './dev-live-feed';
|
|
30
29
|
import { clearLock, preflight, writeLock } from './dev-lock';
|
|
31
30
|
import { createStatementLedger } from './dev-n-plus-one';
|
|
31
|
+
import { coalesceReloads } from './dev-reload';
|
|
32
32
|
import { appRoutes } from './dev-render';
|
|
33
33
|
import { replicaOverrides } from './dev-replica';
|
|
34
34
|
import type { RunningRoles } from './dev-roles';
|
|
@@ -39,10 +39,12 @@ import type { DevServices } from './dev-services';
|
|
|
39
39
|
import { describeServices, reportedUrls, resolveServices } from './dev-services';
|
|
40
40
|
import { storageRoutes } from './dev-storage';
|
|
41
41
|
import { createTraceRecorder } from './dev-traces';
|
|
42
|
+
import { watchTree } from './dev-watch-tree';
|
|
42
43
|
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
43
44
|
import { holdUntilShutdown } from './hold';
|
|
44
45
|
import type { IslandBundle } from './island-bundle';
|
|
45
46
|
import { buildIslands } from './island-bundle';
|
|
47
|
+
import { FRAME_STYLE } from './island-harness';
|
|
46
48
|
import { islandHarnessRoutes } from './island-harness-route';
|
|
47
49
|
import { islandRoutes } from './island-routes';
|
|
48
50
|
import { loadIslandStates } from './island-states-load';
|
|
@@ -53,6 +55,8 @@ import { flagString } from './parse';
|
|
|
53
55
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
54
56
|
import { metricsPortFor } from './serve';
|
|
55
57
|
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
58
|
+
import { styleBundle } from './style-bundle';
|
|
59
|
+
import { styleRoutes } from './style-routes';
|
|
56
60
|
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
57
61
|
import { serviceWorkerRoutes } from './sw-routes';
|
|
58
62
|
|
|
@@ -92,22 +96,6 @@ interface DevState {
|
|
|
92
96
|
islands: IslandBundle;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
/** Debounced: a save that touches five files is one reload, not five. */
|
|
96
|
-
function watchApp(root: string, onChange: (file: string) => void): () => void {
|
|
97
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
98
|
-
let last = '';
|
|
99
|
-
const watcher = watch(root, { recursive: true }, (_event, filename) => {
|
|
100
|
-
if (filename === null || filename.includes('.x/') || filename.includes('node_modules')) return;
|
|
101
|
-
last = filename;
|
|
102
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
103
|
-
timer = setTimeout(() => onChange(last), 30);
|
|
104
|
-
});
|
|
105
|
-
return () => {
|
|
106
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
107
|
-
watcher.close();
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
99
|
export interface StartDevOptions {
|
|
112
100
|
readonly root: string;
|
|
113
101
|
readonly port: number;
|
|
@@ -191,7 +179,13 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
191
179
|
const serviceWorker =
|
|
192
180
|
pwa === undefined
|
|
193
181
|
? undefined
|
|
194
|
-
: serviceWorkerArtifacts({
|
|
182
|
+
: serviceWorkerArtifacts({
|
|
183
|
+
pwa,
|
|
184
|
+
buildId,
|
|
185
|
+
routes: describeRoutes(),
|
|
186
|
+
islands: state.islands,
|
|
187
|
+
styles: styleBundle(),
|
|
188
|
+
});
|
|
195
189
|
|
|
196
190
|
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
|
197
191
|
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
@@ -215,6 +209,10 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
215
209
|
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
216
210
|
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
217
211
|
...islandRoutes(() => state.islands),
|
|
212
|
+
// And the stylesheet every one of those documents links. Read through the getter for the
|
|
213
|
+
// reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
|
|
214
|
+
// captured at boot would answer 404 for the href the document now carries.
|
|
215
|
+
...styleRoutes(() => styleBundle()),
|
|
218
216
|
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
219
217
|
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
220
218
|
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
|
@@ -258,10 +256,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
258
256
|
// The same seam `serve.ts` passes: the app's own error page is a FILE, so the root is what
|
|
259
257
|
// `startWeb` needs to find one.
|
|
260
258
|
root: options.root,
|
|
261
|
-
// The
|
|
262
|
-
// app's
|
|
263
|
-
//
|
|
264
|
-
|
|
259
|
+
// The documents this process serves that the app did not write — the `/_x` shell and the
|
|
260
|
+
// screenshot harness's frame. The app's OWN surfaces need no entry any more: their CSS is a
|
|
261
|
+
// content-hashed file `'self'` already admits (`style-bundle.ts`). `x dev` sends the policy
|
|
262
|
+
// report-only, so an uncovered `<style>` here is a console report rather than a blank page —
|
|
263
|
+
// which is how this reached production.
|
|
264
|
+
inlineStyles: [await devShellStyle(), FRAME_STYLE],
|
|
265
265
|
// The fourth surface, and the only one an author sees without leaving the page they broke:
|
|
266
266
|
// the overlay renders this request's own loops under the error it is already showing.
|
|
267
267
|
// `serve.ts` boots through the same `startRoles` and passes nothing, so production has no
|
|
@@ -274,22 +274,32 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
274
274
|
...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
|
|
275
275
|
});
|
|
276
276
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
277
|
+
// One rebuild at a time, and the last save wins: a tick arriving mid-build coalesces into ONE
|
|
278
|
+
// trailing rebuild instead of racing the one in flight for `state.manifest` and `state.islands`.
|
|
279
|
+
const rebuild = coalesceReloads(
|
|
280
|
+
async (file) => {
|
|
281
|
+
const started = performance.now();
|
|
282
|
+
const [{ manifest }, islands] = await Promise.all([
|
|
283
|
+
appManifest(options.root),
|
|
284
|
+
buildIslands(options.root),
|
|
285
|
+
]);
|
|
286
|
+
state.manifest = manifest;
|
|
287
|
+
state.islands = islands;
|
|
288
|
+
state.reloads += 1;
|
|
289
|
+
state.reloadFinding = undefined;
|
|
290
|
+
options.onReload?.(file, Math.round(performance.now() - started));
|
|
291
|
+
},
|
|
292
|
+
// Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
|
|
293
|
+
// a finding on `/_x`, never an unhandled rejection that takes the dev server down.
|
|
294
|
+
(error: unknown, file: string) => {
|
|
295
|
+
state.reloadFinding = { ...findingFrom(error), at: file };
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
// Watched one directory at a time, so an ignored one costs no descriptor at all — `dev-watch.ts`
|
|
299
|
+
// decides which, from the app's own `.gitignore`. A recursive watch on the root registered one
|
|
300
|
+
// inotify descriptor per directory in the tree, `.git/`, `node_modules/` and the `.x/` this
|
|
301
|
+
// process writes to included, and filtered the events afterwards.
|
|
302
|
+
const watcher = watchTree({ root: options.root, onChange: rebuild });
|
|
293
303
|
|
|
294
304
|
server = {
|
|
295
305
|
url: running.url ?? `http://localhost:${options.port}`,
|
|
@@ -313,7 +323,7 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
313
323
|
runtime,
|
|
314
324
|
panels,
|
|
315
325
|
async stop() {
|
|
316
|
-
|
|
326
|
+
watcher.close();
|
|
317
327
|
await running.stop();
|
|
318
328
|
await runtime.stop();
|
|
319
329
|
// Released after the roles: a span opened by an in-flight request still has an exporter to
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -4,16 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ENV_EXAMPLE_PATH,
|
|
9
|
+
ERROR_DOCS_URL,
|
|
10
|
+
tryResolveEnvironment,
|
|
11
|
+
usesDevCursorSecret,
|
|
12
|
+
} from '@ultimat3/core';
|
|
8
13
|
import { checkDb, createPostgresClient } from '@ultimat3/db';
|
|
9
14
|
import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
|
|
10
15
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
11
16
|
import type { CliCommand, CommandContext } from './command';
|
|
12
17
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
13
|
-
import {
|
|
18
|
+
import { syncPortFor } from './dev-sync';
|
|
19
|
+
import type { OfflineFallbackFact } from './doctor-offline';
|
|
20
|
+
import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
|
|
21
|
+
import { intFlagOr, PORT_RANGE, portPairAfter } from './flag-number';
|
|
14
22
|
import { ICON_SOURCE } from './icon-assets';
|
|
15
23
|
import { msg } from './messages';
|
|
16
24
|
import type { CommandResult, Finding } from './output';
|
|
25
|
+
import { findingFrom } from './output';
|
|
17
26
|
import type { ParsedArgs } from './parse';
|
|
18
27
|
import { portFree } from './port-probe';
|
|
19
28
|
import { checkMigrationDrift } from './schema-drift';
|
|
@@ -59,6 +68,12 @@ export interface DoctorProbe {
|
|
|
59
68
|
* separate remedies — one is "generate a migration", the other is "this migration is incomplete".
|
|
60
69
|
*/
|
|
61
70
|
snapshots(): Promise<readonly Finding[]>;
|
|
71
|
+
/**
|
|
72
|
+
* What the app declared as its offline fallback, and which routes it really serves. A FACT and
|
|
73
|
+
* not a finding, because deciding is `offlineFallbackFinding`'s and this seam's whole job is
|
|
74
|
+
* reaching the disk — the same split `drift()` makes one question over.
|
|
75
|
+
*/
|
|
76
|
+
offlineFallback(): Promise<OfflineFallbackFact>;
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
const finding = (code: string, cause: string, fix: string, at?: string): Finding =>
|
|
@@ -66,7 +81,8 @@ const finding = (code: string, cause: string, fix: string, at?: string): Finding
|
|
|
66
81
|
? { code, cause, fix, docs: ERROR_DOCS_URL }
|
|
67
82
|
: { code, cause, fix, docs: ERROR_DOCS_URL, at };
|
|
68
83
|
|
|
69
|
-
|
|
84
|
+
/** The file `x doctor` reports missing, and the one the reader creates. */
|
|
85
|
+
export const ENV_DEVELOPMENT = '.env.development';
|
|
70
86
|
|
|
71
87
|
/** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
|
|
72
88
|
const DEFAULT_DOCTOR_PORT = 3000;
|
|
@@ -80,12 +96,30 @@ const DEFAULT_DOCTOR_PORT = 3000;
|
|
|
80
96
|
* derives `PORT = .port - 1` from it, so it is part of the contract `x dev` runs by.
|
|
81
97
|
*
|
|
82
98
|
* The suggested port moves BOTH: `x dev --port N` occupies N and N+1, so a free N beside a taken
|
|
83
|
-
* N+1 is still not a runnable command.
|
|
99
|
+
* N+1 is still not a runnable command. It did not move both until 2026-09 — the line was
|
|
100
|
+
* `neighbouringPort(probe.port)`, which for the sync finding IS the port the finding is about, and
|
|
101
|
+
* a docblock claiming otherwise is how it survived. `portPairAfter` is the one reader of that rule
|
|
102
|
+
* and `dev-sync.ts`'s own refusal shares it.
|
|
103
|
+
*
|
|
104
|
+
* The sync port is `syncPortFor`, never `neighbouringPort` again: that helper answers 65534 for a
|
|
105
|
+
* web port of 65535 — BELOW the web port, and a port `x dev` never binds — where the boot refuses
|
|
106
|
+
* the run outright with `X_PORT_INVALID`. A probe that reports on a socket the command would never
|
|
107
|
+
* open is answering a question nobody asked, so the boot's own rule decides, and its refusal is
|
|
108
|
+
* reported instead of a probe. Alone, and ahead of the web port: there is no runnable `x dev` at
|
|
109
|
+
* this port whatever the other one answers, and a second finding about it is noise over the cause.
|
|
84
110
|
*/
|
|
85
111
|
async function portFindings(probe: DoctorProbe): Promise<readonly Finding[]> {
|
|
112
|
+
let syncPort: number;
|
|
113
|
+
try {
|
|
114
|
+
syncPort = syncPortFor(probe.port);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// The boot's own error, carried whole — `findingFrom` reads its code, cause and fix off the
|
|
117
|
+
// value rather than rendering it, which is what `scripts/catch-render.ts` requires.
|
|
118
|
+
return [findingFrom(error)];
|
|
119
|
+
}
|
|
86
120
|
const wanted = [
|
|
87
121
|
{ port: probe.port, role: 'web' },
|
|
88
|
-
{ port:
|
|
122
|
+
{ port: syncPort, role: 'sync' },
|
|
89
123
|
] as const;
|
|
90
124
|
const findings: Finding[] = [];
|
|
91
125
|
for (const entry of wanted) {
|
|
@@ -94,10 +128,7 @@ async function portFindings(probe: DoctorProbe): Promise<readonly Finding[]> {
|
|
|
94
128
|
finding(
|
|
95
129
|
'X_PORT_IN_USE',
|
|
96
130
|
`port ${entry.port} is already listening, and \`x dev --port ${probe.port}\` binds it for the ${entry.role} role`,
|
|
97
|
-
|
|
98
|
-
// `x dev` is guaranteed to accept (`X_CLI_BAD_FLAG` otherwise), which is what
|
|
99
|
-
// `cmd-doctor.test.ts` pins by parsing this line with `x dev`'s own flag reader.
|
|
100
|
-
`x dev --port ${neighbouringPort(probe.port)}`,
|
|
131
|
+
`x dev --port ${portPairAfter(probe.port)}`,
|
|
101
132
|
),
|
|
102
133
|
);
|
|
103
134
|
}
|
|
@@ -125,13 +156,20 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
125
156
|
);
|
|
126
157
|
return findings;
|
|
127
158
|
}
|
|
128
|
-
if (!probe.exists(
|
|
159
|
+
if (!probe.exists(ENV_DEVELOPMENT)) {
|
|
129
160
|
findings.push(
|
|
130
161
|
finding(
|
|
131
162
|
'X_ENV_MISSING',
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
163
|
+
`${ENV_DEVELOPMENT} is missing, so committed defaults cannot be read`,
|
|
164
|
+
// The file write, named — `X_PWA_ICON_MISSING`'s shape below, and for its reason. This
|
|
165
|
+
// said `x new --force`, which cannot run where the reader is standing: `x new` takes a
|
|
166
|
+
// <name> positional (reproduced: `x new --force --json` inside an app answers
|
|
167
|
+
// `X_CLI_BAD_FLAG`), and with one it scaffolds a SECOND app beside the broken one.
|
|
168
|
+
// `.env.example` is the committed projection of `envSchema`, so the copy lands every
|
|
169
|
+
// declared key with its default and blank secrets; `x env example` writes it where an app
|
|
170
|
+
// has none, and `X_ENV_EXAMPLE_DRIFT` is what reports that.
|
|
171
|
+
`cp ${ENV_EXAMPLE_PATH} ${ENV_DEVELOPMENT}`,
|
|
172
|
+
ENV_DEVELOPMENT,
|
|
135
173
|
),
|
|
136
174
|
);
|
|
137
175
|
}
|
|
@@ -183,16 +221,12 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
183
221
|
),
|
|
184
222
|
);
|
|
185
223
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
OFFLINE_FALLBACK,
|
|
193
|
-
),
|
|
194
|
-
);
|
|
195
|
-
}
|
|
224
|
+
// The DECLARED fallback against the ROUTE TABLE, never a filename: this probed
|
|
225
|
+
// `apps/web/app/offline.tsx`, which is not a route file at all (`assertRouteFilename` refuses
|
|
226
|
+
// it), so no app could clear the finding and its own `fix:` did not either. `doctor-offline.ts`
|
|
227
|
+
// holds the rule and the reasons.
|
|
228
|
+
const offline = offlineFallbackFinding(await probe.offlineFallback());
|
|
229
|
+
if (offline !== undefined) findings.push(offline);
|
|
196
230
|
const database = await probe.database();
|
|
197
231
|
if (database !== null) findings.push(database);
|
|
198
232
|
findings.push(...(await probe.drift()));
|
|
@@ -264,6 +298,10 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
|
|
|
264
298
|
database: () => probeDatabase(process.env['DATABASE_URL']),
|
|
265
299
|
drift: async () => (root === undefined ? [] : checkMigrationDrift(root)),
|
|
266
300
|
snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
|
|
301
|
+
// `routes: undefined` outside an app is "not judged", which is what the caller already is:
|
|
302
|
+
// `runDoctor` returns on `X_NOT_IN_APP` before this can be asked.
|
|
303
|
+
offlineFallback: async () =>
|
|
304
|
+
root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
|
|
267
305
|
};
|
|
268
306
|
}
|
|
269
307
|
|
package/src/cmd-generate.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { appManifest, writeAppManifest } from './app-manifest';
|
|
|
8
8
|
import { requireAppRoot } from './app-root';
|
|
9
9
|
import type { CliCommand, CommandContext } from './command';
|
|
10
10
|
import { generate } from './generate-files';
|
|
11
|
-
import { GENERATORS, readKind, readName, readSurface } from './generate-kinds';
|
|
11
|
+
import { GENERATORS, readKind, readName, readPermission, readSurface } from './generate-kinds';
|
|
12
12
|
import { containedPath, writeFiles } from './generate-write';
|
|
13
13
|
import { resolveCatalogModule } from './i18n-audit';
|
|
14
14
|
import { syncI18nIndex } from './i18n-index';
|
|
@@ -63,7 +63,10 @@ export const generateCommand: CliCommand = {
|
|
|
63
63
|
const surface = readSurface(flagString(ctx.args, 'surface'), kind, name);
|
|
64
64
|
const locales = resolveLocales(flagList(ctx.args, 'locales'));
|
|
65
65
|
const at = flagString(ctx.args, 'at');
|
|
66
|
-
|
|
66
|
+
// Read with the two above, and refused here for their reason: the value is spliced into the
|
|
67
|
+
// emitted source three times, and a value that is not a `<resource>:<verb>` is a page the app
|
|
68
|
+
// cannot compile — found after the files are on disk, which is the worst place to find it.
|
|
69
|
+
const permission = readPermission(flagString(ctx.args, 'permission'), kind);
|
|
67
70
|
// Read before a file is planned, like the flags above: which module a generated component
|
|
68
71
|
// imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
|
|
69
72
|
const catalogModule = await resolveCatalogModule(root);
|
package/src/cmd-i18n.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// would, and `node:path` because Bun exposes no path API to build what either of them takes.
|
|
9
9
|
import { type FileHandle, mkdir, open } from 'node:fs/promises';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
|
+
import { stringField } from '@ultimat3/core';
|
|
11
12
|
import type { Catalog } from '@ultimat3/i18n';
|
|
12
13
|
import { auditCatalogs, catalogKeys } from '@ultimat3/i18n';
|
|
13
14
|
import { loadApp } from './app-load';
|
|
@@ -40,9 +41,15 @@ export const I18N_SUBCOMMANDS = ['check', 'add', 'sync'] as const;
|
|
|
40
41
|
/** `ExtractReport` is plain JSON by construction — same idiom as `cmd-registries.ts`'s `asJson`. */
|
|
41
42
|
const asJson = (value: object): Record<string, JsonValue> => value as Record<string, JsonValue>;
|
|
42
43
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* `open`'s failure when the file is already there — the one errno this command translates.
|
|
46
|
+
*
|
|
47
|
+
* `stringField`, never `'code' in error && error.code`: `in` narrows for the COMPILER and promises
|
|
48
|
+
* the runtime nothing, so the read still happens on a value this process did not build and a
|
|
49
|
+
* throwing getter takes the command down one line after the guard written to make it safe. The
|
|
50
|
+
* same repair `dev-lock.ts` took for the cast spelling of the identical read.
|
|
51
|
+
*/
|
|
52
|
+
const isAlreadyExists = (error: unknown): boolean => stringField(error, 'code') === 'EEXIST';
|
|
46
53
|
|
|
47
54
|
/**
|
|
48
55
|
* The exclusive half of the create: `wx` fails rather than truncates, so an existing catalog is
|
package/src/cmd-jobs.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// `jobs-table.ts`, and getting hold of the queue at all is `jobs-driver.ts` — shared with `x db`.
|
|
6
6
|
|
|
7
7
|
import type { JobDriver } from '@ultimat3/jobs';
|
|
8
|
-
import { cancelJob,
|
|
8
|
+
import { cancelJob, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
|
|
9
9
|
import { requireAppRoot } from './app-root';
|
|
10
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
11
|
import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
|
|
12
|
+
import type { DrainOutcome } from './jobs-drain';
|
|
12
13
|
import { drainJobs } from './jobs-drain';
|
|
13
14
|
import { withJobDriver } from './jobs-driver';
|
|
14
15
|
import {
|
|
@@ -28,7 +29,32 @@ import { flagBool, flagString } from './parse';
|
|
|
28
29
|
|
|
29
30
|
export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'cancel', 'drain'] as const;
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The drivers a drain may move work ONTO — every one of them durable, and that is the whole rule.
|
|
34
|
+
* Closed, and read three ways: the flag summary, the refusal, and the `memory` case below.
|
|
35
|
+
*/
|
|
36
|
+
export const DRAIN_TARGETS = ['redis', 'nats'] as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `memory` was on that list until 2026-09 and could not be: `createMemoryDriver()` is a `Map` in
|
|
40
|
+
* THIS process, so `x jobs drain --to memory` enqueued each job into it and then `ack`ed the
|
|
41
|
+
* durable row off the source. Reproduced against two real drivers — source ready 1 -> 0, target
|
|
42
|
+
* ready 1, `ok: true` — and the target dies with the command. `wiki/CLI-Reference.md` said the
|
|
43
|
+
* crash window "duplicates a job … instead of losing it"; this target lost every one of them.
|
|
44
|
+
*
|
|
45
|
+
* Refused by NAME rather than folded into the unknown-value message, for `cmd-deploy.ts`'s
|
|
46
|
+
* `readMethod` reason: `--to memory` is a spelling that used to work, so a reader who types it is
|
|
47
|
+
* owed the fact that it moved work into a process that is about to exit, not a list of words.
|
|
48
|
+
*/
|
|
49
|
+
function refuseMemoryTarget(): never {
|
|
50
|
+
throw new BadFlagError({
|
|
51
|
+
flag: 'to',
|
|
52
|
+
command: 'jobs',
|
|
53
|
+
reason:
|
|
54
|
+
'memory is a Map inside this process — the drain would ack every durable row and lose the copy when the command exits',
|
|
55
|
+
fix: 'x jobs drain --to redis --json # or --to nats; --dry-run reports the plan and moves nothing',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
32
58
|
|
|
33
59
|
function requireIdPositional(ctx: CommandContext, sub: string): string {
|
|
34
60
|
const id = ctx.args.positionals[0];
|
|
@@ -57,10 +83,13 @@ function requireEnvUrl(env: CommandContext['env'], name: string, target: string)
|
|
|
57
83
|
|
|
58
84
|
/**
|
|
59
85
|
* `redis`/`nats` are honest `X_NOT_IMPLEMENTED` stubs in `@ultimat3/jobs` — building one here is
|
|
60
|
-
* fine even though every `enqueue` on it will fail; `drainJobs` reports that per record.
|
|
86
|
+
* fine even though every `enqueue` on it will fail; `drainJobs` reports that per record. What is
|
|
87
|
+
* NOT fine is a target that accepts every enqueue and then vanishes, which is why `memory` is
|
|
88
|
+
* refused first and by name rather than falling into the closed-set message below.
|
|
61
89
|
* Exported so a test can drive the `--to`/env-var validation without a driver or a boot.
|
|
62
90
|
*/
|
|
63
91
|
export function buildDrainTarget(to: string | undefined, env: CommandContext['env']): JobDriver {
|
|
92
|
+
if (to === 'memory') refuseMemoryTarget();
|
|
64
93
|
if (to === undefined || !(DRAIN_TARGETS as readonly string[]).includes(to)) {
|
|
65
94
|
throw new BadFlagError({
|
|
66
95
|
flag: 'to',
|
|
@@ -68,7 +97,6 @@ export function buildDrainTarget(to: string | undefined, env: CommandContext['en
|
|
|
68
97
|
reason: `expects one of: ${DRAIN_TARGETS.join(', ')}`,
|
|
69
98
|
});
|
|
70
99
|
}
|
|
71
|
-
if (to === 'memory') return createMemoryDriver();
|
|
72
100
|
if (to === 'redis') return createRedisDriver({ url: requireEnvUrl(env, 'REDIS_URL', 'redis') });
|
|
73
101
|
return createNatsDriver({ servers: [requireEnvUrl(env, 'NATS_URL', 'nats')] });
|
|
74
102
|
}
|
|
@@ -174,11 +202,13 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
|
|
|
174
202
|
* A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
|
|
175
203
|
* design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
|
|
176
204
|
* empty a driver, and a partial move that exited 0 would read as "the queue is clear".
|
|
205
|
+
*
|
|
206
|
+
* Exported, and separate from the flag reading above it, because every target the flag now accepts
|
|
207
|
+
* needs a server: a test can produce a real outcome from two drivers and render THAT, where
|
|
208
|
+
* driving the whole command would need a redis or a nats to move anything at all.
|
|
177
209
|
*/
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
const dryRun = flagBool(ctx.args, 'dry-run');
|
|
181
|
-
const outcome = await drainJobs(driver, target, dryRun);
|
|
210
|
+
export function drainResult(outcome: DrainOutcome): CommandResult {
|
|
211
|
+
const dryRun = outcome.dryRun;
|
|
182
212
|
const findings = outcome.failures.map((failure) => failure.finding);
|
|
183
213
|
const lines: string[] = [];
|
|
184
214
|
if (outcome.skipped.length > 0) {
|
|
@@ -212,6 +242,15 @@ async function runDrain(driver: JobDriver, ctx: CommandContext): Promise<Command
|
|
|
212
242
|
};
|
|
213
243
|
}
|
|
214
244
|
|
|
245
|
+
/** The move, then the render. The target is built ABOVE `withJobDriver` — see `run` below. */
|
|
246
|
+
async function runDrain(
|
|
247
|
+
driver: JobDriver,
|
|
248
|
+
target: JobDriver,
|
|
249
|
+
ctx: CommandContext,
|
|
250
|
+
): Promise<CommandResult> {
|
|
251
|
+
return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
|
|
252
|
+
}
|
|
253
|
+
|
|
215
254
|
export const jobsCommand: CliCommand = {
|
|
216
255
|
spec: {
|
|
217
256
|
name: 'jobs',
|
|
@@ -245,7 +284,7 @@ export const jobsCommand: CliCommand = {
|
|
|
245
284
|
{
|
|
246
285
|
name: 'to',
|
|
247
286
|
type: 'string',
|
|
248
|
-
summary:
|
|
287
|
+
summary: `drain: target driver — ${DRAIN_TARGETS.join(', ')}`,
|
|
249
288
|
subcommands: ['drain'],
|
|
250
289
|
},
|
|
251
290
|
{
|
|
@@ -259,11 +298,18 @@ export const jobsCommand: CliCommand = {
|
|
|
259
298
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
260
299
|
const root = requireAppRoot('jobs', ctx.cwd).dir;
|
|
261
300
|
const sub = ctx.args.subcommand ?? 'ls';
|
|
301
|
+
// BEFORE `withJobDriver`, which boots the SOURCE queue and pings it. `--to` is a flag, so
|
|
302
|
+
// whether it names a durable driver is answerable with no server at all — and reading it
|
|
303
|
+
// inside meant `x jobs drain --to memory` on a box whose database is down reported the boot
|
|
304
|
+
// failure instead of `X_CLI_BAD_FLAG`, i.e. the operator repaired Postgres to be told the
|
|
305
|
+
// word they typed was refused by name. It also opens a connection to a target the command
|
|
306
|
+
// then refuses, which is a socket nothing closes.
|
|
307
|
+
const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
|
|
262
308
|
return withJobDriver(root, ctx, (driver) => {
|
|
263
309
|
if (sub === 'show') return runShow(driver, ctx);
|
|
264
310
|
if (sub === 'retry') return runRetry(driver, ctx);
|
|
265
311
|
if (sub === 'cancel') return runCancel(driver, ctx);
|
|
266
|
-
if (
|
|
312
|
+
if (target !== null) return runDrain(driver, target, ctx);
|
|
267
313
|
return runLs(driver, ctx);
|
|
268
314
|
});
|
|
269
315
|
},
|
package/src/cmd-shot.ts
CHANGED
|
@@ -322,7 +322,9 @@ export const shotCommand: CliCommand = {
|
|
|
322
322
|
const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
|
|
323
323
|
// Which browser this run gets — start one here, or attach to one somebody else is running.
|
|
324
324
|
// Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
|
|
325
|
-
// provider session exists to pay for a typo.
|
|
325
|
+
// provider session exists to pay for a typo. It also PROBES for an installed Chrome and refuses
|
|
326
|
+
// when there is none: `puppeteer-core` bundles no browser, so a missing one used to surface as
|
|
327
|
+
// a library throw one embedded Postgres later.
|
|
326
328
|
const { cdpUrl, executablePath } = shotBrowserChoice({
|
|
327
329
|
cdpFlag: flagString(ctx.args, 'cdp-url'),
|
|
328
330
|
browserFlag: flagString(ctx.args, 'browser'),
|
package/src/cmd-test.ts
CHANGED
|
@@ -104,8 +104,12 @@ export const testCommand: CliCommand = {
|
|
|
104
104
|
name: 'test',
|
|
105
105
|
summary:
|
|
106
106
|
'run one test type — or the whole suite — across N workers, one isolated database per worker',
|
|
107
|
-
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json]`,
|
|
107
|
+
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json] [-- <bun test flags>]`,
|
|
108
108
|
positionalChoices: TEST_TYPES,
|
|
109
|
+
// The one command that hands a tail to another tool — `bun test` — and the reason
|
|
110
|
+
// `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
|
|
111
|
+
// dropped both, so a run that measured no coverage reported exactly what a coverage run does.
|
|
112
|
+
passthrough: true,
|
|
109
113
|
flags: [
|
|
110
114
|
{
|
|
111
115
|
name: 'workers',
|
|
@@ -173,15 +177,13 @@ export const testCommand: CliCommand = {
|
|
|
173
177
|
}
|
|
174
178
|
const files = sample === undefined ? selected : sampleFiles(selected, sample);
|
|
175
179
|
const requested = readIndex(ctx.args, 'workers', 1) ?? defaultWorkers();
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// profile. Neither is visible without a real `TEST_DATABASE_URL`, which is why the split
|
|
184
|
-
// measured green for as long as it did.
|
|
180
|
+
// The width a `--worker` index is judged against, and nothing else: which files really run one
|
|
181
|
+
// at a time is `test-passes.ts`, off the FILES rather than off the positional. This line read
|
|
182
|
+
// the positional alone until 2026-09 and the comment here claimed it made a serial type serial
|
|
183
|
+
// — true for `x test live`, false for the bare `x test --workers 8` that selects every type,
|
|
184
|
+
// which ran the same `live` and `e2e` files eight at a time. It stays because a `--worker` run
|
|
185
|
+
// is one process per CI JOB: sharding a serial type across N of them is the cluster-wide race
|
|
186
|
+
// in a second disguise, and refusing the index is where that is caught.
|
|
185
187
|
const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
|
|
186
188
|
const workers = Math.max(1, Math.min(requested, ceiling));
|
|
187
189
|
const only = readIndex(ctx.args, 'worker', 0);
|
|
@@ -208,6 +210,9 @@ export const testCommand: CliCommand = {
|
|
|
208
210
|
// corpus, so its shard 2 is a different shard 2 — reproducing nothing, which is the one
|
|
209
211
|
// thing `reproduceFor` exists to prevent.
|
|
210
212
|
...(scope === undefined ? {} : { affected: scope.selection }),
|
|
213
|
+
// The fifth input to the split, and the one a rerun most obviously needs: `--coverage`
|
|
214
|
+
// changes what a run measures, and the reproduce line carries it back out.
|
|
215
|
+
...(ctx.args.passthrough.length === 0 ? {} : { passthrough: ctx.args.passthrough }),
|
|
211
216
|
});
|
|
212
217
|
return scope === undefined ? result : withScope(result, scope);
|
|
213
218
|
},
|
package/src/db-seed.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { isSeed, SEED_TIERS, seedTiersFor } from '@ultimat3/entity';
|
|
|
15
15
|
import { BadFlagError } from './errors';
|
|
16
16
|
import type { Finding, JsonValue } from './output';
|
|
17
17
|
import { findingFrom } from './output';
|
|
18
|
+
import { hasPathSegment } from './path-segments';
|
|
18
19
|
import { renderTable } from './table';
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -101,7 +102,7 @@ export async function discoverSeeds(root: string): Promise<SeedDiscovery> {
|
|
|
101
102
|
const seen = new Set<string>();
|
|
102
103
|
for (const pattern of SEED_GLOBS) {
|
|
103
104
|
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
104
|
-
if (absolute
|
|
105
|
+
if (hasPathSegment(absolute, 'node_modules') || absolute.includes('.test.')) continue;
|
|
105
106
|
if (seen.has(absolute)) continue;
|
|
106
107
|
seen.add(absolute);
|
|
107
108
|
const file = relative(root, absolute).split(sep).join('/');
|
package/src/dev-queue.ts
CHANGED
|
@@ -78,8 +78,16 @@ export interface RunningQueue {
|
|
|
78
78
|
* Before this, `defaultClient()` was the only composer of a replicated pair in the framework and
|
|
79
79
|
* it runs only from `baseClient()` — the client an app installed NONE for. This line installs one,
|
|
80
80
|
* so `DATABASE_REPLICA_URL` was read by no booted process at all.
|
|
81
|
+
*
|
|
82
|
+
* `env` is REQUIRED, and that is the repair: it defaulted to `process.env` and `startQueue` passed
|
|
83
|
+
* nothing, so the standby was decided from the process while the middleware that opens the
|
|
84
|
+
* `withReplicaReads` scope was decided from the boot's own `options.env` (`cmd-dev.ts`,
|
|
85
|
+
* `serve.ts`). Two sources for one question answer differently the moment a boot is handed an
|
|
86
|
+
* environment it did not inherit — a routed client with no scope, or a scope with no standby, and
|
|
87
|
+
* neither reports anything. A default here is what let the caller forget; the type is what stops
|
|
88
|
+
* the next one. Exported for the test that proves which environment decides.
|
|
81
89
|
*/
|
|
82
|
-
function startDb(services: DevServices, env: ReplicaEnv
|
|
90
|
+
export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
|
|
83
91
|
const binding = services.db;
|
|
84
92
|
const client =
|
|
85
93
|
binding.mode === 'embedded'
|
|
@@ -218,8 +226,14 @@ async function releaseQueue(
|
|
|
218
226
|
export async function startQueue(
|
|
219
227
|
services: DevServices,
|
|
220
228
|
overrides?: RuntimeOverrides,
|
|
229
|
+
/**
|
|
230
|
+
* The boot's own environment — `x dev`'s, the container role's, the CLI command's `ctx.env`.
|
|
231
|
+
* `process.env` is the default for a caller that has no other answer, and it is the ONLY place
|
|
232
|
+
* this file reads it: see `startDb` for what two readers of one question cost.
|
|
233
|
+
*/
|
|
234
|
+
env: ReplicaEnv = process.env,
|
|
221
235
|
): Promise<RunningQueue> {
|
|
222
|
-
const { client: db, replica } = startDb(services);
|
|
236
|
+
const { client: db, replica } = startDb(services, env);
|
|
223
237
|
try {
|
|
224
238
|
// Pay the Postgres boot here, so the first request is not the slow one and a broken database
|
|
225
239
|
// fails at boot rather than on some later query.
|