hanzio 3.0.0 → 3.1.1

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 (112) hide show
  1. package/README.md +114 -3
  2. package/dist/api-wrapper/connection-auth.d.ts +61 -0
  3. package/dist/cli/fields.d.ts +30 -0
  4. package/dist/cli/index.d.ts +122 -0
  5. package/dist/format/index.d.ts +91 -0
  6. package/dist/format/phone.d.ts +19 -0
  7. package/dist/http/index.d.ts +14 -0
  8. package/dist/i18n/catalog.d.ts +47 -0
  9. package/dist/i18n/check.d.ts +45 -0
  10. package/dist/i18n/cli.d.ts +2 -0
  11. package/dist/i18n/content-rules.d.ts +55 -0
  12. package/dist/i18n/i18next.d.ts +39 -0
  13. package/dist/i18n/index.d.ts +16 -1
  14. package/dist/i18n/locales.d.ts +62 -0
  15. package/dist/i18n/vite.d.ts +16 -0
  16. package/dist/i18n/vue.d.ts +93 -0
  17. package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
  18. package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
  19. package/dist/index-5n2e326q.js +409 -0
  20. package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
  21. package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
  22. package/dist/index-mq2qgc20.js +116 -0
  23. package/dist/index-najbyzpr.js +99 -0
  24. package/dist/index-nfvq3zax.js +475 -0
  25. package/dist/index-ww09bm49.js +250 -0
  26. package/dist/iso/countries.d.ts +262 -0
  27. package/dist/iso/currencies.d.ts +165 -0
  28. package/dist/iso/index.d.ts +4 -0
  29. package/dist/iso/languages.d.ts +195 -0
  30. package/dist/iso/phone-language.d.ts +5 -0
  31. package/dist/promise/index.d.ts +31 -0
  32. package/dist/redis/client.d.ts +49 -0
  33. package/dist/redis/concurrency-limit.d.ts +63 -0
  34. package/dist/redis/index.d.ts +3 -0
  35. package/dist/redis/lua-script.d.ts +13 -0
  36. package/dist/secrets/infisical-identity-file.d.ts +12 -0
  37. package/dist/src/api-wrapper/connection-auth.js +82 -0
  38. package/dist/src/api-wrapper/index.js +1 -1
  39. package/dist/src/cli/index.js +10 -0
  40. package/dist/src/format/index.js +79 -2
  41. package/dist/src/format/phone.js +21 -0
  42. package/dist/src/http/index.js +69 -0
  43. package/dist/src/i18n/check.js +15 -0
  44. package/dist/src/i18n/cli.js +71 -0
  45. package/dist/src/i18n/i18next.js +48 -0
  46. package/dist/src/i18n/index.js +16 -34
  47. package/dist/src/i18n/vite.js +25 -0
  48. package/dist/src/i18n/vue.js +51 -0
  49. package/dist/src/index.js +14 -4
  50. package/dist/src/iso/index.js +655 -0
  51. package/dist/src/redis/index.js +577 -0
  52. package/dist/src/secrets/index.js +78 -35
  53. package/dist/src/sitemap/index.js +1 -1
  54. package/dist/src/storage/index.js +8 -117
  55. package/dist/src/testing/index.js +348 -0
  56. package/dist/src/zod/index.js +20 -253
  57. package/dist/string/index.d.ts +21 -0
  58. package/dist/testing/connection-auth-contract.d.ts +43 -0
  59. package/dist/testing/helpers.d.ts +26 -0
  60. package/dist/testing/index.d.ts +5 -0
  61. package/dist/testing/module-mocker.d.ts +30 -0
  62. package/dist/testing/prisma.d.ts +30 -0
  63. package/dist/testing/window.d.ts +18 -0
  64. package/dist/zod/index.d.ts +10 -0
  65. package/dist/zod/introspect.d.ts +2 -1
  66. package/package.json +96 -2
  67. package/src/api-wrapper/README.md +52 -0
  68. package/src/api-wrapper/connection-auth.ts +142 -0
  69. package/src/cli/README.md +220 -0
  70. package/src/cli/fields.ts +213 -0
  71. package/src/cli/index.ts +533 -0
  72. package/src/format/README.md +82 -2
  73. package/src/format/index.ts +148 -0
  74. package/src/format/phone.ts +43 -0
  75. package/src/http/README.md +25 -0
  76. package/src/http/index.ts +94 -0
  77. package/src/i18n/README.md +206 -3
  78. package/src/i18n/catalog.ts +139 -0
  79. package/src/i18n/check.ts +376 -0
  80. package/src/i18n/cli.ts +90 -0
  81. package/src/i18n/content-rules.ts +179 -0
  82. package/src/i18n/i18next.ts +89 -0
  83. package/src/i18n/index.ts +34 -0
  84. package/src/i18n/locales.ts +135 -0
  85. package/src/i18n/vite.ts +40 -0
  86. package/src/i18n/vue.ts +82 -0
  87. package/src/index.ts +1 -1
  88. package/src/iso/README.md +44 -0
  89. package/src/iso/countries.ts +272 -0
  90. package/src/iso/currencies.ts +172 -0
  91. package/src/iso/index.ts +8 -0
  92. package/src/iso/languages.ts +208 -0
  93. package/src/iso/phone-language.ts +46 -0
  94. package/src/promise/index.ts +100 -0
  95. package/src/redis/README.md +112 -0
  96. package/src/redis/client.ts +239 -0
  97. package/src/redis/concurrency-limit.ts +591 -0
  98. package/src/redis/index.ts +15 -0
  99. package/src/redis/lua-script.ts +43 -0
  100. package/src/secrets/README.md +5 -1
  101. package/src/secrets/infisical-identity-file.ts +69 -0
  102. package/src/secrets/infisical.ts +18 -3
  103. package/src/string/index.ts +59 -0
  104. package/src/testing/README.md +119 -0
  105. package/src/testing/connection-auth-contract.ts +303 -0
  106. package/src/testing/helpers.ts +41 -0
  107. package/src/testing/index.ts +12 -0
  108. package/src/testing/module-mocker.ts +50 -0
  109. package/src/testing/prisma.ts +78 -0
  110. package/src/testing/window.ts +119 -0
  111. package/src/zod/index.ts +20 -0
  112. package/src/zod/introspect.ts +1 -1
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "hanzio",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "A lightweight set of utility functions for TypeScript",
5
5
  "type": "module",
