@ultimat3/cli 1.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
package/src/cmd-mcp.ts ADDED
@@ -0,0 +1,176 @@
1
+ // `x mcp serve` — the framework's dev MCP server over stdio or HTTP. The 13 tools, the JSON-RPC
2
+ // dispatch, both transports and the structural SQL refusals all come from `@ultimat3/mcp`; the CLI
3
+ // supplies only the app, the caller and the socket. A tool answered here would be a second answer
4
+ // to a question the framework already answers.
5
+
6
+ import { markListening, nanoid } from '@ultimat3/core';
7
+ import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
8
+ import { requireAppRoot } from './app-root';
9
+ import type { CliCommand, CommandContext } from './command';
10
+ import { BadFlagError } from './errors';
11
+ import { holdUntilShutdown } from './hold';
12
+ import type { CliMcpServer } from './mcp-host';
13
+ import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
14
+ import { msg } from './messages';
15
+ import type { CommandResult } from './output';
16
+ import { flagString } from './parse';
17
+
18
+ const DEFAULT_PORT = 9229;
19
+ const TRANSPORTS = ['stdio', 'http'] as const;
20
+ type Transport = (typeof TRANSPORTS)[number];
21
+
22
+ /** One reading of the entitlement, so `--json` and the terminal can never disagree about it. */
23
+ const scopes = (): readonly string[] => [...DEV_TOOL_SCOPES].sort();
24
+
25
+ const scopeLine = (): string => msg('cli.mcp.scopes', { scopes: scopes().join(' ') });
26
+
27
+ const isTransport = (value: string): value is Transport =>
28
+ (TRANSPORTS as readonly string[]).includes(value);
29
+
30
+ /** The catalog, from the server's own registry — never a list kept here. */
31
+ async function catalog(host: CliMcpServer): Promise<CommandResult> {
32
+ const tools = host.server.list(host.caller);
33
+ await host.close();
34
+ return {
35
+ ok: true,
36
+ command: 'mcp',
37
+ summary: msg('cli.mcp.serving', { transport: 'none', tools: tools.length }),
38
+ // `scopes` rides in `data` because it rides in `lines`: every fact the terminal prints is a
39
+ // fact `--json` carries, or the two renderers have drifted.
40
+ data: {
41
+ tools: tools.map((tool) => ({ name: tool.name, description: tool.description })),
42
+ scopes: scopes(),
43
+ },
44
+ lines: [...tools.map((tool) => ` ${tool.name.padEnd(20)} ${tool.description}`), scopeLine()],
45
+ };
46
+ }
47
+
48
+ const notFound = (path: string): Response =>
49
+ new Response(
50
+ JSON.stringify({
51
+ code: 'X_MCP_PROTOCOL',
52
+ cause: 'the MCP server answers exactly one route',
53
+ fix: `POST ${path} with an Authorization: Bearer header`,
54
+ }),
55
+ { status: 404, headers: { 'content-type': 'application/json' } },
56
+ );
57
+
58
+ /** A running HTTP transport. `stop()` releases the socket AND the host's lazily booted services. */
59
+ export interface McpHttpServer {
60
+ readonly result: CommandResult;
61
+ stop(): Promise<void>;
62
+ }
63
+
64
+ /**
65
+ * HTTP demands a bearer token, and a token in a config file is one more thing to keep in sync — so
66
+ * it is minted per process and returned in `data`, where `--json` puts it one read away.
67
+ *
68
+ * Exported with its stop handle rather than swallowing it: the socket and the host's PGlite data
69
+ * directory outlive `run()` otherwise, and nothing — a test, an embedding caller, or a signal —
70
+ * could ever release them.
71
+ */
72
+ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
73
+ const token = nanoid(32);
74
+ const route = mcpHttpRoute({
75
+ server: host.server,
76
+ resolveToken: (candidate) =>
77
+ candidate === token ? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES } : null,
78
+ });
79
+ const handle = Bun.serve({
80
+ port,
81
+ hostname: 'localhost',
82
+ fetch: (request: Request): Response | Promise<Response> => {
83
+ const url = new URL(request.url);
84
+ if (request.method !== route.method || url.pathname !== route.path) {
85
+ return notFound(route.path);
86
+ }
87
+ return route.handle(request);
88
+ },
89
+ });
90
+ // Announces the socket as this process's own, so a caller on it is never mistaken for egress.
91
+ const stopListening = markListening(handle.url.origin);
92
+ const url = `${handle.url.origin}${route.path}`;
93
+ return {
94
+ result: {
95
+ ok: true,
96
+ command: 'mcp',
97
+ summary: msg('cli.mcp.serving', { transport: 'http', tools: host.tools.length }),
98
+ data: { url, token, tools: host.tools.length, scopes: scopes() },
99
+ lines: [` POST ${url}`, ` authorization: Bearer ${token}`, scopeLine()],
100
+ },
101
+ async stop() {
102
+ await handle.stop(true);
103
+ stopListening();
104
+ await host.close();
105
+ },
106
+ };
107
+ }
108
+
109
+ /**
110
+ * stdout is the WIRE. `dispatch.ts` renders a `CommandResult` only after `run` resolves, and this
111
+ * resolves when the peer closes stdin — so the command's own output cannot reach stdout while a
112
+ * session is live, and nothing here writes to it directly.
113
+ */
114
+ async function serveOverStdio(host: CliMcpServer): Promise<CommandResult> {
115
+ await serveStdio({ server: host.server, caller: host.caller });
116
+ await host.close();
117
+ return {
118
+ ok: true,
119
+ command: 'mcp',
120
+ summary: msg('cli.mcp.serving', { transport: 'stdio', tools: host.tools.length }),
121
+ data: { transport: 'stdio', tools: host.tools.length },
122
+ };
123
+ }
124
+
125
+ function readPort(ctx: CommandContext): number {
126
+ const raw = flagString(ctx.args, 'port') ?? String(DEFAULT_PORT);
127
+ const port = Number.parseInt(raw, 10);
128
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
129
+ throw new BadFlagError({
130
+ flag: 'port',
131
+ command: 'mcp serve',
132
+ reason: `expects a port in 0..65535, got "${raw}"`,
133
+ fix: `x mcp serve --transport http --port ${DEFAULT_PORT}`,
134
+ });
135
+ }
136
+ return port;
137
+ }
138
+
139
+ export const mcpCommand: CliCommand = {
140
+ spec: {
141
+ name: 'mcp',
142
+ summary: 'serve the dev tools: routes, schema, policies, db, queues, logs, tests, verify',
143
+ usage: 'x mcp tools | x mcp serve [--transport stdio|http] [--port 9229] [--json]',
144
+ requiresApp: true,
145
+ subcommands: ['serve', 'tools'],
146
+ flags: [
147
+ { name: 'transport', type: 'string', summary: 'stdio | http', default: 'stdio' },
148
+ { name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
149
+ ],
150
+ },
151
+ async run(ctx: CommandContext): Promise<CommandResult> {
152
+ const root = requireAppRoot('mcp', ctx.cwd).dir;
153
+ const transport = flagString(ctx.args, 'transport') ?? 'stdio';
154
+ // Validated before the app is loaded: a typo must not cost a boot to report.
155
+ if (!isTransport(transport)) {
156
+ throw new BadFlagError({
157
+ flag: 'transport',
158
+ command: 'mcp serve',
159
+ reason: `expects ${TRANSPORTS.join(' or ')}, got "${transport}"`,
160
+ fix: 'x mcp serve --transport stdio',
161
+ });
162
+ }
163
+ const port = readPort(ctx);
164
+ const host = await createDevMcpServer({ root, env: ctx.env, runner: ctx.runner });
165
+ if (ctx.args.subcommand === 'tools') return catalog(host);
166
+ if (transport !== 'http') return serveOverStdio(host);
167
+ // Long-running: the process stays alive on the server handle. The stop handle goes to the
168
+ // shutdown registry so a signal releases the socket and the database, not the exit code alone.
169
+ // Long-running, exactly as `x dev` is: `dispatch` awaits the hold, and the drain a signal
170
+ // starts is what releases the socket and the database. Registering a shutdown hook and
171
+ // returning was the older shape — nothing installed a signal handler, so the hook was never
172
+ // reached and the exit code closed the socket the line above had just announced.
173
+ const served = startMcpHttp(host, port);
174
+ return { ...served.result, hold: holdUntilShutdown('mcp', () => served.stop()) };
175
+ },
176
+ };
package/src/cmd-new.ts ADDED
@@ -0,0 +1,133 @@
1
+ // `x new <name>` — a monorepo that already runs: auth, a seeded database, a 0kb landing page, a
2
+ // streaming dashboard, an admin app with MCP on, and an example feature slice with passing tests.
3
+ // Interactive-free: every choice is a flag with a default, because an agent cannot answer prompts.
4
+
5
+ import { existsSync } from 'node:fs';
6
+ import { chmod } from 'node:fs/promises';
7
+ import { isAbsolute, join, resolve } from 'node:path';
8
+ import { dedupe } from './cmd-generate';
9
+ import type { CliCommand, CommandContext } from './command';
10
+ import { writeSchemaHash } from './drift';
11
+ import { msg } from './messages';
12
+ import type { CommandResult } from './output';
13
+ import { flagBool, flagString } from './parse';
14
+ import type { GeneratedFile } from './templates';
15
+ import { appFiles, EXECUTABLE_FILES, names, repoFiles, resourceFiles } from './templates';
16
+ import { loadVersion } from './version-loader';
17
+
18
+ export interface NewAppOptions {
19
+ readonly name: string;
20
+ /** The example feature slice. `--no-example` gives the same shape with an empty app/. */
21
+ readonly example: boolean;
22
+ }
23
+
24
+ /** Pure: the complete file list for a new app, so `--dry-run` and the test see the same thing. */
25
+ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
26
+ const app = names(options.name);
27
+ const files: GeneratedFile[] = [
28
+ ...repoFiles(app, loadVersion(), options.example),
29
+ ...appFiles(app),
30
+ ];
31
+ if (options.example) {
32
+ files.push(...resourceFiles('post', { surfaceDir: 'apps/web/app', feature: 'post' }));
33
+ }
34
+ // `repoFiles`' own catalog entry and the example resource's both target the same flat catalog
35
+ // file, so this has to be the merge-aware dedupe — the one `cmd-generate.ts` uses for `x g` —
36
+ // or the second contributor's keys would silently vanish instead of landing in the one file.
37
+ return dedupe(files);
38
+ }
39
+
40
+ export interface WrittenApp {
41
+ readonly dir: string;
42
+ readonly files: readonly string[];
43
+ readonly schemaHash: string;
44
+ }
45
+
46
+ export async function writeNewApp(target: string, options: NewAppOptions): Promise<WrittenApp> {
47
+ const files = planNewApp(options);
48
+ for (const file of files) await Bun.write(join(target, file.path), file.contents);
49
+ for (const path of EXECUTABLE_FILES) await chmod(join(target, path), 0o755);
50
+ // Record the schema hash beside the initial migration so `x verify` sees no drift on run one.
51
+ const hash = await writeSchemaHash(target, '0000_initial');
52
+ return { dir: target, files: files.map((file) => file.path), schemaHash: hash };
53
+ }
54
+
55
+ function parentDir(cwd: string, dirFlag: string | undefined): string {
56
+ if (dirFlag === undefined) return cwd;
57
+ return isAbsolute(dirFlag) ? dirFlag : join(cwd, dirFlag);
58
+ }
59
+
60
+ export const newCommand: CliCommand = {
61
+ spec: {
62
+ name: 'new',
63
+ summary: 'scaffold a new Ultimate monorepo that already runs',
64
+ usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--json]',
65
+ flags: [
66
+ { name: 'dir', type: 'string', summary: 'parent directory (default: cwd)' },
67
+ {
68
+ name: 'example',
69
+ type: 'boolean',
70
+ summary: 'include the example feature slice',
71
+ default: true,
72
+ },
73
+ { name: 'dry-run', type: 'boolean', summary: 'print the file list, write nothing' },
74
+ { name: 'force', type: 'boolean', summary: 'write into a directory that already exists' },
75
+ ],
76
+ },
77
+ async run(ctx: CommandContext): Promise<CommandResult> {
78
+ const raw = ctx.args.positionals[0];
79
+ if (raw === undefined) {
80
+ return {
81
+ ok: false,
82
+ command: 'new',
83
+ summary: msg('cli.usage'),
84
+ findings: [
85
+ {
86
+ code: 'X_CLI_BAD_FLAG',
87
+ cause: 'x new needs a name',
88
+ fix: 'x new myapp',
89
+ docs: 'https://ultimate.dev/errors/X_CLI_BAD_FLAG',
90
+ },
91
+ ],
92
+ };
93
+ }
94
+ const app = names(raw);
95
+ const target = resolve(parentDir(ctx.cwd, flagString(ctx.args, 'dir')), app.kebab);
96
+ const options: NewAppOptions = { name: raw, example: ctx.args.flags.get('example') !== false };
97
+
98
+ if (flagBool(ctx.args, 'dry-run')) {
99
+ const files = planNewApp(options);
100
+ return {
101
+ ok: true,
102
+ command: 'new',
103
+ summary: msg('cli.new.done', { name: app.kebab }),
104
+ data: { dir: target, files: files.map((file) => file.path), dryRun: true },
105
+ lines: files.map((file) => ` + ${app.kebab}/${file.path}`),
106
+ };
107
+ }
108
+ if (existsSync(target) && !flagBool(ctx.args, 'force')) {
109
+ return {
110
+ ok: false,
111
+ command: 'new',
112
+ summary: msg('cli.usage'),
113
+ findings: [
114
+ {
115
+ code: 'X_GENERATE_CONFLICT',
116
+ cause: `${target} already exists`,
117
+ fix: `x new ${app.kebab} --force, or choose another name`,
118
+ docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
119
+ at: target,
120
+ },
121
+ ],
122
+ };
123
+ }
124
+ const written = await writeNewApp(target, options);
125
+ return {
126
+ ok: true,
127
+ command: 'new',
128
+ summary: msg('cli.new.done', { name: app.kebab }),
129
+ data: { dir: written.dir, files: written.files, schemaHash: written.schemaHash },
130
+ lines: [` ${written.files.length} files in ${target}`],
131
+ };
132
+ },
133
+ };
@@ -0,0 +1,119 @@
1
+ // The commands the design docs specify and this build does not yet implement. They are in the
2
+ // registry on purpose: `x cache bust` exiting X_CLI_UNKNOWN_COMMAND says "you typed something that
3
+ // does not exist", which is false and sends an agent looking for a typo. X_NOT_IMPLEMENTED plus a
4
+ // fix naming the closest shipped command says the true thing, and `x help` lists them honestly.
5
+
6
+ import type { CliCommand } from './command';
7
+ import { CliNotImplementedError } from './errors';
8
+ import type { CommandResult } from './output';
9
+ import type { CommandSpec } from './parse';
10
+
11
+ export interface PlannedCommand {
12
+ readonly name: string;
13
+ readonly summary: string;
14
+ readonly usage: string;
15
+ readonly subcommands?: readonly string[];
16
+ /** Runnable today, and closer to the answer than nothing. Never a doc link. */
17
+ readonly fix: string;
18
+ }
19
+
20
+ /**
21
+ * `wiki/CLI-Reference.md`'s planned table, verbatim in shape. Every `fix` names a command this
22
+ * build actually ships — a fix line pointing at another unbuilt command is the failure mode this
23
+ * table exists to close, and `cmd-planned.test.ts` asserts the whole set against the registry.
24
+ */
25
+ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
26
+ {
27
+ name: 'cache',
28
+ summary: 'the tag graph, targeted eviction, hit stats',
29
+ usage: 'x cache [graph|bust <tag>|clear|stats] [--json]',
30
+ subcommands: ['graph', 'bust', 'clear', 'stats'],
31
+ fix: 'x dev # then the cache panel at /_x',
32
+ },
33
+ {
34
+ name: 'branch',
35
+ summary: 'copy-on-write branch environments with a preview URL',
36
+ usage: 'x branch [<name>|rm <name>] [--json]',
37
+ fix: 'x db branch <name> # the database half, shipped today',
38
+ },
39
+ {
40
+ name: 'status',
41
+ summary: 'role health and the build-ID distribution of connected clients',
42
+ usage: 'x status [--json]',
43
+ fix: 'x doctor --json',
44
+ },
45
+ {
46
+ name: 'upgrade',
47
+ summary: 'move every @ultimat3/* in lockstep, with codemods',
48
+ usage: 'x upgrade [--dry-run] [--json]',
49
+ fix: 'bun update --latest && x verify',
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
+ {
59
+ name: 'logs',
60
+ summary: 'structured logs and OTel spans, filterable',
61
+ usage: 'x logs tail [--json]',
62
+ subcommands: ['tail'],
63
+ fix: 'x dev # then the timeline panel at /_x',
64
+ },
65
+ {
66
+ name: 'token',
67
+ summary: 'create MCP tokens and grant scopes',
68
+ usage: 'x token [create --scopes <s>|grant <scope>] [--json]',
69
+ subcommands: ['create', 'grant'],
70
+ fix: 'x mcp serve --help # the scopes this build serves',
71
+ },
72
+ {
73
+ name: 'ai',
74
+ summary: 'eval scores, semantic-cache stats, vector reindex',
75
+ usage: 'x ai [eval <name>|cache|reindex] [--json]',
76
+ subcommands: ['eval', 'cache', 'reindex'],
77
+ fix: 'x test eval --json # every eval, scored against its committed baseline',
78
+ },
79
+ {
80
+ name: 'money',
81
+ summary: 'extend the currency table',
82
+ usage: 'x money add-currency <ISO> --exponent <n> [--json]',
83
+ subcommands: ['add-currency'],
84
+ fix: 'x manifest --json # currencies ship in @ultimat3/money; add yours in app.config.ts',
85
+ },
86
+ {
87
+ name: 'config',
88
+ summary: 'the resolved app.config.ts, defaults included',
89
+ usage: 'x config show [--json]',
90
+ subcommands: ['show'],
91
+ fix: 'x manifest --json # the facts the resolved config produced',
92
+ },
93
+ ];
94
+
95
+ const specFor = (planned: PlannedCommand): CommandSpec => ({
96
+ name: planned.name,
97
+ summary: `${planned.summary} (planned)`,
98
+ usage: planned.usage,
99
+ ...(planned.subcommands === undefined ? {} : { subcommands: planned.subcommands }),
100
+ });
101
+
102
+ /**
103
+ * Throws rather than returning a failed `CommandResult`: `dispatch` renders a thrown
104
+ * `UltimateError` through the same 3-line contract and the same `--json` shape, so a planned
105
+ * command reads byte-identically to any other typed failure.
106
+ */
107
+ const toCommand = (planned: PlannedCommand): CliCommand => ({
108
+ spec: specFor(planned),
109
+ // `async` is load-bearing: a synchronous throw would escape every caller that awaits the
110
+ // promise this signature promises, including the dispatcher's own error path.
111
+ async run(): Promise<CommandResult> {
112
+ throw new CliNotImplementedError({
113
+ feature: `x ${planned.name}`,
114
+ fix: planned.fix,
115
+ });
116
+ },
117
+ });
118
+
119
+ export const plannedCommands = (): readonly CliCommand[] => PLANNED_COMMANDS.map(toCommand);
@@ -0,0 +1,136 @@
1
+ // `x policy list|explain` — which clause decided a permission, and why. This file is CLI wiring
2
+ // only: the fact-gathering (registries, matrix rows) lives in `policy-facts.ts`, so the matrix
3
+ // logic is testable without an app — same split as `cmd-jobs.ts` / `jobs-report.ts`.
4
+
5
+ import { loadApp } from './app-load';
6
+ import { requireAppRoot } from './app-root';
7
+ import type { CliCommand, CommandContext } from './command';
8
+ import { BadFlagError, DeclarationUnknownError } from './errors';
9
+ import { msg } from './messages';
10
+ import type { CommandResult, Finding, JsonValue } from './output';
11
+ import { nearest } from './parse';
12
+ import type { DeclarationExplanation } from './policy-facts';
13
+ import { explainPolicy, knownPolicySubjects, listPolicy } from './policy-facts';
14
+ import { renderTable } from './table';
15
+
16
+ /** A descriptor is plain JSON by construction — same idiom as `cmd-registries.ts`'s `asJson`. */
17
+ const asJson = (value: object): Record<string, JsonValue> => value as Record<string, JsonValue>;
18
+
19
+ const joinOrDash = (values: readonly string[]): string =>
20
+ values.length === 0 ? msg('cli.policy.none') : values.join(',');
21
+
22
+ function runList(findings: readonly Finding[]): CommandResult {
23
+ const facts = listPolicy();
24
+ const header = ['permission', 'roles', 'actions', 'queries'];
25
+ const rows = facts.rows.map((row) => [
26
+ row.permission,
27
+ joinOrDash(row.roles),
28
+ joinOrDash(row.actions),
29
+ joinOrDash(row.queries),
30
+ ]);
31
+ const lines: string[] =
32
+ facts.rows.length === 0 ? [] : renderTable(header, rows).map((line) => ` ${line}`);
33
+ if (facts.unenforced.length > 0) {
34
+ lines.push(` ${msg('cli.policy.unenforced', { count: facts.unenforced.length })}`);
35
+ for (const permission of facts.unenforced) lines.push(` ${permission}`);
36
+ }
37
+ return {
38
+ ok: findings.length === 0,
39
+ command: 'policy',
40
+ summary: msg('cli.policy.count', {
41
+ permissions: facts.rows.length,
42
+ roles: facts.roleCount,
43
+ enforced: facts.enforcedCount,
44
+ }),
45
+ lines,
46
+ findings,
47
+ data: facts.rows.map((row) => asJson(row)),
48
+ };
49
+ }
50
+
51
+ /** A header naming the declaration and its policy label, then the actor/verdict/deciding table. */
52
+ function declarationLines(declaration: DeclarationExplanation): readonly string[] {
53
+ const header = ` ${msg('cli.policy.declaration', {
54
+ kind: declaration.kind,
55
+ name: declaration.name,
56
+ label: declaration.label,
57
+ })}`;
58
+ // Both notes say the same thing `dev-policy.ts` writes into the `/_x` trace: this ran outside a
59
+ // request. A policy that merely reads input gets its table plus the caveat; one that cannot be
60
+ // evaluated at all gets the caveat instead of a table, never a synthetic deny dressed as one.
61
+ if (!declaration.decidable) return [header, ` ${msg('cli.policy.undecidable')}`];
62
+ const rows = declaration.rows.map((row) => [
63
+ row.actor,
64
+ msg(row.allowed ? 'cli.policy.allow' : 'cli.policy.deny'),
65
+ row.deciding ?? msg('cli.policy.none'),
66
+ row.reason ?? msg('cli.policy.none'),
67
+ ]);
68
+ const table = renderTable(['actor', 'verdict', 'deciding', 'reason'], rows).map(
69
+ (line) => ` ${line}`,
70
+ );
71
+ return [header, ...table, ` ${msg('cli.policy.noInput')}`];
72
+ }
73
+
74
+ function requireSubject(ctx: CommandContext): string {
75
+ const name = ctx.args.positionals[0];
76
+ if (name === undefined) {
77
+ throw new BadFlagError({
78
+ flag: 'subject',
79
+ command: 'policy',
80
+ reason: 'x policy explain <subject> needs a permission, action, query or route path',
81
+ fix: 'x policy list --json',
82
+ });
83
+ }
84
+ return name;
85
+ }
86
+
87
+ function runExplain(ctx: CommandContext, findings: readonly Finding[]): CommandResult {
88
+ const name = requireSubject(ctx);
89
+ const explanation = explainPolicy(name);
90
+ if (explanation === undefined) {
91
+ const known = knownPolicySubjects();
92
+ const suggestion = nearest(name, known);
93
+ throw new DeclarationUnknownError(
94
+ suggestion === undefined
95
+ ? { kind: 'policy', singular: 'policy subject', name, known, verb: 'explain' }
96
+ : { kind: 'policy', singular: 'policy subject', name, known, suggestion, verb: 'explain' },
97
+ );
98
+ }
99
+ // One row per (declaration, actor) pair — a permission two declarations enforce evaluates every
100
+ // actor twice, so this counts evaluations and never roles.
101
+ const rows = explanation.declarations.flatMap((declaration) => declaration.rows);
102
+ const allowed = rows.filter((row) => row.allowed).length;
103
+ return {
104
+ ok: findings.length === 0,
105
+ command: 'policy',
106
+ summary: msg('cli.policy.explained', {
107
+ subject: explanation.subject,
108
+ allowed,
109
+ evaluations: rows.length,
110
+ }),
111
+ lines: explanation.declarations.flatMap(declarationLines),
112
+ findings,
113
+ data: asJson({
114
+ subject: explanation.subject,
115
+ kind: explanation.kind,
116
+ grantingRoles: explanation.grantingRoles,
117
+ declarations: explanation.declarations.map((declaration) => asJson(declaration)),
118
+ }),
119
+ };
120
+ }
121
+
122
+ export const policyCommand: CliCommand = {
123
+ spec: {
124
+ name: 'policy',
125
+ summary: 'which clause decided a permission, and why',
126
+ usage: 'x policy [list|explain <subject>] [--json]',
127
+ requiresApp: true,
128
+ subcommands: ['list', 'explain'],
129
+ },
130
+ async run(ctx: CommandContext): Promise<CommandResult> {
131
+ const root = requireAppRoot('policy', ctx.cwd).dir;
132
+ const { findings } = await loadApp(root);
133
+ const sub = ctx.args.subcommand ?? 'list';
134
+ return sub === 'explain' ? runExplain(ctx, findings) : runList(findings);
135
+ },
136
+ };