@ultimat3/cli 22.15.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -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 +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- 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-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- 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 +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- 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-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- 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/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -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 +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- 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 +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- 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-edit.ts +49 -0
- 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 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- 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-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- 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/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
package/src/cmd-jobs.ts
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
|
-
// `x jobs ls|show|retry|cancel|drain` — introspect and recover the job queue, bound to
|
|
1
|
+
// `x jobs ls|show|retry|cancel|rm|promote|pause|resume|drain` — introspect and recover the job queue, bound to
|
|
2
2
|
// `@ultimat3/jobs`'s
|
|
3
3
|
// own introspection so the CLI, `/_x` and MCP report identically. This file is CLI wiring only:
|
|
4
4
|
// the driver-injected logic is `jobs-report.ts`, the `--json` shapes `jobs-json.ts`, the table
|
|
5
5
|
// `jobs-table.ts`, and getting hold of the queue at all is `jobs-driver.ts` — shared with `x db`.
|
|
6
6
|
|
|
7
|
+
import { renderFixShellArg } from '@ultimat3/core';
|
|
7
8
|
import type { JobDriver } from '@ultimat3/jobs';
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
cancelJob,
|
|
11
|
+
createNatsDriver,
|
|
12
|
+
createRedisDriver,
|
|
13
|
+
DEFAULT_JOB_PAGE,
|
|
14
|
+
jobCursor,
|
|
15
|
+
MAX_JOB_PAGE,
|
|
16
|
+
pauseQueue,
|
|
17
|
+
promoteJob,
|
|
18
|
+
removeJob,
|
|
19
|
+
resumeQueue,
|
|
20
|
+
} from '@ultimat3/jobs';
|
|
21
|
+
import { loadApp } from './app-load';
|
|
9
22
|
import { requireAppRoot } from './app-root';
|
|
10
23
|
import { DRAIN_TARGETS, jobsSpec } from './cmd-jobs-spec';
|
|
11
24
|
import type { CliCommand, CommandContext } from './command';
|
|
@@ -21,6 +34,8 @@ import {
|
|
|
21
34
|
drainSkipToJson,
|
|
22
35
|
jobRecordToJson,
|
|
23
36
|
jobTraceToJson,
|
|
37
|
+
pausedToJson,
|
|
38
|
+
workerToJson,
|
|
24
39
|
} from './jobs-json';
|
|
25
40
|
import { listJobs, retryJob, showJob } from './jobs-report';
|
|
26
41
|
import { renderJobTable } from './jobs-table';
|
|
@@ -51,7 +66,18 @@ function refuseMemoryTarget(): never {
|
|
|
51
66
|
});
|
|
52
67
|
}
|
|
53
68
|
|
|
54
|
-
|
|
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 {
|
|
55
81
|
const id = ctx.args.positionals[0];
|
|
56
82
|
if (id === undefined) {
|
|
57
83
|
// `--id on "x jobs"` is a flag `x jobs` does not declare; the id is a positional and says so.
|
|
@@ -61,9 +87,37 @@ function requireIdPositional(ctx: CommandContext, sub: string): string {
|
|
|
61
87
|
example: 'x jobs ls --json',
|
|
62
88
|
});
|
|
63
89
|
}
|
|
90
|
+
if (!JOB_ID.test(id)) throw new JobUnknownError({ id, driver: driver.name });
|
|
64
91
|
return id;
|
|
65
92
|
}
|
|
66
93
|
|
|
94
|
+
function requireQueuePositional(ctx: CommandContext, sub: string): string {
|
|
95
|
+
const queue = ctx.args.positionals[0];
|
|
96
|
+
if (queue === undefined) {
|
|
97
|
+
throw new MissingPositionalError({
|
|
98
|
+
command: `jobs ${sub}`,
|
|
99
|
+
positional: 'queue',
|
|
100
|
+
example: `x jobs ${sub} default --json`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return queue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A page past the queue's own bound is refused HERE, as a flag error with the command that walks
|
|
108
|
+
* instead — `list()` would refuse it too, in the vocabulary of a caller holding a driver.
|
|
109
|
+
*/
|
|
110
|
+
function refuseOversizedPage(limit: string | undefined): void {
|
|
111
|
+
if (limit === undefined || !/^\d+$/.test(limit) || Number(limit) <= MAX_JOB_PAGE) return;
|
|
112
|
+
throw new BadFlagError({
|
|
113
|
+
flag: 'limit',
|
|
114
|
+
command: 'jobs',
|
|
115
|
+
reason: `one page holds at most ${MAX_JOB_PAGE} jobs`,
|
|
116
|
+
// The bound is the queue's own constant, screened like any value spliced into a command.
|
|
117
|
+
fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.next as --after for the page that follows`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
67
121
|
function requireEnvUrl(env: CommandContext['env'], name: string, target: string): string {
|
|
68
122
|
const value = env[name];
|
|
69
123
|
if (value === undefined || value.trim().length === 0) {
|
|
@@ -97,16 +151,34 @@ export function buildDrainTarget(to: string | undefined, env: CommandContext['en
|
|
|
97
151
|
}
|
|
98
152
|
|
|
99
153
|
async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
154
|
+
const limit = flagString(ctx.args, 'limit');
|
|
155
|
+
refuseOversizedPage(limit);
|
|
100
156
|
const result = await listJobs(driver, {
|
|
101
157
|
queue: flagString(ctx.args, 'queue'),
|
|
102
158
|
state: flagString(ctx.args, 'state'),
|
|
103
159
|
name: flagString(ctx.args, 'name'),
|
|
104
|
-
limit
|
|
160
|
+
limit,
|
|
161
|
+
after: flagString(ctx.args, 'after'),
|
|
105
162
|
});
|
|
163
|
+
// A FULL page may have another behind it: `next` is the cursor that asks for it, and `null`
|
|
164
|
+
// says this was the last one — so a walk never has to guess from a row count.
|
|
165
|
+
const size = limit === undefined ? DEFAULT_JOB_PAGE : Number(limit);
|
|
166
|
+
const last = result.rows[result.rows.length - 1];
|
|
167
|
+
const next = last === undefined || result.rows.length < size ? null : jobCursor(last);
|
|
168
|
+
const [paused, workers] = await Promise.all([
|
|
169
|
+
driver.introspect?.pausedQueues() ?? [],
|
|
170
|
+
driver.introspect?.workers() ?? [],
|
|
171
|
+
]);
|
|
106
172
|
const lines = [` ${msg('cli.jobs.listed', { count: result.rows.length })}`];
|
|
107
173
|
if (result.rows.length > 0) {
|
|
108
174
|
lines.push(...renderJobTable(result.rows).map((line) => ` ${line}`));
|
|
109
175
|
}
|
|
176
|
+
if (next !== null) lines.push(` ${msg('cli.jobs.nextPage', { cursor: next })}`);
|
|
177
|
+
if (paused.length > 0) {
|
|
178
|
+
lines.push(
|
|
179
|
+
` ${msg('cli.jobs.pausedQueues', { queues: paused.map((q) => q.name).join(', ') })}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
110
182
|
if (result.deadLetters.length > 0) {
|
|
111
183
|
lines.push(` ${msg('cli.jobs.deadLetters', { count: result.deadLetters.length })}`);
|
|
112
184
|
for (const entry of result.deadLetters) {
|
|
@@ -143,12 +215,15 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
143
215
|
rows: result.rows.map(jobRecordToJson),
|
|
144
216
|
deadLetters: result.deadLetters.map(deadLetterToJson),
|
|
145
217
|
backfills: result.backfills.map(backfillToJson),
|
|
218
|
+
next,
|
|
219
|
+
pausedQueues: paused.map(pausedToJson),
|
|
220
|
+
workers: workers.map(workerToJson),
|
|
146
221
|
},
|
|
147
222
|
};
|
|
148
223
|
}
|
|
149
224
|
|
|
150
225
|
async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
151
|
-
const trace = await showJob(driver, requireIdPositional(ctx, 'show'));
|
|
226
|
+
const trace = await showJob(driver, requireIdPositional(ctx, 'show', driver));
|
|
152
227
|
return {
|
|
153
228
|
ok: true,
|
|
154
229
|
command: 'jobs',
|
|
@@ -163,7 +238,7 @@ async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandR
|
|
|
163
238
|
}
|
|
164
239
|
|
|
165
240
|
async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
166
|
-
const id = requireIdPositional(ctx, 'retry');
|
|
241
|
+
const id = requireIdPositional(ctx, 'retry', driver);
|
|
167
242
|
const trace = await retryJob(driver, id, flagString(ctx.args, 'from-step'));
|
|
168
243
|
return {
|
|
169
244
|
ok: true,
|
|
@@ -180,7 +255,7 @@ async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<Command
|
|
|
180
255
|
* The trace is rendered by the same projection `show` and `retry` use — one shape for one job.
|
|
181
256
|
*/
|
|
182
257
|
async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
183
|
-
const id = requireIdPositional(ctx, 'cancel');
|
|
258
|
+
const id = requireIdPositional(ctx, 'cancel', driver);
|
|
184
259
|
const trace = await cancelJob(driver, id, flagString(ctx.args, 'reason'));
|
|
185
260
|
// `cancelJob` re-reads the job after cancelling, so `undefined` would mean the row vanished
|
|
186
261
|
// between the two — reported as the same refusal rather than rendered as a success with no job.
|
|
@@ -193,6 +268,48 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
|
|
|
193
268
|
};
|
|
194
269
|
}
|
|
195
270
|
|
|
271
|
+
/** Exit 0 means the row is gone. An id nobody queued is `X_JOB_UNKNOWN`; a running one refuses. */
|
|
272
|
+
async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
273
|
+
const id = requireIdPositional(ctx, 'rm', driver);
|
|
274
|
+
const removed = await removeJob(driver, id);
|
|
275
|
+
if (removed === undefined) throw new JobUnknownError({ id, driver: driver.name });
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
command: 'jobs',
|
|
279
|
+
summary: msg('cli.jobs.removed', { id: removed.id, state: removed.state }),
|
|
280
|
+
data: jobRecordToJson(removed),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function runPromote(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
285
|
+
const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote', driver));
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
command: 'jobs',
|
|
289
|
+
summary: msg('cli.jobs.promoted', { id: promoted.id }),
|
|
290
|
+
data: jobRecordToJson(promoted),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Both verbs answer the list as the queue NOW holds it, read back after the write: the pause is a
|
|
296
|
+
* row every worker's next claim reads, so "paused" here is paused fleet-wide within one poll.
|
|
297
|
+
*/
|
|
298
|
+
async function runPause(
|
|
299
|
+
driver: JobDriver,
|
|
300
|
+
ctx: CommandContext,
|
|
301
|
+
sub: 'pause' | 'resume',
|
|
302
|
+
): Promise<CommandResult> {
|
|
303
|
+
const queue = requireQueuePositional(ctx, sub);
|
|
304
|
+
const paused = await (sub === 'pause' ? pauseQueue(driver, queue) : resumeQueue(driver, queue));
|
|
305
|
+
return {
|
|
306
|
+
ok: true,
|
|
307
|
+
command: 'jobs',
|
|
308
|
+
summary: msg(sub === 'pause' ? 'cli.jobs.paused' : 'cli.jobs.resumed', { queue }),
|
|
309
|
+
data: { queue, paused: sub === 'pause', pausedQueues: paused.map(pausedToJson) },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
196
313
|
/**
|
|
197
314
|
* A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
|
|
198
315
|
* design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
|
|
@@ -246,6 +363,9 @@ async function runDrain(
|
|
|
246
363
|
return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
|
|
247
364
|
}
|
|
248
365
|
|
|
366
|
+
/** The subcommands that answer a `JobTrace`. `ls`, `rm`, `promote` and the rest read rows only. */
|
|
367
|
+
const TRACE_SUBCOMMANDS: ReadonlySet<string> = new Set(['show', 'retry', 'cancel']);
|
|
368
|
+
|
|
249
369
|
export const jobsCommand: CliCommand = {
|
|
250
370
|
spec: jobsSpec,
|
|
251
371
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
@@ -258,10 +378,19 @@ export const jobsCommand: CliCommand = {
|
|
|
258
378
|
// word they typed was refused by name. It also opens a connection to a target the command
|
|
259
379
|
// then refuses, which is a socket nothing closes.
|
|
260
380
|
const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
|
|
381
|
+
// A TRACE is the queue row projected through the job's own declaration — its concurrency key,
|
|
382
|
+
// its retry schedule — and a declaration exists in this process only once the app is loaded.
|
|
383
|
+
// Unloaded, `show` answered `concurrencyKey: null` and `retryDelaysMs: []` for every job of
|
|
384
|
+
// every app. The findings are deliberately dropped: a trace is read to debug a stuck queue,
|
|
385
|
+
// so an app that half-loads degrades those two fields and still answers.
|
|
386
|
+
if (TRACE_SUBCOMMANDS.has(sub)) await loadApp(root);
|
|
261
387
|
return withJobDriver(root, ctx, (driver) => {
|
|
262
388
|
if (sub === 'show') return runShow(driver, ctx);
|
|
263
389
|
if (sub === 'retry') return runRetry(driver, ctx);
|
|
264
390
|
if (sub === 'cancel') return runCancel(driver, ctx);
|
|
391
|
+
if (sub === 'rm') return runRm(driver, ctx);
|
|
392
|
+
if (sub === 'promote') return runPromote(driver, ctx);
|
|
393
|
+
if (sub === 'pause' || sub === 'resume') return runPause(driver, ctx, sub);
|
|
265
394
|
if (target !== null) return runDrain(driver, target, ctx);
|
|
266
395
|
return runLs(driver, ctx);
|
|
267
396
|
});
|
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',
|
package/src/cmd-mcp.ts
CHANGED
|
@@ -3,17 +3,24 @@
|
|
|
3
3
|
// supplies only the app, the caller and the socket. A tool answered here would be a second answer
|
|
4
4
|
// to a question the framework already answers.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
markListening,
|
|
8
|
+
nanoid,
|
|
9
|
+
renderFixShellArg,
|
|
10
|
+
timingSafeEqual,
|
|
11
|
+
UltimateError,
|
|
12
|
+
} from '@ultimat3/core';
|
|
7
13
|
import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
|
|
8
14
|
import { requireAppRoot } from './app-root';
|
|
9
15
|
import { DEFAULT_PORT, mcpSpec } from './cmd-mcp-spec';
|
|
10
16
|
import type { CliCommand, CommandContext } from './command';
|
|
11
17
|
import { BadFlagError } from './errors';
|
|
12
|
-
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
18
|
+
import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
|
|
13
19
|
import { holdUntilShutdown } from './hold';
|
|
14
20
|
import type { CliMcpServer } from './mcp-host';
|
|
15
21
|
import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
|
|
16
22
|
import { msg } from './messages';
|
|
23
|
+
import { isAddressInUse } from './metrics-endpoint';
|
|
17
24
|
import type { CommandResult } from './output';
|
|
18
25
|
import { flagString } from './parse';
|
|
19
26
|
|
|
@@ -56,6 +63,21 @@ const notFound = (path: string): Response =>
|
|
|
56
63
|
{ status: 404, headers: { 'content-type': 'application/json' } },
|
|
57
64
|
);
|
|
58
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The MCP port is bound already — by another `x mcp serve`, or by the inspector, whose default
|
|
68
|
+
* 9229 this one shares. `metrics-endpoint.ts`'s pattern: an `EADDRINUSE` throw becomes the
|
|
69
|
+
* framework's `X_PORT_IN_USE`, with the invocation one port over.
|
|
70
|
+
*/
|
|
71
|
+
export class McpPortInUseError extends UltimateError {
|
|
72
|
+
constructor(input: { port: number }) {
|
|
73
|
+
super({
|
|
74
|
+
code: 'X_PORT_IN_USE',
|
|
75
|
+
cause: `port ${String(input.port)} is already bound, so x mcp serve could not open its HTTP transport`,
|
|
76
|
+
fix: `x mcp serve --transport http --port ${renderFixShellArg(String(neighbouringPort(input.port)), '9230')}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
/** A running HTTP transport. `stop()` releases the socket AND the host's lazily booted services. */
|
|
60
82
|
export interface McpHttpServer {
|
|
61
83
|
readonly result: CommandResult;
|
|
@@ -70,7 +92,7 @@ export interface McpHttpServer {
|
|
|
70
92
|
* directory outlive `run()` otherwise, and nothing — a test, an embedding caller, or a signal —
|
|
71
93
|
* could ever release them.
|
|
72
94
|
*/
|
|
73
|
-
export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
|
|
95
|
+
export async function startMcpHttp(host: CliMcpServer, port: number): Promise<McpHttpServer> {
|
|
74
96
|
const token = nanoid(32);
|
|
75
97
|
const route = mcpHttpRoute({
|
|
76
98
|
server: host.server,
|
|
@@ -83,17 +105,26 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
|
|
|
83
105
|
? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES }
|
|
84
106
|
: null,
|
|
85
107
|
});
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
let handle: ReturnType<typeof Bun.serve>;
|
|
109
|
+
try {
|
|
110
|
+
handle = Bun.serve({
|
|
111
|
+
port,
|
|
112
|
+
hostname: 'localhost',
|
|
113
|
+
fetch: (request: Request, server): Response | Promise<Response> => {
|
|
114
|
+
const url = new URL(request.url);
|
|
115
|
+
if (request.method !== route.method || url.pathname !== route.path) {
|
|
116
|
+
return notFound(route.path);
|
|
117
|
+
}
|
|
118
|
+
// The socket's peer: failed tokens are metered per address, and only the server knows it.
|
|
119
|
+
return route.handle(request, { address: server.requestIP(request)?.address });
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// The host booted before the bind; a refusal that left it open would leak its database.
|
|
124
|
+
await host.close();
|
|
125
|
+
if (isAddressInUse(error)) throw new McpPortInUseError({ port });
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
97
128
|
// Announces the socket as this process's own, so a caller on it is never mistaken for egress.
|
|
98
129
|
const stopListening = markListening(handle.url.origin);
|
|
99
130
|
const url = `${handle.url.origin}${route.path}`;
|
|
@@ -179,7 +210,7 @@ export const mcpCommand: CliCommand = {
|
|
|
179
210
|
// starts is what releases the socket and the database. Registering a shutdown hook and
|
|
180
211
|
// returning was the older shape — nothing installed a signal handler, so the hook was never
|
|
181
212
|
// reached and the exit code closed the socket the line above had just announced.
|
|
182
|
-
const served = startMcpHttp(host, port);
|
|
213
|
+
const served = await startMcpHttp(host, port);
|
|
183
214
|
return { ...served.result, hold: holdUntilShutdown('mcp', () => served.stop()) };
|
|
184
215
|
},
|
|
185
216
|
};
|
package/src/cmd-new.ts
CHANGED
|
@@ -110,7 +110,21 @@ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
|
|
|
110
110
|
...appFiles(app, options.example),
|
|
111
111
|
];
|
|
112
112
|
if (options.example) {
|
|
113
|
-
files.push(
|
|
113
|
+
files.push(
|
|
114
|
+
...resourceFiles('post', {
|
|
115
|
+
surfaceDir: 'apps/web/app',
|
|
116
|
+
feature: 'post',
|
|
117
|
+
// The package `repoFiles` just named: the slice's `repo.ts` imports the typed handle from it.
|
|
118
|
+
dbModule: `@${app.kebab}/db`,
|
|
119
|
+
// And the catalog package beside it: every component the slice writes reads its strings
|
|
120
|
+
// through `useT()` from the module that registers them — the idiom `x g resource` writes
|
|
121
|
+
// into this same app, so the scaffold shows one way and not two (issue #249).
|
|
122
|
+
catalogModule: `@${app.kebab}/i18n`,
|
|
123
|
+
// `appFiles` above writes `apps/web/shared/shell.tsx`, so the page sits in the app frame —
|
|
124
|
+
// the same answer `x g resource` reads off the disk of this app afterwards.
|
|
125
|
+
shell: true,
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
114
128
|
}
|
|
115
129
|
// `repoFiles`' own catalog entry and the example resource's both target the same flat catalog
|
|
116
130
|
// file, so this has to be the merge-aware dedupe — the one `cmd-generate.ts` uses for `x g` —
|
package/src/cmd-planned.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// does not exist", which is false and sends an agent looking for a typo. X_NOT_IMPLEMENTED plus a
|
|
4
4
|
// fix naming the closest shipped command says the true thing, and `x help` lists them honestly.
|
|
5
5
|
|
|
6
|
+
import { NotImplementedError } from '@ultimat3/core';
|
|
6
7
|
import type { CliCommand } from './command';
|
|
7
|
-
import { CliNotImplementedError } from './errors';
|
|
8
8
|
import type { CommandResult } from './output';
|
|
9
9
|
import type { CommandSpec } from './parse';
|
|
10
10
|
|
|
@@ -134,18 +134,28 @@ export const PLANNED_SUBCOMMANDS: readonly PlannedSubcommand[] = [
|
|
|
134
134
|
* the error rather than throwing it, because a `run` that throws synchronously escapes the
|
|
135
135
|
* promise its signature promises — the caller does `throw plannedSubcommand(...)`.
|
|
136
136
|
*/
|
|
137
|
-
export function plannedSubcommand(command: string, subcommand: string):
|
|
137
|
+
export function plannedSubcommand(command: string, subcommand: string): NotImplementedError {
|
|
138
138
|
const planned = PLANNED_SUBCOMMANDS.find(
|
|
139
139
|
(entry) => entry.command === command && entry.subcommand === subcommand,
|
|
140
140
|
);
|
|
141
|
-
return new
|
|
142
|
-
|
|
141
|
+
return new NotImplementedError({
|
|
142
|
+
cause: `x ${command} ${subcommand} is not implemented in this build`,
|
|
143
143
|
// `x help <command>`, not `x <command> --help`: for a command that declares subcommands and no
|
|
144
144
|
// default, the latter is refused by the parser before help is ever rendered.
|
|
145
145
|
fix: planned?.fix ?? `x help ${command}`,
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* What every invocation of a planned command answers — its `run`, and the dispatcher when the
|
|
151
|
+
* parser refused a flag before any `run` was reached. One builder, so the two cannot drift.
|
|
152
|
+
*/
|
|
153
|
+
export const plannedCommandError = (planned: PlannedCommand): NotImplementedError =>
|
|
154
|
+
new NotImplementedError({
|
|
155
|
+
cause: `x ${planned.name} is not implemented in this build`,
|
|
156
|
+
fix: planned.fix,
|
|
157
|
+
});
|
|
158
|
+
|
|
149
159
|
const specFor = (planned: PlannedCommand): CommandSpec => ({
|
|
150
160
|
name: planned.name,
|
|
151
161
|
summary: `${planned.summary} (planned)`,
|
|
@@ -167,10 +177,7 @@ const toCommand = (planned: PlannedCommand): CliCommand => ({
|
|
|
167
177
|
// `async` is load-bearing: a synchronous throw would escape every caller that awaits the
|
|
168
178
|
// promise this signature promises, including the dispatcher's own error path.
|
|
169
179
|
async run(): Promise<CommandResult> {
|
|
170
|
-
throw
|
|
171
|
-
feature: `x ${planned.name}`,
|
|
172
|
-
fix: planned.fix,
|
|
173
|
-
});
|
|
180
|
+
throw plannedCommandError(planned);
|
|
174
181
|
},
|
|
175
182
|
});
|
|
176
183
|
|
package/src/cmd-routes.ts
CHANGED
|
@@ -10,11 +10,21 @@ import { loadApp } from './app-load';
|
|
|
10
10
|
import { requireAppRoot } from './app-root';
|
|
11
11
|
import { routesSpec } from './cmd-routes-spec';
|
|
12
12
|
import type { CliCommand, CommandContext } from './command';
|
|
13
|
-
import { BadFlagError } from './errors';
|
|
13
|
+
import { BadFlagError, UnknownCommandError } from './errors';
|
|
14
14
|
import { msg } from './messages';
|
|
15
15
|
import type { CommandResult, JsonValue } from './output';
|
|
16
16
|
import { flagString } from './parse';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Where a route comes from. A file route names its file; a MOUNTED one names the call that
|
|
20
|
+
* mounted it and every permission that gates it — there is no `page.tsx` to name, and printing the
|
|
21
|
+
* package would send a reader looking for a route file inside `node_modules`.
|
|
22
|
+
*/
|
|
23
|
+
const declaredBy = (route: RouteDescriptor): string =>
|
|
24
|
+
route.mount === undefined
|
|
25
|
+
? route.file
|
|
26
|
+
: `${route.mount.by}() · ${route.mount.permissions.join(' + ')}`;
|
|
27
|
+
|
|
18
28
|
/** Fixed-width columns so the output diffs cleanly between runs and between machines. */
|
|
19
29
|
export function renderRouteTable(routes: readonly RouteDescriptor[]): readonly string[] {
|
|
20
30
|
const rows = routes.map((route) => [
|
|
@@ -23,7 +33,7 @@ export function renderRouteTable(routes: readonly RouteDescriptor[]): readonly s
|
|
|
23
33
|
route.mode,
|
|
24
34
|
route.hydrate,
|
|
25
35
|
route.offline,
|
|
26
|
-
route
|
|
36
|
+
declaredBy(route),
|
|
27
37
|
]);
|
|
28
38
|
const header = ['path', 'surface', 'render', 'hydrate', 'offline', 'file'];
|
|
29
39
|
const widths = header.map((title, index) =>
|
|
@@ -42,7 +52,11 @@ const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
|
|
|
42
52
|
render: route.mode,
|
|
43
53
|
hydrate: route.hydrate,
|
|
44
54
|
offline: route.offline,
|
|
45
|
-
budget: { js: route.budgetJs
|
|
55
|
+
budget: { js: route.budgetJs },
|
|
56
|
+
// Absent on a file route, never `null`: only a mounted route has the fact.
|
|
57
|
+
...(route.mount === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: { mount: { by: route.mount.by, permissions: [...route.mount.permissions] } }),
|
|
46
60
|
}));
|
|
47
61
|
|
|
48
62
|
/**
|
|
@@ -69,6 +83,16 @@ export const routesCommand: CliCommand = {
|
|
|
69
83
|
spec: routesSpec,
|
|
70
84
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
71
85
|
const root = requireAppRoot('routes', ctx.cwd).dir;
|
|
86
|
+
// No positional: `x routes list --json` ran only because a stray word was ignored, which is
|
|
87
|
+
// how a retired spelling stayed in the route-miss fix lines. Refused before the app loads.
|
|
88
|
+
const stray = ctx.args.positionals[0];
|
|
89
|
+
if (stray !== undefined) {
|
|
90
|
+
throw new UnknownCommandError({
|
|
91
|
+
path: `routes ${stray}`,
|
|
92
|
+
known: ['routes'],
|
|
93
|
+
suggestion: 'routes --json',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
72
96
|
// Read before the app is loaded: a typo must not cost a boot to report, the rule `x mcp`'s
|
|
73
97
|
// `--transport` already follows.
|
|
74
98
|
const surface = readSurfaceFilter(flagString(ctx.args, 'surface'));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `x secrets rotate`'s key ring: the retired master keys kept in the sealed file so a value
|
|
2
|
+
// `seal()` wrote under the previous key still opens after a rotation. Pure over the decrypted
|
|
3
|
+
// map — `cmd-secrets.ts` owns the files and the terminal; `@ultimat3/core` owns the variable's
|
|
4
|
+
// name and how a process reads it.
|
|
5
|
+
|
|
6
|
+
import type { SecretValues } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
masterKeyId,
|
|
9
|
+
parseMasterKey,
|
|
10
|
+
SECRETS_FILE,
|
|
11
|
+
SECRETS_RETIRED_KEYS_ENV,
|
|
12
|
+
splitRetiredKeys,
|
|
13
|
+
} from '@ultimat3/core';
|
|
14
|
+
import { BadFlagError } from './errors';
|
|
15
|
+
import { quoteArg } from './shell-quote';
|
|
16
|
+
|
|
17
|
+
/** The app's own secrets: the ring is the framework's entry, never one `envSchema` declares. */
|
|
18
|
+
export function appSecrets(values: SecretValues): SecretValues {
|
|
19
|
+
const { [SECRETS_RETIRED_KEYS_ENV]: _ring, ...rest } = values;
|
|
20
|
+
return rest;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const withRing = (values: SecretValues, ring: readonly string[]): SecretValues =>
|
|
24
|
+
ring.length === 0
|
|
25
|
+
? appSecrets(values)
|
|
26
|
+
: { ...values, [SECRETS_RETIRED_KEYS_ENV]: ring.join(',') };
|
|
27
|
+
|
|
28
|
+
const idOf = (hex: string, index: number): Promise<string> =>
|
|
29
|
+
masterKeyId(
|
|
30
|
+
parseMasterKey(
|
|
31
|
+
hex,
|
|
32
|
+
`${SECRETS_RETIRED_KEYS_ENV} (entry ${index + 1}) in ${SECRETS_FILE}`,
|
|
33
|
+
SECRETS_RETIRED_KEYS_ENV,
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** The ids of the keys the file still declares as retired, newest first. Safe to print. */
|
|
38
|
+
export function retiredKeyIds(values: SecretValues): Promise<readonly string[]> {
|
|
39
|
+
return Promise.all(splitRetiredKeys(values[SECRETS_RETIRED_KEYS_ENV]).map(idOf));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The values a rotation seals: the same ones, with the key being replaced put at the head of the
|
|
44
|
+
* ring. Kept by DEFAULT and never behind a flag — the rotation overwrites the only copy of the old
|
|
45
|
+
* key on disk, so a rotation that did not keep it would make every sealed value unreadable with
|
|
46
|
+
* nothing left to recover it from.
|
|
47
|
+
*/
|
|
48
|
+
export function retire(values: SecretValues, previousHex: string): SecretValues {
|
|
49
|
+
const ring = splitRetiredKeys(values[SECRETS_RETIRED_KEYS_ENV]);
|
|
50
|
+
return withRing(values, [previousHex, ...ring.filter((hex) => hex !== previousHex)]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The values without one retired key, named by id — or `X_CLI_BAD_FLAG` listing the ids there are. */
|
|
54
|
+
export async function dropRetired(values: SecretValues, keyId: string): Promise<SecretValues> {
|
|
55
|
+
const ring = splitRetiredKeys(values[SECRETS_RETIRED_KEYS_ENV]);
|
|
56
|
+
const ids = await retiredKeyIds(values);
|
|
57
|
+
if (!ids.includes(keyId)) {
|
|
58
|
+
const [first] = ids;
|
|
59
|
+
throw new BadFlagError({
|
|
60
|
+
flag: 'drop',
|
|
61
|
+
command: 'secrets rotate',
|
|
62
|
+
reason:
|
|
63
|
+
first === undefined
|
|
64
|
+
? `${SECRETS_FILE} declares no retired master key, so there is none to drop`
|
|
65
|
+
: `no retired master key has that id — ${SECRETS_FILE} declares ${ids.join(', ')}`,
|
|
66
|
+
fix:
|
|
67
|
+
first === undefined
|
|
68
|
+
? 'x secrets show --json'
|
|
69
|
+
: `x secrets rotate --drop ${quoteArg(first)} --json`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return withRing(
|
|
73
|
+
values,
|
|
74
|
+
ring.filter((_hex, index) => ids[index] !== keyId),
|
|
75
|
+
);
|
|
76
|
+
}
|
package/src/cmd-secrets-spec.ts
CHANGED
|
@@ -9,11 +9,19 @@ export const SECRETS_SUBCOMMANDS = ['show', 'init', 'edit', 'set', 'rotate'] as
|
|
|
9
9
|
export const secretsSpec: CommandSpec = {
|
|
10
10
|
name: 'secrets',
|
|
11
11
|
summary: `the committed encrypted secrets, decrypted into the ${ENV_SCHEMA_EXPORT} variables of the same names`,
|
|
12
|
-
usage: 'x secrets [show|init|edit|set <NAME>|rotate] [--json]',
|
|
12
|
+
usage: 'x secrets [show|init|edit|set <NAME>|rotate [--drop <keyId>]] [--json]',
|
|
13
13
|
requiresApp: true,
|
|
14
14
|
subcommands: [...SECRETS_SUBCOMMANDS],
|
|
15
15
|
// The bare `x secrets` answers without a key ever leaving the file. Declared, not inherited
|
|
16
16
|
// from the array's order — `init`, `edit`, `set` and `rotate` all write.
|
|
17
17
|
defaultSubcommand: 'show',
|
|
18
|
-
flags: [
|
|
18
|
+
flags: [
|
|
19
|
+
{
|
|
20
|
+
name: 'drop',
|
|
21
|
+
type: 'string',
|
|
22
|
+
summary:
|
|
23
|
+
'rotate: remove one retired master key by id once every value sealed under it is re-sealed; generates no new key',
|
|
24
|
+
subcommands: ['rotate'],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
19
27
|
};
|