@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
@@ -0,0 +1,212 @@
1
+ // `x i18n check|add <locale>|sync <locale>` — the command every X_LOCALE_UNSUPPORTED,
2
+ // X_CATALOG_MISSING_KEYS and X_CATALOG_INVALID fix line already names. CLI wiring only: the
3
+ // facts come from `i18n-audit.ts`, the audit itself from `@ultimat3/i18n`'s own `auditCatalogs`.
4
+
5
+ // `node:` and not Bun: Bun has no exclusive-create write, and `open(path, 'wx')` is the only one
6
+ // there is — `Bun.write` overwrites, so an existence check before it is a race a second `x i18n
7
+ // add` wins. `mkdir` comes with it because `open` does not create the parent directory `Bun.write`
8
+ // would, and `node:path` because Bun exposes no path API to build what either of them takes.
9
+ import { type FileHandle, mkdir, open } from 'node:fs/promises';
10
+ import { dirname, join } from 'node:path';
11
+ import { catalogKeys, catalogMissingKeys } from '@ultimat3/i18n';
12
+ import { loadApp } from './app-load';
13
+ import { requireAppRoot } from './app-root';
14
+ import type { CliCommand, CommandContext } from './command';
15
+ import { BadFlagError, CatalogExistsError } from './errors';
16
+ import {
17
+ auditApp,
18
+ loadCatalogs,
19
+ resolveDefaultLocale,
20
+ seedCatalog,
21
+ serializeCatalog,
22
+ syncCatalog,
23
+ } from './i18n-audit';
24
+ import { msg } from './messages';
25
+ import type { CommandResult, Finding, JsonValue } from './output';
26
+ import { findingFrom } from './output';
27
+ import { renderTable } from './table';
28
+ import { catalogPath, resolveLocales } from './templates/locales';
29
+
30
+ export const I18N_SUBCOMMANDS = ['check', 'add', 'sync'] as const;
31
+
32
+ /** `ExtractReport` is plain JSON by construction — same idiom as `cmd-registries.ts`'s `asJson`. */
33
+ const asJson = (value: object): Record<string, JsonValue> => value as Record<string, JsonValue>;
34
+
35
+ /** `open`'s failure when the file is already there — the one errno this command translates. */
36
+ const isAlreadyExists = (error: unknown): boolean =>
37
+ typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
38
+
39
+ /**
40
+ * The exclusive half of the create: `wx` fails rather than truncates, so an existing catalog is
41
+ * refused by the write itself. An `existsSync` before a `Bun.write` is the same answer with a
42
+ * window in it, and what falls through that window is a translator's work.
43
+ */
44
+ async function openExclusive(absolute: string, locale: string): Promise<FileHandle> {
45
+ try {
46
+ return await open(absolute, 'wx');
47
+ } catch (error) {
48
+ if (isAlreadyExists(error)) throw new CatalogExistsError({ locale, path: catalogPath(locale) });
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ /** `x i18n add`'s only write. `catalogs/` may not exist yet, and `open` never creates it. */
54
+ async function writeNewCatalog(absolute: string, locale: string, contents: string): Promise<void> {
55
+ await mkdir(dirname(absolute), { recursive: true });
56
+ const handle = await openExclusive(absolute, locale);
57
+ try {
58
+ await handle.writeFile(contents);
59
+ } finally {
60
+ await handle.close();
61
+ }
62
+ }
63
+
64
+ function requireLocalePositional(ctx: CommandContext, sub: string): string {
65
+ const raw = ctx.args.positionals[0];
66
+ if (raw === undefined) {
67
+ throw new BadFlagError({
68
+ flag: 'locale',
69
+ command: 'i18n',
70
+ reason: `"x i18n ${sub}" needs a locale: x i18n ${sub} <locale>`,
71
+ });
72
+ }
73
+ return raw;
74
+ }
75
+
76
+ /**
77
+ * Validates + canonicalizes the positional locale — `resolveLocales`'s own BCP-47/escape checks
78
+ * do the refusing, against this command's context rather than the generic `x g --locales` default:
79
+ * the cause names `--locale on "x i18n"` and the fix is `x i18n add es` / `x i18n sync es`.
80
+ */
81
+ function resolveOneLocale(ctx: CommandContext, sub: string): string {
82
+ const raw = requireLocalePositional(ctx, sub);
83
+ const resolved = resolveLocales([raw], {
84
+ fix: `x i18n ${sub} es`,
85
+ command: 'i18n',
86
+ flag: 'locale',
87
+ });
88
+ const locale = resolved[0];
89
+ if (locale === undefined) {
90
+ // Unreachable in practice — resolveLocales only returns [] for zero requested entries, and
91
+ // this always passes exactly one. Guarded rather than cast past, for noUncheckedIndexedAccess.
92
+ throw new BadFlagError({
93
+ flag: 'locale',
94
+ command: 'i18n',
95
+ reason: 'could not resolve a locale',
96
+ });
97
+ }
98
+ return locale;
99
+ }
100
+
101
+ async function runCheck(root: string): Promise<CommandResult> {
102
+ const { report, catalogs } = await auditApp(root);
103
+
104
+ const findings: Finding[] = report.locales
105
+ .filter((audit) => audit.missing.length > 0)
106
+ .map((audit) => ({
107
+ ...findingFrom(catalogMissingKeys(audit.locale, audit.missing)),
108
+ at: catalogPath(audit.locale),
109
+ }));
110
+
111
+ const header = ['locale', 'keys', 'missing', 'unused'];
112
+ const rows = report.locales.map((audit) => [
113
+ audit.locale,
114
+ String(catalogKeys(catalogs[audit.locale] ?? {}).length),
115
+ String(audit.missing.length),
116
+ String(audit.unused.length),
117
+ ]);
118
+ const lines = renderTable(header, rows).map((line) => ` ${line}`);
119
+
120
+ for (const audit of report.locales) {
121
+ if (audit.unused.length === 0) continue;
122
+ lines.push(` ${msg('cli.i18n.unused', { count: audit.unused.length, locale: audit.locale })}`);
123
+ for (const key of audit.unused) lines.push(` ${key}`);
124
+ }
125
+ if (report.dynamic.length > 0) {
126
+ lines.push(` ${msg('cli.i18n.dynamic', { count: report.dynamic.length })}`);
127
+ for (const entry of report.dynamic) {
128
+ // A multi-line argument (a ternary over string literals) is one finding, so it is one line —
129
+ // an entry that wrapped would break the indented list it belongs to.
130
+ const expression = entry.expression.replace(/\s+/g, ' ');
131
+ lines.push(` ${entry.file}:${entry.line}:${entry.column} ${expression}`);
132
+ }
133
+ }
134
+
135
+ const gapLocales = report.locales.filter((audit) => audit.missing.length > 0).length;
136
+ const missingTotal = report.locales.reduce((sum, audit) => sum + audit.missing.length, 0);
137
+ const summary = report.ok
138
+ ? msg('cli.i18n.ok', { locales: report.locales.length, keys: report.used.length })
139
+ : msg('cli.i18n.gaps', { missing: missingTotal, locales: gapLocales });
140
+
141
+ return { ok: report.ok, command: 'i18n', summary, lines, findings, data: asJson(report) };
142
+ }
143
+
144
+ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult> {
145
+ const locale = resolveOneLocale(ctx, 'add');
146
+ const path = catalogPath(locale);
147
+ const app = await loadApp(root);
148
+ const catalogs = await loadCatalogs(root);
149
+ const from = resolveDefaultLocale(app.defaultLocale, catalogs);
150
+ const seeded = seedCatalog(from === undefined ? {} : (catalogs[from] ?? {}));
151
+ await writeNewCatalog(join(root, path), locale, serializeCatalog(seeded));
152
+
153
+ const keys = catalogKeys(seeded).length;
154
+ return {
155
+ // The catalog is on disk, so the command did what it was asked. `loadApp`'s findings ride along
156
+ // rather than flip that: a `packages/i18n/src/index.ts` that would not import is why `from` may
157
+ // be the framework's `en` instead of the app's own default, and silence there is the bug.
158
+ ok: true,
159
+ command: 'i18n',
160
+ summary: msg('cli.i18n.added', { locale, keys, from: from ?? locale }),
161
+ findings: app.findings,
162
+ data: { locale, from: from ?? locale, keys, path },
163
+ };
164
+ }
165
+
166
+ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult> {
167
+ const locale = resolveOneLocale(ctx, 'sync');
168
+ const catalogs = await loadCatalogs(root);
169
+ const target = catalogs[locale];
170
+ if (target === undefined) {
171
+ throw new BadFlagError({
172
+ flag: 'locale',
173
+ command: 'i18n',
174
+ reason: `no catalog for "${locale}" at ${catalogPath(locale)}`,
175
+ fix: `x i18n add ${locale}`,
176
+ });
177
+ }
178
+
179
+ const app = await loadApp(root);
180
+ const from = resolveDefaultLocale(app.defaultLocale, catalogs);
181
+ const source = from === undefined ? {} : (catalogs[from] ?? {});
182
+ const { merged, added } = syncCatalog(target, source);
183
+ if (added.length > 0) await Bun.write(join(root, catalogPath(locale)), serializeCatalog(merged));
184
+
185
+ const total = catalogKeys(merged).length;
186
+ return {
187
+ // Same as `runAdd`: the merge landed, and `loadApp`'s findings say whether `from` is the app's
188
+ // own default or the framework's fallback standing in for an i18n module that would not import.
189
+ ok: true,
190
+ command: 'i18n',
191
+ summary: msg('cli.i18n.synced', { locale, from: from ?? locale, added: added.length, total }),
192
+ findings: app.findings,
193
+ data: { locale, from: from ?? locale, added, total, path: catalogPath(locale) },
194
+ };
195
+ }
196
+
197
+ export const i18nCommand: CliCommand = {
198
+ spec: {
199
+ name: 'i18n',
200
+ summary: 'catalogs: add a locale, sync keys, check for gaps',
201
+ usage: 'x i18n [check|add <locale>|sync <locale>] [--json]',
202
+ requiresApp: true,
203
+ subcommands: I18N_SUBCOMMANDS,
204
+ },
205
+ async run(ctx: CommandContext): Promise<CommandResult> {
206
+ const root = requireAppRoot('i18n', ctx.cwd).dir;
207
+ const sub = ctx.args.subcommand ?? 'check';
208
+ if (sub === 'add') return runAdd(root, ctx);
209
+ if (sub === 'sync') return runSync(root, ctx);
210
+ return runCheck(root);
211
+ },
212
+ };
@@ -0,0 +1,237 @@
1
+ // `x jobs ls|show|retry|drain` — introspect and recover the job queue, bound to `@ultimat3/jobs`'s
2
+ // own introspection so the CLI, `/_x` and MCP report identically. This file is CLI wiring only:
3
+ // the driver-injected logic is `jobs-report.ts`, the `--json` shapes `jobs-json.ts`, the table
4
+ // `jobs-table.ts`.
5
+
6
+ import type { JobDriver } from '@ultimat3/jobs';
7
+ import { createMemoryDriver, createNatsDriver, createRedisDriver, jobDriver } from '@ultimat3/jobs';
8
+ import { requireAppRoot } from './app-root';
9
+ import type { CliCommand, CommandContext } from './command';
10
+ import { startQueue } from './dev-queue';
11
+ import { resolveServices } from './dev-services';
12
+ import { BadFlagError } from './errors';
13
+ import { drainJobs } from './jobs-drain';
14
+ import {
15
+ deadLetterToJson,
16
+ depthToJson,
17
+ drainFailureToJson,
18
+ drainSkipToJson,
19
+ jobRecordToJson,
20
+ jobTraceToJson,
21
+ } from './jobs-json';
22
+ import { listJobs, retryJob, showJob } from './jobs-report';
23
+ import { renderJobTable } from './jobs-table';
24
+ import { msg } from './messages';
25
+ import type { CommandResult } from './output';
26
+ import { flagBool, flagString } from './parse';
27
+
28
+ export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'drain'] as const;
29
+
30
+ const DRAIN_TARGETS = ['memory', 'redis', 'nats'] as const;
31
+
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
+ function requireIdPositional(ctx: CommandContext, sub: string): string {
56
+ const id = ctx.args.positionals[0];
57
+ if (id === undefined) {
58
+ throw new BadFlagError({
59
+ flag: 'id',
60
+ command: 'jobs',
61
+ reason: `"x jobs ${sub}" needs a job id: x jobs ${sub} <id>`,
62
+ });
63
+ }
64
+ return id;
65
+ }
66
+
67
+ function requireEnvUrl(env: CommandContext['env'], name: string, target: string): string {
68
+ const value = env[name];
69
+ if (value === undefined || value.trim().length === 0) {
70
+ throw new BadFlagError({
71
+ flag: 'to',
72
+ command: 'jobs',
73
+ reason: `--to ${target} needs ${name} set in the environment`,
74
+ });
75
+ }
76
+ return value;
77
+ }
78
+
79
+ /**
80
+ * `redis`/`nats` are honest `X_NOT_IMPLEMENTED` stubs in `@ultimat3/jobs` — building one here is
81
+ * fine even though every `enqueue` on it will fail; `drainJobs` reports that per record.
82
+ * Exported so a test can drive the `--to`/env-var validation without a driver or a boot.
83
+ */
84
+ export function buildDrainTarget(to: string | undefined, env: CommandContext['env']): JobDriver {
85
+ if (to === undefined || !(DRAIN_TARGETS as readonly string[]).includes(to)) {
86
+ throw new BadFlagError({
87
+ flag: 'to',
88
+ command: 'jobs',
89
+ reason: `expects one of: ${DRAIN_TARGETS.join(', ')}`,
90
+ });
91
+ }
92
+ if (to === 'memory') return createMemoryDriver();
93
+ if (to === 'redis') return createRedisDriver({ url: requireEnvUrl(env, 'REDIS_URL', 'redis') });
94
+ return createNatsDriver({ servers: [requireEnvUrl(env, 'NATS_URL', 'nats')] });
95
+ }
96
+
97
+ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
98
+ const result = await listJobs(driver, {
99
+ queue: flagString(ctx.args, 'queue'),
100
+ state: flagString(ctx.args, 'state'),
101
+ name: flagString(ctx.args, 'name'),
102
+ limit: flagString(ctx.args, 'limit'),
103
+ });
104
+ const lines = [` ${msg('cli.jobs.listed', { count: result.rows.length })}`];
105
+ if (result.rows.length > 0) {
106
+ lines.push(...renderJobTable(result.rows).map((line) => ` ${line}`));
107
+ }
108
+ if (result.deadLetters.length > 0) {
109
+ lines.push(` ${msg('cli.jobs.deadLetters', { count: result.deadLetters.length })}`);
110
+ for (const entry of result.deadLetters) {
111
+ const why = entry.lastError ?? msg('cli.jobs.noError');
112
+ lines.push(
113
+ ` ${entry.id} ${entry.name} (${entry.queue}) ${why} — ${entry.retryCommand}`,
114
+ );
115
+ }
116
+ }
117
+ return {
118
+ ok: true,
119
+ command: 'jobs',
120
+ summary: msg('cli.jobs.depth', {
121
+ ready: result.depth.totals.ready,
122
+ running: result.depth.totals.running,
123
+ delayed: result.depth.totals.delayed,
124
+ dead: result.depth.totals.dead,
125
+ queues: result.depth.queues.length,
126
+ }),
127
+ lines,
128
+ data: {
129
+ depth: depthToJson(result.depth),
130
+ rows: result.rows.map(jobRecordToJson),
131
+ deadLetters: result.deadLetters.map(deadLetterToJson),
132
+ },
133
+ };
134
+ }
135
+
136
+ async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
137
+ const trace = await showJob(driver, requireIdPositional(ctx, 'show'));
138
+ return {
139
+ ok: true,
140
+ command: 'jobs',
141
+ summary: msg('cli.jobs.shown', {
142
+ id: trace.id,
143
+ state: trace.state,
144
+ attempt: trace.attempt,
145
+ attempts: trace.maxAttempts,
146
+ }),
147
+ data: jobTraceToJson(trace),
148
+ };
149
+ }
150
+
151
+ async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
152
+ const id = requireIdPositional(ctx, 'retry');
153
+ const trace = await retryJob(driver, id, flagString(ctx.args, 'from-step'));
154
+ return {
155
+ ok: true,
156
+ command: 'jobs',
157
+ summary: msg('cli.jobs.retried', { id: trace.id, state: trace.state }),
158
+ data: jobTraceToJson(trace),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
164
+ * design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
165
+ * empty a driver, and a partial move that exited 0 would read as "the queue is clear".
166
+ */
167
+ async function runDrain(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
168
+ const target = buildDrainTarget(flagString(ctx.args, 'to'), ctx.env);
169
+ const dryRun = flagBool(ctx.args, 'dry-run');
170
+ const outcome = await drainJobs(driver, target, dryRun);
171
+ const findings = outcome.failures.map((failure) => failure.finding);
172
+ const lines: string[] = [];
173
+ if (outcome.skipped.length > 0) {
174
+ const count = outcome.skipped.length;
175
+ lines.push(` ${msg('cli.jobs.skipped', { count, from: outcome.from })}`);
176
+ for (const skip of outcome.skipped) {
177
+ lines.push(` ${skip.id} ${skip.name} (${skip.queue}) ${skip.state} ${skip.reason}`);
178
+ }
179
+ }
180
+ const partial = outcome.skipped.length > 0;
181
+ return {
182
+ ok: findings.length === 0 && !partial,
183
+ command: 'jobs',
184
+ summary: msg(partial ? 'cli.jobs.drainedPartial' : 'cli.jobs.drained', {
185
+ count: dryRun ? outcome.candidates.length : outcome.moved.length,
186
+ from: outcome.from,
187
+ to: outcome.to,
188
+ skipped: outcome.skipped.length,
189
+ }),
190
+ lines,
191
+ findings,
192
+ data: {
193
+ from: outcome.from,
194
+ to: outcome.to,
195
+ dryRun: outcome.dryRun,
196
+ candidates: outcome.candidates.map(jobRecordToJson),
197
+ moved: outcome.moved.map(jobRecordToJson),
198
+ skipped: outcome.skipped.map(drainSkipToJson),
199
+ failures: outcome.failures.map(drainFailureToJson),
200
+ },
201
+ };
202
+ }
203
+
204
+ export const jobsCommand: CliCommand = {
205
+ spec: {
206
+ name: 'jobs',
207
+ summary: 'list, show, retry and drain the job queue',
208
+ 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]',
210
+ requiresApp: true,
211
+ subcommands: JOBS_SUBCOMMANDS,
212
+ flags: [
213
+ { name: 'queue', type: 'string', summary: 'filter by queue name' },
214
+ { name: 'state', type: 'string', summary: 'filter by job state' },
215
+ { name: 'limit', type: 'string', summary: 'max rows to return' },
216
+ { name: 'name', type: 'string', summary: 'filter by job name' },
217
+ { name: 'from-step', type: 'string', summary: 'retry: drop this step so it re-executes' },
218
+ { name: 'to', type: 'string', summary: 'drain target driver: memory, redis, nats' },
219
+ { name: 'dry-run', type: 'boolean', summary: 'drain: report the plan, move nothing' },
220
+ ],
221
+ },
222
+ async run(ctx: CommandContext): Promise<CommandResult> {
223
+ const root = requireAppRoot('jobs', ctx.cwd).dir;
224
+ const sub = ctx.args.subcommand ?? 'ls';
225
+ return withDriver(root, ctx, (driver) => {
226
+ if (sub === 'show') return runShow(driver, ctx);
227
+ if (sub === 'retry') return runRetry(driver, ctx);
228
+ if (sub === 'drain') return runDrain(driver, ctx);
229
+ return runLs(driver, ctx);
230
+ });
231
+ },
232
+ };
233
+
234
+ export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
235
+ export { drainJobs } from './jobs-drain';
236
+ export type { JobsListFilter, JobsListResult } from './jobs-report';
237
+ export { listJobs, retryJob, showJob } from './jobs-report';
@@ -0,0 +1,97 @@
1
+ // `x manifest` — regenerate x.manifest.json and openapi.json from the code. Facts are generated,
2
+ // conventions are hand-written: nothing in this file writes prose, and nothing else in the app is
3
+ // allowed to hand-edit these two files.
4
+
5
+ import { join } from 'node:path';
6
+ import type { Manifest } from '@ultimat3/manifest';
7
+ import { assertNoDrift, MANIFEST_FILENAME } from '@ultimat3/manifest';
8
+ import { appManifest, writeAppManifest } from './app-manifest';
9
+ import { OPENAPI_FILE, openApiJson } from './app-openapi';
10
+ import { requireAppRoot } from './app-root';
11
+ import type { CliCommand, CommandContext } from './command';
12
+ import { msg } from './messages';
13
+ import type { CommandResult, Finding, JsonValue } from './output';
14
+ import { findingFrom } from './output';
15
+ import { flagBool } from './parse';
16
+
17
+ const countsOf = (manifest: Manifest): JsonValue => ({
18
+ routes: manifest.routes.length,
19
+ actions: manifest.actions.length,
20
+ mutators: manifest.actions.filter((action) => action.mutator === true).length,
21
+ queries: manifest.queries.length,
22
+ jobs: manifest.jobs.length,
23
+ tasks: manifest.tasks.length,
24
+ entities: manifest.entities.length,
25
+ policies: manifest.policies.length,
26
+ });
27
+
28
+ /**
29
+ * The identical comparison `x verify`'s `manifest` step makes, through the identical function.
30
+ * A buildId equality test here would answer "fresh" for a hand-edited file whose body no longer
31
+ * hashes to the id it carries — and two commands giving two answers about one file is itself the
32
+ * drift the manifest exists to prevent.
33
+ */
34
+ async function staleness(root: string, manifest: Manifest): Promise<Finding | undefined> {
35
+ try {
36
+ await assertNoDrift({ manifest, path: join(root, MANIFEST_FILENAME) });
37
+ return undefined;
38
+ } catch (error) {
39
+ return { ...findingFrom(error), at: MANIFEST_FILENAME };
40
+ }
41
+ }
42
+
43
+ export const manifestCommand: CliCommand = {
44
+ spec: {
45
+ name: 'manifest',
46
+ summary: 'regenerate x.manifest.json and openapi.json from the code',
47
+ usage: 'x manifest [--check] [--json]',
48
+ requiresApp: true,
49
+ flags: [
50
+ { name: 'check', type: 'boolean', summary: 'fail if the committed files are stale' },
51
+ { name: 'openapi', type: 'boolean', summary: 'also write openapi.json', default: true },
52
+ ],
53
+ },
54
+ async run(ctx: CommandContext): Promise<CommandResult> {
55
+ const root = requireAppRoot('manifest', ctx.cwd).dir;
56
+ const { manifest, findings } = await appManifest(root);
57
+ const counts = countsOf(manifest);
58
+
59
+ if (flagBool(ctx.args, 'check')) {
60
+ const stale = await staleness(root, manifest);
61
+ return {
62
+ ok: stale === undefined && findings.length === 0,
63
+ command: 'manifest',
64
+ summary: stale === undefined ? msg('cli.manifest.fresh') : msg('cli.manifest.stale'),
65
+ findings: stale === undefined ? findings : [...findings, stale],
66
+ data: { buildId: manifest.buildId, counts },
67
+ };
68
+ }
69
+
70
+ // A module that would not load is omitted from the registries, so this projection describes a
71
+ // subset of the app — and `x.manifest.json` is the compatibility contract. Write nothing.
72
+ if (findings.length > 0) {
73
+ return {
74
+ ok: false,
75
+ command: 'manifest',
76
+ summary: msg('cli.manifest.blocked', { count: findings.length }),
77
+ findings,
78
+ data: { buildId: manifest.buildId, counts },
79
+ };
80
+ }
81
+
82
+ const path = await writeAppManifest(root, manifest);
83
+ if (ctx.args.flags.get('openapi') !== false) {
84
+ await Bun.write(join(root, OPENAPI_FILE), openApiJson(manifest));
85
+ }
86
+ return {
87
+ ok: true,
88
+ command: 'manifest',
89
+ summary: msg('cli.manifest.wrote', {
90
+ path: MANIFEST_FILENAME,
91
+ routes: manifest.routes.length,
92
+ actions: manifest.actions.length,
93
+ }),
94
+ data: { path, buildId: manifest.buildId, counts },
95
+ };
96
+ },
97
+ };