@ultimat3/cli 22.15.0 → 23.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 +23 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -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 +25 -1
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- 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/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +9 -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/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- 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 +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/permission-grants.ts +41 -9
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +6 -0
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -157
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -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/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 +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- 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 +142 -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-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- 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 +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- 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 +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- 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/cmd-jobs-spec.ts
CHANGED
|
@@ -3,7 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
import type { CommandSpec } from './parse';
|
|
5
5
|
|
|
6
|
-
export const JOBS_SUBCOMMANDS = [
|
|
6
|
+
export const JOBS_SUBCOMMANDS = [
|
|
7
|
+
'ls',
|
|
8
|
+
'show',
|
|
9
|
+
'retry',
|
|
10
|
+
'cancel',
|
|
11
|
+
'rm',
|
|
12
|
+
'promote',
|
|
13
|
+
'pause',
|
|
14
|
+
'resume',
|
|
15
|
+
'drain',
|
|
16
|
+
] as const;
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* The drivers a drain may move work ONTO — every one of them durable, and that is the whole rule.
|
|
@@ -13,9 +23,9 @@ export const DRAIN_TARGETS = ['redis', 'nats'] as const;
|
|
|
13
23
|
|
|
14
24
|
export const jobsSpec: CommandSpec = {
|
|
15
25
|
name: 'jobs',
|
|
16
|
-
summary: 'list, show, retry, cancel and
|
|
26
|
+
summary: 'list, show, retry, cancel, remove and promote jobs; pause and resume a queue; drain',
|
|
17
27
|
usage:
|
|
18
|
-
'x jobs [ls|show <id>|retry <id>|cancel <id>|drain --to <driver>] [--queue q] [--state s] [--limit n] [--from-step name] [--reason text] [--to driver] [--dry-run] [--json]',
|
|
28
|
+
'x jobs [ls|show <id>|retry <id>|cancel <id>|rm <id>|promote <id>|pause <queue>|resume <queue>|drain --to <driver>] [--queue q] [--state s] [--name n] [--limit n] [--after cursor] [--from-step name] [--reason text] [--to driver] [--dry-run] [--json]',
|
|
19
29
|
requiresApp: true,
|
|
20
30
|
subcommands: JOBS_SUBCOMMANDS,
|
|
21
31
|
// The bare `x jobs` lists; it never retries, cancels or drains anything.
|
|
@@ -25,6 +35,12 @@ export const jobsSpec: CommandSpec = {
|
|
|
25
35
|
{ name: 'state', type: 'string', summary: 'filter by job state' },
|
|
26
36
|
{ name: 'limit', type: 'string', summary: 'max rows to return' },
|
|
27
37
|
{ name: 'name', type: 'string', summary: 'filter by job name' },
|
|
38
|
+
{
|
|
39
|
+
name: 'after',
|
|
40
|
+
type: 'string',
|
|
41
|
+
summary: 'ls: the next page — the cursor the previous page printed',
|
|
42
|
+
subcommands: ['ls'],
|
|
43
|
+
},
|
|
28
44
|
// Each of these is read by ONE subcommand — `retryJob`, `cancelJob`, `runDrain` — and says
|
|
29
45
|
// so in its own summary. The scope is what makes the parser refuse it anywhere else instead
|
|
30
46
|
// of accepting it and ignoring it: `x db gen --dry-run` parsed and wrote the migration.
|
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';
|
|
@@ -64,6 +79,33 @@ function requireIdPositional(ctx: CommandContext, sub: string): string {
|
|
|
64
79
|
return id;
|
|
65
80
|
}
|
|
66
81
|
|
|
82
|
+
function requireQueuePositional(ctx: CommandContext, sub: string): string {
|
|
83
|
+
const queue = ctx.args.positionals[0];
|
|
84
|
+
if (queue === undefined) {
|
|
85
|
+
throw new MissingPositionalError({
|
|
86
|
+
command: `jobs ${sub}`,
|
|
87
|
+
positional: 'queue',
|
|
88
|
+
example: `x jobs ${sub} default --json`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return queue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A page past the queue's own bound is refused HERE, as a flag error with the command that walks
|
|
96
|
+
* instead — `list()` would refuse it too, in the vocabulary of a caller holding a driver.
|
|
97
|
+
*/
|
|
98
|
+
function refuseOversizedPage(limit: string | undefined): void {
|
|
99
|
+
if (limit === undefined || !/^\d+$/.test(limit) || Number(limit) <= MAX_JOB_PAGE) return;
|
|
100
|
+
throw new BadFlagError({
|
|
101
|
+
flag: 'limit',
|
|
102
|
+
command: 'jobs',
|
|
103
|
+
reason: `one page holds at most ${MAX_JOB_PAGE} jobs`,
|
|
104
|
+
// The bound is the queue's own constant, screened like any value spliced into a command.
|
|
105
|
+
fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.next as --after for the page that follows`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
67
109
|
function requireEnvUrl(env: CommandContext['env'], name: string, target: string): string {
|
|
68
110
|
const value = env[name];
|
|
69
111
|
if (value === undefined || value.trim().length === 0) {
|
|
@@ -97,16 +139,34 @@ export function buildDrainTarget(to: string | undefined, env: CommandContext['en
|
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
142
|
+
const limit = flagString(ctx.args, 'limit');
|
|
143
|
+
refuseOversizedPage(limit);
|
|
100
144
|
const result = await listJobs(driver, {
|
|
101
145
|
queue: flagString(ctx.args, 'queue'),
|
|
102
146
|
state: flagString(ctx.args, 'state'),
|
|
103
147
|
name: flagString(ctx.args, 'name'),
|
|
104
|
-
limit
|
|
148
|
+
limit,
|
|
149
|
+
after: flagString(ctx.args, 'after'),
|
|
105
150
|
});
|
|
151
|
+
// A FULL page may have another behind it: `next` is the cursor that asks for it, and `null`
|
|
152
|
+
// says this was the last one — so a walk never has to guess from a row count.
|
|
153
|
+
const size = limit === undefined ? DEFAULT_JOB_PAGE : Number(limit);
|
|
154
|
+
const last = result.rows[result.rows.length - 1];
|
|
155
|
+
const next = last === undefined || result.rows.length < size ? null : jobCursor(last);
|
|
156
|
+
const [paused, workers] = await Promise.all([
|
|
157
|
+
driver.introspect?.pausedQueues() ?? [],
|
|
158
|
+
driver.introspect?.workers() ?? [],
|
|
159
|
+
]);
|
|
106
160
|
const lines = [` ${msg('cli.jobs.listed', { count: result.rows.length })}`];
|
|
107
161
|
if (result.rows.length > 0) {
|
|
108
162
|
lines.push(...renderJobTable(result.rows).map((line) => ` ${line}`));
|
|
109
163
|
}
|
|
164
|
+
if (next !== null) lines.push(` ${msg('cli.jobs.nextPage', { cursor: next })}`);
|
|
165
|
+
if (paused.length > 0) {
|
|
166
|
+
lines.push(
|
|
167
|
+
` ${msg('cli.jobs.pausedQueues', { queues: paused.map((q) => q.name).join(', ') })}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
110
170
|
if (result.deadLetters.length > 0) {
|
|
111
171
|
lines.push(` ${msg('cli.jobs.deadLetters', { count: result.deadLetters.length })}`);
|
|
112
172
|
for (const entry of result.deadLetters) {
|
|
@@ -143,6 +203,9 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
143
203
|
rows: result.rows.map(jobRecordToJson),
|
|
144
204
|
deadLetters: result.deadLetters.map(deadLetterToJson),
|
|
145
205
|
backfills: result.backfills.map(backfillToJson),
|
|
206
|
+
next,
|
|
207
|
+
pausedQueues: paused.map(pausedToJson),
|
|
208
|
+
workers: workers.map(workerToJson),
|
|
146
209
|
},
|
|
147
210
|
};
|
|
148
211
|
}
|
|
@@ -193,6 +256,48 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
|
|
|
193
256
|
};
|
|
194
257
|
}
|
|
195
258
|
|
|
259
|
+
/** Exit 0 means the row is gone. An id nobody queued is `X_JOB_UNKNOWN`; a running one refuses. */
|
|
260
|
+
async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
261
|
+
const id = requireIdPositional(ctx, 'rm');
|
|
262
|
+
const removed = await removeJob(driver, id);
|
|
263
|
+
if (removed === undefined) throw new JobUnknownError({ id, driver: driver.name });
|
|
264
|
+
return {
|
|
265
|
+
ok: true,
|
|
266
|
+
command: 'jobs',
|
|
267
|
+
summary: msg('cli.jobs.removed', { id: removed.id, state: removed.state }),
|
|
268
|
+
data: jobRecordToJson(removed),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function runPromote(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
273
|
+
const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote'));
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
command: 'jobs',
|
|
277
|
+
summary: msg('cli.jobs.promoted', { id: promoted.id }),
|
|
278
|
+
data: jobRecordToJson(promoted),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Both verbs answer the list as the queue NOW holds it, read back after the write: the pause is a
|
|
284
|
+
* row every worker's next claim reads, so "paused" here is paused fleet-wide within one poll.
|
|
285
|
+
*/
|
|
286
|
+
async function runPause(
|
|
287
|
+
driver: JobDriver,
|
|
288
|
+
ctx: CommandContext,
|
|
289
|
+
sub: 'pause' | 'resume',
|
|
290
|
+
): Promise<CommandResult> {
|
|
291
|
+
const queue = requireQueuePositional(ctx, sub);
|
|
292
|
+
const paused = await (sub === 'pause' ? pauseQueue(driver, queue) : resumeQueue(driver, queue));
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
command: 'jobs',
|
|
296
|
+
summary: msg(sub === 'pause' ? 'cli.jobs.paused' : 'cli.jobs.resumed', { queue }),
|
|
297
|
+
data: { queue, paused: sub === 'pause', pausedQueues: paused.map(pausedToJson) },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
196
301
|
/**
|
|
197
302
|
* A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
|
|
198
303
|
* design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
|
|
@@ -246,6 +351,9 @@ async function runDrain(
|
|
|
246
351
|
return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
|
|
247
352
|
}
|
|
248
353
|
|
|
354
|
+
/** The subcommands that answer a `JobTrace`. `ls`, `rm`, `promote` and the rest read rows only. */
|
|
355
|
+
const TRACE_SUBCOMMANDS: ReadonlySet<string> = new Set(['show', 'retry', 'cancel']);
|
|
356
|
+
|
|
249
357
|
export const jobsCommand: CliCommand = {
|
|
250
358
|
spec: jobsSpec,
|
|
251
359
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
@@ -258,10 +366,19 @@ export const jobsCommand: CliCommand = {
|
|
|
258
366
|
// word they typed was refused by name. It also opens a connection to a target the command
|
|
259
367
|
// then refuses, which is a socket nothing closes.
|
|
260
368
|
const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
|
|
369
|
+
// A TRACE is the queue row projected through the job's own declaration — its concurrency key,
|
|
370
|
+
// its retry schedule — and a declaration exists in this process only once the app is loaded.
|
|
371
|
+
// Unloaded, `show` answered `concurrencyKey: null` and `retryDelaysMs: []` for every job of
|
|
372
|
+
// every app. The findings are deliberately dropped: a trace is read to debug a stuck queue,
|
|
373
|
+
// so an app that half-loads degrades those two fields and still answers.
|
|
374
|
+
if (TRACE_SUBCOMMANDS.has(sub)) await loadApp(root);
|
|
261
375
|
return withJobDriver(root, ctx, (driver) => {
|
|
262
376
|
if (sub === 'show') return runShow(driver, ctx);
|
|
263
377
|
if (sub === 'retry') return runRetry(driver, ctx);
|
|
264
378
|
if (sub === 'cancel') return runCancel(driver, ctx);
|
|
379
|
+
if (sub === 'rm') return runRm(driver, ctx);
|
|
380
|
+
if (sub === 'promote') return runPromote(driver, ctx);
|
|
381
|
+
if (sub === 'pause' || sub === 'resume') return runPause(driver, ctx, sub);
|
|
265
382
|
if (target !== null) return runDrain(driver, target, ctx);
|
|
266
383
|
return runLs(driver, ctx);
|
|
267
384
|
});
|
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-routes.ts
CHANGED
|
@@ -15,6 +15,16 @@ 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) =>
|
|
@@ -43,6 +53,10 @@ const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
|
|
|
43
53
|
hydrate: route.hydrate,
|
|
44
54
|
offline: route.offline,
|
|
45
55
|
budget: { js: route.budgetJs, lcp: route.budgetLcp },
|
|
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
|
/**
|
|
@@ -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
|
};
|
package/src/cmd-secrets.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from '@ultimat3/core';
|
|
31
31
|
import { ENV_SCHEMA_EXPORT, loadEnvSchema } from './app-env';
|
|
32
32
|
import { requireAppRoot } from './app-root';
|
|
33
|
+
import { appSecrets, dropRetired, retire, retiredKeyIds } from './cmd-secrets-ring';
|
|
33
34
|
import { secretsSpec } from './cmd-secrets-spec';
|
|
34
35
|
import type { CliCommand, CommandContext } from './command';
|
|
35
36
|
import {
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
} from './errors';
|
|
41
42
|
import { msg } from './messages';
|
|
42
43
|
import type { CommandResult, JsonValue } from './output';
|
|
44
|
+
import { flagString } from './parse';
|
|
43
45
|
import { recoverRotation, rotateMasterKey } from './secrets-rotation';
|
|
44
46
|
import { shredOnSignal } from './signal-shred';
|
|
45
47
|
import { renderTable } from './table';
|
|
@@ -244,36 +246,65 @@ async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
|
|
|
244
246
|
};
|
|
245
247
|
}
|
|
246
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The closing half of a rotation: one retired key leaves the ring, by id, once nothing sealed
|
|
251
|
+
* under it is left. No new key is generated and the key file is not touched — the committed file
|
|
252
|
+
* is resealed under the key already in force.
|
|
253
|
+
*/
|
|
254
|
+
async function drop(session: Session, keyId: string): Promise<CommandResult> {
|
|
255
|
+
const { root, key } = session;
|
|
256
|
+
const after = await dropRetired(await readSecretsFile(root, key), keyId);
|
|
257
|
+
await writeSecretsFile(root, after, key);
|
|
258
|
+
const retired = await retiredKeyIds(after);
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
command: 'secrets',
|
|
262
|
+
summary: msg('cli.secrets.dropped', { path: SECRETS_FILE, kid: keyId, count: retired.length }),
|
|
263
|
+
data: {
|
|
264
|
+
path: SECRETS_FILE,
|
|
265
|
+
keyId: await masterKeyIdOf(key),
|
|
266
|
+
dropped: keyId,
|
|
267
|
+
retiredKeyIds: [...retired],
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
247
272
|
/**
|
|
248
273
|
* A new master key over the same values: staged beside the old one, the file sealed with it, then
|
|
249
274
|
* made live (`rotateMasterKey`). Sealed first with the key file written last, a crash between the
|
|
250
275
|
* two left a committed file no key on disk could open.
|
|
276
|
+
*
|
|
277
|
+
* The key being replaced joins the ring INSIDE the sealed file (`cmd-secrets-ring.ts`), so a value
|
|
278
|
+
* `seal()` wrote under it still opens and a deploy is still handed exactly one key.
|
|
251
279
|
*/
|
|
252
280
|
async function rotate(ctx: CommandContext): Promise<CommandResult> {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
281
|
+
const session = await open(ctx, 'rotate');
|
|
282
|
+
const dropping = flagString(ctx.args, 'drop');
|
|
283
|
+
if (dropping !== undefined) return drop(session, dropping);
|
|
284
|
+
const { root, key } = session;
|
|
285
|
+
const values = retire(await readSecretsFile(root, key), key.hex);
|
|
255
286
|
const previous = await masterKeyIdOf(key);
|
|
256
287
|
// Ignored BEFORE any key file is written, staged one included; the order inside is
|
|
257
288
|
// `secrets-rotation.ts`'s: stage the key, seal, then rename it live.
|
|
258
289
|
await ensureIgnored(root);
|
|
259
290
|
const next = await rotateMasterKey(root, values);
|
|
260
291
|
const keyId = await masterKeyIdOf(next);
|
|
292
|
+
const count = Object.keys(appSecrets(values)).length;
|
|
261
293
|
return {
|
|
262
294
|
ok: true,
|
|
263
295
|
command: 'secrets',
|
|
264
|
-
summary: msg('cli.secrets.rotated', {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}),
|
|
270
|
-
lines: [msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE })],
|
|
296
|
+
summary: msg('cli.secrets.rotated', { path: SECRETS_FILE, from: previous, to: keyId, count }),
|
|
297
|
+
lines: [
|
|
298
|
+
msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE }),
|
|
299
|
+
msg('cli.secrets.retired', { kid: previous }),
|
|
300
|
+
],
|
|
271
301
|
data: {
|
|
272
302
|
path: SECRETS_FILE,
|
|
273
303
|
keyPath: SECRETS_KEY_FILE,
|
|
274
304
|
previousKeyId: previous,
|
|
275
305
|
keyId,
|
|
276
|
-
count
|
|
306
|
+
count,
|
|
307
|
+
retiredKeyIds: [...(await retiredKeyIds(values))],
|
|
277
308
|
},
|
|
278
309
|
};
|
|
279
310
|
}
|
|
@@ -285,9 +316,12 @@ async function rotate(ctx: CommandContext): Promise<CommandResult> {
|
|
|
285
316
|
*/
|
|
286
317
|
async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
287
318
|
const { root, key } = await open(ctx, 'show');
|
|
288
|
-
const
|
|
319
|
+
const sealed = await readSecretsFile(root, key);
|
|
320
|
+
// The ring is the framework's own entry: reported by key id below, never as an app secret that
|
|
321
|
+
// `envSchema` forgot to declare.
|
|
322
|
+
const retired = await retiredKeyIds(sealed);
|
|
289
323
|
const schema = await loadEnvSchema(root);
|
|
290
|
-
const summaries = describeSecrets(
|
|
324
|
+
const summaries = describeSecrets(appSecrets(sealed));
|
|
291
325
|
const keyId = await masterKeyIdOf(key);
|
|
292
326
|
const declared = (name: string): boolean | undefined =>
|
|
293
327
|
schema === undefined ? undefined : schema[name] !== undefined;
|
|
@@ -309,6 +343,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
|
309
343
|
: [
|
|
310
344
|
msg('cli.secrets.undeclared', { count: undeclared.length, names: undeclared.join(', ') }),
|
|
311
345
|
]),
|
|
346
|
+
...(retired.length === 0 ? [] : [msg('cli.secrets.ring', { kids: retired.join(', ') })]),
|
|
312
347
|
];
|
|
313
348
|
return {
|
|
314
349
|
ok: true,
|
|
@@ -322,6 +357,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
|
322
357
|
path: SECRETS_FILE,
|
|
323
358
|
keySource: key.source,
|
|
324
359
|
keyId,
|
|
360
|
+
retiredKeyIds: [...retired],
|
|
325
361
|
count: summaries.length,
|
|
326
362
|
secrets: summaries.map((entry) => ({
|
|
327
363
|
name: entry.name,
|