@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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- package/src/workspace-checks.ts +288 -0
package/src/mcp-host.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// The `DevCapabilities` half of `@ultimat3/mcp`'s `DevHost`: the shell-side facts no registry
|
|
2
|
+
// holds — a database to query, a migrator, a test runner, the dev log, the committed manifest and
|
|
3
|
+
// the gate. The description half is the framework's own `frameworkIntrospection`, so nothing here
|
|
4
|
+
// is a second catalog of routes, entities, actions, queries or jobs.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { agentActor, UltimateError } from '@ultimat3/core';
|
|
9
|
+
import type { DbClient } from '@ultimat3/db';
|
|
10
|
+
import { ensureReadOnlyRole, readLedger, readOnlyQuery } from '@ultimat3/db';
|
|
11
|
+
import { inspectJobList, inspectQueues } from '@ultimat3/jobs';
|
|
12
|
+
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
13
|
+
import type {
|
|
14
|
+
DevCapabilities,
|
|
15
|
+
McpCaller,
|
|
16
|
+
McpServer,
|
|
17
|
+
QueryLimits,
|
|
18
|
+
QueryRows,
|
|
19
|
+
VerifyResult,
|
|
20
|
+
VerifyStep,
|
|
21
|
+
} from '@ultimat3/mcp';
|
|
22
|
+
import { createDevServer, DEV_SCOPES, devHost, frameworkIntrospection } from '@ultimat3/mcp';
|
|
23
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
24
|
+
import { loadApp } from './app-load';
|
|
25
|
+
import { appManifest, policyFacts } from './app-manifest';
|
|
26
|
+
import { runVerify, VERIFY_STEPS } from './cmd-verify';
|
|
27
|
+
import type { RunningServices } from './dev-runtime';
|
|
28
|
+
import { startServices } from './dev-runtime';
|
|
29
|
+
import type { DevServices, Env } from './dev-services';
|
|
30
|
+
import { resolveServices } from './dev-services';
|
|
31
|
+
import { MIGRATIONS_DIR } from './drift';
|
|
32
|
+
import { CliNotImplementedError } from './errors';
|
|
33
|
+
import type { Runner } from './exec';
|
|
34
|
+
import { execOutput } from './exec';
|
|
35
|
+
import { databaseTarget } from './mcp-db-target';
|
|
36
|
+
import { explainErrorCode } from './mcp-errors';
|
|
37
|
+
import { parseBunTest } from './mcp-test-output';
|
|
38
|
+
|
|
39
|
+
export interface DevHostInput {
|
|
40
|
+
readonly root: string;
|
|
41
|
+
readonly env: Env;
|
|
42
|
+
readonly runner: Runner;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CliMcpServer {
|
|
46
|
+
readonly server: McpServer;
|
|
47
|
+
readonly caller: McpCaller;
|
|
48
|
+
/** Tool names this caller can see, sorted. The catalog `x mcp tools` prints. */
|
|
49
|
+
readonly tools: readonly string[];
|
|
50
|
+
close(): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every dev scope. Both transports resolve to this set — one surface, one entitlement. */
|
|
54
|
+
export const DEV_TOOL_SCOPES: ReadonlySet<string> = new Set(Object.values(DEV_SCOPES));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The developer's own shell. A stdio peer already owns the process, so there is no network
|
|
58
|
+
* boundary to defend and the caller carries every dev scope. `kind: 'agent'` because
|
|
59
|
+
* `transport-http.ts` structurally refuses anything else and both transports must resolve to the
|
|
60
|
+
* same caller — an MCP call is an agent call, never the human behind it.
|
|
61
|
+
*/
|
|
62
|
+
export function localCaller(): McpCaller {
|
|
63
|
+
return {
|
|
64
|
+
actor: agentActor({ id: 'x-cli', scopes: [...DEV_TOOL_SCOPES] }),
|
|
65
|
+
scopes: DEV_TOOL_SCOPES,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── the lazily booted services ───────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface LazyServices {
|
|
72
|
+
readonly services: DevServices;
|
|
73
|
+
running(): Promise<RunningServices>;
|
|
74
|
+
close(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The database boots on FIRST USE, once, and is shared: answering `routes.list` must not pay a
|
|
79
|
+
* PGlite boot. Same resolver and same drivers as `x dev` — a dev-only second driver is exactly the
|
|
80
|
+
* bug that design exists to prevent. Exported as the boot seam a test can drive without a server.
|
|
81
|
+
*/
|
|
82
|
+
export function lazyServices(input: DevHostInput): LazyServices {
|
|
83
|
+
const services = resolveServices(input.root, input.env);
|
|
84
|
+
let started: Promise<RunningServices> | undefined;
|
|
85
|
+
let closed = false;
|
|
86
|
+
return {
|
|
87
|
+
services,
|
|
88
|
+
running(): Promise<RunningServices> {
|
|
89
|
+
// A boot started after close() would hold the PGlite data directory for the life of the
|
|
90
|
+
// process with nobody left to stop it — the host is closed, so the call is the bug.
|
|
91
|
+
if (closed) {
|
|
92
|
+
throw new CliNotImplementedError({
|
|
93
|
+
feature: 'an MCP tool that needs the database after the host closed',
|
|
94
|
+
fix: 'x mcp serve --transport stdio # keep the host open for the whole session',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
started ??= startServices(services, input.env);
|
|
98
|
+
return started;
|
|
99
|
+
},
|
|
100
|
+
async close(): Promise<void> {
|
|
101
|
+
if (closed) return;
|
|
102
|
+
closed = true;
|
|
103
|
+
// A boot that rejected has nothing to stop, and close() must not throw on the way out.
|
|
104
|
+
await (await started?.catch(() => undefined))?.stop();
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Migration ids on disk (`0001_init.sql` → `0001_init`) that the ledger does not record. */
|
|
110
|
+
async function pendingMigrations(root: string, lazy: LazyServices): Promise<readonly string[]> {
|
|
111
|
+
const dir = join(root, MIGRATIONS_DIR);
|
|
112
|
+
if (!existsSync(dir)) return [];
|
|
113
|
+
const ids: string[] = [];
|
|
114
|
+
for await (const file of new Bun.Glob('*.sql').scan({ cwd: dir, absolute: false })) {
|
|
115
|
+
if (!file.endsWith('.down.sql')) ids.push(file.replace(/\.sql$/, ''));
|
|
116
|
+
}
|
|
117
|
+
const { db } = await lazy.running();
|
|
118
|
+
// No ledger table means nothing has been applied. `ensureLedger` would create it, and a dry run
|
|
119
|
+
// is not allowed to write.
|
|
120
|
+
const ledger = await readLedger(db).catch(() => []);
|
|
121
|
+
const applied = new Set(ledger.map((row) => row.id));
|
|
122
|
+
return ids.filter((id) => !applied.has(id)).sort();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── the capabilities ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `db.query`'s layers 1–2 against a real client: a SELECT-only role assumed inside a
|
|
129
|
+
* `BEGIN READ ONLY` transaction with a statement timeout, then the rows shaped into the tool's
|
|
130
|
+
* column-major form. Layer 3 (`assertReadOnlyQuery`) and layer 4 (the caps) run in the tool,
|
|
131
|
+
* before and after this — a second copy here would be a second place to keep right.
|
|
132
|
+
*
|
|
133
|
+
* Exported as the seam a test drives with a recording client, so the statement sequence is
|
|
134
|
+
* asserted without booting a database.
|
|
135
|
+
*/
|
|
136
|
+
export async function readOnlyRows(
|
|
137
|
+
db: DbClient,
|
|
138
|
+
sql: string,
|
|
139
|
+
limits: QueryLimits,
|
|
140
|
+
role: string | null,
|
|
141
|
+
): Promise<QueryRows> {
|
|
142
|
+
const { rows, guards } = await readOnlyQuery<Record<string, unknown>>(sql, {
|
|
143
|
+
client: db,
|
|
144
|
+
role,
|
|
145
|
+
timeoutMs: limits.timeoutMs,
|
|
146
|
+
// One row past the ceiling: the tool needs to know there was more. Asked of the *server*,
|
|
147
|
+
// through the cursor the pinned read-only transaction already makes available, so a
|
|
148
|
+
// `select * from events` cannot be paged into this process before layer 4 gets to drop it.
|
|
149
|
+
// Wrapping the statement in `select * from (…) limit n` instead would change the meaning of
|
|
150
|
+
// a statement carrying its own LIMIT and fail outright on `EXPLAIN` and `SHOW`.
|
|
151
|
+
maxRows: limits.maxRows + 1,
|
|
152
|
+
});
|
|
153
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
154
|
+
// `EXPLAIN` and `SHOW` are commands, not cursorable, so they arrive whole — the slice is the
|
|
155
|
+
// only bound they have.
|
|
156
|
+
const kept = rows.slice(0, limits.maxRows + 1);
|
|
157
|
+
return { columns, rows: kept.map((row) => columns.map((column) => row[column])), guards };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities {
|
|
161
|
+
const { root, runner } = input;
|
|
162
|
+
// Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
|
|
163
|
+
// loop — resolve the role once per process and reuse the answer, `null` included.
|
|
164
|
+
let readOnlyRole: Promise<string | null> | undefined;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
database: databaseTarget(lazy.services),
|
|
168
|
+
|
|
169
|
+
async runQuery(sql: string, limits: QueryLimits): Promise<QueryRows> {
|
|
170
|
+
const { db } = await lazy.running();
|
|
171
|
+
// A managed Postgres may refuse CREATE ROLE; `ensureReadOnlyRole` answers null and the
|
|
172
|
+
// layer is reported absent in `guards` rather than quietly assumed present.
|
|
173
|
+
readOnlyRole ??= ensureReadOnlyRole(db);
|
|
174
|
+
return readOnlyRows(db, sql, limits, await readOnlyRole);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async runMigrations(branch: string, dryRun: boolean) {
|
|
178
|
+
const before = await pendingMigrations(root, lazy);
|
|
179
|
+
if (dryRun) return { branch, applied: [], pending: before };
|
|
180
|
+
const result = await runner(['bunx', 'drizzle-kit', 'migrate'], { cwd: root });
|
|
181
|
+
if (!result.ok) {
|
|
182
|
+
// Thrown, not returned: `server.ts` renders any X_* error as the three-line
|
|
183
|
+
// code/cause/fix result, which is what an agent needs to act without a round trip.
|
|
184
|
+
throw new UltimateError({
|
|
185
|
+
code: 'X_DB_MIGRATE_FAILED',
|
|
186
|
+
cause: `${result.command.join(' ')} exited ${result.code}: ${execOutput(result).slice(0, 400)}`,
|
|
187
|
+
fix: 'x db reset',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// The ledger is the evidence for "applied" — never the migrator's own stdout.
|
|
191
|
+
const pending = await pendingMigrations(root, lazy);
|
|
192
|
+
return { branch, applied: before.filter((id) => !pending.includes(id)), pending };
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async queueDepth() {
|
|
196
|
+
const { jobs } = await lazy.running();
|
|
197
|
+
const report = await inspectQueues(jobs);
|
|
198
|
+
// `stats()` counts states, and a job is `failed` only until it is retried or dead-lettered;
|
|
199
|
+
// the honest count comes from the job list. Same reading as /_x's queues panel.
|
|
200
|
+
const failed =
|
|
201
|
+
jobs.introspect === undefined ? [] : await inspectJobList(jobs, { state: 'failed' });
|
|
202
|
+
return report.queues.map((queue) => ({
|
|
203
|
+
queue: queue.queue,
|
|
204
|
+
pending: queue.ready + queue.delayed,
|
|
205
|
+
running: queue.running,
|
|
206
|
+
failed: failed.filter((record) => record.queue === queue.queue).length,
|
|
207
|
+
}));
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
// `bun test <filter>` matches on the test path, the same rule `x test`'s `discoverTests` uses.
|
|
211
|
+
async runTests(filter: string | undefined) {
|
|
212
|
+
const result = await runner(
|
|
213
|
+
filter === undefined ? ['bun', 'test'] : ['bun', 'test', filter],
|
|
214
|
+
{
|
|
215
|
+
cwd: root,
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
return parseBunTest(execOutput(result), result.durationMs);
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async tailLogs(lines: number, role: string | undefined) {
|
|
222
|
+
const dir = lazy.services.stateDir;
|
|
223
|
+
const path = role === undefined ? join(dir, 'dev.log') : join(dir, 'logs', `${role}.log`);
|
|
224
|
+
const file = Bun.file(path);
|
|
225
|
+
if (!(await file.exists())) {
|
|
226
|
+
// `@ultimat3/core`'s `logger` is a module const with no sink seam, so there is nothing to
|
|
227
|
+
// intercept in-process — a log on disk is the only honest source, and the fix creates one.
|
|
228
|
+
throw new CliNotImplementedError({
|
|
229
|
+
feature: `logs.tail without ${path}`,
|
|
230
|
+
fix:
|
|
231
|
+
role === undefined
|
|
232
|
+
? `x dev > ${path} 2>&1`
|
|
233
|
+
: `mkdir -p ${join(dir, 'logs')} && x dev --role ${role} > ${path} 2>&1`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return (await file.text()).trimEnd().split('\n').slice(-lines);
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
async readManifest() {
|
|
240
|
+
const file = Bun.file(join(root, MANIFEST_FILENAME));
|
|
241
|
+
// The contract is "the generated manifest as text", so an app that has never run
|
|
242
|
+
// `x manifest` gets the current one rather than a failure.
|
|
243
|
+
if (await file.exists()) return file.text();
|
|
244
|
+
return JSON.stringify((await appManifest(root)).manifest, null, 2);
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
explainError: explainErrorCode,
|
|
248
|
+
|
|
249
|
+
async verify(fix: boolean): Promise<VerifyResult> {
|
|
250
|
+
// The one safe autofix this repo actually has. Anything more would be the gate rewriting
|
|
251
|
+
// code it was asked to judge.
|
|
252
|
+
if (fix) await runner(['bunx', 'biome', 'check', '--write', '.'], { cwd: root });
|
|
253
|
+
const result = await runVerify(VERIFY_STEPS, { root, runner });
|
|
254
|
+
return {
|
|
255
|
+
ok: result.ok,
|
|
256
|
+
steps: (result.steps ?? []).map((step): VerifyStep => {
|
|
257
|
+
const detail = step.findings[0]?.cause;
|
|
258
|
+
// exactOptionalPropertyTypes: omit `detail`, never hand over an explicit undefined.
|
|
259
|
+
return detail === undefined
|
|
260
|
+
? { name: step.name, ok: step.ok }
|
|
261
|
+
: { name: step.name, ok: step.ok, detail };
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Loading the app IS the registration, so it happens before the server is built: every
|
|
270
|
+
* introspection tool then answers from the framework's own registries, not from a scan.
|
|
271
|
+
*/
|
|
272
|
+
export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpServer> {
|
|
273
|
+
await loadApp(input.root);
|
|
274
|
+
const lazy = lazyServices(input);
|
|
275
|
+
const introspection = frameworkIntrospection({
|
|
276
|
+
routes: () => describeRoutes(),
|
|
277
|
+
policies: () => policyFacts(),
|
|
278
|
+
});
|
|
279
|
+
const server = createDevServer({ host: devHost(introspection, capabilities(input, lazy)) });
|
|
280
|
+
const caller = localCaller();
|
|
281
|
+
return { server, caller, tools: server.tools.names(caller), close: () => lazy.close() };
|
|
282
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Reading `bun test`'s own summary back into a `TestRun`. Separate from the host because it is a
|
|
2
|
+
// pure string reader with no services behind it — and because the one thing it must never do,
|
|
3
|
+
// report a crashed runner as a green run, is worth pinning on its own.
|
|
4
|
+
|
|
5
|
+
import type { TestRun } from '@ultimat3/mcp';
|
|
6
|
+
|
|
7
|
+
const TAIL_LINES = 20;
|
|
8
|
+
const FAIL_LINE = /^\(fail\)\s+(.*?)(?:\s+\[[\d.]+\s*m?s\])?$/;
|
|
9
|
+
const ERROR_LINE = /^\s*error:\s*(.+)$/;
|
|
10
|
+
|
|
11
|
+
const lastCount = (output: string, label: string): number | undefined => {
|
|
12
|
+
const last = [...output.matchAll(new RegExp(`^\\s*(\\d+)\\s+${label}\\b`, 'gm'))].at(-1);
|
|
13
|
+
return last === undefined ? undefined : Number.parseInt(last[1] ?? '0', 10);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const tailOf = (output: string): string => {
|
|
17
|
+
const lines = output.split('\n').filter((line) => line.trim().length > 0);
|
|
18
|
+
return lines.length === 0 ? 'bun test produced no output' : lines.slice(-TAIL_LINES).join('\n');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bun prints its own summary, so this reads it instead of counting anything a second time. Output
|
|
23
|
+
* it cannot recognise is reported as a FAILED run carrying the raw tail: returning zeros there
|
|
24
|
+
* would turn a runner that crashed before it started into a green run.
|
|
25
|
+
*/
|
|
26
|
+
export function parseBunTest(output: string, durationMs: number): TestRun {
|
|
27
|
+
const passed = lastCount(output, 'pass');
|
|
28
|
+
const failed = lastCount(output, 'fail');
|
|
29
|
+
if (passed === undefined && failed === undefined) {
|
|
30
|
+
const failure = { test: 'bun test', message: tailOf(output) };
|
|
31
|
+
return { passed: 0, failed: 1, skipped: 0, durationMs, failures: [failure] };
|
|
32
|
+
}
|
|
33
|
+
const failures: { test: string; message: string }[] = [];
|
|
34
|
+
let message = '';
|
|
35
|
+
for (const line of output.split('\n')) {
|
|
36
|
+
const error = ERROR_LINE.exec(line);
|
|
37
|
+
if (error !== null) {
|
|
38
|
+
message = error[1] ?? '';
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const fail = FAIL_LINE.exec(line.trimEnd());
|
|
42
|
+
if (fail === null) continue;
|
|
43
|
+
failures.push({
|
|
44
|
+
test: (fail[1] ?? '').trim(),
|
|
45
|
+
message: message === '' ? 'no error message in the run output' : message,
|
|
46
|
+
});
|
|
47
|
+
message = '';
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
passed: passed ?? 0,
|
|
51
|
+
failed: failed ?? 0,
|
|
52
|
+
// `skip` and `todo` print on separate lines and both mean "not run".
|
|
53
|
+
skipped: (lastCount(output, 'skip') ?? 0) + (lastCount(output, 'todo') ?? 0),
|
|
54
|
+
durationMs,
|
|
55
|
+
failures,
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The CLI's own flat message catalog. The CLI must render errors before an app (and therefore
|
|
2
|
+
// an app's i18n runtime) exists — `x new` and `x doctor` run outside any app — so it owns a
|
|
3
|
+
// built-in catalog in the same flat-key/loud-miss shape as @ultimat3/i18n instead of importing
|
|
4
|
+
// one. Missing keys render as ⟦key⟧, never as English fallback.
|
|
5
|
+
|
|
6
|
+
const CATALOG = {
|
|
7
|
+
'cli.tagline': 'Ultimate — one command means shippable',
|
|
8
|
+
'cli.usage': 'usage: x <command> [options]',
|
|
9
|
+
'cli.hint.help': 'run `x help <command>` for details, add --json to any command',
|
|
10
|
+
'cli.flags.heading': 'flags',
|
|
11
|
+
'cli.commands.heading': 'commands',
|
|
12
|
+
'cli.build.done': 'built {target}',
|
|
13
|
+
// `describeCron`'s vocabulary. `@ultimat3/time` is tier 1 and reaches no i18n runtime, so the
|
|
14
|
+
// caller supplies the words — and the caller here is a rendered `x tasks show` line, which is
|
|
15
|
+
// exactly what this catalog holds. `msg()` leaves an un-supplied `{n}`/`{time}`/`{days}`/
|
|
16
|
+
// `{months}` intact, so each value arrives at `describeCron` as the template it interpolates.
|
|
17
|
+
'cli.cron.andMore': 'and {n} more',
|
|
18
|
+
'cli.cron.at': 'at {time}',
|
|
19
|
+
'cli.cron.everyDay': 'every day',
|
|
20
|
+
'cli.cron.everyHour': 'every hour',
|
|
21
|
+
'cli.cron.everyMinute': 'every minute',
|
|
22
|
+
'cli.cron.everyNHours': 'every {n} hours',
|
|
23
|
+
'cli.cron.everyNMinutes': 'every {n} minutes',
|
|
24
|
+
'cli.cron.inMonths': 'in {months}',
|
|
25
|
+
'cli.cron.onDaysOfMonth': 'on day {days} of the month',
|
|
26
|
+
'cli.cron.onWeekdays': 'on {days}',
|
|
27
|
+
'cli.db.branch.ready': 'branch {name} ready',
|
|
28
|
+
'cli.dev.ready': 'dev ready on {url} — /_x mounted ({panels} panels), {services}',
|
|
29
|
+
// The mail and CDN halves of that boot line. Rendered text, so it lives here — while
|
|
30
|
+
// `describeMail`/`describeCdn` keep the same wording as the fixed vocabulary `x dev --json`
|
|
31
|
+
// carries, and `dev-runtime.test.ts` pins the two together so neither can drift alone.
|
|
32
|
+
'cli.dev.cdn.external': 'cdn=external({driver} via {detail})',
|
|
33
|
+
'cli.dev.cdn.none': 'cdn=none',
|
|
34
|
+
'cli.dev.mail.embedded': 'mail=embedded',
|
|
35
|
+
'cli.dev.mail.external': 'mail=external({driver} via {detail})',
|
|
36
|
+
'cli.dev.hmr': 'reloaded {file} in {ms}ms',
|
|
37
|
+
'cli.dev.roles': ' roles {roles}',
|
|
38
|
+
'cli.dev.panels': ' panels {panels}',
|
|
39
|
+
'cli.dev.introspect': ' introspect {url}',
|
|
40
|
+
'cli.dev.manifest': ' manifest {path}',
|
|
41
|
+
'cli.deploy.plan': 'containers only: {images} image, roles {roles}',
|
|
42
|
+
'cli.doctor.clean': 'no findings — environment is shippable',
|
|
43
|
+
'cli.doctor.findings': '{count} finding(s)',
|
|
44
|
+
'cli.errors.count': '{count} registered error code(s)',
|
|
45
|
+
'cli.errors.explained': '{code} — {title}',
|
|
46
|
+
'cli.fix.clean': 'no boundary violation involves {file}',
|
|
47
|
+
'cli.fix.plan': '{count} boundary violation(s) involve {file} — {edits} edit(s) to make',
|
|
48
|
+
'cli.generate.wrote': 'wrote {count} file(s) for {kind} {name}',
|
|
49
|
+
'cli.i18n.added': 'added {locale} — {keys} key(s) seeded from {from}',
|
|
50
|
+
'cli.i18n.dynamic': '{count} dynamic t() call(s) the extractor cannot verify:',
|
|
51
|
+
'cli.i18n.gaps': '{missing} missing key(s) across {locales} locale(s)',
|
|
52
|
+
'cli.i18n.ok': '{locales} locale(s), {keys} key(s) used — no gaps',
|
|
53
|
+
'cli.i18n.synced': 'synced {locale} from {from} — {added} key(s) added, {total} total',
|
|
54
|
+
'cli.i18n.unused': '{count} key(s) defined in {locale} and never used:',
|
|
55
|
+
'cli.jobs.deadLetters': '{count} dead letter(s):',
|
|
56
|
+
'cli.jobs.depth':
|
|
57
|
+
'{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
|
|
58
|
+
'cli.jobs.drained': 'drained {count} job(s) from {from} to {to}',
|
|
59
|
+
'cli.jobs.drainedPartial':
|
|
60
|
+
'drained {count} job(s) from {from} to {to} — {skipped} left on {from}',
|
|
61
|
+
'cli.jobs.listed': '{count} job(s)',
|
|
62
|
+
'cli.jobs.noError': 'no error recorded',
|
|
63
|
+
'cli.jobs.retried': 'job {id} re-queued — {state}',
|
|
64
|
+
'cli.jobs.shown': 'job {id} — {state}, attempt {attempt} of {attempts}',
|
|
65
|
+
'cli.jobs.skipped': '{count} job(s) left on {from} — re-run the drain once each is claimable:',
|
|
66
|
+
'cli.manifest.blocked': 'manifest not written — {count} module(s) did not load',
|
|
67
|
+
'cli.manifest.fresh': 'manifest is fresh',
|
|
68
|
+
'cli.manifest.stale': 'manifest is stale',
|
|
69
|
+
'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
|
|
70
|
+
'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
|
|
71
|
+
'cli.mcp.scopes': ' scopes {scopes}',
|
|
72
|
+
'cli.new.done': 'created {name} — next: cd {name} && x dev',
|
|
73
|
+
'cli.policy.count':
|
|
74
|
+
'{permissions} permission(s), {roles} role(s), {enforced} enforced by a declaration',
|
|
75
|
+
// One row per (declaration, actor) pair, never per role: a permission two declarations enforce
|
|
76
|
+
// evaluates every actor twice, so "of N role(s)" over-counted whenever it had more than one.
|
|
77
|
+
'cli.policy.explained': '{subject} — allowed for {allowed} of {evaluations} actor evaluation(s)',
|
|
78
|
+
'cli.policy.allow': 'allow',
|
|
79
|
+
'cli.policy.deny': 'deny',
|
|
80
|
+
'cli.policy.declaration': '{kind} {name} — policy {label}',
|
|
81
|
+
/** The empty cell in a `x policy list` column — a value, not a column key. */
|
|
82
|
+
'cli.policy.none': '-',
|
|
83
|
+
'cli.policy.noInput':
|
|
84
|
+
'evaluated with no request input and no row — a rule reading either decides again on the real request',
|
|
85
|
+
'cli.policy.undecidable': 'not decidable outside a request — this policy reads request input',
|
|
86
|
+
'cli.policy.unenforced': '{count} permission(s) no action or query enforces:',
|
|
87
|
+
'cli.registry.count': '{count} {kind}',
|
|
88
|
+
'cli.registry.described': '{kind} {name}',
|
|
89
|
+
'cli.routes.count': '{count} routes',
|
|
90
|
+
'cli.routes.empty': 'no routes in the manifest — run `x manifest` first',
|
|
91
|
+
'cli.tasks.count': '{count} task(s)',
|
|
92
|
+
'cli.tasks.shown': '{name} — {cron} ({tz}), next {next}',
|
|
93
|
+
'cli.test.fail': '{failed} of {workers} shard(s) failed',
|
|
94
|
+
'cli.test.pass': '{files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
95
|
+
'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
|
|
96
|
+
'cli.test.type.fail': '{type} — {failed} of {workers} shard(s) failed',
|
|
97
|
+
'cli.test.type.pass': '{type} — {files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
98
|
+
'cli.verify.pass': 'all {count} steps passed in {ms}ms',
|
|
99
|
+
'cli.verify.fail': '{failed} of {count} steps failed',
|
|
100
|
+
} as const;
|
|
101
|
+
|
|
102
|
+
export type MessageKey = keyof typeof CATALOG;
|
|
103
|
+
|
|
104
|
+
export type MessageParams = Readonly<Record<string, string | number>>;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Look up a message and interpolate `{name}` placeholders. An unknown key renders `⟦key⟧` so a
|
|
108
|
+
* miss is visible in the terminal and in `--json`, never silently papered over.
|
|
109
|
+
*/
|
|
110
|
+
export function msg(key: MessageKey | string, params: MessageParams = {}): string {
|
|
111
|
+
const template: string | undefined = (CATALOG as Record<string, string>)[key];
|
|
112
|
+
if (template === undefined) return `⟦${key}⟧`;
|
|
113
|
+
return template.replace(/\{(\w+)\}/g, (whole, name: string) => {
|
|
114
|
+
const value = params[name];
|
|
115
|
+
return value === undefined ? whole : String(value);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const messageKeys = (): readonly string[] => Object.keys(CATALOG);
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// One data shape, two renderers. Every command returns a `CommandResult`; the human renderer
|
|
2
|
+
// and the JSON renderer are projections of it, so `--json` can never drift from the terminal
|
|
3
|
+
// output (axiom 4). The human renderer owns the canonical 3-line error format.
|
|
4
|
+
|
|
5
|
+
export interface Finding {
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly cause: string;
|
|
8
|
+
readonly fix: string;
|
|
9
|
+
readonly docs?: string;
|
|
10
|
+
/** Optional locator: a file, route, or table the finding is about. */
|
|
11
|
+
readonly at?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StepResult {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly ok: boolean;
|
|
17
|
+
readonly durationMs: number;
|
|
18
|
+
readonly skipped?: boolean;
|
|
19
|
+
readonly findings: readonly Finding[];
|
|
20
|
+
/** Captured stdout/stderr, shown on failure or with --verbose. */
|
|
21
|
+
readonly output?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type JsonValue =
|
|
25
|
+
| string
|
|
26
|
+
| number
|
|
27
|
+
| boolean
|
|
28
|
+
| null
|
|
29
|
+
| readonly JsonValue[]
|
|
30
|
+
| { readonly [key: string]: JsonValue };
|
|
31
|
+
|
|
32
|
+
export interface CommandResult {
|
|
33
|
+
readonly ok: boolean;
|
|
34
|
+
readonly command: string;
|
|
35
|
+
/** One line, already localized through `msg()`. */
|
|
36
|
+
readonly summary: string;
|
|
37
|
+
readonly steps?: readonly StepResult[];
|
|
38
|
+
readonly findings?: readonly Finding[];
|
|
39
|
+
readonly data?: JsonValue;
|
|
40
|
+
/** Extra human-only lines (tables, file lists). Never carries data JSON does not have. */
|
|
41
|
+
readonly lines?: readonly string[];
|
|
42
|
+
readonly exitCode?: number;
|
|
43
|
+
/**
|
|
44
|
+
* A long-running command's "still running" handle: `dispatch` renders the result — the command
|
|
45
|
+
* has already reported that it is up — and then awaits this before the process exits. Neither
|
|
46
|
+
* renderer carries it, because it is behaviour rather than a fact, and a fact only one of them
|
|
47
|
+
* could show is how the two drift.
|
|
48
|
+
*/
|
|
49
|
+
readonly hold?: () => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UltimateErrorShape {
|
|
53
|
+
readonly code: string;
|
|
54
|
+
readonly cause: string;
|
|
55
|
+
readonly fix: string;
|
|
56
|
+
readonly docs?: string;
|
|
57
|
+
readonly message: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
61
|
+
typeof value === 'object' && value !== null;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Structural check, deliberately not `instanceof`: an error may cross a subprocess or worker
|
|
65
|
+
* boundary and arrive as a plain object, and the renderer must still produce the fix line.
|
|
66
|
+
*/
|
|
67
|
+
export function isUltimateErrorShape(value: unknown): value is UltimateErrorShape {
|
|
68
|
+
if (!isRecord(value)) return false;
|
|
69
|
+
return (
|
|
70
|
+
typeof value['code'] === 'string' &&
|
|
71
|
+
value['code'].startsWith('X_') &&
|
|
72
|
+
typeof value['cause'] === 'string' &&
|
|
73
|
+
typeof value['fix'] === 'string'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function findingFrom(value: unknown): Finding {
|
|
78
|
+
if (isUltimateErrorShape(value)) {
|
|
79
|
+
const docs = value.docs;
|
|
80
|
+
return docs === undefined
|
|
81
|
+
? { code: value.code, cause: value.cause, fix: value.fix }
|
|
82
|
+
: { code: value.code, cause: value.cause, fix: value.fix, docs };
|
|
83
|
+
}
|
|
84
|
+
const cause = value instanceof Error ? value.message : String(value);
|
|
85
|
+
return {
|
|
86
|
+
code: 'X_CLI_UNEXPECTED',
|
|
87
|
+
cause,
|
|
88
|
+
fix: 'x doctor --json',
|
|
89
|
+
docs: 'https://ultimate.dev/errors/X_CLI_UNEXPECTED',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const summaryOf = (value: UltimateErrorShape): string =>
|
|
94
|
+
value.message.length > 0 && value.message !== value.code ? value.message : '';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The 3-line contract format. Identical bytes in the terminal, the browser overlay and CI logs:
|
|
98
|
+
*
|
|
99
|
+
* ```
|
|
100
|
+
* X_DB_DRIFT: schema differs from migrations
|
|
101
|
+
* cause: table "posts" has column "publish_at" not present in any migration
|
|
102
|
+
* fix: x db gen "add publish_at"
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export function renderFinding(finding: Finding, indent = ''): string {
|
|
106
|
+
const head = finding.at === undefined ? finding.code : `${finding.code} (${finding.at})`;
|
|
107
|
+
const lines = [
|
|
108
|
+
`${indent}${head}`,
|
|
109
|
+
`${indent} cause: ${finding.cause}`,
|
|
110
|
+
`${indent} fix: ${finding.fix}`,
|
|
111
|
+
];
|
|
112
|
+
if (finding.docs !== undefined) lines.push(`${indent} docs: ${finding.docs}`);
|
|
113
|
+
return lines.join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Same format, but titled with the error's own summary line when it has one. */
|
|
117
|
+
export function renderUltimateError(error: UltimateErrorShape, indent = ''): string {
|
|
118
|
+
const summary = summaryOf(error);
|
|
119
|
+
const head = summary === '' ? error.code : `${error.code}: ${summary}`;
|
|
120
|
+
const docs = error.docs;
|
|
121
|
+
const finding: Finding =
|
|
122
|
+
docs === undefined
|
|
123
|
+
? { code: head, cause: error.cause, fix: error.fix }
|
|
124
|
+
: { code: head, cause: error.cause, fix: error.fix, docs };
|
|
125
|
+
return renderFinding(finding, indent);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const mark = (step: StepResult): string => {
|
|
129
|
+
if (step.skipped === true) return '-';
|
|
130
|
+
return step.ok ? '✓' : '✗';
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function renderHuman(result: CommandResult, verbose = false): string {
|
|
134
|
+
const out: string[] = [];
|
|
135
|
+
for (const step of result.steps ?? []) {
|
|
136
|
+
out.push(` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms`);
|
|
137
|
+
for (const finding of step.findings) out.push(renderFinding(finding, ' '));
|
|
138
|
+
if (step.output !== undefined && step.output.length > 0 && (verbose || !step.ok)) {
|
|
139
|
+
for (const line of step.output.trimEnd().split('\n')) out.push(` | ${line}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const line of result.lines ?? []) out.push(line);
|
|
143
|
+
for (const finding of result.findings ?? []) out.push(renderFinding(finding, ' '));
|
|
144
|
+
out.push(`${result.ok ? '✓' : '✗'} ${result.summary}`);
|
|
145
|
+
return out.join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function renderJson(result: CommandResult): string {
|
|
149
|
+
const steps = (result.steps ?? []).map((step) => ({
|
|
150
|
+
name: step.name,
|
|
151
|
+
ok: step.ok,
|
|
152
|
+
durationMs: step.durationMs,
|
|
153
|
+
skipped: step.skipped === true,
|
|
154
|
+
findings: step.findings,
|
|
155
|
+
}));
|
|
156
|
+
const payload = {
|
|
157
|
+
ok: result.ok,
|
|
158
|
+
command: result.command,
|
|
159
|
+
summary: result.summary,
|
|
160
|
+
...(result.steps === undefined ? {} : { steps }),
|
|
161
|
+
...(result.findings === undefined ? {} : { findings: result.findings }),
|
|
162
|
+
...(result.data === undefined ? {} : { data: result.data }),
|
|
163
|
+
};
|
|
164
|
+
return JSON.stringify(payload);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function render(result: CommandResult, json: boolean, verbose = false): string {
|
|
168
|
+
return json ? renderJson(result) : renderHuman(result, verbose);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function exitCodeFor(result: CommandResult): number {
|
|
172
|
+
if (result.exitCode !== undefined) return result.exitCode;
|
|
173
|
+
return result.ok ? 0 : 1;
|
|
174
|
+
}
|