6
6
  "module": "./dist/src/index.js",
7
7
  "main": "./dist/src/index.js",
8
8
  "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "hanzio-i18n": "./dist/src/i18n/cli.js"
11
+ },
9
12
  "license": "MIT",
10
13
  "repository": {
11
14
  "type": "git",
@@ -56,6 +59,12 @@
56
59
  "import": "./dist/src/api-wrapper/testing.js",
57
60
  "default": "./dist/src/api-wrapper/testing.js"
58
61
  },
62
+ "./api-wrapper/connection-auth": {
63
+ "types": "./dist/api-wrapper/connection-auth.d.ts",
64
+ "bun": "./src/api-wrapper/connection-auth.ts",
65
+ "import": "./dist/src/api-wrapper/connection-auth.js",
66
+ "default": "./dist/src/api-wrapper/connection-auth.js"
67
+ },
59
68
  "./jwt": {
60
69
  "types": "./dist/jwt/index.d.ts",
61
70
  "bun": "./src/jwt/index.ts",
@@ -86,6 +95,12 @@
86
95
  "import": "./dist/src/format/index.js",
87
96
  "default": "./dist/src/format/index.js"
88
97
  },
98
+ "./format/phone": {
99
+ "types": "./dist/format/phone.d.ts",
100
+ "bun": "./src/format/phone.ts",
101
+ "import": "./dist/src/format/phone.js",
102
+ "default": "./dist/src/format/phone.js"
103
+ },
89
104
  "./crypto": {
90
105
  "types": "./dist/crypto/index.d.ts",
91
106
  "bun": "./src/crypto/index.ts",
@@ -104,6 +119,36 @@
104
119
  "import": "./dist/src/i18n/index.js",
105
120
  "default": "./dist/src/i18n/index.js"
106
121
  },
