hanzio 3.2.0 → 3.3.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.
@@ -8,10 +8,19 @@ export type RedisConcurrencyLimitOptions = {
8
8
  maxStartsPerSecond?: number;
9
9
  failOpenAfterMs?: number;
10
10
  commandTimeoutMs?: number;
11
- /** Receives throttled warnings. Defaults to `console`. */
12
- logger?: Pick<Console, 'warn'>;
11
+ /** Receives throttled warnings with `{ key, error }`. Defaults to `console`. */
12
+ logger?: {
13
+ warn(message: string, attributes: Record<string, unknown>): void;
14
+ };
15
+ /** The label `onWait` and `onQueueDepth` report under. Default: `key`. */
16
+ metricLabel?: string;
13
17
  /** Called when a caller starts queueing; the returned function runs once it stops. */
14
- onWait?: (stage: ConcurrencyLimitStage) => (() => void) | void;
18
+ onWait?: (stage: ConcurrencyLimitStage, metricLabel: string) => (() => void) | void;
19
+ /**
20
+ * Called once with `queueDepth`, to register a gauge; the returned function
21
+ * runs on `dispose()`.
22
+ */
23
+ onQueueDepth?: (queueDepth: () => number, metricLabel: string) => (() => void) | void;
15
24
  };
16
25
  export type RedisConcurrencyLimit = {
17
26
  <T>(run: () => Promise<T>, signal?: AbortSignal): Promise<T>;
@@ -46,10 +55,11 @@ export type RedisConcurrencyLimit = {
46
55
  * That verdict is shared per limiter: once tripped, later calls degrade
47
56
  * immediately while a background probe watches for Redis to come back.
48
57
  *
49
- * `onWait` and `queueDepth` feed metrics. A limiter minted per tenant or per
50
- * customer host should report under a shared pool label rather than its key,
51
- * or every key ever touched leaves a permanent series. Call `dispose()` when
52
- * such a limiter is discarded, and unregister any gauge reading `queueDepth`.
58
+ * `onWait` and `onQueueDepth` feed metrics under `metricLabel`. A limiter
59
+ * minted per tenant or per customer host should share one pool label rather
60
+ * than default to its key, or every key ever touched leaves a permanent
61
+ * series. Call `dispose()` when such a limiter is discarded; it also runs the
62
+ * unregister function `onQueueDepth` returned.
53
63
  *
54
64
  * @example
55
65
  * const withLimit = createRedisConcurrencyLimit({
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  CliError2,
3
+ CliUsageError2,
3
4
  defineCommand2,
4
5
  defineCli2
5
- } from "../../index-nfvq3zax.js";
6
+ } from "../../index-818f7jz4.js";
6
7
  export {
7
8
  CliError2 as CliError,
9
+ CliUsageError2 as CliUsageError,
8
10
  defineCli2 as defineCli,
9
11
  defineCommand2 as defineCommand
10
12
  };
@@ -1,3 +1,11 @@
1
+ import {
2
+ SealerError2,
3
+ sealerKeyInputs2,
4
+ formatEnvelope2,
5
+ parseEnvelope2,
6
+ jsonToSeal2,
7
+ createSealer2
8
+ } from "../../index-zz773050.js";
1
9
  import {
2
10
  utf8Encode2,
3
11
  utf8Decode2,
@@ -7,117 +15,14 @@ import {
7
15
  decodeBase64Url2,
8
16
  bytesToHex2,
9
17
  hexToBytes2,
10
- toBytes,
11
18
  safeEqual2,
12
19
  randomBytes2,
13
20
  randomToken2,
14
21
  sha2562,
15
22
  sha256Hex2,
16
23
  hmacSign2,
17
- verifyHmacSignature2,
18
- getSubtleCrypto
24
+ verifyHmacSignature2
19
25
  } from "../../index-rp5pqghz.js";
20
- // src/crypto/sealer.ts
21
- var ERROR_MESSAGES = {
22
- malformed: "Malformed sealed value",
23
- unsupported_version: "Unsupported sealed value version",
24
- decrypt_failed: "Could not open sealed value: wrong key or tampered data"
25
- };
26
-
27
- class SealerError extends Error {
28
- code;
29
- constructor(code) {
30
- super(ERROR_MESSAGES[code]);
31
- this.name = "SealerError";
32
- this.code = code;
33
- }
34
- }
35
- var PREFIX = "enc:v1:";
36
- var IV_BYTES = 12;
37
- var TAG_BYTES = 16;
38
- var DEFAULT_INFO = "hanzio.sealer.aes-256-gcm.v1";
39
- function createSealer(options) {
40
- const { secret, salt = "", info = DEFAULT_INFO } = options;
41
- const secretBytes = toBytes(secret);
42
- if (secretBytes.length === 0) {
43
- throw new TypeError("Sealer secret must not be empty");
44
- }
45
- let keyPromise;
46
- const getKey = () => {
47
- if (!keyPromise) {
48
- keyPromise = deriveKey(secretBytes, salt, info);
49
- keyPromise.catch(() => {
50
- keyPromise = undefined;
51
- });
52
- }
53
- return keyPromise;
54
- };
55
- const seal = async (plaintext) => {
56
- const iv = randomBytes2(IV_BYTES);
57
- const encrypted = new Uint8Array(await getSubtleCrypto().encrypt({ name: "AES-GCM", iv }, await getKey(), utf8Encode2(plaintext)));
58
- const tagStart = encrypted.length - TAG_BYTES;
59
- return `${PREFIX}${encodeBase64Url2(iv)}.${encodeBase64Url2(encrypted.subarray(tagStart))}.${encodeBase64Url2(encrypted.subarray(0, tagStart))}`;
60
- };
61
- const open = async (sealed) => {
62
- if (typeof sealed !== "string" || !sealed.startsWith(PREFIX)) {
63
- const isOtherVersion = typeof sealed === "string" && /^enc:v\d+:/.test(sealed);
64
- throw new SealerError(isOtherVersion ? "unsupported_version" : "malformed");
65
- }
66
- const parts = sealed.slice(PREFIX.length).split(".");
67
- let iv;
68
- let tag;
69
- let ciphertext;
70
- try {
71
- if (parts.length !== 3)
72
- throw new Error;
73
- iv = decodeBase64Url2(parts[0]);
74
- tag = decodeBase64Url2(parts[1]);
75
- ciphertext = decodeBase64Url2(parts[2]);
76
- } catch (_error) {
77
- throw new SealerError("malformed");
78
- }
79
- if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
80
- throw new SealerError("malformed");
81
- }
82
- const encrypted = new Uint8Array(ciphertext.length + TAG_BYTES);
83
- encrypted.set(ciphertext);
84
- encrypted.set(tag, ciphertext.length);
85
- const key = await getKey();
86
- let plaintext;
87
- try {
88
- plaintext = await getSubtleCrypto().decrypt({ name: "AES-GCM", iv }, key, encrypted);
89
- } catch (_error) {
90
- throw new SealerError("decrypt_failed");
91
- }
92
- return utf8Decode2(plaintext);
93
- };
94
- return {
95
- seal,
96
- open,
97
- async sealJson(value) {
98
- const json = JSON.stringify(value);
99
- if (json === undefined) {
100
- throw new TypeError("sealJson value is not JSON-serializable");
101
- }
102
- return seal(json);
103
- },
104
- async openJson(sealed) {
105
- return JSON.parse(await open(sealed));
106
- }
107
- };
108
- }
109
- async function deriveKey(secret, salt, info) {
110
- const subtle = getSubtleCrypto();
111
- const baseKey = await subtle.importKey("raw", secret, "HKDF", false, [
112
- "deriveKey"
113
- ]);
114
- return subtle.deriveKey({
115
- name: "HKDF",
116
- hash: "SHA-256",
117
- salt: utf8Encode2(salt),
118
- info: utf8Encode2(info)
119
- }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
120
- }
121
26
  // src/crypto/webhook.ts
122
27
  var TIMESTAMP = /^\d+$/;
123
28
  async function verifySvixSignature(options) {
@@ -174,18 +79,22 @@ function readHeader(headers, name) {
174
79
  return;
175
80
  }
176
81
  export {
177
- SealerError,
82
+ SealerError2 as SealerError,
178
83
  bytesToHex2 as bytesToHex,
179
- createSealer,
84
+ createSealer2 as createSealer,
180
85
  decodeBase642 as decodeBase64,
181
86
  decodeBase64Url2 as decodeBase64Url,
182
87
  encodeBase642 as encodeBase64,
183
88
  encodeBase64Url2 as encodeBase64Url,
89
+ formatEnvelope2 as formatEnvelope,
184
90
  hexToBytes2 as hexToBytes,
185
91
  hmacSign2 as hmacSign,
92
+ jsonToSeal2 as jsonToSeal,
93
+ parseEnvelope2 as parseEnvelope,
186
94
  randomBytes2 as randomBytes,
187
95
  randomToken2 as randomToken,
188
96
  safeEqual2 as safeEqual,
97
+ sealerKeyInputs2 as sealerKeyInputs,
189
98
  sha2562 as sha256,
190
99
  sha256Hex2 as sha256Hex,
191
100
  utf8Decode2 as utf8Decode,
@@ -0,0 +1,67 @@
1
+ import {
2
+ SealerError2,
3
+ sealerKeyInputs2,
4
+ formatEnvelope2,
5
+ parseEnvelope2,
6
+ jsonToSeal2
7
+ } from "../../index-zz773050.js";
8
+
9
+ // src/crypto/node.ts
10
+ import {
11
+ createCipheriv,
12
+ createDecipheriv,
13
+ hkdfSync,
14
+ randomBytes
15
+ } from "node:crypto";
16
+ var ALGORITHM = "aes-256-gcm";
17
+ function createSealerSync(options) {
18
+ const { secret, contexts } = sealerKeyInputs2(options);
19
+ const keys = [];
20
+ const getKey = (index) => {
21
+ let key = keys[index];
22
+ if (!key) {
23
+ const { salt, info } = contexts[index];
24
+ key = Buffer.from(hkdfSync("sha256", secret, salt, info, 32));
25
+ keys[index] = key;
26
+ }
27
+ return key;
28
+ };
29
+ const seal = (plaintext) => {
30
+ const iv = randomBytes(12);
31
+ const cipher = createCipheriv(ALGORITHM, getKey(0), iv);
32
+ const ciphertext = Buffer.concat([
33
+ cipher.update(plaintext, "utf8"),
34
+ cipher.final()
35
+ ]);
36
+ return formatEnvelope2(iv, cipher.getAuthTag(), ciphertext);
37
+ };
38
+ const openEnvelope = ({ iv, tag, ciphertext }) => {
39
+ for (let index = 0;index < contexts.length; index++) {
40
+ try {
41
+ const decipher = createDecipheriv(ALGORITHM, getKey(index), iv);
42
+ decipher.setAuthTag(tag);
43
+ return Buffer.concat([
44
+ decipher.update(ciphertext),
45
+ decipher.final()
46
+ ]).toString("utf8");
47
+ } catch (_error) {}
48
+ }
49
+ throw new SealerError2("decrypt_failed");
50
+ };
51
+ return {
52
+ seal,
53
+ open(sealed) {
54
+ const envelope = parseEnvelope2(sealed, options.passthroughUnsealed);
55
+ return envelope ? openEnvelope(envelope) : sealed;
56
+ },
57
+ sealJson: (value) => seal(jsonToSeal2(value)),
58
+ openJson(sealed) {
59
+ const envelope = parseEnvelope2(sealed, options.passthroughUnsealed);
60
+ return envelope ? JSON.parse(openEnvelope(envelope)) : sealed;
61
+ }
62
+ };
63
+ }
64
+ export {
65
+ SealerError2 as SealerError,
66
+ createSealerSync
67
+ };
@@ -3,7 +3,7 @@ import {
3
3
  CliError2,
4
4
  defineCommand2,
5
5
  defineCli2
6
- } from "../../index-nfvq3zax.js";
6
+ } from "../../index-818f7jz4.js";
7
7
  import"../../index-najbyzpr.js";
8
8
  import {
9
9
  validateCatalogs2,
@@ -315,6 +315,7 @@ function createRedisConcurrencyLimit(input) {
315
315
  if (!Number.isInteger(input.max) || input.max < 1)
316
316
  throw new Error(`max must be a positive integer, got ${input.max}`);
317
317
  const logger = input.logger ?? console;
318
+ const metricLabel = input.metricLabel ?? input.key;
318
319
  const commandTimeoutMs = input.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
319
320
  const send = withCommandTimeout(input.send, commandTimeoutMs);
320
321
  const ttlMs = String(input.slotTtlMs);
@@ -435,7 +436,7 @@ function createRedisConcurrencyLimit(input) {
435
436
  return {
436
437
  depth: () => queue.length,
437
438
  acquire(token) {
438
- const stopWait = input.onWait?.(stage);
439
+ const stopWait = input.onWait?.(stage, metricLabel);
439
440
  const granted = new Promise((resolve) => {
440
441
  queue.push({ token, settle: resolve });
441
442
  });
@@ -451,6 +452,7 @@ function createRedisConcurrencyLimit(input) {
451
452
  const slotPump = createPump("slot", input.max, async (tokens) => parseGrantReply(await acquireScript([inflightKey, cooldownKey], [String(input.max), ttlMs, ...tokens])));
452
453
  const startPump = maxStartsPerSecond === undefined ? null : createPump("start", maxStartsPerSecond, async (tokens) => parseGrantReply(await startScript([startsKey, cooldownKey], [String(maxStartsPerSecond), String(START_WINDOW_MS), ...tokens])));
453
454
  const queueDepth = () => slotPump.depth() + (startPump?.depth() ?? 0);
455
+ let unregisterQueueDepth = input.onQueueDepth?.(queueDepth, metricLabel);
454
456
  const heldTokens = new Set;
455
457
  let heartbeatTimer = null;
456
458
  function scheduleHeartbeat() {
@@ -555,6 +557,8 @@ function createRedisConcurrencyLimit(input) {
555
557
  }
556
558
  }
557
559
  function dispose() {
560
+ unregisterQueueDepth?.();
561
+ unregisterQueueDepth = undefined;
558
562
  if (probeTimer) {
559
563
  clearTimeout(probeTimer);
560
564
  probeTimer = null;
@@ -205,11 +205,30 @@ function mockCallArg(fn, callIndex = 0, argIndex = 0) {
205
205
  return fn.mock.calls[callIndex]?.[argIndex];
206
206
  }
207
207
  // src/testing/module-mocker.ts
208
+ import { isAbsolute } from "node:path";
209
+ function resolveFrom(baseDir) {
210
+ return (specifier) => {
211
+ if (isAbsolute(specifier))
212
+ return specifier;
213
+ if (specifier.startsWith("."))
214
+ return Bun.resolveSync(specifier, baseDir);
215
+ try {
216
+ return Bun.resolveSync(specifier, baseDir);
217
+ } catch (error) {
218
+ try {
219
+ return Bun.resolveSync(`./${specifier}`, baseDir);
220
+ } catch {
221
+ throw error;
222
+ }
223
+ }
224
+ };
225
+ }
226
+
208
227
  class ModuleMocker {
209
- resolve;
210
228
  restorers = [];
211
- constructor(resolve = (specifier) => specifier) {
212
- this.resolve = resolve;
229
+ resolve;
230
+ constructor(baseDirOrResolve = (specifier) => specifier) {
231
+ this.resolve = typeof baseDirOrResolve === "string" ? resolveFrom(baseDirOrResolve) : baseDirOrResolve;
213
232
  }
214
233
  async mock(specifier, renderMocks) {
215
234
  const { mock } = await import("bun:test");
@@ -222,7 +241,7 @@ class ModuleMocker {
222
241
  });
223
242
  }
224
243
  clear() {
225
- for (const restore of this.restorers)
244
+ for (const restore of this.restorers.toReversed())
226
245
  restore();
227
246
  this.restorers = [];
228
247
  }
@@ -3,7 +3,7 @@ export type JsonValue = string | number | boolean | null | JsonObject | JsonArra
3
3
  export type JsonObject = {
4
4
  [key: string]: JsonValue | undefined;
5
5
  };
6
- export type JsonArray = Array<JsonValue | undefined>;
6
+ export type JsonArray = JsonValue[];
7
7
  /**
8
8
  * Parses the first number-like token in a string. Only a `-` directly before
9
9
  * the digits is a sign, so `'2024-01-05'` gives `2024`. Whitespace and the
@@ -9,21 +9,21 @@ type ModuleExports = Record<string, unknown>;
9
9
  * keep working, and `clear()` re-registers the originals. Call it in
10
10
  * `afterEach` or `afterAll`.
11
11
  *
12
- * Bun resolves specifiers relative to the file that registers the mock, so
13
- * pass absolute paths or package names. A `resolve` function maps your own
14
- * shorthand (an `@/` alias, a source-root-relative path) to absolute paths.
12
+ * Without an argument, pass absolute paths. Pass your source root to resolve
13
+ * against it: package names from there (so mocks hit the same copy your code
14
+ * imports), and `./lib/x` or `lib/x` relative to it. A function maps your own
15
+ * shorthand, such as an `@/` alias, to absolute paths instead.
15
16
  *
16
17
  * @example
17
- * const moduleMocker = new ModuleMocker((specifier) =>
18
- * specifier.startsWith('@/') ? path.resolve(srcDir, specifier.slice(2)) : specifier
19
- * )
20
- * await moduleMocker.mock('@/lib/posthog', () => ({ captureException: mock() }))
18
+ * const moduleMocker = new ModuleMocker(srcDir)
19
+ * await moduleMocker.mock('lib/posthog.ts', () => ({ captureException: mock() }))
20
+ * await moduleMocker.mock('ai', () => ({ generateText: mock() }))
21
21
  * afterAll(() => moduleMocker.clear())
22
22
  */
23
23
  export declare class ModuleMocker {
24
- private readonly resolve;
25
24
  private restorers;
26
- constructor(resolve?: (specifier: string) => string);
25
+ private readonly resolve;
26
+ constructor(baseDirOrResolve?: string | ((specifier: string) => string));
27
27
  mock(specifier: string, renderMocks: () => ModuleExports): Promise<void>;
28
28
  clear(): void;
29
29
  }
@@ -1,5 +1,9 @@
1
1
  import type { Mock, mock } from 'bun:test';
2
- export type PrismaMethodMock = Mock<(...args: unknown[]) => Promise<unknown>>;
2
+ /**
3
+ * `never[]` parameters let `mockImplementation` take a callback that types its
4
+ * arguments; the plain call signature keeps the mock callable with any.
5
+ */
6
+ export type PrismaMethodMock = Mock<(...args: never[]) => Promise<unknown>> & ((...args: unknown[]) => Promise<unknown>);
3
7
  /**
4
8
  * A Prisma client where every model delegate and every method is a Bun mock,
5
9
  * created on first access, like `jest-mock-extended`'s `mockDeep()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanzio",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "A lightweight set of utility functions for TypeScript",
5
5
  "type": "module",
6
6
  "module": "./dist/src/index.js",
@@ -107,6 +107,12 @@
107
107
  "import": "./dist/src/crypto/index.js",
108
108
  "default": "./dist/src/crypto/index.js"
109
109
  },
110
+ "./crypto/node": {
111
+ "types": "./dist/crypto/node.d.ts",
112
+ "bun": "./src/crypto/node.ts",
113
+ "import": "./dist/src/crypto/node.js",
114
+ "default": "./dist/src/crypto/node.js"
115
+ },
110
116
  "./storage": {
111
117
  "types": "./dist/storage/index.d.ts",
112
118
  "bun": "./src/storage/index.ts",
@@ -194,7 +200,7 @@
194
200
  },
195
201
  "scripts": {
196
202
  "build": "rm -rf dist && bun run build:js && bun run build:types",
197
- "build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/api-wrapper/testing.ts ./src/api-wrapper/connection-auth.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/cool-console-log/index.ts ./src/format/index.ts ./src/format/phone.ts ./src/crypto/index.ts ./src/storage/index.ts ./src/i18n/index.ts ./src/i18n/vue.ts ./src/i18n/i18next.ts ./src/i18n/vite.ts ./src/i18n/check.ts ./src/i18n/cli.ts ./src/iso/index.ts ./src/secrets/index.ts ./src/secrets/vite.ts ./src/secrets/worker.ts ./src/redis/index.ts ./src/http/index.ts ./src/cli/index.ts ./src/testing/index.ts --outdir ./dist --target node --packages external --splitting",
203
+ "build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/api-wrapper/testing.ts ./src/api-wrapper/connection-auth.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/cool-console-log/index.ts ./src/format/index.ts ./src/format/phone.ts ./src/crypto/index.ts ./src/crypto/node.ts ./src/storage/index.ts ./src/i18n/index.ts ./src/i18n/vue.ts ./src/i18n/i18next.ts ./src/i18n/vite.ts ./src/i18n/check.ts ./src/i18n/cli.ts ./src/iso/index.ts ./src/secrets/index.ts ./src/secrets/vite.ts ./src/secrets/worker.ts ./src/redis/index.ts ./src/http/index.ts ./src/cli/index.ts ./src/testing/index.ts --outdir ./dist --target node --packages external --splitting",
198
204
  "build:types": "tsgo -p tsconfig.build.json",
199
205
  "prepublishOnly": "bun run build",
200
206
  "test": "bun test",
package/src/cli/README.md CHANGED
@@ -6,7 +6,8 @@ description: Typed CLIs from Zod object schemas, parsed with node:util parseArgs
6
6
 
7
7
  Each command's `input` is a Zod object: its keys become flags, Zod validates
8
8
  them, and `run` gets the typed result. Parsing is `node:util` `parseArgs`, so
9
- there is no extra dependency. Runs in Bun and Node 22.4+; needs `zod` 4.
9
+ there is no extra dependency. Runs in Bun and Node 22.4+. Commands with an
10
+ `input` need `zod` 4; a CLI made only of `raw` commands loads without it.
10
11
 
11
12
  ```ts
12
13
  // src/cli.ts
@@ -51,20 +52,27 @@ handles would keep the process alive.
51
52
  | `z.string()`, `z.enum([...])` | `--name value` or `--name=value` |
52
53
  | `z.number()` | `--repeats 3`, coerced before validation |
53
54
  | `z.boolean()` | `--json`; `--no-json` sets `false`; missing is `false` |
55
+ | `noWatch: z.boolean()` | its own flag `--no-watch`, not `--watch` negated |
54
56
  | `z.array(z.string())` | repeatable: `--model a --model b`; missing is `[]` |
57
+ | `z.union([z.boolean(), z.string()])` | `--expose` is `true`, `--expose=api,web` is the string |
55
58
  | `.optional()`, `.default(x)` | may be left out |
56
59
  | `.describe('…')` | help text |
57
60
  | `.meta({ positional: true })` | positional, in key order; an array takes the rest |
58
- | `.meta({ alias: 'r' })` | `-r value` |
59
- | `.meta({ env: 'APP_REGION' })` | read from the environment when the flag is missing |
61
+ | `.meta({ alias: 'r' })` | `-r value`; longer aliases are flags: `alias: ['r', 'region-name']` |
62
+ | `.meta({ env: 'APP_REGION' })` | read from the environment when the flag is missing; a list is tried in order, and blank values count as unset |
60
63
 
61
64
  Keys are camelCase in the schema and kebab-case on the command line (`dryRun`
62
65
  → `--dry-run`). These are the same `.meta()` keys as `trpc-cli`, plus `env`;
63
66
  importing `hanzio/cli` types them on Zod's `GlobalMeta`.
64
67
  Pipes read their input side, so `z.stringbool()` takes `--submit true`.
65
68
 
66
- Every CLI gets `-h`/`--help` (root, groups and commands), `--version` when you
67
- pass `version`, and "Did you mean …?" for mistyped commands and flags.
69
+ Every CLI gets `-h`/`--help` (root, groups and commands), `--version` and
70
+ `-v` when you pass `version`, and "Did you mean …?" for mistyped commands and
71
+ flags. A root command named `version` also answers `--version` and `-v`, so it
72
+ decides the format (`tool v1.2.3`).
73
+
74
+ `defineCommand({ strict: false })` ignores unknown flags and extra
75
+ positionals instead of exiting 2.
68
76
 
69
77
  ## Commands
70
78
 
@@ -82,6 +90,30 @@ A command is one of:
82
90
  - `defineCommand({ raw: true, run })`, which skips parsing: every argument
83
91
  after the command name is in `ctx.argv` (for `tool prisma migrate dev`).
84
92
 
93
+ The first line of `description` is the command's summary in command lists;
94
+ its help shows all of it. `usage: '[name...] [--all]'` replaces the generated
95
+ arguments in both, and `examples: ['tool stop api']` adds an Examples section
96
+ to its help.
97
+
98
+ ```ts
99
+ defineCli({
100
+ name: 'evals',
101
+ defaultCommand: 'run', // `evals` and `evals --case x` run `evals run`
102
+ commands: { run, inspect }
103
+ })
104
+ ```
105
+
106
+ `defaultCommand` runs when no command name is given, or when the first
107
+ argument is a flag that isn't a global. Pass a function to read it from the
108
+ environment: `defaultCommand: (env) => env.RELEASE_COMMAND`.
109
+
110
+ `unknownCommand: (name, path) => node` resolves a name the group doesn't
111
+ have, for commands that come from config. Return `undefined` for the usual
112
+ "Unknown command" error.
113
+
114
+ `help: (text, { path }) => text` rewrites every help screen (`path` is `[]`
115
+ for the root), to add sections such as the tasks a config defines.
116
+
85
117
  `run(input, ctx)` gets:
86
118
 
87
119
  | `ctx` | |
@@ -118,7 +150,9 @@ const cli = defineCli({
118
150
  })
119
151
  ```
120
152
 
121
- Globals are accepted before or after the command names. `context` runs after
153
+ Globals are accepted before or after the command names. A positional global
154
+ comes before the first command name, so `globals: z.object({ module:
155
+ z.string().meta({ positional: true }) })` reads `tool ./app.ts list`. `context` runs after
122
156
  parsing, so `--help` never connects anywhere. `guard` runs before every
123
157
  command. A command defined in its own file types its context by annotating
124
158
  `ctx`:
@@ -151,6 +185,29 @@ message is printed without a stack. Other errors print their stack. With
151
185
  Run "better-mastra-evals run --help" for usage.
152
186
  ```
153
187
 
188
+ `onError(error, io)` sees every error first. Return the exit code, or
189
+ `undefined` for the default output; `io.print()` writes that output and
190
+ returns its code. Usage errors are `CliUsageError`s with a `kind`
191
+ (`missing-command`, `unknown-command`, `invalid-arguments`), the command
192
+ `path`, and the `help` for it:
193
+
194
+ ```ts
195
+ defineCli({
196
+ name: 'tool',
197
+ commands,
198
+ onError: (error, io) => {
199
+ // Help instead of an error when run without a command.
200
+ if (error instanceof CliUsageError && error.kind === 'missing-command') {
201
+ io.stdout.write(error.help)
202
+ return 0
203
+ }
204
+ // Keep the message, but exit 1 on bad arguments.
205
+ const code = io.print()
206
+ return code === 2 ? 1 : code
207
+ }
208
+ })
209
+ ```
210
+
154
211
  ## Shell completion
155
212
 
156
213
  A CLI with commands also gets `tool completion bash` and `tool completion