@ultimat3/cli 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +87 -17
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +13 -7
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +186 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +205 -140
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +87 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +73 -0
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +202 -18
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. package/src/write-line.ts +34 -0
@@ -0,0 +1,368 @@
1
+ // `x secrets` — the committed encrypted file, end to end: seal it, edit it, add one value, rotate
2
+ // the master key, and report what is in it without ever printing what is in it. Every fact about
3
+ // the envelope comes from `@ultimat3/core`; this file owns the terminal, the temporary plaintext
4
+ // buffer, and the `.gitignore` rule that keeps the master key out of the repository.
5
+
6
+ // Bun ships no temp-directory primitive and no path join. `rmSync` is the synchronous half: a
7
+ // signal handler that awaited would be racing the default action it is trying to beat.
8
+ import { rmSync } from 'node:fs';
9
+ import { mkdtemp } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import type { MasterKeyRef, SecretValues } from '@ultimat3/core';
13
+ import {
14
+ assertSecretValues,
15
+ describeSecrets,
16
+ generateMasterKey,
17
+ masterKeyIdOf,
18
+ masterKeyPath,
19
+ REDACTED,
20
+ readSecretsFile,
21
+ requireMasterKey,
22
+ SECRETS_FILE,
23
+ SECRETS_KEY_ENV,
24
+ SECRETS_KEY_FILE,
25
+ SecretsPlaintextInvalidError,
26
+ secretsFileExists,
27
+ serializeSecretValues,
28
+ writeMasterKeyFile,
29
+ writeSecretsFile,
30
+ } from '@ultimat3/core';
31
+ import { ENV_SCHEMA_EXPORT, loadEnvSchema } from './app-env';
32
+ import { requireAppRoot } from './app-root';
33
+ import type { CliCommand, CommandContext } from './command';
34
+ import {
35
+ BadFlagError,
36
+ SecretsEditFailedError,
37
+ SecretsEditorMissingError,
38
+ SecretsExistsError,
39
+ } from './errors';
40
+ import { msg } from './messages';
41
+ import type { CommandResult, JsonValue } from './output';
42
+ import { renderTable } from './table';
43
+
44
+ export const SECRETS_SUBCOMMANDS = ['show', 'init', 'edit', 'set', 'rotate'] as const;
45
+
46
+ /** In order of precedence. `VISUAL` outranks `EDITOR` by POSIX convention on an interactive tty. */
47
+ export const EDITOR_VARS = ['VISUAL', 'EDITOR'] as const;
48
+
49
+ const GITIGNORE = '.gitignore';
50
+
51
+ export type EditorLauncher = (command: readonly string[], cwd: string) => Promise<number>;
52
+
53
+ /** The two things this command needs from the terminal, injected so a test can supply both. */
54
+ export interface SecretsIo {
55
+ readonly launch: EditorLauncher;
56
+ readonly readValue: () => Promise<string>;
57
+ }
58
+
59
+ /**
60
+ * Deliberately NOT through `exec.ts`. That boundary pipes stdout and ignores stdin by contract, and
61
+ * an editor handed a pipe where it expected a terminal draws nothing and never returns. The seam
62
+ * that keeps this testable is `SecretsIo` on the command, not a `Runner` on the context.
63
+ */
64
+ const spawnEditor: EditorLauncher = (command, cwd) =>
65
+ Bun.spawn([...command], { cwd, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' }).exited;
66
+
67
+ export const DEFAULT_SECRETS_IO: SecretsIo = {
68
+ launch: spawnEditor,
69
+ readValue: () => Bun.stdin.text(),
70
+ };
71
+
72
+ interface Session {
73
+ readonly root: string;
74
+ readonly key: MasterKeyRef;
75
+ }
76
+
77
+ const open = (ctx: CommandContext, subcommand: string): Session => {
78
+ const root = requireAppRoot(`secrets ${subcommand}`, ctx.cwd).dir;
79
+ return { root, key: requireMasterKey(root, ctx.env) };
80
+ };
81
+
82
+ const names = (values: SecretValues): readonly string[] => Object.keys(values).sort();
83
+
84
+ /** A buffer that will not parse is `X_SECRETS_PLAINTEXT_INVALID`, never a bare `SyntaxError`. */
85
+ function parseBuffer(text: string): SecretValues {
86
+ let parsed: unknown;
87
+ try {
88
+ parsed = JSON.parse(text);
89
+ } catch {
90
+ throw new SecretsPlaintextInvalidError({ at: SECRETS_FILE, reason: 'are not JSON' });
91
+ }
92
+ return assertSecretValues(parsed, SECRETS_FILE);
93
+ }
94
+
95
+ /**
96
+ * Make the ignore rule true before any key file exists. Ordering is the whole point: a key written
97
+ * first is a key that is committable for however long it takes to get here, and one commit is
98
+ * enough. A rule the repository already has is left alone rather than duplicated.
99
+ */
100
+ async function ensureIgnored(root: string): Promise<'added' | 'present'> {
101
+ const path = join(root, GITIGNORE);
102
+ const file = Bun.file(path);
103
+ const text = (await file.exists()) ? await file.text() : '';
104
+ if (text.split('\n').some((line) => line.trim() === SECRETS_KEY_FILE)) return 'present';
105
+ const head = text.length === 0 || text.endsWith('\n') ? text : `${text}\n`;
106
+ const gap = head.length === 0 ? '' : '\n';
107
+ await Bun.write(
108
+ path,
109
+ `${head}${gap}# The secrets master key. Never commit it — x secrets init writes this line first.\n${SECRETS_KEY_FILE}\n`,
110
+ );
111
+ return 'added';
112
+ }
113
+
114
+ async function init(ctx: CommandContext): Promise<CommandResult> {
115
+ const root = requireAppRoot('secrets init', ctx.cwd).dir;
116
+ if (secretsFileExists(root)) {
117
+ throw new SecretsExistsError({ path: SECRETS_FILE, fix: 'x secrets edit' });
118
+ }
119
+ if (await Bun.file(masterKeyPath(root)).exists()) {
120
+ throw new SecretsExistsError({ path: SECRETS_KEY_FILE, fix: 'x secrets rotate --json' });
121
+ }
122
+ const ignore = await ensureIgnored(root);
123
+ const key: MasterKeyRef = {
124
+ hex: generateMasterKey(),
125
+ source: 'file',
126
+ at: masterKeyPath(root),
127
+ };
128
+ writeMasterKeyFile(root, key.hex);
129
+ await writeSecretsFile(root, {}, key);
130
+ const keyId = await masterKeyIdOf(key);
131
+ return {
132
+ ok: true,
133
+ command: 'secrets',
134
+ summary: msg('cli.secrets.init', { path: SECRETS_FILE, kid: keyId }),
135
+ // The key itself is never printed. This is the one line an operator needs to carry it into a
136
+ // deploy, and it names the file rather than the value so a terminal transcript stays safe.
137
+ lines: [msg('cli.secrets.deploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE })],
138
+ data: { path: SECRETS_FILE, keyPath: SECRETS_KEY_FILE, keyId, gitignore: ignore },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Decrypt into a temporary buffer, hand it to `$EDITOR`, reseal what comes back. The buffer lives
144
+ * in the system temp directory and never inside the repository — a plaintext file under the app
145
+ * root is one `git add -A` away from being the thing this whole feature prevents.
146
+ *
147
+ * A buffer that will not validate is refused AND discarded: the edit is lost, which is the smaller
148
+ * of the two harms. Leaving decrypted values on disk so they can be recovered is the larger one.
149
+ */
150
+ async function edit(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
151
+ const { root, key } = open(ctx, 'edit');
152
+ const editor = EDITOR_VARS.map((name) => ctx.env[name]).find(
153
+ (value): value is string => value !== undefined && value.trim().length > 0,
154
+ );
155
+ if (editor === undefined) throw new SecretsEditorMissingError({ vars: EDITOR_VARS });
156
+ const before = await readSecretsFile(root, key);
157
+ const dir = await mkdtemp(join(tmpdir(), 'ultimate-secrets-'));
158
+ const buffer = join(dir, 'secrets.json');
159
+ const shred = (): void => rmSync(dir, { recursive: true, force: true });
160
+ // A `finally` does not run for a signal, and Ctrl-C inside an editor kills this process too.
161
+ process.once('SIGINT', shred);
162
+ process.once('SIGTERM', shred);
163
+ try {
164
+ await Bun.write(buffer, serializeSecretValues(before));
165
+ // `$EDITOR` may carry flags (`code --wait`), and there is no shell here to split them.
166
+ const code = await io.launch([...editor.trim().split(/\s+/), buffer], root);
167
+ if (code !== 0) throw new SecretsEditFailedError({ editor, code });
168
+ const after = parseBuffer(await Bun.file(buffer).text());
169
+ // Compared as PLAINTEXT: the IV is fresh on every seal, so comparing ciphertexts would rewrite
170
+ // the committed file on every edit session whether or not a value moved.
171
+ if (serializeSecretValues(before) === serializeSecretValues(after)) {
172
+ return {
173
+ ok: true,
174
+ command: 'secrets',
175
+ summary: msg('cli.secrets.unchanged', { path: SECRETS_FILE }),
176
+ data: { path: SECRETS_FILE, changed: false, added: [], updated: [], removed: [] },
177
+ };
178
+ }
179
+ await writeSecretsFile(root, after, key);
180
+ const added = names(after).filter((name) => before[name] === undefined);
181
+ const removed = names(before).filter((name) => after[name] === undefined);
182
+ const updated = names(after).filter(
183
+ (name) => before[name] !== undefined && before[name] !== after[name],
184
+ );
185
+ return {
186
+ ok: true,
187
+ command: 'secrets',
188
+ summary: msg('cli.secrets.edited', {
189
+ path: SECRETS_FILE,
190
+ added: added.length,
191
+ updated: updated.length,
192
+ removed: removed.length,
193
+ }),
194
+ data: { path: SECRETS_FILE, changed: true, added, updated, removed },
195
+ };
196
+ } finally {
197
+ shred();
198
+ process.off('SIGINT', shred);
199
+ process.off('SIGTERM', shred);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * The non-interactive write. The value comes from **stdin**, never from argv: an argument is in the
205
+ * shell history of whoever ran it and in `ps` for everyone on the box for as long as the process
206
+ * lives, and the primary user of this CLI is an agent that has no `$EDITOR` at all.
207
+ *
208
+ * printf %s "$TOKEN" | x secrets set STRIPE_KEY --json
209
+ */
210
+ async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
211
+ const name = ctx.args.positionals[0];
212
+ if (name === undefined) {
213
+ throw new BadFlagError({
214
+ flag: 'name',
215
+ command: 'secrets',
216
+ reason: 'x secrets set <NAME> needs the environment variable name to seal the value under',
217
+ fix: 'printf %s "$TOKEN" | x secrets set STRIPE_KEY --json',
218
+ });
219
+ }
220
+ const { root, key } = open(ctx, 'set');
221
+ const before = await readSecretsFile(root, key);
222
+ // Exactly one trailing newline, because `echo` adds one and a secret with a stray `\n` fails
223
+ // against the service it authenticates to with an error that names nothing.
224
+ const value = (await io.readValue()).replace(/\r?\n$/, '');
225
+ const after = assertSecretValues({ ...before, [name]: value }, SECRETS_FILE);
226
+ await writeSecretsFile(root, after, key);
227
+ return {
228
+ ok: true,
229
+ command: 'secrets',
230
+ summary: msg('cli.secrets.set', {
231
+ name,
232
+ path: SECRETS_FILE,
233
+ count: Object.keys(after).length,
234
+ }),
235
+ data: {
236
+ path: SECRETS_FILE,
237
+ name,
238
+ added: before[name] === undefined,
239
+ count: Object.keys(after).length,
240
+ },
241
+ };
242
+ }
243
+
244
+ /**
245
+ * A new master key over the same values. The committed file is written FIRST and the key file last,
246
+ * because only one of the two can be recovered: `secrets.enc.json` is in git, and a master key that
247
+ * is half-overwritten is gone. Interrupted between the two writes, the old key still on disk meets
248
+ * a file it cannot open — `X_SECRETS_KEY_MISMATCH`, whose fix restores the file from git.
249
+ */
250
+ async function rotate(ctx: CommandContext): Promise<CommandResult> {
251
+ const { root, key } = open(ctx, 'rotate');
252
+ const values = await readSecretsFile(root, key);
253
+ const previous = await masterKeyIdOf(key);
254
+ const next: MasterKeyRef = {
255
+ hex: generateMasterKey(),
256
+ source: 'file',
257
+ at: masterKeyPath(root),
258
+ };
259
+ await writeSecretsFile(root, values, next);
260
+ await ensureIgnored(root);
261
+ writeMasterKeyFile(root, next.hex);
262
+ const keyId = await masterKeyIdOf(next);
263
+ return {
264
+ ok: true,
265
+ command: 'secrets',
266
+ summary: msg('cli.secrets.rotated', {
267
+ path: SECRETS_FILE,
268
+ from: previous,
269
+ to: keyId,
270
+ count: Object.keys(values).length,
271
+ }),
272
+ lines: [msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE })],
273
+ data: {
274
+ path: SECRETS_FILE,
275
+ keyPath: SECRETS_KEY_FILE,
276
+ previousKeyId: previous,
277
+ keyId,
278
+ count: Object.keys(values).length,
279
+ },
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Names, lengths and whether `envSchema` declares each one. No value, in either renderer — the one
285
+ * way to read a secret's value is the app reading `env.<NAME>`, and the one way to see it is
286
+ * `x secrets edit`. A `--reveal` flag would be a second path that `--json` could not honour.
287
+ */
288
+ async function show(ctx: CommandContext): Promise<CommandResult> {
289
+ const { root, key } = open(ctx, 'show');
290
+ const values = await readSecretsFile(root, key);
291
+ const schema = await loadEnvSchema(root);
292
+ const summaries = describeSecrets(values);
293
+ const keyId = await masterKeyIdOf(key);
294
+ const declared = (name: string): boolean | undefined =>
295
+ schema === undefined ? undefined : schema[name] !== undefined;
296
+ const undeclared = summaries
297
+ .filter((entry) => declared(entry.name) === false)
298
+ .map((entry) => entry.name);
299
+ const rows = summaries.map((entry) => [
300
+ entry.name,
301
+ REDACTED,
302
+ `${entry.length} chars`,
303
+ declared(entry.name) === undefined ? '?' : declared(entry.name) === true ? 'yes' : 'no',
304
+ ]);
305
+ const lines = [
306
+ ...(summaries.length === 0
307
+ ? []
308
+ : renderTable(['name', 'value', 'length', ENV_SCHEMA_EXPORT], rows)),
309
+ ...(undeclared.length === 0
310
+ ? []
311
+ : [
312
+ msg('cli.secrets.undeclared', { count: undeclared.length, names: undeclared.join(', ') }),
313
+ ]),
314
+ ];
315
+ return {
316
+ ok: true,
317
+ command: 'secrets',
318
+ summary:
319
+ summaries.length === 0
320
+ ? msg('cli.secrets.empty', { path: SECRETS_FILE })
321
+ : msg('cli.secrets.shown', { count: summaries.length, path: SECRETS_FILE, kid: keyId }),
322
+ lines,
323
+ data: {
324
+ path: SECRETS_FILE,
325
+ keySource: key.source,
326
+ keyId,
327
+ count: summaries.length,
328
+ secrets: summaries.map((entry) => ({
329
+ name: entry.name,
330
+ length: entry.length,
331
+ declared: declared(entry.name) ?? null,
332
+ })) as JsonValue,
333
+ undeclared,
334
+ },
335
+ };
336
+ }
337
+
338
+ export function createSecretsCommand(io: SecretsIo): CliCommand {
339
+ return {
340
+ spec: {
341
+ name: 'secrets',
342
+ summary: `the committed encrypted secrets, decrypted into the ${ENV_SCHEMA_EXPORT} variables of the same names`,
343
+ usage: 'x secrets [show|init|edit|set <NAME>|rotate] [--json]',
344
+ requiresApp: true,
345
+ subcommands: [...SECRETS_SUBCOMMANDS],
346
+ // The bare `x secrets` answers without a key ever leaving the file. Declared, not inherited
347
+ // from the array's order — `init`, `edit`, `set` and `rotate` all write.
348
+ defaultSubcommand: 'show',
349
+ flags: [],
350
+ },
351
+ async run(ctx: CommandContext): Promise<CommandResult> {
352
+ switch (ctx.args.subcommand ?? 'show') {
353
+ case 'init':
354
+ return init(ctx);
355
+ case 'edit':
356
+ return edit(ctx, io);
357
+ case 'set':
358
+ return set(ctx, io);
359
+ case 'rotate':
360
+ return rotate(ctx);
361
+ default:
362
+ return show(ctx);
363
+ }
364
+ },
365
+ };
366
+ }
367
+
368
+ export const secretsCommand: CliCommand = createSecretsCommand(DEFAULT_SECRETS_IO);
package/src/cmd-tasks.ts CHANGED
@@ -136,6 +136,7 @@ export const tasksCommand: CliCommand = {
136
136
  usage: 'x tasks [list|show <name>] [--count n] [--json]',
137
137
  requiresApp: true,
138
138
  subcommands: ['list', 'show'],
139
+ defaultSubcommand: 'list',
139
140
  flags: [
140
141
  { name: 'count', type: 'string', summary: 'show: how many upcoming occurrences to list' },
141
142
  ],
package/src/cmd-test.ts CHANGED
@@ -3,37 +3,30 @@
3
3
  // spawned is test-shards.ts — this file only turns argv into their inputs, so a parsing bug can
4
4
  // never be read as a sharding one.
5
5
 
6
- // Bun ships no CPU-count primitive; `cpus()` is the fallback when navigator cannot answer.
7
- import { cpus } from 'node:os';
8
6
  import type { CliCommand, CommandContext } from './command';
9
7
  import { BadFlagError, NoTestFilesError } from './errors';
8
+ import { readIntFlag } from './flag-number';
10
9
  import type { CommandResult } from './output';
11
10
  import type { ParsedArgs } from './parse';
12
11
  import { flagString } from './parse';
13
12
  import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
14
13
  import { quoteArg, runShards } from './test-shards';
14
+ import { defaultWorkers } from './test-workers';
15
15
  import type { TestType } from './verify-tests';
16
16
  import { TEST_TYPES } from './verify-tests';
17
17
 
18
- /** navigator first: it is the runtime's own answer, and it respects a container's CPU limit. */
19
- export function availableCpus(): number {
20
- const hinted = typeof navigator === 'undefined' ? Number.NaN : navigator.hardwareConcurrency;
21
- return Math.max(1, Number.isFinite(hinted) && hinted > 0 ? Math.trunc(hinted) : cpus().length);
22
- }
23
-
24
- function readIndex(args: ParsedArgs, name: string, min: number): number | undefined {
25
- const raw = flagString(args, name);
26
- if (raw === undefined) return undefined;
27
- const value = Number.parseInt(raw, 10);
28
- if (!Number.isInteger(value) || value < min) {
29
- throw new BadFlagError({
30
- flag: name,
31
- command: 'test',
32
- reason: `expects an integer >= ${min}, got "${raw}"`,
33
- });
34
- }
35
- return value;
36
- }
18
+ /**
19
+ * `--workers` and `--shard`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while
20
+ * `cmd-verify.ts`'s own comment claimed `x test --workers` refused the same values `x verify`
21
+ * does — so the two commands disagreed about the same flag. One reader now answers for both.
22
+ */
23
+ const readIndex = (args: ParsedArgs, name: string, min: number): number | undefined =>
24
+ readIntFlag(args, {
25
+ name,
26
+ command: 'test',
27
+ min,
28
+ example: `x test --${name} ${Math.max(min, 1)}`,
29
+ });
37
30
 
38
31
  /**
39
32
  * One positional, and it is the type. `x test contract live` used to run `contract` and drop
@@ -59,8 +52,9 @@ export const testCommand: CliCommand = {
59
52
  summary:
60
53
  'run one test type — or the whole suite — across N processes, one isolated database per worker',
61
54
  usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--workers N] [--worker I] [--json]`,
55
+ positionalChoices: TEST_TYPES,
62
56
  flags: [
63
- { name: 'workers', type: 'string', summary: 'process count (default: available CPUs)' },
57
+ { name: 'workers', type: 'string', summary: 'process count (default: CPUs - 1, max 8)' },
64
58
  {
65
59
  name: 'worker',
66
60
  type: 'string',
@@ -84,7 +78,7 @@ export const testCommand: CliCommand = {
84
78
  throw new NoTestFilesError({ root: ctx.cwd, ...missingSelection(type, filter) });
85
79
  }
86
80
  const files = sample === undefined ? discovered : sampleFiles(discovered, sample);
87
- const requested = readIndex(ctx.args, 'workers', 1) ?? availableCpus();
81
+ const requested = readIndex(ctx.args, 'workers', 1) ?? defaultWorkers();
88
82
  const workers = Math.max(1, Math.min(requested, files.length));
89
83
  const only = readIndex(ctx.args, 'worker', 0);
90
84
  if (only !== undefined && only >= workers) {