122
+ "./i18n/vue": {
123
+ "types": "./dist/i18n/vue.d.ts",
124
+ "bun": "./src/i18n/vue.ts",
125
+ "import": "./dist/src/i18n/vue.js",
126
+ "default": "./dist/src/i18n/vue.js"
127
+ },
128
+ "./i18n/i18next": {
129
+ "types": "./dist/i18n/i18next.d.ts",
130
+ "bun": "./src/i18n/i18next.ts",
131
+ "import": "./dist/src/i18n/i18next.js",
132
+ "default": "./dist/src/i18n/i18next.js"
133
+ },
134
+ "./i18n/vite": {
135
+ "types": "./dist/i18n/vite.d.ts",
136
+ "bun": "./src/i18n/vite.ts",
137
+ "import": "./dist/src/i18n/vite.js",
138
+ "default": "./dist/src/i18n/vite.js"
139
+ },
140
+ "./i18n/check": {
141
+ "types": "./dist/i18n/check.d.ts",
142
+ "bun": "./src/i18n/check.ts",
143
+ "import": "./dist/src/i18n/check.js",
144
+ "default": "./dist/src/i18n/check.js"
145
+ },
146
+ "./iso": {
147
+ "types": "./dist/iso/index.d.ts",
148
+ "bun": "./src/iso/index.ts",
149
+ "import": "./dist/src/iso/index.js",
150
+ "default": "./dist/src/iso/index.js"
151
+ },
107
152
  "./secrets": {
108
153
  "types": "./dist/secrets/index.d.ts",
109
154
  "bun": "./src/secrets/index.ts",
@@ -121,11 +166,35 @@
121
166
  "bun": "./src/secrets/worker.ts",
122
167
  "import": "./dist/src/secrets/worker.js",
123
168
  "default": "./dist/src/secrets/worker.js"
169
+ },
170
+ "./redis": {
171
+ "types": "./dist/redis/index.d.ts",
172
+ "bun": "./src/redis/index.ts",
173
+ "import": "./dist/src/redis/index.js",
174
+ "default": "./dist/src/redis/index.js"
175
+ },
176
+ "./http": {
177
+ "types": "./dist/http/index.d.ts",
178
+ "bun": "./src/http/index.ts",
179
+ "import": "./dist/src/http/index.js",
180
+ "default": "./dist/src/http/index.js"
181
+ },
182
+ "./cli": {
183
+ "types": "./dist/cli/index.d.ts",
184
+ "bun": "./src/cli/index.ts",
185
+ "import": "./dist/src/cli/index.js",
186
+ "default": "./dist/src/cli/index.js"
187
+ },
188
+ "./testing": {
189
+ "types": "./dist/testing/index.d.ts",
190
+ "bun": "./src/testing/index.ts",
191
+ "import": "./dist/src/testing/index.js",
192
+ "default": "./dist/src/testing/index.js"
124
193
  }
125
194
  },
126
195
  "scripts": {
127
196
  "build": "rm -rf dist && bun run build:js && bun run build:types",
128
- "build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/api-wrapper/testing.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/crypto/index.ts ./src/storage/index.ts ./src/i18n/index.ts ./src/secrets/index.ts ./src/secrets/vite.ts ./src/secrets/worker.ts --outdir ./dist --target node --packages external --splitting",
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",
129
198
  "build:types": "tsgo -p tsconfig.build.json",
130
199
  "prepublishOnly": "bun run build",
131
200
  "test": "bun test",
@@ -139,19 +208,44 @@
139
208
  },
140
209
  "devDependencies": {
141
210
  "@biomejs/biome": "^2.4.6",
211
+ "@intlify/message-compiler": "^11.4.16",
142
212
  "@opentelemetry/api": "^1.9.1",
143
213
  "@types/bun": "latest",
144
214
  "@typescript/native-preview": "^7.0.0-dev.20260306.1",
145
215
  "husky": "^9.1.7",
216
+ "i18next": "^25.10.10",
217
+ "libphonenumber-js": "^1.13.15",
146
218
  "miniflare": "^4.20260730.0",
147
219
  "typescript": "^5.7.0",
148
220
  "vite": "^8.2.2",
221
+ "vue": "^3.5.44",
222
+ "vue-i18n": "^11.4.16",
149
223
  "zod": "^4.3.6"
150
224
  },
151
225
  "peerDependencies": {
226
+ "@intlify/message-compiler": ">=10.0.0",
227
+ "i18next": ">=23.0.0",
228
+ "libphonenumber-js": "^1.10.0",
229
+ "vite": ">=6.0.0",
230
+ "vue-i18n": ">=10.0.0",
152
231
  "zod": "^4.3.6"
153
232
  },
