@ultimat3/cli 1.1.0 → 2.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 +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/cmd-jobs.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
// `x jobs ls|show|retry|drain` — introspect and recover the job queue, bound to
|
|
1
|
+
// `x jobs ls|show|retry|cancel|drain` — introspect and recover the job queue, bound to
|
|
2
|
+
// `@ultimat3/jobs`'s
|
|
2
3
|
// own introspection so the CLI, `/_x` and MCP report identically. This file is CLI wiring only:
|
|
3
4
|
// the driver-injected logic is `jobs-report.ts`, the `--json` shapes `jobs-json.ts`, the table
|
|
4
|
-
// `jobs-table.ts`.
|
|
5
|
+
// `jobs-table.ts`, and getting hold of the queue at all is `jobs-driver.ts` — shared with `x db`.
|
|
5
6
|
|
|
6
7
|
import type { JobDriver } from '@ultimat3/jobs';
|
|
7
|
-
import { createMemoryDriver, createNatsDriver, createRedisDriver
|
|
8
|
+
import { cancelJob, createMemoryDriver, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
|
|
8
9
|
import { requireAppRoot } from './app-root';
|
|
9
10
|
import type { CliCommand, CommandContext } from './command';
|
|
10
|
-
import {
|
|
11
|
-
import { resolveServices } from './dev-services';
|
|
12
|
-
import { BadFlagError } from './errors';
|
|
11
|
+
import { BadFlagError, JobUnknownError } from './errors';
|
|
13
12
|
import { drainJobs } from './jobs-drain';
|
|
13
|
+
import { withJobDriver } from './jobs-driver';
|
|
14
14
|
import {
|
|
15
|
+
backfillToJson,
|
|
15
16
|
deadLetterToJson,
|
|
16
17
|
depthToJson,
|
|
17
18
|
drainFailureToJson,
|
|
@@ -25,33 +26,10 @@ import { msg } from './messages';
|
|
|
25
26
|
import type { CommandResult } from './output';
|
|
26
27
|
import { flagBool, flagString } from './parse';
|
|
27
28
|
|
|
28
|
-
export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'drain'] as const;
|
|
29
|
+
export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'cancel', 'drain'] as const;
|
|
29
30
|
|
|
30
31
|
const DRAIN_TARGETS = ['memory', 'redis', 'nats'] as const;
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
* `x jobs` needs the app's real driver. Reuse an already-running one first — inside `x dev` or
|
|
34
|
-
* `x mcp serve`, `jobDriver()` is already set and booting a second queue on top of it would talk
|
|
35
|
-
* to the wrong database. Otherwise boot just the db + jobs half (`startQueue`, not the full
|
|
36
|
-
* `startServices`: this command touches no transport, storage or mail) and always release it, or
|
|
37
|
-
* a CLI that exits holding the PGlite lock breaks the next command run against this app.
|
|
38
|
-
*/
|
|
39
|
-
async function withDriver(
|
|
40
|
-
root: string,
|
|
41
|
-
ctx: CommandContext,
|
|
42
|
-
fn: (driver: JobDriver) => Promise<CommandResult>,
|
|
43
|
-
): Promise<CommandResult> {
|
|
44
|
-
const ambient = jobDriver();
|
|
45
|
-
if (ambient !== undefined) return fn(ambient);
|
|
46
|
-
const services = resolveServices(root, ctx.env);
|
|
47
|
-
const queue = await startQueue(services);
|
|
48
|
-
try {
|
|
49
|
-
return await fn(queue.jobs);
|
|
50
|
-
} finally {
|
|
51
|
-
await queue.stop();
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
33
|
function requireIdPositional(ctx: CommandContext, sub: string): string {
|
|
56
34
|
const id = ctx.args.positionals[0];
|
|
57
35
|
if (id === undefined) {
|
|
@@ -114,6 +92,17 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
114
92
|
);
|
|
115
93
|
}
|
|
116
94
|
}
|
|
95
|
+
// Same shape as the dead-letter section above, and here for the same reason: a sweep in flight
|
|
96
|
+
// is a fact the depth counts cannot show. Name, rows so far and cursor, because "how far has it
|
|
97
|
+
// got" is the whole question — the finished passes are `x db backfill --list`'s answer.
|
|
98
|
+
if (result.backfills.length > 0) {
|
|
99
|
+
lines.push(` ${msg('cli.jobs.backfills', { count: result.backfills.length })}`);
|
|
100
|
+
for (const pass of result.backfills) {
|
|
101
|
+
const cursor = pass.cursor ?? msg('cli.jobs.backfillNoCursor');
|
|
102
|
+
const progress = msg('cli.jobs.backfillRow', { name: pass.name, rows: pass.rows, cursor });
|
|
103
|
+
lines.push(` ${pass.runId} ${progress}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
117
106
|
return {
|
|
118
107
|
ok: true,
|
|
119
108
|
command: 'jobs',
|
|
@@ -129,6 +118,7 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
129
118
|
depth: depthToJson(result.depth),
|
|
130
119
|
rows: result.rows.map(jobRecordToJson),
|
|
131
120
|
deadLetters: result.deadLetters.map(deadLetterToJson),
|
|
121
|
+
backfills: result.backfills.map(backfillToJson),
|
|
132
122
|
},
|
|
133
123
|
};
|
|
134
124
|
}
|
|
@@ -159,6 +149,26 @@ async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<Command
|
|
|
159
149
|
};
|
|
160
150
|
}
|
|
161
151
|
|
|
152
|
+
/**
|
|
153
|
+
* There is no silent-success path here, and that is the whole reason this subcommand can exist as
|
|
154
|
+
* four lines: `cancelJob` throws `X_JOB_NOT_CANCELLABLE` for a job that has already finished and
|
|
155
|
+
* for a driver with no `cancel` at all, so an exit code of 0 means the job is genuinely stopped.
|
|
156
|
+
* The trace is rendered by the same projection `show` and `retry` use — one shape for one job.
|
|
157
|
+
*/
|
|
158
|
+
async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
159
|
+
const id = requireIdPositional(ctx, 'cancel');
|
|
160
|
+
const trace = await cancelJob(driver, id, flagString(ctx.args, 'reason'));
|
|
161
|
+
// `cancelJob` re-reads the job after cancelling, so `undefined` would mean the row vanished
|
|
162
|
+
// between the two — reported as the same refusal rather than rendered as a success with no job.
|
|
163
|
+
if (trace === undefined) throw new JobUnknownError({ id, driver: driver.name });
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
command: 'jobs',
|
|
167
|
+
summary: msg('cli.jobs.cancelled', { id: trace.id, state: trace.state }),
|
|
168
|
+
data: jobTraceToJson(trace),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
162
172
|
/**
|
|
163
173
|
* A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
|
|
164
174
|
* design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
|
|
@@ -204,17 +214,20 @@ async function runDrain(driver: JobDriver, ctx: CommandContext): Promise<Command
|
|
|
204
214
|
export const jobsCommand: CliCommand = {
|
|
205
215
|
spec: {
|
|
206
216
|
name: 'jobs',
|
|
207
|
-
summary: 'list, show, retry and drain the job queue',
|
|
217
|
+
summary: 'list, show, retry, cancel and drain the job queue',
|
|
208
218
|
usage:
|
|
209
|
-
'x jobs [ls|show <id>|retry <id>|drain --to <driver>] [--queue q] [--state s] [--limit n] [--from-step name] [--to driver] [--dry-run] [--json]',
|
|
219
|
+
'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]',
|
|
210
220
|
requiresApp: true,
|
|
211
221
|
subcommands: JOBS_SUBCOMMANDS,
|
|
222
|
+
// The bare `x jobs` lists; it never retries, cancels or drains anything.
|
|
223
|
+
defaultSubcommand: 'ls',
|
|
212
224
|
flags: [
|
|
213
225
|
{ name: 'queue', type: 'string', summary: 'filter by queue name' },
|
|
214
226
|
{ name: 'state', type: 'string', summary: 'filter by job state' },
|
|
215
227
|
{ name: 'limit', type: 'string', summary: 'max rows to return' },
|
|
216
228
|
{ name: 'name', type: 'string', summary: 'filter by job name' },
|
|
217
229
|
{ name: 'from-step', type: 'string', summary: 'retry: drop this step so it re-executes' },
|
|
230
|
+
{ name: 'reason', type: 'string', summary: 'cancel: why, recorded on the job' },
|
|
218
231
|
{ name: 'to', type: 'string', summary: 'drain target driver: memory, redis, nats' },
|
|
219
232
|
{ name: 'dry-run', type: 'boolean', summary: 'drain: report the plan, move nothing' },
|
|
220
233
|
],
|
|
@@ -222,9 +235,10 @@ export const jobsCommand: CliCommand = {
|
|
|
222
235
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
223
236
|
const root = requireAppRoot('jobs', ctx.cwd).dir;
|
|
224
237
|
const sub = ctx.args.subcommand ?? 'ls';
|
|
225
|
-
return
|
|
238
|
+
return withJobDriver(root, ctx, (driver) => {
|
|
226
239
|
if (sub === 'show') return runShow(driver, ctx);
|
|
227
240
|
if (sub === 'retry') return runRetry(driver, ctx);
|
|
241
|
+
if (sub === 'cancel') return runCancel(driver, ctx);
|
|
228
242
|
if (sub === 'drain') return runDrain(driver, ctx);
|
|
229
243
|
return runLs(driver, ctx);
|
|
230
244
|
});
|
package/src/cmd-mcp.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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 { markListening, nanoid } from '@ultimat3/core';
|
|
6
|
+
import { markListening, nanoid, timingSafeEqual } from '@ultimat3/core';
|
|
7
7
|
import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
|
|
8
8
|
import { requireAppRoot } from './app-root';
|
|
9
9
|
import type { CliCommand, CommandContext } from './command';
|
|
@@ -73,8 +73,14 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
|
|
|
73
73
|
const token = nanoid(32);
|
|
74
74
|
const route = mcpHttpRoute({
|
|
75
75
|
server: host.server,
|
|
76
|
+
// `timingSafeEqual`, not `===`: this was the only secret comparison in the framework that
|
|
77
|
+
// short-circuited on the first differing character. Localhost and a per-process `nanoid(32)`
|
|
78
|
+
// make it hard to exploit and neither makes it correct — an exception nobody can point at is
|
|
79
|
+
// an exception the next transport copies.
|
|
76
80
|
resolveToken: (candidate) =>
|
|
77
|
-
candidate
|
|
81
|
+
timingSafeEqual(candidate, token)
|
|
82
|
+
? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES }
|
|
83
|
+
: null,
|
|
78
84
|
});
|
|
79
85
|
const handle = Bun.serve({
|
|
80
86
|
port,
|
|
@@ -143,6 +149,9 @@ export const mcpCommand: CliCommand = {
|
|
|
143
149
|
usage: 'x mcp tools | x mcp serve [--transport stdio|http] [--port 9229] [--json]',
|
|
144
150
|
requiresApp: true,
|
|
145
151
|
subcommands: ['serve', 'tools'],
|
|
152
|
+
// No default, deliberately: `wiki/CLI-Reference.md` already says to write `x mcp serve` rather
|
|
153
|
+
// than a bare `x mcp`, and the parser's old first-element guess made the bare form START A
|
|
154
|
+
// SERVER — the one thing a word typed by mistake must not do. Refusing enforces the guidance.
|
|
146
155
|
flags: [
|
|
147
156
|
{ name: 'transport', type: 'string', summary: 'stdio | http', default: 'stdio' },
|
|
148
157
|
{ name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
|
package/src/cmd-new.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { chmod } from 'node:fs/promises';
|
|
|
7
7
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
8
8
|
import { dedupe } from './cmd-generate';
|
|
9
9
|
import type { CliCommand, CommandContext } from './command';
|
|
10
|
-
import { writeSchemaHash } from './drift';
|
|
11
10
|
import { msg } from './messages';
|
|
12
11
|
import type { CommandResult } from './output';
|
|
13
12
|
import { flagBool, flagString } from './parse';
|
|
@@ -40,16 +39,23 @@ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
|
|
|
40
39
|
export interface WrittenApp {
|
|
41
40
|
readonly dir: string;
|
|
42
41
|
readonly files: readonly string[];
|
|
43
|
-
readonly schemaHash: string;
|
|
44
42
|
}
|
|
45
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Every byte it writes is in `planNewApp`, so `--dry-run` and the disk cannot disagree.
|
|
46
|
+
*
|
|
47
|
+
* It writes NO migration and no `.hash`: `x db gen` is the one writer of `packages/db/migrations`,
|
|
48
|
+
* and it writes `.sql`, `.snapshot.json` and `.hash` together. A scaffold that wrote a `.hash` for
|
|
49
|
+
* a migration whose snapshot never existed is what made the app's first two database commands
|
|
50
|
+
* refuse each other — `x db migrate` naming `x db gen`, and `x db gen` refusing a sidecar version
|
|
51
|
+
* control never had. The consequence is deliberate: `x verify`'s `drift` step is red on a pristine
|
|
52
|
+
* scaffold until `x db gen "initial"` runs, which is what `cli.new.done` tells the author to do.
|
|
53
|
+
*/
|
|
46
54
|
export async function writeNewApp(target: string, options: NewAppOptions): Promise<WrittenApp> {
|
|
47
55
|
const files = planNewApp(options);
|
|
48
56
|
for (const file of files) await Bun.write(join(target, file.path), file.contents);
|
|
49
57
|
for (const path of EXECUTABLE_FILES) await chmod(join(target, path), 0o755);
|
|
50
|
-
|
|
51
|
-
const hash = await writeSchemaHash(target, '0000_initial');
|
|
52
|
-
return { dir: target, files: files.map((file) => file.path), schemaHash: hash };
|
|
58
|
+
return { dir: target, files: files.map((file) => file.path) };
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
function parentDir(cwd: string, dirFlag: string | undefined): string {
|
|
@@ -102,7 +108,7 @@ export const newCommand: CliCommand = {
|
|
|
102
108
|
command: 'new',
|
|
103
109
|
summary: msg('cli.new.done', { name: app.kebab }),
|
|
104
110
|
data: { dir: target, files: files.map((file) => file.path), dryRun: true },
|
|
105
|
-
lines: files.map((file) =>
|
|
111
|
+
lines: files.map((file) => msg('cli.file.added', { path: `${app.kebab}/${file.path}` })),
|
|
106
112
|
};
|
|
107
113
|
}
|
|
108
114
|
if (existsSync(target) && !flagBool(ctx.args, 'force')) {
|
|
@@ -126,7 +132,7 @@ export const newCommand: CliCommand = {
|
|
|
126
132
|
ok: true,
|
|
127
133
|
command: 'new',
|
|
128
134
|
summary: msg('cli.new.done', { name: app.kebab }),
|
|
129
|
-
data: { dir: written.dir, files: written.files
|
|
135
|
+
data: { dir: written.dir, files: written.files },
|
|
130
136
|
lines: [` ${written.files.length} files in ${target}`],
|
|
131
137
|
};
|
|
132
138
|
},
|
package/src/cmd-planned.ts
CHANGED
|
@@ -34,7 +34,7 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
|
|
|
34
34
|
name: 'branch',
|
|
35
35
|
summary: 'copy-on-write branch environments with a preview URL',
|
|
36
36
|
usage: 'x branch [<name>|rm <name>] [--json]',
|
|
37
|
-
fix: 'x db branch
|
|
37
|
+
fix: 'x db branch ls --json # the database half: ls, create <name>, drop <name>',
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
name: 'status',
|
|
@@ -48,13 +48,6 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
|
|
|
48
48
|
usage: 'x upgrade [--dry-run] [--json]',
|
|
49
49
|
fix: 'bun update --latest && x verify',
|
|
50
50
|
},
|
|
51
|
-
{
|
|
52
|
-
name: 'env',
|
|
53
|
-
summary: 'validate the typed env; --fix writes the missing keys',
|
|
54
|
-
usage: 'x env check [--fix] [--json]',
|
|
55
|
-
subcommands: ['check'],
|
|
56
|
-
fix: 'x doctor --json # reports the env problems it can already see',
|
|
57
|
-
},
|
|
58
51
|
{
|
|
59
52
|
name: 'logs',
|
|
60
53
|
summary: 'structured logs and OTel spans, filterable',
|
|
@@ -76,12 +69,18 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
|
|
|
76
69
|
subcommands: ['eval', 'cache', 'reindex'],
|
|
77
70
|
fix: 'x test eval --json # every eval, scored against its committed baseline',
|
|
78
71
|
},
|
|
72
|
+
// `registerCurrency()` ships `As of 2026-08`, and this row is deliberately NOT deleted with it.
|
|
73
|
+
// What is planned is a GENERATOR — the one thing a process that exits can contribute, since a
|
|
74
|
+
// registration has to land in the app's own source to survive the run — and a generator that
|
|
75
|
+
// emits the one call is not a second path any more than `x g route` is a second way to declare a
|
|
76
|
+
// route (axiom 1). The whole table is shaped this way: `x status` beside `x doctor`, `x cache`
|
|
77
|
+
// beside the `/_x` panel. What changed is only that the `fix:` is now true.
|
|
79
78
|
{
|
|
80
79
|
name: 'money',
|
|
81
80
|
summary: 'extend the currency table',
|
|
82
81
|
usage: 'x money add-currency <ISO> --exponent <n> [--json]',
|
|
83
82
|
subcommands: ['add-currency'],
|
|
84
|
-
fix:
|
|
83
|
+
fix: "x errors explain X_CURRENCY_UNKNOWN --json # extend it in code: registerCurrency({ code: 'GHS', exponent: 2, name: 'Ghana Cedi' }) at boot",
|
|
85
84
|
},
|
|
86
85
|
{
|
|
87
86
|
name: 'config',
|
|
@@ -92,11 +91,57 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
|
|
|
92
91
|
},
|
|
93
92
|
];
|
|
94
93
|
|
|
94
|
+
export interface PlannedSubcommand {
|
|
95
|
+
readonly command: string;
|
|
96
|
+
readonly subcommand: string;
|
|
97
|
+
/** Runnable today, and closer to the answer than nothing. Never a doc link. */
|
|
98
|
+
readonly fix: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A subcommand of a shipped command that this build does not implement. Same promise as the table
|
|
103
|
+
* above, one level down: `x db studio` stays in `x db`'s subcommand list, so the parser accepts it
|
|
104
|
+
* and `x help db` still lists it, and it exits X_NOT_IMPLEMENTED naming what to run instead.
|
|
105
|
+
*
|
|
106
|
+
* `studio` is here because the migration engine is `@ultimat3/db`'s and only that. It used to
|
|
107
|
+
* shell out to `bunx drizzle-kit studio` — a second schema tool, fetched unpinned at run time,
|
|
108
|
+
* declared in no `package.json` — while every other `x db` subcommand went through the framework's
|
|
109
|
+
* own ledger. One subcommand is not worth a second engine.
|
|
110
|
+
*/
|
|
111
|
+
export const PLANNED_SUBCOMMANDS: readonly PlannedSubcommand[] = [
|
|
112
|
+
{
|
|
113
|
+
command: 'db',
|
|
114
|
+
subcommand: 'studio',
|
|
115
|
+
fix: 'x dev # then the db panel at /_x: schema, rows, and a guarded SQL console',
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Thrown by the owning command, so the subcommand fails exactly where it would have run. Returns
|
|
121
|
+
* the error rather than throwing it, because a `run` that throws synchronously escapes the
|
|
122
|
+
* promise its signature promises — the caller does `throw plannedSubcommand(...)`.
|
|
123
|
+
*/
|
|
124
|
+
export function plannedSubcommand(command: string, subcommand: string): CliNotImplementedError {
|
|
125
|
+
const planned = PLANNED_SUBCOMMANDS.find(
|
|
126
|
+
(entry) => entry.command === command && entry.subcommand === subcommand,
|
|
127
|
+
);
|
|
128
|
+
return new CliNotImplementedError({
|
|
129
|
+
feature: `x ${command} ${subcommand}`,
|
|
130
|
+
// `x help <command>`, not `x <command> --help`: for a command that declares subcommands and no
|
|
131
|
+
// default, the latter is refused by the parser before help is ever rendered.
|
|
132
|
+
fix: planned?.fix ?? `x help ${command}`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
95
136
|
const specFor = (planned: PlannedCommand): CommandSpec => ({
|
|
96
137
|
name: planned.name,
|
|
97
138
|
summary: `${planned.summary} (planned)`,
|
|
98
139
|
usage: planned.usage,
|
|
99
|
-
|
|
140
|
+
// A planned command answers X_NOT_IMPLEMENTED however it is invoked, so the bare form must
|
|
141
|
+
// reach its `run` rather than being refused for a subcommand that does not exist yet.
|
|
142
|
+
...(planned.subcommands === undefined
|
|
143
|
+
? {}
|
|
144
|
+
: { subcommands: planned.subcommands, defaultSubcommand: planned.subcommands[0] }),
|
|
100
145
|
});
|
|
101
146
|
|
|
102
147
|
/**
|
package/src/cmd-policy.ts
CHANGED
|
@@ -126,6 +126,7 @@ export const policyCommand: CliCommand = {
|
|
|
126
126
|
usage: 'x policy [list|explain <subject>] [--json]',
|
|
127
127
|
requiresApp: true,
|
|
128
128
|
subcommands: ['list', 'explain'],
|
|
129
|
+
defaultSubcommand: 'list',
|
|
129
130
|
},
|
|
130
131
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
131
132
|
const root = requireAppRoot('policy', ctx.cwd).dir;
|
package/src/cmd-registries.ts
CHANGED
|
@@ -57,6 +57,7 @@ const ACTIONS: RegistryKind<ActionDescriptor, AnyAction> = {
|
|
|
57
57
|
summary: 'the action registry: input/output schema, policy, tags, MCP exposure',
|
|
58
58
|
usage: 'x actions [list|describe <name>] [--json]',
|
|
59
59
|
subcommands: ['list', 'describe'],
|
|
60
|
+
defaultSubcommand: 'list',
|
|
60
61
|
requiresApp: true,
|
|
61
62
|
},
|
|
62
63
|
header: ['name', 'verb', 'resource', 'path', 'capability', 'mcp'],
|
|
@@ -74,6 +75,7 @@ const QUERIES: RegistryKind<QueryDescriptor, AnyQuery> = {
|
|
|
74
75
|
summary: 'the query registry: schema, policy, live, cache tags',
|
|
75
76
|
usage: 'x queries [list|describe <name>] [--json]',
|
|
76
77
|
subcommands: ['list', 'describe'],
|
|
78
|
+
defaultSubcommand: 'list',
|
|
77
79
|
requiresApp: true,
|
|
78
80
|
},
|
|
79
81
|
header: ['name', 'live', 'capability', 'tags', 'ttlMs'],
|
|
@@ -99,6 +101,7 @@ const ENTITIES: RegistryKind<EntityDescription, RegistryEntry> = {
|
|
|
99
101
|
summary: 'the entity registry: columns, invariants, indexes, tenancy',
|
|
100
102
|
usage: 'x entities [list|describe <name>] [--json]',
|
|
101
103
|
subcommands: ['list', 'describe'],
|
|
104
|
+
defaultSubcommand: 'list',
|
|
102
105
|
requiresApp: true,
|
|
103
106
|
},
|
|
104
107
|
header: ['name', 'table', 'columns', 'invariants', 'indexes', 'orgScoped'],
|