@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/cmd-deploy.ts
CHANGED
|
@@ -176,10 +176,10 @@ export function planDeploy(
|
|
|
176
176
|
command: [
|
|
177
177
|
'docker',
|
|
178
178
|
'compose',
|
|
179
|
-
// Compose interpolates `${SYNC_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from
|
|
180
|
-
// `--env-file` only — never from a service's `env_file:`. Without this an
|
|
181
|
-
// them in `.env.production`, the one file the compose file tells them to
|
|
182
|
-
// step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
|
|
179
|
+
// Compose interpolates `${SYNC_URL:?…}`, `${APP_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from
|
|
180
|
+
// the shell and `--env-file` only — never from a service's `env_file:`. Without this an
|
|
181
|
+
// operator who put them in `.env.production`, the one file the compose file tells them to
|
|
182
|
+
// fill, had every step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
|
|
183
183
|
'--env-file',
|
|
184
184
|
join(root, PROD_ENV_FILE),
|
|
185
185
|
'-f',
|
package/src/cmd-dev.ts
CHANGED
|
@@ -13,7 +13,6 @@ import type { OverlayNotice, RequestContext } from '@ultimat3/http';
|
|
|
13
13
|
import { asCtx } from '@ultimat3/http';
|
|
14
14
|
import type { Manifest } from '@ultimat3/manifest';
|
|
15
15
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
16
|
-
import { createIsrController } from '@ultimat3/render/server';
|
|
17
16
|
import { loadSignInPath } from './app-auth';
|
|
18
17
|
import { appManifest } from './app-manifest';
|
|
19
18
|
import type { StalePin } from './app-reload-graph';
|
|
@@ -50,6 +49,7 @@ import {
|
|
|
50
49
|
resolveServices,
|
|
51
50
|
withRealtimeEvents,
|
|
52
51
|
} from './runtime-bindings';
|
|
52
|
+
import { attachedIsr } from './runtime-isr';
|
|
53
53
|
import { liveFeedLabel } from './runtime-live-feed';
|
|
54
54
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
55
55
|
import { replicaOverrides } from './runtime-replica';
|
|
@@ -239,9 +239,10 @@ async function bootDev(
|
|
|
239
239
|
};
|
|
240
240
|
const panels = devPanels(dashboard).map((panel) => panel.key);
|
|
241
241
|
|
|
242
|
-
// `x dev`'s own, so a reload can empty it
|
|
243
|
-
//
|
|
244
|
-
const isr =
|
|
242
|
+
// `x dev`'s own, so a reload can empty it (a save otherwise kept a page's first render for its
|
|
243
|
+
// ttl); attached, so a tag bust reaches it too. Released with the roles.
|
|
244
|
+
const { isr, release: releaseIsr } = attachedIsr({ buildId });
|
|
245
|
+
acquired.push(releaseIsr);
|
|
245
246
|
const { routes, theme, speculation, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
|
|
246
247
|
isr,
|
|
247
248
|
root: options.root,
|
|
@@ -350,16 +351,16 @@ async function bootDev(
|
|
|
350
351
|
async stop() {
|
|
351
352
|
watcher.close();
|
|
352
353
|
await running.stop();
|
|
354
|
+
releaseIsr();
|
|
353
355
|
await runtime.stop();
|
|
354
356
|
// Released after the roles: a span opened by an in-flight request still has an exporter to
|
|
355
357
|
// end into. `configureTelemetry` merges, so handing back the noop is how it is uninstalled —
|
|
356
358
|
// leaving it in place would keep every span of the next `startDev` in this process's buffer.
|
|
357
359
|
configureTelemetry({ exporter: noopExporter });
|
|
358
360
|
traces.reset();
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
// and, worse, keep instrumentation on in a process that is no longer a dev server.
|
|
361
|
+
// With the exporter, for the same reason: an in-flight statement is observed by the ledger
|
|
362
|
+
// that counted its request. Left installed, the next `startDev`'s statements land in these
|
|
363
|
+
// counts — and instrumentation stays on in a process that is no longer a dev server.
|
|
363
364
|
setStatementObserver(undefined);
|
|
364
365
|
statements.reset();
|
|
365
366
|
},
|
package/src/cmd-doctor-spec.ts
CHANGED
|
@@ -3,19 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
import type { CommandSpec } from './parse';
|
|
5
5
|
|
|
6
|
-
/** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
|
|
7
|
-
export const DEFAULT_DOCTOR_PORT = 3000;
|
|
8
|
-
|
|
9
6
|
export const doctorSpec: CommandSpec = {
|
|
10
7
|
name: 'doctor',
|
|
11
8
|
summary: 'environment, versions, drift, ports, PWA prerequisites — each with a fix command',
|
|
12
9
|
usage: 'x doctor [--port 3000] [--json]',
|
|
13
10
|
flags: [
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
type: 'string',
|
|
17
|
-
summary: 'port to test',
|
|
18
|
-
default: String(DEFAULT_DOCTOR_PORT),
|
|
19
|
-
},
|
|
11
|
+
// No `default`: it would be indistinguishable from a typed value, and `PORT` could never win.
|
|
12
|
+
{ name: 'port', type: 'string', summary: 'port to test (default: PORT, then 3000)' },
|
|
20
13
|
],
|
|
21
14
|
};
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -19,16 +19,18 @@ import {
|
|
|
19
19
|
} from '@ultimat3/db';
|
|
20
20
|
import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
|
|
21
21
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
22
|
-
import {
|
|
22
|
+
import { doctorSpec } from './cmd-doctor-spec';
|
|
23
23
|
import type { CliCommand, CommandContext } from './command';
|
|
24
24
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
25
|
+
import { appUrlFindings } from './doctor-app-url';
|
|
26
|
+
import { type AuthStorageFact, authStorageFindings, authStorageProbe } from './doctor-auth';
|
|
25
27
|
import { coverageExcludesProbe, withCoverageExcludes } from './doctor-coverage';
|
|
26
28
|
import { shippedGuardsProbe, withGuardListing } from './doctor-guards';
|
|
27
29
|
import type { OfflineFallbackFact } from './doctor-offline';
|
|
28
30
|
import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
|
|
29
31
|
import type { SealedKeysFact } from './doctor-sealed';
|
|
30
32
|
import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
|
|
31
|
-
import {
|
|
33
|
+
import { PORT_RANGE, portPairAfter, readIntFlag } from './flag-number';
|
|
32
34
|
import { ICON_SOURCE } from './icon-assets';
|
|
33
35
|
import { msg } from './messages';
|
|
34
36
|
import type { CommandResult, Finding } from './output';
|
|
@@ -37,6 +39,7 @@ import type { ParsedArgs } from './parse';
|
|
|
37
39
|
import { portFree } from './port-probe';
|
|
38
40
|
import { syncPortFor } from './role-sync';
|
|
39
41
|
import { checkMigrationDrift } from './schema-drift';
|
|
42
|
+
import { portFromEnv } from './serve-env';
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
|
|
@@ -49,6 +52,8 @@ export interface DoctorProbe {
|
|
|
49
52
|
/** App root, or undefined when the command runs outside an app. */
|
|
50
53
|
readonly root: string | undefined;
|
|
51
54
|
readonly port: number;
|
|
55
|
+
/** `APP_URL` as this environment declares it; `undefined` when unset. */
|
|
56
|
+
readonly appUrl: string | undefined;
|
|
52
57
|
/** True while cursors are signed with the key shipped in the published package. */
|
|
53
58
|
readonly devCursorSecret: boolean;
|
|
54
59
|
/**
|
|
@@ -101,6 +106,11 @@ export interface DoctorProbe {
|
|
|
101
106
|
* a retired key beside a sealed column means is `sealedKeyFindings`' pure rule.
|
|
102
107
|
*/
|
|
103
108
|
sealedKeys(): Promise<SealedKeysFact>;
|
|
109
|
+
/**
|
|
110
|
+
* What an upgrade left in the auth tables for an operator to finish: unsealed second-factor
|
|
111
|
+
* secrets, and tables no release reads. Asked of an external database only.
|
|
112
|
+
*/
|
|
113
|
+
authStorage(): Promise<AuthStorageFact>;
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
/** What `x doctor` reads about the embedded database, without opening it. */
|
|
@@ -257,6 +267,7 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
257
267
|
);
|
|
258
268
|
}
|
|
259
269
|
findings.push(...(await portFindings(probe)));
|
|
270
|
+
findings.push(...appUrlFindings(probe.appUrl, probe.port));
|
|
260
271
|
// `@ultimat3/pwa`'s own codes, not CLI twins of them. `X_PWA_NO_ICON_SOURCE` and
|
|
261
272
|
// `X_PWA_NO_FALLBACK` used to be declared here for the same two conditions the package already
|
|
262
273
|
// names — two codes for one condition, one of them registered by nobody, so `x errors explain`
|
|
@@ -293,20 +304,28 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
293
304
|
findings.push(...(await probe.snapshots()));
|
|
294
305
|
// Sealed columns against the key ring: no key at all, or a rotation whose re-seal is pending.
|
|
295
306
|
findings.push(...sealedKeyFindings(await probe.sealedKeys()));
|
|
307
|
+
findings.push(...authStorageFindings(await probe.authStorage()));
|
|
296
308
|
return findings;
|
|
297
309
|
}
|
|
298
310
|
|
|
299
311
|
/**
|
|
300
|
-
* The port to TEST, and the one place `x doctor` reads it
|
|
301
|
-
*
|
|
302
|
-
*
|
|
312
|
+
* The port to TEST, and the one place `x doctor` reads it: `devPortFor`'s order — `--port`, then
|
|
313
|
+
* `PORT` (`portFromEnv`, the one reader of it), then 3000 — so the probe answers about the port
|
|
314
|
+
* `x dev` will bind. It probed 3000 under `PORT=4000` while dev bound 4000 and 4001. `PORT_RANGE.min`
|
|
315
|
+
* is 0 because `x dev --port 0` means "let the kernel pick"; here 0 means nothing, and
|
|
316
|
+
* `Bun.serve({ port: 0 })` always succeeds — so the port check could not fail.
|
|
303
317
|
*/
|
|
304
|
-
export const doctorPort = (
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
318
|
+
export const doctorPort = (
|
|
319
|
+
args: ParsedArgs,
|
|
320
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
321
|
+
): number =>
|
|
322
|
+
readIntFlag(args, {
|
|
323
|
+
name: 'port',
|
|
324
|
+
command: 'doctor',
|
|
325
|
+
...PORT_RANGE,
|
|
326
|
+
min: 1,
|
|
327
|
+
example: 'x doctor --port 3000',
|
|
328
|
+
}) ?? portFromEnv(env);
|
|
310
329
|
|
|
311
330
|
/**
|
|
312
331
|
* `DATABASE_URL` as BOTH halves of the database question read it: unset and blank are one case.
|
|
@@ -375,12 +394,19 @@ async function probeDatabase(url: string | undefined): Promise<Finding | null> {
|
|
|
375
394
|
}
|
|
376
395
|
}
|
|
377
396
|
|
|
378
|
-
export function probeFor(
|
|
397
|
+
export function probeFor(
|
|
398
|
+
cwd: string,
|
|
399
|
+
bunVersion: string,
|
|
400
|
+
port: number,
|
|
401
|
+
// The environment `doctorPort` read `PORT` from, so `APP_URL` is judged against the same one.
|
|
402
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
403
|
+
): DoctorProbe {
|
|
379
404
|
const root = findAppRoot(cwd)?.dir;
|
|
380
405
|
return {
|
|
381
406
|
bunVersion,
|
|
382
407
|
root,
|
|
383
408
|
port,
|
|
409
|
+
appUrl: env['APP_URL'],
|
|
384
410
|
devCursorSecret: usesDevCursorSecret(),
|
|
385
411
|
devStorageSecret: usesDevStorageSecret(),
|
|
386
412
|
// `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
|
|
@@ -408,14 +434,15 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
|
|
|
408
434
|
root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
|
|
409
435
|
sealedKeys: async () =>
|
|
410
436
|
root === undefined ? { columns: undefined, keys: undefined } : sealedKeysProbe(root),
|
|
437
|
+
authStorage: () => authStorageProbe(process.env['DATABASE_URL']),
|
|
411
438
|
};
|
|
412
439
|
}
|
|
413
440
|
|
|
414
441
|
export const doctorCommand: CliCommand = {
|
|
415
442
|
spec: doctorSpec,
|
|
416
443
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
417
|
-
const port = doctorPort(ctx.args);
|
|
418
|
-
const probe = probeFor(ctx.cwd, ctx.bunVersion, port);
|
|
444
|
+
const port = doctorPort(ctx.args, ctx.env);
|
|
445
|
+
const probe = probeFor(ctx.cwd, ctx.bunVersion, port, ctx.env);
|
|
419
446
|
const findings = await runDoctor(probe);
|
|
420
447
|
const listed = withGuardListing(
|
|
421
448
|
{
|
package/src/cmd-env.ts
CHANGED
|
@@ -4,15 +4,39 @@
|
|
|
4
4
|
|
|
5
5
|
// Bun ships no path-join primitive, and `.env.example` is written app-root-relative.
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
checkEnv,
|
|
9
|
+
ENV_EXAMPLE_PATH,
|
|
10
|
+
ERROR_DOCS_URL,
|
|
11
|
+
maskedEnvValues,
|
|
12
|
+
UltimateError,
|
|
13
|
+
} from '@ultimat3/core';
|
|
8
14
|
import { envExampleFor, loadEnvSchema } from './app-env';
|
|
9
15
|
import { requireAppRoot } from './app-root';
|
|
10
16
|
import { envSpec } from './cmd-env-spec';
|
|
11
17
|
import type { CliCommand, CommandContext } from './command';
|
|
12
|
-
import { EnvSchemaMissingError } from './errors';
|
|
13
18
|
import { msg } from './messages';
|
|
14
19
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
15
20
|
|
|
21
|
+
/**
|
|
22
|
+
* `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
|
|
23
|
+
* writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
|
|
24
|
+
* present", both read as a working environment declaration to whoever runs the command next.
|
|
25
|
+
*
|
|
26
|
+
* `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
|
|
27
|
+
* `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
|
|
28
|
+
* than minting a synonym.
|
|
29
|
+
*/
|
|
30
|
+
export class EnvSchemaMissingError extends UltimateError {
|
|
31
|
+
constructor(input: { subcommand: string }) {
|
|
32
|
+
super({
|
|
33
|
+
code: 'X_CONFIG_INVALID',
|
|
34
|
+
cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
|
|
35
|
+
fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
/**
|
|
17
41
|
* Every subcommand needs the declaration, and an app without one is a usage error rather than an
|
|
18
42
|
* empty success: `x env example` writing a two-comment file would look like it worked.
|
package/src/cmd-generate.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
7
7
|
import { registerAdminResources } from './admin-registration';
|
|
8
|
-
import { registerGeneratedPrimitives } from './api-registration';
|
|
8
|
+
import { indexBindingFindings, registerGeneratedPrimitives } from './api-registration';
|
|
9
9
|
import { writeAppArtifacts } from './app-artifacts';
|
|
10
10
|
import { appManifest } from './app-manifest';
|
|
11
11
|
import { requireAppRoot } from './app-root';
|
|
@@ -18,7 +18,8 @@ import { ungrantedByGenerator } from './generate-grant-findings';
|
|
|
18
18
|
import { grantGeneratedPermissions } from './generate-grants';
|
|
19
19
|
import type { Generator } from './generate-kinds';
|
|
20
20
|
import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
|
|
21
|
-
import {
|
|
21
|
+
import { refuseShadowedTypes } from './generate-shadow';
|
|
22
|
+
import { containedPath, planWrites, writeFiles } from './generate-write';
|
|
22
23
|
import { declareGeneratedImports } from './generated-imports';
|
|
23
24
|
import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
|
|
24
25
|
import { resolveCatalogModule } from './i18n-audit';
|
|
@@ -28,6 +29,7 @@ import { msg } from './messages';
|
|
|
28
29
|
import type { CommandResult, Finding } from './output';
|
|
29
30
|
import { flagBool, flagList, flagString } from './parse';
|
|
30
31
|
import { quoteArg } from './shell-quote';
|
|
32
|
+
import type { GeneratedFile } from './templates';
|
|
31
33
|
import { kebab, names, resolveLocales } from './templates';
|
|
32
34
|
|
|
33
35
|
// One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
|
|
@@ -40,6 +42,24 @@ export { GENERATORS } from './generate-kinds';
|
|
|
40
42
|
export type { WriteReport } from './generate-write';
|
|
41
43
|
export { dedupe, writeFiles } from './generate-write';
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Names to suggest when the API index already holds the requested one, in order: the feature's
|
|
47
|
+
* prefix, the kind's suffix, then that suffix numbered — kebab output, so each pastes into a shell
|
|
48
|
+
* as one argument. The caller re-plans each and offers the first the index does not hold either.
|
|
49
|
+
*/
|
|
50
|
+
const freeNames = (
|
|
51
|
+
kind: Generator,
|
|
52
|
+
name: string,
|
|
53
|
+
feature: string | undefined,
|
|
54
|
+
): readonly string[] => {
|
|
55
|
+
const suffixed = `${kebab(name)}-${kind.split(':').at(-1) ?? kind}`;
|
|
56
|
+
return [
|
|
57
|
+
...(feature === undefined ? [] : [`${kebab(feature)}-${kebab(name)}`]),
|
|
58
|
+
suffixed,
|
|
59
|
+
...Array.from({ length: 8 }, (_unused, index) => `${suffixed}-${index + 2}`),
|
|
60
|
+
];
|
|
61
|
+
};
|
|
62
|
+
|
|
43
63
|
export const generateCommand: CliCommand = {
|
|
44
64
|
spec: generateSpec,
|
|
45
65
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
@@ -72,41 +92,68 @@ export const generateCommand: CliCommand = {
|
|
|
72
92
|
// THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
|
|
73
93
|
const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
|
|
74
94
|
const sliceRepo = await readSliceFile(root, kind, slice, 'repo.ts');
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
const planFor = (planned: string) =>
|
|
96
|
+
generate({
|
|
97
|
+
kind,
|
|
98
|
+
name: planned,
|
|
99
|
+
...(featureFlag === undefined ? {} : { feature: featureFlag }),
|
|
100
|
+
...(sliceErrors === undefined ? {} : { sliceErrors }),
|
|
101
|
+
...(sliceEntity === undefined ? {} : { sliceEntity }),
|
|
102
|
+
...(sliceRepo === undefined ? {} : { sliceRepo }),
|
|
103
|
+
...(at === undefined ? {} : { at }),
|
|
104
|
+
...(permission === undefined ? {} : { permission }),
|
|
105
|
+
surface,
|
|
106
|
+
live: flagBool(ctx.args, 'live'),
|
|
107
|
+
admin: flagBool(ctx.args, 'admin'),
|
|
108
|
+
locales,
|
|
109
|
+
...(catalogModule === undefined ? {} : { catalogModule }),
|
|
110
|
+
...(dbModule === undefined ? {} : { dbModule }),
|
|
111
|
+
shell,
|
|
112
|
+
});
|
|
113
|
+
const files = planFor(name);
|
|
114
|
+
// On the planned files, before a dry run answers or anything lands: a name whose type spelling
|
|
115
|
+
// the emitted code also uses as a global is a slice that does not compile.
|
|
116
|
+
refuseShadowedTypes(files, kind, name);
|
|
117
|
+
// The caller's own invocation, EVERY flag it set included: without `--feature` the fix wrote
|
|
118
|
+
// a second slice beside the one that conflicted.
|
|
119
|
+
const flags = reproducedFlags(generateCommand.spec, ctx.args);
|
|
120
|
+
const invocation = [invocationOf(ctx, 'g'), kind, quoteArg(name), ...flags].join(' ');
|
|
121
|
+
// A module the API index would list under a name it already holds: refused before anything is
|
|
122
|
+
// written, with the same run under a name that is free — re-planned and re-checked, so the
|
|
123
|
+
// suggestion is never itself a conflict.
|
|
124
|
+
const pathsOf = (planned: readonly GeneratedFile[]) => planned.map((file) => file.path);
|
|
125
|
+
const held = await indexBindingFindings(root, pathsOf(files), '');
|
|
126
|
+
let bindings: readonly Finding[] = [];
|
|
127
|
+
if (held.length > 0) {
|
|
128
|
+
let free = freeNames(kind, name, featureFlag).at(-1) ?? name;
|
|
129
|
+
for (const candidate of freeNames(kind, name, featureFlag)) {
|
|
130
|
+
if ((await indexBindingFindings(root, pathsOf(planFor(candidate)), '')).length > 0)
|
|
131
|
+
continue;
|
|
132
|
+
free = candidate;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
const renamed = [invocationOf(ctx, 'g'), kind, quoteArg(free), ...flags].join(' ');
|
|
136
|
+
bindings = held.map((finding) => ({ ...finding, fix: renamed }));
|
|
137
|
+
}
|
|
138
|
+
const force = flagBool(ctx.args, 'force');
|
|
92
139
|
if (flagBool(ctx.args, 'dry-run')) {
|
|
140
|
+
// The write plan, never the bare file list: what the real run would write, skip and refuse.
|
|
141
|
+
const plan = await planWrites(root, files, force, invocation);
|
|
142
|
+
const findings = [...plan.conflicts, ...bindings];
|
|
143
|
+
const planned = findings.length === 0 ? plan.written : [];
|
|
93
144
|
return {
|
|
94
|
-
ok:
|
|
145
|
+
ok: findings.length === 0,
|
|
95
146
|
command: 'g',
|
|
96
|
-
summary: msg('cli.generate.planned', { count:
|
|
97
|
-
data: { files:
|
|
98
|
-
lines:
|
|
147
|
+
summary: msg('cli.generate.planned', { count: planned.length, kind, name }),
|
|
148
|
+
data: { files: planned, dryRun: true },
|
|
149
|
+
lines: planned.map((file) => msg('cli.file.added', { path: file })),
|
|
150
|
+
findings,
|
|
99
151
|
};
|
|
100
152
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
kind,
|
|
106
|
-
quoteArg(name),
|
|
107
|
-
...reproducedFlags(generateCommand.spec, ctx.args),
|
|
108
|
-
].join(' ');
|
|
109
|
-
const report = await writeFiles(root, files, flagBool(ctx.args, 'force'), invocation);
|
|
153
|
+
const report =
|
|
154
|
+
bindings.length > 0
|
|
155
|
+
? { written: [], conflicts: bindings }
|
|
156
|
+
: await writeFiles(root, files, force, invocation);
|
|
110
157
|
// The three edits a generated primitive needs outside its own slice, performed rather than
|
|
111
158
|
// left as findings: a declared permission granted to a role, a job listed in `defineApi`, and
|
|
112
159
|
// an entity added to the typed handle its `repo.ts` reads through. Before the manifest load
|
package/src/cmd-help.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// never describe a flag that does not exist. Built as a factory over the spec list to keep the
|
|
3
3
|
// registry acyclic.
|
|
4
4
|
|
|
5
|
+
import { nearestName } from '@ultimat3/core';
|
|
5
6
|
import type { CliCommand, CommandContext } from './command';
|
|
7
|
+
import { UnknownCommandError } from './errors';
|
|
6
8
|
import { msg } from './messages';
|
|
7
9
|
import type { CommandResult, JsonValue } from './output';
|
|
8
10
|
import type { CommandSpec, FlagSpec } from './parse';
|
|
@@ -18,17 +20,28 @@ const flagLine = (flag: FlagSpec): string => {
|
|
|
18
20
|
* The one resolution of a topic, read by both renderers. `--json` filtered on `spec.name === topic`
|
|
19
21
|
* of its own, so the two disagreed about exactly the inputs a caller is least sure of: `x help
|
|
20
22
|
* generate --json` answered `[]` — "that command does not exist" — while the page beside it printed
|
|
21
|
-
* `g
|
|
23
|
+
* `g`. A topic naming no command is refused, as `x <topic>` itself is: answering the catalogue
|
|
24
|
+
* with exit 0 read as that command's usage to a caller that never compared the names.
|
|
22
25
|
*/
|
|
23
26
|
const specFor = (
|
|
24
27
|
specs: readonly CommandSpec[],
|
|
25
28
|
topic: string | undefined,
|
|
26
|
-
): CommandSpec | undefined =>
|
|
27
|
-
topic === undefined
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
): CommandSpec | undefined => {
|
|
30
|
+
if (topic === undefined) return undefined;
|
|
31
|
+
const found = specs.find(
|
|
32
|
+
(entry) => entry.name === topic || (entry.aliases ?? []).includes(topic),
|
|
33
|
+
);
|
|
34
|
+
if (found !== undefined) return found;
|
|
35
|
+
const names = specs.map((entry) => entry.name);
|
|
36
|
+
const nearest = nearestName(topic, [...names, ...specs.flatMap((entry) => entry.aliases ?? [])]);
|
|
37
|
+
throw new UnknownCommandError({
|
|
38
|
+
path: `help ${topic}`,
|
|
39
|
+
known: names,
|
|
40
|
+
suggestion: nearest === undefined ? 'help' : `help ${nearest}`,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
30
43
|
|
|
31
|
-
/** What `--json` reports: the one resolved command, or —
|
|
44
|
+
/** What `--json` reports: the one resolved command, or — with no topic — all of them. */
|
|
32
45
|
export function helpTopic(
|
|
33
46
|
specs: readonly CommandSpec[],
|
|
34
47
|
topic: string | undefined,
|
package/src/cmd-i18n.ts
CHANGED
|
@@ -196,14 +196,15 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
|
|
|
196
196
|
const { registered, findings: indexFindings } = await syncI18nIndex(root);
|
|
197
197
|
|
|
198
198
|
const keys = catalogKeys(seeded).length;
|
|
199
|
+
// The catalog is on disk either way, but a finding fails the run: a `packages/i18n/src/index.ts`
|
|
200
|
+
// that would not import is why `from` may be the framework's `en` instead of the app's own
|
|
201
|
+
// default, and an index the locale could not be registered in is a red `i18n` gate step.
|
|
202
|
+
const findings = [...indexFindings, ...app.findings];
|
|
199
203
|
return {
|
|
200
|
-
|
|
201
|
-
// rather than flip that: a `packages/i18n/src/index.ts` that would not import is why `from` may
|
|
202
|
-
// be the framework's `en` instead of the app's own default, and silence there is the bug.
|
|
203
|
-
ok: true,
|
|
204
|
+
ok: findings.length === 0,
|
|
204
205
|
command: 'i18n',
|
|
205
206
|
summary: msg('cli.i18n.added', { locale, keys, from: from ?? locale }),
|
|
206
|
-
findings
|
|
207
|
+
findings,
|
|
207
208
|
// `registered` is false only for an app with no `packages/i18n` at all — a fact a caller has
|
|
208
209
|
// to be able to read, because it is the one case where the locale is on disk and unselectable.
|
|
209
210
|
data: { locale, from: from ?? locale, keys, path, registered },
|
|
@@ -274,16 +275,16 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
|
|
|
274
275
|
const { registered, findings: indexFindings } = await syncI18nIndex(root);
|
|
275
276
|
|
|
276
277
|
const total = catalogKeys(merged).length;
|
|
278
|
+
// Same as `runAdd`: the merge landed, and a finding still fails the run.
|
|
279
|
+
const findings = [...indexFindings, ...app.findings];
|
|
277
280
|
return {
|
|
278
|
-
|
|
279
|
-
// own default or the framework's fallback standing in for an i18n module that would not import.
|
|
280
|
-
ok: true,
|
|
281
|
+
ok: findings.length === 0,
|
|
281
282
|
command: 'i18n',
|
|
282
283
|
summary: msg('cli.i18n.synced', { locale, from: from ?? locale, added: added.length, total }),
|
|
283
284
|
// The keys themselves, raw — `runCheck` lists gaps the same way, because a key is a value an
|
|
284
285
|
// author copies and never prose the catalog owns.
|
|
285
286
|
lines: seeded ? added.map((key) => ` ${key}`) : [],
|
|
286
|
-
findings
|
|
287
|
+
findings,
|
|
287
288
|
data: {
|
|
288
289
|
locale,
|
|
289
290
|
from: from ?? locale,
|
package/src/cmd-jobs.ts
CHANGED
|
@@ -66,7 +66,18 @@ function refuseMemoryTarget(): never {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* A uuid in any spelling Postgres' `uuid` input accepts: canonical, upper-case, unhyphenated,
|
|
71
|
+
* braced. Every driver mints its ids with core's `uuid()`, and `x_jobs.id` is a `uuid` column.
|
|
72
|
+
*/
|
|
73
|
+
const JOB_ID = /^\{?[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\}?$/i;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The id, parsed at the door — admin's `readableId` rule: an id no driver could hold is a job that
|
|
77
|
+
* does not exist, answered before a statement is sent. `x jobs show nosuch` reached Postgres and
|
|
78
|
+
* came back a raw `X_DB_STATEMENT_FAILED [22P02]` whose fix was a psql session.
|
|
79
|
+
*/
|
|
80
|
+
function requireIdPositional(ctx: CommandContext, sub: string, driver: JobDriver): string {
|
|
70
81
|
const id = ctx.args.positionals[0];
|
|
71
82
|
if (id === undefined) {
|
|
72
83
|
// `--id on "x jobs"` is a flag `x jobs` does not declare; the id is a positional and says so.
|
|
@@ -76,6 +87,7 @@ function requireIdPositional(ctx: CommandContext, sub: string): string {
|
|
|
76
87
|
example: 'x jobs ls --json',
|
|
77
88
|
});
|
|
78
89
|
}
|
|
90
|
+
if (!JOB_ID.test(id)) throw new JobUnknownError({ id, driver: driver.name });
|
|
79
91
|
return id;
|
|
80
92
|
}
|
|
81
93
|
|
|
@@ -211,7 +223,7 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
211
223
|
}
|
|
212
224
|
|
|
213
225
|
async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
214
|
-
const trace = await showJob(driver, requireIdPositional(ctx, 'show'));
|
|
226
|
+
const trace = await showJob(driver, requireIdPositional(ctx, 'show', driver));
|
|
215
227
|
return {
|
|
216
228
|
ok: true,
|
|
217
229
|
command: 'jobs',
|
|
@@ -226,7 +238,7 @@ async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandR
|
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
229
|
-
const id = requireIdPositional(ctx, 'retry');
|
|
241
|
+
const id = requireIdPositional(ctx, 'retry', driver);
|
|
230
242
|
const trace = await retryJob(driver, id, flagString(ctx.args, 'from-step'));
|
|
231
243
|
return {
|
|
232
244
|
ok: true,
|
|
@@ -243,7 +255,7 @@ async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<Command
|
|
|
243
255
|
* The trace is rendered by the same projection `show` and `retry` use — one shape for one job.
|
|
244
256
|
*/
|
|
245
257
|
async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
246
|
-
const id = requireIdPositional(ctx, 'cancel');
|
|
258
|
+
const id = requireIdPositional(ctx, 'cancel', driver);
|
|
247
259
|
const trace = await cancelJob(driver, id, flagString(ctx.args, 'reason'));
|
|
248
260
|
// `cancelJob` re-reads the job after cancelling, so `undefined` would mean the row vanished
|
|
249
261
|
// between the two — reported as the same refusal rather than rendered as a success with no job.
|
|
@@ -258,7 +270,7 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
|
|
|
258
270
|
|
|
259
271
|
/** Exit 0 means the row is gone. An id nobody queued is `X_JOB_UNKNOWN`; a running one refuses. */
|
|
260
272
|
async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
261
|
-
const id = requireIdPositional(ctx, 'rm');
|
|
273
|
+
const id = requireIdPositional(ctx, 'rm', driver);
|
|
262
274
|
const removed = await removeJob(driver, id);
|
|
263
275
|
if (removed === undefined) throw new JobUnknownError({ id, driver: driver.name });
|
|
264
276
|
return {
|
|
@@ -270,7 +282,7 @@ async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
270
282
|
}
|
|
271
283
|
|
|
272
284
|
async function runPromote(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
273
|
-
const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote'));
|
|
285
|
+
const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote', driver));
|
|
274
286
|
return {
|
|
275
287
|
ok: true,
|
|
276
288
|
command: 'jobs',
|
package/src/cmd-manifest-spec.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { CommandSpec } from './parse';
|
|
|
6
6
|
export const manifestSpec: CommandSpec = {
|
|
7
7
|
name: 'manifest',
|
|
8
8
|
summary: 'regenerate x.manifest.json and openapi.json from the code',
|
|
9
|
-
usage: 'x manifest [--check] [--json]',
|
|
9
|
+
usage: 'x manifest [--check] [--no-openapi] [--json]',
|
|
10
10
|
requiresApp: true,
|
|
11
11
|
flags: [
|
|
12
12
|
{ name: 'check', type: 'boolean', summary: 'fail if the committed files are stale' },
|
package/src/cmd-manifest.ts
CHANGED
|
@@ -4,16 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import type { Manifest } from '@ultimat3/manifest';
|
|
7
|
-
import {
|
|
7
|
+
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
8
8
|
import { writeAppArtifacts } from './app-artifacts';
|
|
9
9
|
import { appManifest } from './app-manifest';
|
|
10
|
-
import { openApiStaleness } from './app-openapi';
|
|
11
10
|
import { requireAppRoot } from './app-root';
|
|
12
11
|
import { manifestSpec } from './cmd-manifest-spec';
|
|
13
12
|
import type { CliCommand, CommandContext } from './command';
|
|
13
|
+
import { manifestStaleness } from './manifest-staleness';
|
|
14
14
|
import { msg } from './messages';
|
|
15
|
-
import type { CommandResult,
|
|
16
|
-
import { findingFrom } from './output';
|
|
15
|
+
import type { CommandResult, JsonValue } from './output';
|
|
17
16
|
import { flagBool } from './parse';
|
|
18
17
|
|
|
19
18
|
const countsOf = (manifest: Manifest): JsonValue => ({
|
|
@@ -27,21 +26,6 @@ const countsOf = (manifest: Manifest): JsonValue => ({
|
|
|
27
26
|
policies: manifest.policies.length,
|
|
28
27
|
});
|
|
29
28
|
|
|
30
|
-
/**
|
|
31
|
-
* The identical comparison `x verify`'s `manifest` step makes, through the identical function.
|
|
32
|
-
* A buildId equality test here would answer "fresh" for a hand-edited file whose body no longer
|
|
33
|
-
* hashes to the id it carries — and two commands giving two answers about one file is itself the
|
|
34
|
-
* drift the manifest exists to prevent.
|
|
35
|
-
*/
|
|
36
|
-
async function staleness(root: string, manifest: Manifest): Promise<Finding | undefined> {
|
|
37
|
-
try {
|
|
38
|
-
await assertNoDrift({ manifest, path: join(root, MANIFEST_FILENAME) });
|
|
39
|
-
return undefined;
|
|
40
|
-
} catch (error) {
|
|
41
|
-
return { ...findingFrom(error), at: MANIFEST_FILENAME };
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
29
|
export const manifestCommand: CliCommand = {
|
|
46
30
|
spec: manifestSpec,
|
|
47
31
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
@@ -50,12 +34,9 @@ export const manifestCommand: CliCommand = {
|
|
|
50
34
|
const counts = countsOf(manifest);
|
|
51
35
|
|
|
52
36
|
if (flagBool(ctx.args, 'check')) {
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
const stale =
|
|
56
|
-
...[await staleness(root, manifest)].filter((one) => one !== undefined),
|
|
57
|
-
...(await openApiStaleness(root, manifest)),
|
|
58
|
-
];
|
|
37
|
+
// The gate's own check, through its function (`manifest-staleness.ts`): both files this
|
|
38
|
+
// command writes, and the same code for each condition `x verify`'s `manifest` step gives.
|
|
39
|
+
const stale = await manifestStaleness(root, manifest);
|
|
59
40
|
return {
|
|
60
41
|
ok: stale.length === 0 && findings.length === 0,
|
|
61
42
|
command: 'manifest',
|