154
233
  "peerDependenciesMeta": {
234
+ "@intlify/message-compiler": {
235
+ "optional": true
236
+ },
237
+ "i18next": {
238
+ "optional": true
239
+ },
240
+ "libphonenumber-js": {
241
+ "optional": true
242
+ },
243
+ "vite": {
244
+ "optional": true
245
+ },
246
+ "vue-i18n": {
247
+ "optional": true
248
+ },
155
249
  "zod": {
156
250
  "optional": true
157
251
  }
@@ -648,6 +648,58 @@ Cache API:
648
648
  The cache is also exposed on the client itself as `api.cache` for use outside
649
649
  of action handlers (e.g. clearing auth on logout).
650
650
 
651
+ ### Sharing a login across clients
652
+
653
+ `ctx.cache` lives for one client. When every request builds its own client
654
+ (one per tenant or per connection, from stored credentials), use
655
+ `ConnectionAuthCache` from `hanzio/api-wrapper/connection-auth` instead: one
656
+ module-level cache shares a single token exchange per connection across all
657
+ of them. It needs `node:crypto` (Bun, Node, Deno, or Workers with
658
+ `nodejs_compat`), so it is not part of `hanzio/api-wrapper`.
659
+
660
+ ```ts
661
+ import { ConnectionAuthCache } from 'hanzio/api-wrapper/connection-auth'
662
+
663
+ const authCache = new ConnectionAuthCache() // keeps at most 1,000 connections
664
+
665
+ export function createShopClient(connection: { id: string; apiKey: string; secret: string }) {
666
+ const auth = authCache.bind({
667
+ connectionId: connection.id,
668
+ authInputs: [connection.apiKey, connection.secret],
669
+ refreshMarginMs: 60_000,
670
+ exchange: () => login(connection) // resolves { token, expiresAt }
671
+ })
672
+
673
+ return {
674
+ async get(path: string, signal?: AbortSignal) {
675
+ for (let attempt = 0; ; attempt++) {
676
+ const current = await auth.get({ signal })
677
+ const response = await fetch(`https://shop.example.com${path}`, {
678
+ headers: { authorization: `Bearer ${current.token}` },
679
+ signal
680
+ })
681
+ // One retry with a new login when the token was refused.
682
+ if (response.status !== 401 || attempt > 0) return response
683
+ auth.reject(current)
684
+ }
685
+ }
686
+ }
687
+ }
688
+ ```
689
+
690
+ - Concurrent callers share one exchange, and aborting one caller's wait leaves
691
+ it running for the others.
692
+ - Changing any `authInputs` value (a rotated secret) retires the old token: a
693
+ client still using the old credentials can finish its request but cannot
694
+ store its token. The inputs are kept only as an HMAC under a random key.
695
+ - `reject(token)` drops a token the API refused, unless a newer login has
696
+ already replaced it. `get({ fresh: true })` always exchanges, for an explicit
697
+ credential check, and never falls back to the old token when it fails.
698
+ - `clear()` forgets everything, including exchanges still in flight.
699
+
700
+ `connectionAuthContract` in [`hanzio/testing`](../testing/README.md#connection-auth-contract)
701
+ runs these races against your client.
702
+
651
703
  ## Endpoint Configuration Reference
652
704
 
653
705
  | Option | Required | Description |
@@ -0,0 +1,142 @@
1
+ import { createHmac, randomBytes } from 'node:crypto'
2
+ import { raceAbort } from '../promise'
3
+
4
+ export type ConnectionAuthToken = { token: string; expiresAt: number }
5
+
6
+ type Entry = {
7
+ identity: string
8
+ token?: ConnectionAuthToken
9
+ pending?: Promise<ConnectionAuthToken>
10
+ }
11
+
12
+ /**
13
+ * Shares one login per connection across every client instance in the process,
14
+ * for APIs that exchange credentials for a short-lived token.
15
+ *
16
+ * - Concurrent callers share one exchange; aborting one caller's wait leaves the
17
+ * exchange running for the others.
18
+ * - Changing any of `authInputs` (rotated password, new client id) retires the
19
+ * old token: a retired client may finish its request but cannot store its
20
+ * token. Inputs are kept only as an HMAC under a random per-cache key, so
21
+ * the cache never holds, and cannot be used to guess, the credentials.
22
+ * - On a 401, `reject(token)` drops the refused token, unless a newer one has
23
+ * already replaced it, and the next `get()` exchanges again.
24
+ * - `get({ fresh: true })` always exchanges, for an explicit credential check;
25
+ * its failure never falls back to the previous token.
26
+ * - At most `maxEntries` connections are kept, least recently used first;
27
+ * connections with an exchange in flight are never evicted.
28
+ *
29
+ * @example
30
+ * const auth = new ConnectionAuthCache()
31
+ *
32
+ * class ShopApi {
33
+ * private readonly token
34
+ * constructor(credentials: { connectionId: string; apiKey: string; secret: string }) {
35
+ * this.token = auth.bind({
36
+ * connectionId: credentials.connectionId,
37
+ * authInputs: [credentials.apiKey, credentials.secret],
38
+ * refreshMarginMs: 60_000,
39
+ * exchange: () => login(credentials) // resolves { token, expiresAt }
40
+ * })
41
+ * }
42
+ * async read(signal?: AbortSignal) {
43
+ * const current = await this.token.get({ signal })
44
+ * // On a 401: this.token.reject(current), then retry once with this.token.get().
45
+ * }
46
+ * }
47
+ */
48
+ export class ConnectionAuthCache {
49
+ private readonly key = randomBytes(32)
50
+ private readonly entries = new Map<string, Entry>()
51
+
52
+ constructor(private readonly maxEntries = 1_000) {}
53
+
54
+ get size() {
55
+ return this.entries.size
56
+ }
57
+
58
+ clear() {
59
+ for (const entry of this.entries.values()) entry.token = undefined
60
+ this.entries.clear()
61
+ }
62
+
63
+ private prune(protectedEntry?: Entry) {
64
+ for (const [connectionId, entry] of this.entries) {
65
+ if (this.entries.size <= this.maxEntries) break
66
+ // In-flight work must remain discoverable by identical callers. Its
67
+ // ownership ends on settlement, when pruning runs again.
68
+ if (entry.pending || entry === protectedEntry) continue
69
+ entry.token = undefined
70
+ this.entries.delete(connectionId)
71
+ }
72
+ }
73
+
74
+ bind(input: {
75
+ connectionId: string
76
+ authInputs: readonly (string | number)[]
77
+ refreshMarginMs: number
78
+ exchange: () => Promise<ConnectionAuthToken>
79
+ }) {
80
+ const identity = createHmac('sha256', this.key)
81
+ .update(JSON.stringify(input.authInputs))
82
+ .digest('hex')
83
+ let entry = this.entries.get(input.connectionId)
84
+
85
+ if (!entry || entry.identity !== identity) {
86
+ if (entry) entry.token = undefined
87
+ entry = { identity }
88
+ }
89
+
90
+ // Map order is LRU. Eviction never cancels an exchange already in use.
91
+ this.entries.delete(input.connectionId)
92
+ this.entries.set(input.connectionId, entry)
93
+ this.prune(entry)
94
+
95
+ const state = entry
96
+ const get = (options?: { fresh?: boolean; signal?: AbortSignal }) => {
97
+ if (options?.signal?.aborted) {
98
+ return Promise.reject<ConnectionAuthToken>(options.signal.reason)
99
+ }
100
+
101
+ if (this.entries.get(input.connectionId) === state) {
102
+ this.entries.delete(input.connectionId)
103
+ this.entries.set(input.connectionId, state)
104
+ }
105
+
106
+ // Aborting cancels only this wait; the exchange belongs to every waiter.
107
+ if (state.pending) return raceAbort(state.pending, options?.signal)
108
+ if (
109
+ !options?.fresh &&
110
+ state.token &&
111
+ state.token.expiresAt - Date.now() > input.refreshMarginMs
112
+ ) {
113
+ return Promise.resolve(state.token)
114
+ }
115
+
116
+ // A fresh check cannot leave the previous token usable after failure.
117
+ state.token = undefined
118
+ const pending = input
119
+ .exchange()
120
+ .then((token) => {
121
+ if (this.entries.get(input.connectionId) === state) {
122
+ state.token = token
123
+ }
124
+ return token
125
+ })
126
+ .finally(() => {
127
+ if (state.pending === pending) state.pending = undefined
128
+ this.prune()
129
+ })
130
+ state.pending = pending
131
+
132
+ return raceAbort(pending, options?.signal)
133
+ }
134
+
135
+ return {
136
+ get,
137
+ reject(token: ConnectionAuthToken) {
138
+ if (state.token === token) state.token = undefined
139
+ }
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,220 @@
1
+ ---
2
+ description: Typed CLIs from Zod object schemas, parsed with node:util parseArgs, with generated help, completion and consistent exit codes.
3
+ ---
4
+
5
+ # hanzio/cli
6
+
7
+ Each command's `input` is a Zod object: its keys become flags, Zod validates
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.
10
+
11
+ ```ts
12
+ // src/cli.ts
13
+ import { z } from 'zod'
14
+ import { defineCli, defineCommand } from 'hanzio/cli'
15
+
16
+ export default defineCli({
17
+ name: 'better-mastra-evals',
18
+ version: '1.0.0',
19
+ commands: {
20
+ run: defineCommand({
21
+ description: 'Run eval suites',
22
+ input: z.object({
23
+ suite: z.string().meta({ positional: true }),
24
+ repeats: z.number().default(1).describe('Runs per case'),
25
+ json: z.boolean().optional()
26
+ }),
27
+ run: async (input, ctx) => {
28
+ // input: { suite: string; repeats: number; json?: boolean }
29
+ }
30
+ }),
31
+ inspect: () => import('./commands/inspect') // loaded only when used
32
+ }
33
+ })
34
+ ```
35
+
36
+ ```ts
37
+ #!/usr/bin/env node
38
+ // bin.ts
39
+ import cli from './cli.js'
40
+
41
+ process.exitCode = await cli.run()
42
+ ```
43
+
44
+ Use `process.exit(await cli.run())` instead when open database or Redis
45
+ handles would keep the process alive.
46
+
47
+ ## Flags
48
+
49
+ | Schema | Command line |
50
+ | --- | --- |
51
+ | `z.string()`, `z.enum([...])` | `--name value` or `--name=value` |
52
+ | `z.number()` | `--repeats 3`, coerced before validation |
53
+ | `z.boolean()` | `--json`; `--no-json` sets `false`; missing is `false` |
54
+ | `z.array(z.string())` | repeatable: `--model a --model b`; missing is `[]` |
55
+ | `.optional()`, `.default(x)` | may be left out |
56
+ | `.describe('…')` | help text |
57
+ | `.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 |
60
+
61
+ Keys are camelCase in the schema and kebab-case on the command line (`dryRun`
62
+ → `--dry-run`). These are the same `.meta()` keys as `trpc-cli`, plus `env`;
63
+ importing `hanzio/cli` types them on Zod's `GlobalMeta`.
64
+ Pipes read their input side, so `z.stringbool()` takes `--submit true`.
65
+
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.
68
+
69
+ ## Commands
70
+
71
+ `commands` is a single command for a one-command CLI, or an object of
72
+ commands and nested groups, so `{ check: { docs } }` runs as `tool check
73
+ docs`. Groups are plain objects, so an adapter can build them from data.
74
+
75
+ A command is one of:
76
+
77
+ - `defineCommand({ description, input, run, output })`.
78
+ - `() => import('./commands/heavy')`, a module whose default export is a
79
+ command. It loads only when it runs.
80
+ - `defineCommand({ description, load: () => import('./commands/heavy') })`,
81
+ the same with a description for the command list.
82
+ - `defineCommand({ raw: true, run })`, which skips parsing: every argument
83
+ after the command name is in `ctx.argv` (for `tool prisma migrate dev`).
84
+
85
+ `run(input, ctx)` gets:
86
+
87
+ | `ctx` | |
88
+ | --- | --- |
89
+ | `context` | what `defineCli({ context })` returned |
90
+ | `globals` | the parsed global options |
91
+ | `path` | command names, e.g. `['deal', 'list']` |
92
+ | `passthrough` | arguments after `--` (`tool exec -- bun test`) |
93
+ | `argv` | every argument after the command names, unparsed |
94
+ | `json` | `--json` was passed |
95
+ | `stdout`, `stderr`, `env` | the CLI's streams and environment |
96
+ | `exitCode` | set it to fail without an error message |
97
+
98
+ Whatever `run` returns goes to `output(result, ctx)`. The default prints
99
+ strings as they are and anything else as indented JSON, or compact JSON with
100
+ `--json`; `undefined` prints nothing.
101
+
102
+ ## Globals, context and guard
103
+
104
+ ```ts
105
+ const cli = defineCli({
106
+ name: 'app',
107
+ globals: z.object({
108
+ org: z.string().optional(),
109
+ allowProduction: z.boolean().optional(),
110
+ json: z.boolean().optional()
111
+ }),
112
+ context: async (globals) => ({ db: await connect(), org: globals.org }),
113
+ guard: ({ context, globals }) => {
114
+ if (context.db.isProduction && !globals.allowProduction)
115
+ throw new CliError('Refusing production; pass --allow-production')
116
+ },
117
+ commands: { deal: { list, get } }
118
+ })
119
+ ```
120
+
121
+ Globals are accepted before or after the command names. `context` runs after
122
+ parsing, so `--help` never connects anywhere. `guard` runs before every
123
+ command. A command defined in its own file types its context by annotating
124
+ `ctx`:
125
+
126
+ ```ts
127
+ export const list = defineCommand({
128
+ input: z.object({ limit: z.number().default(20) }),
129
+ run: (input, { context }: CommandContext<AppContext>) =>
130
+ context.db.deal.findMany({ take: input.limit })
131
+ })
132
+ ```
133
+
134
+ `defineCli` rejects a command whose context type doesn't match `context`, and
135
+ requires `context` once any command needs one.
136
+
137
+ ## Errors and exit codes
138
+
139
+ | Exit | When |
140
+ | --- | --- |
141
+ | 0 | the command finished |
142
+ | 1 | `run` threw, or set `ctx.exitCode = 1` |
143
+ | 2 | bad arguments: unknown command or flag, failed validation |
144
+
145
+ Throw `new CliError(message, { exitCode })` for expected failures: the
146
+ message is printed without a stack. Other errors print their stack. With
147
+ `--json`, errors print as `{"error": "…"}`. Usage errors name the flag:
148
+
149
+ ```
150
+ --repeats: Invalid input: expected number, received string
151
+ Run "better-mastra-evals run --help" for usage.
152
+ ```
153
+
154
+ ## Shell completion
155
+
156
+ A CLI with commands also gets `tool completion bash` and `tool completion
157
+ zsh`, which print a script that completes command names and flags:
158
+
159
+ ```sh
160
+ # in ~/.zshrc (or ~/.bashrc with bash)
161
+ source <(tool completion zsh)
162
+ ```
163
+
164
+ ## Testing
165
+
166
+ `cli.run(argv, { stdout, stderr, env })` resolves to the exit code and never
167
+ touches `process`, so tests don't spawn anything:
168
+
169
+ ```ts
170
+ let out = ''
171
+ const code = await cli.run(['run', 'smoke', '--json'], {
172
+ stdout: { write: (text) => (out += text) },
173
+ stderr: { write: () => {} },
174
+ env: {}
175
+ })
176
+ expect(code).toBe(0)
177
+ ```
178
+
179
+ ## Migrating from `parseArgs`
180
+
181
+ 1. Turn the `options` object into a `z.object`: `type: 'string'` becomes
182
+ `z.string()`, `multiple: true` becomes `z.array(...)`, `default` becomes
183
+ `.default()`, `short: 'r'` becomes `.meta({ alias: 'r' })`, and
184
+ positionals become keys with `.meta({ positional: true })`.
185
+ 2. Delete the hand-written validation (`Number(values.repeats)`, enum
186
+ checks, "--x required" errors): `z.number()`, `z.enum()` and required keys
187
+ do it, with the flag in the error.
188
+ 3. Move each `switch (positionals[0])` branch into a `defineCommand`, and its
189
+ help line into `description` / `.describe()`. Delete the `USAGE` string.
190
+ 4. Replace `console.error(...); process.exit(1)` with `throw new CliError(...)`,
191
+ and the `main().catch(...)` wrapper with `process.exitCode = await cli.run()`.
192
+
193
+ ```ts
194
+ // before
195
+ const { values, positionals } = parseArgs({
196
+ allowPositionals: true,
197
+ options: { out: { type: 'string', short: 'o' }, force: { type: 'boolean' } }
198
+ })
199
+ if (positionals[0] !== 'add' || !values.out) {
200
+ console.error(USAGE)
201
+ process.exit(2)
202
+ }
203
+ await add(positionals.slice(1), values)
204
+
205
+ // after
206
+ defineCli({
207
+ name: 'tool',
208
+ commands: {
209
+ add: defineCommand({
210
+ description: 'Copy fragments into a schema folder',
211
+ input: z.object({
212
+ fragments: z.array(z.string()).min(1).meta({ positional: true }),
213
+ out: z.string().meta({ alias: 'o' }),
214
+ force: z.boolean()
215
+ }),
216
+ run: (input) => add(input.fragments, input)
217
+ })
218
+ }
219
+ })
220
+ ```