experimental-a2 0.3.0 → 0.5.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 (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
package/src/cli.ts ADDED
@@ -0,0 +1,1046 @@
1
+ import { open as nodeOpen, readFile as nodeReadFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import {
4
+ DEVTOOLS_PROTOCOL_VERSION,
5
+ DEVTOOLS_SESSION_PAGE_LIMIT,
6
+ devtoolsSessionRevision,
7
+ parseDevtoolsCapture,
8
+ verifyDevtoolsCapture,
9
+ type DevtoolsCapture,
10
+ type DevtoolsContractsResponse,
11
+ type DevtoolsSessionDetail,
12
+ type DevtoolsSessionPage,
13
+ type DevtoolsSessionsResponse,
14
+ } from './devtools.ts'
15
+
16
+ export const DEVTOOLS_CLI_EXIT_ERROR = 1 as const
17
+ export const DEVTOOLS_CLI_EXIT_CHECK_FAILED = 2 as const
18
+
19
+ type OutputFormat = 'human' | 'json' | 'ndjson'
20
+ type Command = 'contracts' | 'sessions' | 'show' | 'export' | 'check' | 'verify'
21
+
22
+ export type DevtoolsCliOptions = {
23
+ fetch?: typeof globalThis.fetch
24
+ env?: Readonly<Record<string, string | undefined>>
25
+ cwd?: string
26
+ stdout?: (text: string) => void
27
+ stderr?: (text: string) => void
28
+ readFile?: (path: string) => Promise<Uint8Array>
29
+ writeFile?: (
30
+ path: string,
31
+ data: Uint8Array,
32
+ options: { flag: 'w' | 'wx'; mode: number },
33
+ ) => Promise<void>
34
+ }
35
+
36
+ type ParsedArguments = {
37
+ command: Command | null
38
+ help: boolean
39
+ url: string | undefined
40
+ format: OutputFormat
41
+ contract: string | undefined
42
+ session: string | undefined
43
+ cursor: string | undefined
44
+ limit: number | undefined
45
+ output: string | undefined
46
+ force: boolean
47
+ bearerEnv: string | undefined
48
+ headerEnvs: string[]
49
+ settled: boolean
50
+ noDeadLetters: boolean
51
+ noCaughtFailures: boolean
52
+ maxRedispatches: number | undefined
53
+ positionals: string[]
54
+ }
55
+
56
+ type CheckSummary = {
57
+ ok: boolean
58
+ contract: string
59
+ sessionId: string
60
+ revision: string
61
+ eventCount: number
62
+ snapshotCount: number
63
+ pendingCount: number
64
+ deadLetterCount: number
65
+ caughtFailureCount: number
66
+ redispatchCount: number
67
+ failures: string[]
68
+ }
69
+
70
+ class CliFailure extends Error {
71
+ readonly exitCode: number
72
+
73
+ constructor(message: string, exitCode: number = DEVTOOLS_CLI_EXIT_ERROR) {
74
+ super(message)
75
+ this.name = 'CliFailure'
76
+ this.exitCode = exitCode
77
+ }
78
+ }
79
+
80
+ class IntegrityFailure extends CliFailure {
81
+ constructor(message: string) {
82
+ super(message, DEVTOOLS_CLI_EXIT_CHECK_FAILED)
83
+ this.name = 'IntegrityFailure'
84
+ }
85
+ }
86
+
87
+ const USAGE = `Usage: a2 devtools <command> [options]
88
+
89
+ Commands:
90
+ contracts List mounted contracts
91
+ sessions --contract NAME List a contract's sessions
92
+ show --contract NAME --session ID
93
+ Show one durable session
94
+ export --contract NAME --session ID [--output FILE]
95
+ Save an integrity-protected .a2log capture
96
+ check --contract NAME --session ID [assertions]
97
+ Verify a live session capture
98
+ verify FILE Verify a saved .a2log capture
99
+
100
+ Connection:
101
+ --url URL Mounted devtools URL (or A2_DEVTOOLS_URL)
102
+ --bearer-env ENV Read a bearer token from ENV
103
+ --header-env HEADER=ENV Read a request header from ENV; repeatable
104
+
105
+ Output:
106
+ --format human|json|ndjson Human output is the default
107
+ --output FILE, -o FILE Export destination
108
+ --force Allow export to replace FILE
109
+
110
+ Check assertions:
111
+ --settled Require no pending events
112
+ --no-dead-letters Require no dead-lettered events
113
+ --no-caught-failures Require no caught handler failures
114
+ --max-redispatches N Limit dispatches after each event's first claim
115
+ `
116
+
117
+ const commandNames = new Set<Command>([
118
+ 'contracts',
119
+ 'sessions',
120
+ 'show',
121
+ 'export',
122
+ 'check',
123
+ 'verify',
124
+ ])
125
+
126
+ const fail = (message: string): never => {
127
+ throw new CliFailure(message)
128
+ }
129
+
130
+ const nextValue = (
131
+ argv: readonly string[],
132
+ index: number,
133
+ option: string,
134
+ ): { value: string; next: number } => {
135
+ const token = argv[index]!
136
+ const equals = token.indexOf('=')
137
+ if (equals !== -1) {
138
+ const value = token.slice(equals + 1)
139
+ if (value.length === 0) fail(`${option} requires a value`)
140
+ return { value, next: index }
141
+ }
142
+ const value = argv[index + 1]
143
+ if (value === undefined || value.startsWith('-')) {
144
+ fail(`${option} requires a value`)
145
+ }
146
+ return { value: value!, next: index + 1 }
147
+ }
148
+
149
+ const nonNegativeInteger = (value: string, option: string): number => {
150
+ const parsed = Number(value)
151
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
152
+ fail(`${option} must be a non-negative integer`)
153
+ }
154
+ return parsed
155
+ }
156
+
157
+ const positiveInteger = (value: string, option: string): number => {
158
+ const parsed = nonNegativeInteger(value, option)
159
+ if (parsed === 0) fail(`${option} must be a positive integer`)
160
+ return parsed
161
+ }
162
+
163
+ const parseArguments = (argv: readonly string[]): ParsedArguments => {
164
+ if (argv[0] !== 'devtools') {
165
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
166
+ return {
167
+ command: null,
168
+ help: true,
169
+ url: undefined,
170
+ format: 'human',
171
+ contract: undefined,
172
+ session: undefined,
173
+ cursor: undefined,
174
+ limit: undefined,
175
+ output: undefined,
176
+ force: false,
177
+ bearerEnv: undefined,
178
+ headerEnvs: [],
179
+ settled: false,
180
+ noDeadLetters: false,
181
+ noCaughtFailures: false,
182
+ maxRedispatches: undefined,
183
+ positionals: [],
184
+ }
185
+ }
186
+ fail("expected the 'devtools' command namespace")
187
+ }
188
+
189
+ const parsed: ParsedArguments = {
190
+ command: null,
191
+ help: false,
192
+ url: undefined,
193
+ format: 'human',
194
+ contract: undefined,
195
+ session: undefined,
196
+ cursor: undefined,
197
+ limit: undefined,
198
+ output: undefined,
199
+ force: false,
200
+ bearerEnv: undefined,
201
+ headerEnvs: [],
202
+ settled: false,
203
+ noDeadLetters: false,
204
+ noCaughtFailures: false,
205
+ maxRedispatches: undefined,
206
+ positionals: [],
207
+ }
208
+
209
+ for (let index = 1; index < argv.length; index += 1) {
210
+ const token = argv[index]!
211
+ if (token === '--help' || token === '-h' || token === 'help') {
212
+ parsed.help = true
213
+ continue
214
+ }
215
+ if (
216
+ token === '--token' ||
217
+ token.startsWith('--token=') ||
218
+ token === '--bearer' ||
219
+ token.startsWith('--bearer=') ||
220
+ token === '--header' ||
221
+ token.startsWith('--header=')
222
+ ) {
223
+ fail(
224
+ 'literal credential flags are not supported; use A2_DEVTOOLS_TOKEN, --bearer-env, or --header-env',
225
+ )
226
+ }
227
+ if (token === '--force') {
228
+ parsed.force = true
229
+ continue
230
+ }
231
+ if (token === '--settled') {
232
+ parsed.settled = true
233
+ continue
234
+ }
235
+ if (token === '--no-dead-letters') {
236
+ parsed.noDeadLetters = true
237
+ continue
238
+ }
239
+ if (token === '--no-caught-failures') {
240
+ parsed.noCaughtFailures = true
241
+ continue
242
+ }
243
+
244
+ const option = token.split('=', 1)[0]!
245
+ if (
246
+ option === '--url' ||
247
+ option === '--format' ||
248
+ option === '--contract' ||
249
+ option === '--session' ||
250
+ option === '--cursor' ||
251
+ option === '--limit' ||
252
+ option === '--output' ||
253
+ option === '-o' ||
254
+ option === '--bearer-env' ||
255
+ option === '--header-env' ||
256
+ option === '--max-redispatches'
257
+ ) {
258
+ const result = nextValue(argv, index, option)
259
+ index = result.next
260
+ switch (option) {
261
+ case '--url':
262
+ parsed.url = result.value
263
+ break
264
+ case '--format':
265
+ if (
266
+ result.value !== 'human' &&
267
+ result.value !== 'json' &&
268
+ result.value !== 'ndjson'
269
+ ) {
270
+ fail('--format must be human, json, or ndjson')
271
+ }
272
+ parsed.format = result.value as OutputFormat
273
+ break
274
+ case '--contract':
275
+ parsed.contract = result.value
276
+ break
277
+ case '--session':
278
+ parsed.session = result.value
279
+ break
280
+ case '--cursor':
281
+ parsed.cursor = result.value
282
+ break
283
+ case '--limit':
284
+ parsed.limit = positiveInteger(result.value, '--limit')
285
+ break
286
+ case '--output':
287
+ case '-o':
288
+ parsed.output = result.value
289
+ break
290
+ case '--bearer-env':
291
+ parsed.bearerEnv = result.value
292
+ break
293
+ case '--header-env':
294
+ parsed.headerEnvs.push(result.value)
295
+ break
296
+ case '--max-redispatches':
297
+ parsed.maxRedispatches = nonNegativeInteger(
298
+ result.value,
299
+ '--max-redispatches',
300
+ )
301
+ break
302
+ }
303
+ continue
304
+ }
305
+ if (token.startsWith('-')) fail(`unknown option '${token}'`)
306
+ if (parsed.command === null && commandNames.has(token as Command)) {
307
+ parsed.command = token as Command
308
+ continue
309
+ }
310
+ if (parsed.command === null) fail(`unknown devtools command '${token}'`)
311
+ parsed.positionals.push(token)
312
+ }
313
+ return parsed
314
+ }
315
+
316
+ const required = (value: string | undefined, option: string): string => {
317
+ if (value === undefined) fail(`${option} is required`)
318
+ return value!
319
+ }
320
+
321
+ const assertCommandArguments = (args: ParsedArguments): void => {
322
+ if (args.command === null) return
323
+ if (args.command === 'verify') {
324
+ if (args.positionals.length !== 1) {
325
+ fail('verify requires exactly one capture file')
326
+ }
327
+ return
328
+ }
329
+ if (args.positionals.length > 0) {
330
+ fail(`unexpected argument '${args.positionals[0]}'`)
331
+ }
332
+ if (args.command !== 'contracts') {
333
+ required(args.contract, '--contract')
334
+ }
335
+ if (
336
+ args.command === 'show' ||
337
+ args.command === 'export' ||
338
+ args.command === 'check'
339
+ ) {
340
+ required(args.session, '--session')
341
+ }
342
+ }
343
+
344
+ const baseUrl = (value: string): URL => {
345
+ const url = (() => {
346
+ try {
347
+ return new URL(value)
348
+ } catch {
349
+ return fail('--url must be an absolute HTTP(S) URL')
350
+ }
351
+ })()
352
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
353
+ fail('--url must use HTTP or HTTPS')
354
+ }
355
+ if (url.username || url.password) {
356
+ fail('credentials must not be embedded in --url')
357
+ }
358
+ if (url.search || url.hash) {
359
+ fail('--url must not contain a query string or fragment')
360
+ }
361
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/`
362
+ return url
363
+ }
364
+
365
+ const environmentName = (value: string, option: string): string => {
366
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
367
+ fail(`${option} must reference a valid environment variable name`)
368
+ }
369
+ return value
370
+ }
371
+
372
+ const requestHeaders = (
373
+ args: ParsedArguments,
374
+ env: Readonly<Record<string, string | undefined>>,
375
+ accept: string,
376
+ ): Headers => {
377
+ const headers = new Headers({ accept })
378
+ const bearerName =
379
+ args.bearerEnv === undefined
380
+ ? 'A2_DEVTOOLS_TOKEN'
381
+ : environmentName(args.bearerEnv, '--bearer-env')
382
+ const bearer = env[bearerName]
383
+ if (args.bearerEnv !== undefined && !bearer) {
384
+ fail(`environment variable '${bearerName}' is not set`)
385
+ }
386
+ if (bearer) headers.set('authorization', `Bearer ${bearer}`)
387
+
388
+ for (const mapping of args.headerEnvs) {
389
+ const equals = mapping.indexOf('=')
390
+ if (equals < 1 || equals === mapping.length - 1) {
391
+ fail('--header-env must use HEADER=ENV')
392
+ }
393
+ const name = mapping.slice(0, equals)
394
+ const envName = environmentName(mapping.slice(equals + 1), '--header-env')
395
+ const value = env[envName]
396
+ if (!value) fail(`environment variable '${envName}' is not set`)
397
+ if (headers.has(name)) fail(`request header '${name}' was configured twice`)
398
+ try {
399
+ headers.set(name, value!)
400
+ } catch {
401
+ fail(`--header-env contains an invalid header name or value`)
402
+ }
403
+ }
404
+ return headers
405
+ }
406
+
407
+ const resourceUrl = (
408
+ base: URL,
409
+ resource: string,
410
+ params: Readonly<Record<string, string | number | undefined>>,
411
+ ): URL => {
412
+ const url = new URL(`_a2/${resource}`, base)
413
+ for (const [name, value] of Object.entries(params)) {
414
+ if (value !== undefined) url.searchParams.set(name, String(value))
415
+ }
416
+ return url
417
+ }
418
+
419
+ const terminalText = (value: string): string =>
420
+ [...value]
421
+ .map((character) => {
422
+ const codePoint = character.codePointAt(0)!
423
+ const isControl =
424
+ codePoint <= 0x1f ||
425
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
426
+ codePoint === 0x2028 ||
427
+ codePoint === 0x2029
428
+ if (!isControl) return character
429
+ if (character === '\n') return '\\n'
430
+ if (character === '\r') return '\\r'
431
+ if (character === '\t') return '\\t'
432
+ return codePoint <= 0xff
433
+ ? `\\x${codePoint.toString(16).padStart(2, '0')}`
434
+ : `\\u${codePoint.toString(16).padStart(4, '0')}`
435
+ })
436
+ .join('')
437
+
438
+ const fetchResponse = async (
439
+ fetcher: typeof globalThis.fetch,
440
+ url: URL,
441
+ headers: Headers,
442
+ resource: string,
443
+ ): Promise<Response> => {
444
+ let response: Response
445
+ try {
446
+ response = await fetcher(url, {
447
+ method: 'GET',
448
+ headers,
449
+ redirect: 'manual',
450
+ })
451
+ } catch (error) {
452
+ const message = error instanceof Error ? error.message : String(error)
453
+ throw new CliFailure(`could not fetch ${resource}: ${message}`)
454
+ }
455
+ if (!response.ok) {
456
+ let text = ''
457
+ try {
458
+ text = await response.text()
459
+ } catch {
460
+ // The status remains useful when an error response has an unreadable body.
461
+ }
462
+ const detail = terminalText(text.trim().slice(0, 300))
463
+ throw new CliFailure(
464
+ `${resource} returned ${response.status}${detail ? `: ${detail}` : ''}`,
465
+ )
466
+ }
467
+ return response
468
+ }
469
+
470
+ const responseText = async (
471
+ fetcher: typeof globalThis.fetch,
472
+ url: URL,
473
+ headers: Headers,
474
+ resource: string,
475
+ ): Promise<string> =>
476
+ (await fetchResponse(fetcher, url, headers, resource)).text()
477
+
478
+ const responseBytes = async (
479
+ fetcher: typeof globalThis.fetch,
480
+ url: URL,
481
+ headers: Headers,
482
+ resource: string,
483
+ ): Promise<Uint8Array> =>
484
+ new Uint8Array(
485
+ await (await fetchResponse(fetcher, url, headers, resource)).arrayBuffer(),
486
+ )
487
+
488
+ const objectValue = (value: unknown): Record<string, unknown> | null =>
489
+ typeof value === 'object' && value !== null && !Array.isArray(value)
490
+ ? (value as Record<string, unknown>)
491
+ : null
492
+
493
+ const parseJson = (text: string, resource: string): unknown => {
494
+ try {
495
+ return JSON.parse(text) as unknown
496
+ } catch {
497
+ throw new CliFailure(`${resource} did not return valid JSON`)
498
+ }
499
+ }
500
+
501
+ const assertProtocol = (
502
+ value: Record<string, unknown>,
503
+ resource: string,
504
+ ): void => {
505
+ if (value['protocolVersion'] !== DEVTOOLS_PROTOCOL_VERSION) {
506
+ throw new CliFailure(`${resource} returned an unsupported protocol version`)
507
+ }
508
+ }
509
+
510
+ const hasContractName = (value: unknown): boolean => {
511
+ const object = objectValue(value)
512
+ return typeof object?.['name'] === 'string' && object['name'].length > 0
513
+ }
514
+
515
+ const contractsResponse = (value: unknown): DevtoolsContractsResponse => {
516
+ const object = objectValue(value)
517
+ if (!object) throw new CliFailure('contracts returned an invalid response')
518
+ assertProtocol(object, 'contracts')
519
+ if (
520
+ !Array.isArray(object['contracts']) ||
521
+ !object['contracts'].every(hasContractName)
522
+ ) {
523
+ throw new CliFailure('contracts returned an invalid response')
524
+ }
525
+ return object as DevtoolsContractsResponse
526
+ }
527
+
528
+ const sessionsResponse = (value: unknown): DevtoolsSessionsResponse => {
529
+ const object = objectValue(value)
530
+ if (!object) throw new CliFailure('sessions returned an invalid response')
531
+ assertProtocol(object, 'sessions')
532
+ if (
533
+ !Array.isArray(object['sessions']) ||
534
+ (object['cursor'] !== null && typeof object['cursor'] !== 'string')
535
+ ) {
536
+ throw new CliFailure('sessions returned an invalid response')
537
+ }
538
+ return object as DevtoolsSessionsResponse
539
+ }
540
+
541
+ const sessionResponse = (value: unknown): DevtoolsSessionDetail => {
542
+ const object = objectValue(value)
543
+ if (!object) throw new CliFailure('session returned an invalid response')
544
+ assertProtocol(object, 'session')
545
+ if (
546
+ typeof object['contract'] !== 'string' ||
547
+ typeof object['sessionId'] !== 'string' ||
548
+ typeof object['revision'] !== 'string' ||
549
+ !Array.isArray(object['events']) ||
550
+ !Array.isArray(object['snapshots'])
551
+ ) {
552
+ throw new CliFailure('session returned an invalid response')
553
+ }
554
+ return object as DevtoolsSessionDetail
555
+ }
556
+
557
+ const sessionPageResponse = (value: unknown): DevtoolsSessionPage => {
558
+ const object = objectValue(value)
559
+ if (!object) throw new CliFailure('session returned an invalid response')
560
+ assertProtocol(object, 'session')
561
+ if (
562
+ typeof object['contract'] !== 'string' ||
563
+ typeof object['sessionId'] !== 'string' ||
564
+ !Number.isSafeInteger(object['throughIndex']) ||
565
+ (object['throughIndex'] as number) < 0 ||
566
+ (object['cursor'] !== null && typeof object['cursor'] !== 'string') ||
567
+ !Array.isArray(object['events']) ||
568
+ !Array.isArray(object['snapshots'])
569
+ ) {
570
+ throw new CliFailure('session returned an invalid response')
571
+ }
572
+ return object as DevtoolsSessionPage
573
+ }
574
+
575
+ const readSessionDetail = async (
576
+ fetcher: typeof globalThis.fetch,
577
+ base: URL,
578
+ headers: Headers,
579
+ contract: string,
580
+ session: string,
581
+ ): Promise<DevtoolsSessionDetail> => {
582
+ const events: DevtoolsSessionDetail['events'] = []
583
+ let snapshots: DevtoolsSessionDetail['snapshots'] = []
584
+ let cursor: string | undefined
585
+ let throughIndex: number | undefined
586
+ const seenCursors = new Set<string>()
587
+ for (;;) {
588
+ // oxlint-disable-next-line no-await-in-loop -- each HTTP request advances one frozen session page
589
+ const text = await responseText(
590
+ fetcher,
591
+ resourceUrl(base, 'session', {
592
+ contract,
593
+ session,
594
+ limit: DEVTOOLS_SESSION_PAGE_LIMIT,
595
+ cursor,
596
+ }),
597
+ headers,
598
+ 'session',
599
+ )
600
+ const parsed = parseJson(text, 'session')
601
+ const object = objectValue(parsed)
602
+ if (object && typeof object['revision'] === 'string') {
603
+ if (events.length > 0) {
604
+ throw new CliFailure('session changed response shape between pages')
605
+ }
606
+ return sessionResponse(parsed)
607
+ }
608
+ const page = sessionPageResponse(parsed)
609
+ if (page.contract !== contract || page.sessionId !== session) {
610
+ throw new CliFailure('session page returned a different identity')
611
+ }
612
+ if (throughIndex === undefined) {
613
+ throughIndex = page.throughIndex
614
+ snapshots = page.snapshots
615
+ } else if (
616
+ page.throughIndex !== throughIndex ||
617
+ page.snapshots.length > 0
618
+ ) {
619
+ throw new CliFailure('session page changed its frozen frontier')
620
+ }
621
+ for (const event of page.events) {
622
+ if (event.index !== events.length + 1 || event.index > throughIndex) {
623
+ throw new CliFailure('session event pages are not contiguous')
624
+ }
625
+ events.push(event)
626
+ }
627
+ if (page.cursor === null) break
628
+ if (
629
+ page.events.length === 0 ||
630
+ events.length === throughIndex ||
631
+ seenCursors.has(page.cursor)
632
+ ) {
633
+ throw new CliFailure('session cursor did not advance')
634
+ }
635
+ seenCursors.add(page.cursor)
636
+ cursor = page.cursor
637
+ }
638
+ if (throughIndex !== events.length) {
639
+ throw new CliFailure('session ended before its frozen frontier')
640
+ }
641
+ return {
642
+ protocolVersion: DEVTOOLS_PROTOCOL_VERSION,
643
+ contract,
644
+ sessionId: session,
645
+ revision: await devtoolsSessionRevision({ events, snapshots }),
646
+ events,
647
+ snapshots,
648
+ }
649
+ }
650
+
651
+ const jsonText = (value: unknown): string =>
652
+ `${JSON.stringify(value, null, 2)}\n`
653
+
654
+ const ndjsonText = (values: readonly unknown[]): string =>
655
+ values.map((value) => JSON.stringify(value)).join('\n') +
656
+ (values.length === 0 ? '' : '\n')
657
+
658
+ const humanContracts = (response: DevtoolsContractsResponse): string =>
659
+ response.contracts.length === 0
660
+ ? 'No contracts.\n'
661
+ : `${response.contracts.map((contract) => terminalText(contract.name)).join('\n')}\n`
662
+
663
+ const humanSessions = (response: DevtoolsSessionsResponse): string => {
664
+ if (response.sessions.length === 0) return 'No sessions.\n'
665
+ const rows = response.sessions.map((session) =>
666
+ [
667
+ terminalText(session.sessionId),
668
+ session.eventCount,
669
+ session.pendingCount,
670
+ session.failedCount,
671
+ session.attemptCount,
672
+ session.failureCount,
673
+ terminalText(session.updatedAt),
674
+ ].join('\t'),
675
+ )
676
+ return (
677
+ [
678
+ 'SESSION\tEVENTS\tPENDING\tDEAD\tATTEMPTS\tFAILURES\tUPDATED',
679
+ ...rows,
680
+ ].join('\n') + '\n'
681
+ )
682
+ }
683
+
684
+ const humanSession = (detail: DevtoolsSessionDetail): string => {
685
+ const rows = detail.events.map((event) => {
686
+ const status = event.failedAt
687
+ ? 'dead-lettered'
688
+ : event.processedAt
689
+ ? 'completed'
690
+ : 'pending'
691
+ return `${event.index}\t${status}\t${terminalText(event.type)}\t${terminalText(event.id)}`
692
+ })
693
+ const heading = `${terminalText(detail.contract)}/${terminalText(detail.sessionId)}: ${detail.events.length} events, ${detail.snapshots.length} snapshots\n`
694
+ return rows.length === 0
695
+ ? heading
696
+ : `${heading}INDEX\tSTATUS\tTYPE\tID\n${rows.join('\n')}\n`
697
+ }
698
+
699
+ const emitResponse = (
700
+ format: OutputFormat,
701
+ value: unknown,
702
+ ndjson: readonly unknown[],
703
+ human: string,
704
+ stdout: (text: string) => void,
705
+ ): void => {
706
+ if (format === 'json') stdout(jsonText(value))
707
+ else if (format === 'ndjson') stdout(ndjsonText(ndjson))
708
+ else stdout(human)
709
+ }
710
+
711
+ const parseAndVerifyCapture = async (
712
+ bytes: Uint8Array,
713
+ ): Promise<DevtoolsCapture> => {
714
+ let text: string
715
+ try {
716
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
717
+ } catch {
718
+ throw new IntegrityFailure('capture is not valid UTF-8')
719
+ }
720
+ try {
721
+ const capture = parseDevtoolsCapture(text)
722
+ await verifyDevtoolsCapture(capture)
723
+ return capture
724
+ } catch (error) {
725
+ const message = error instanceof Error ? error.message : String(error)
726
+ throw new IntegrityFailure(message)
727
+ }
728
+ }
729
+
730
+ const captureSummary = (capture: DevtoolsCapture): CheckSummary => {
731
+ let pendingCount = 0
732
+ let deadLetterCount = 0
733
+ let caughtFailureCount = 0
734
+ let redispatchCount = 0
735
+ for (const event of capture.events) {
736
+ if (event.processedAt === null && event.failedAt === null) pendingCount += 1
737
+ if (event.failedAt !== null) deadLetterCount += 1
738
+ if (
739
+ !Number.isSafeInteger(event.failureCount) ||
740
+ event.failureCount < 0 ||
741
+ !Number.isSafeInteger(event.attemptCount) ||
742
+ event.attemptCount < 0
743
+ ) {
744
+ throw new IntegrityFailure(
745
+ `event #${event.index} has invalid attempt or failure counts`,
746
+ )
747
+ }
748
+ caughtFailureCount += event.failureCount
749
+ redispatchCount += Math.max(0, event.attemptCount - 1)
750
+ }
751
+ return {
752
+ ok: true,
753
+ contract: capture.contract,
754
+ sessionId: capture.sessionId,
755
+ revision: capture.revision,
756
+ eventCount: capture.events.length,
757
+ snapshotCount: capture.snapshots.length,
758
+ pendingCount,
759
+ deadLetterCount,
760
+ caughtFailureCount,
761
+ redispatchCount,
762
+ failures: [],
763
+ }
764
+ }
765
+
766
+ const checkSummary = (
767
+ capture: DevtoolsCapture,
768
+ args: ParsedArguments,
769
+ ): CheckSummary => {
770
+ const summary = captureSummary(capture)
771
+ if (capture.contract !== args.contract) {
772
+ summary.failures.push(
773
+ `capture contract '${capture.contract}' does not match '${args.contract}'`,
774
+ )
775
+ }
776
+ if (capture.sessionId !== args.session) {
777
+ summary.failures.push(
778
+ `capture session '${capture.sessionId}' does not match '${args.session}'`,
779
+ )
780
+ }
781
+ if (args.settled && summary.pendingCount > 0) {
782
+ summary.failures.push(`${summary.pendingCount} event(s) are pending`)
783
+ }
784
+ if (args.noDeadLetters && summary.deadLetterCount > 0) {
785
+ summary.failures.push(
786
+ `${summary.deadLetterCount} event(s) are dead-lettered`,
787
+ )
788
+ }
789
+ if (args.noCaughtFailures && summary.caughtFailureCount > 0) {
790
+ summary.failures.push(
791
+ `${summary.caughtFailureCount} caught handler failure(s) were recorded`,
792
+ )
793
+ }
794
+ if (
795
+ args.maxRedispatches !== undefined &&
796
+ summary.redispatchCount > args.maxRedispatches
797
+ ) {
798
+ summary.failures.push(
799
+ `${summary.redispatchCount} redispatch(es) exceed the limit of ${args.maxRedispatches}`,
800
+ )
801
+ }
802
+ summary.ok = summary.failures.length === 0
803
+ return summary
804
+ }
805
+
806
+ const assertCaptureIdentity = (
807
+ capture: DevtoolsCapture,
808
+ contract: string,
809
+ sessionId: string,
810
+ ): void => {
811
+ if (capture.contract !== contract) {
812
+ throw new IntegrityFailure(
813
+ `capture contract '${capture.contract}' does not match '${contract}'`,
814
+ )
815
+ }
816
+ if (capture.sessionId !== sessionId) {
817
+ throw new IntegrityFailure(
818
+ `capture session '${capture.sessionId}' does not match '${sessionId}'`,
819
+ )
820
+ }
821
+ }
822
+
823
+ const humanCheck = (summary: CheckSummary, verb: string): string =>
824
+ `${verb} ${terminalText(summary.contract)}/${terminalText(summary.sessionId)}: ${summary.eventCount} events, ${summary.snapshotCount} snapshots, ${summary.pendingCount} pending, ${summary.deadLetterCount} dead-lettered, ${summary.caughtFailureCount} caught failures, ${summary.redispatchCount} redispatches\n`
825
+
826
+ const defaultCaptureName = (contract: string, sessionId: string): string => {
827
+ const safe = `${contract}-${sessionId}`
828
+ .replaceAll(/[^a-zA-Z0-9._-]/g, '_')
829
+ .slice(0, 160)
830
+ return `${safe || 'a2-session'}.a2log`
831
+ }
832
+
833
+ const errorCode = (error: unknown): string | undefined => {
834
+ const object = objectValue(error)
835
+ return typeof object?.['code'] === 'string' ? object['code'] : undefined
836
+ }
837
+
838
+ const writeCaptureFile = async (
839
+ path: string,
840
+ data: Uint8Array,
841
+ options: { flag: 'w' | 'wx'; mode: number },
842
+ ): Promise<void> => {
843
+ const file = await nodeOpen(path, options.flag, options.mode)
844
+ try {
845
+ await file.chmod(options.mode)
846
+ await file.writeFile(data)
847
+ } finally {
848
+ await file.close()
849
+ }
850
+ }
851
+
852
+ /** Run the `a2 devtools` command namespace and return its process exit code. */
853
+ export async function runCli(
854
+ argv: readonly string[],
855
+ options: DevtoolsCliOptions = {},
856
+ ): Promise<number> {
857
+ const stdout =
858
+ options.stdout ?? ((text: string) => process.stdout.write(text))
859
+ const stderr =
860
+ options.stderr ?? ((text: string) => process.stderr.write(text))
861
+ const env = options.env ?? process.env
862
+ const fetcher = options.fetch ?? globalThis.fetch
863
+ const cwd = options.cwd ?? process.cwd()
864
+ const readFile = options.readFile ?? nodeReadFile
865
+ const writeFile = options.writeFile ?? writeCaptureFile
866
+
867
+ try {
868
+ const args = parseArguments(argv)
869
+ if (args.help || args.command === null) {
870
+ stdout(USAGE)
871
+ return 0
872
+ }
873
+ assertCommandArguments(args)
874
+
875
+ if (args.command === 'verify') {
876
+ const path = resolve(cwd, args.positionals[0]!)
877
+ let bytes: Uint8Array
878
+ try {
879
+ bytes = await readFile(path)
880
+ } catch (error) {
881
+ const message = error instanceof Error ? error.message : String(error)
882
+ throw new CliFailure(`could not read '${path}': ${message}`)
883
+ }
884
+ const summary = captureSummary(await parseAndVerifyCapture(bytes))
885
+ emitResponse(
886
+ args.format,
887
+ summary,
888
+ [summary],
889
+ humanCheck(summary, 'Verified'),
890
+ stdout,
891
+ )
892
+ return 0
893
+ }
894
+
895
+ const configuredUrl = args.url ?? env['A2_DEVTOOLS_URL']
896
+ if (!configuredUrl) {
897
+ fail('--url or A2_DEVTOOLS_URL is required')
898
+ }
899
+ const base = baseUrl(configuredUrl!)
900
+ const headers = requestHeaders(args, env, 'application/json')
901
+
902
+ if (args.command === 'contracts') {
903
+ const text = await responseText(
904
+ fetcher,
905
+ resourceUrl(base, 'contracts', {}),
906
+ headers,
907
+ 'contracts',
908
+ )
909
+ const response = contractsResponse(parseJson(text, 'contracts'))
910
+ emitResponse(
911
+ args.format,
912
+ response,
913
+ response.contracts,
914
+ humanContracts(response),
915
+ stdout,
916
+ )
917
+ return 0
918
+ }
919
+
920
+ const contract = required(args.contract, '--contract')
921
+ if (args.command === 'sessions') {
922
+ const text = await responseText(
923
+ fetcher,
924
+ resourceUrl(base, 'sessions', {
925
+ contract,
926
+ cursor: args.cursor,
927
+ limit: args.limit,
928
+ }),
929
+ headers,
930
+ 'sessions',
931
+ )
932
+ const response = sessionsResponse(parseJson(text, 'sessions'))
933
+ emitResponse(
934
+ args.format,
935
+ response,
936
+ response.sessions,
937
+ humanSessions(response),
938
+ stdout,
939
+ )
940
+ return 0
941
+ }
942
+
943
+ const session = required(args.session, '--session')
944
+ if (args.command === 'show') {
945
+ const detail = await readSessionDetail(
946
+ fetcher,
947
+ base,
948
+ headers,
949
+ contract,
950
+ session,
951
+ )
952
+ emitResponse(
953
+ args.format,
954
+ detail,
955
+ [
956
+ {
957
+ kind: 'session',
958
+ protocolVersion: detail.protocolVersion,
959
+ contract: detail.contract,
960
+ sessionId: detail.sessionId,
961
+ revision: detail.revision,
962
+ },
963
+ ...detail.events.map((event) => ({ ...event, kind: 'event' })),
964
+ ...detail.snapshots.map((snapshot) => ({
965
+ ...snapshot,
966
+ kind: 'snapshot',
967
+ })),
968
+ ],
969
+ humanSession(detail),
970
+ stdout,
971
+ )
972
+ return 0
973
+ }
974
+
975
+ const exportHeaders = requestHeaders(args, env, 'application/x-ndjson')
976
+ const captureBytes = await responseBytes(
977
+ fetcher,
978
+ resourceUrl(base, 'export', { contract, session }),
979
+ exportHeaders,
980
+ 'export',
981
+ )
982
+ const capture = await parseAndVerifyCapture(captureBytes)
983
+
984
+ if (args.command === 'check') {
985
+ const summary = checkSummary(capture, args)
986
+ emitResponse(
987
+ args.format,
988
+ summary,
989
+ [summary],
990
+ humanCheck(summary, summary.ok ? 'Checked' : 'Check failed for'),
991
+ stdout,
992
+ )
993
+ if (!summary.ok) {
994
+ for (const failure of summary.failures) {
995
+ stderr(`a2 devtools: ${terminalText(failure)}\n`)
996
+ }
997
+ return DEVTOOLS_CLI_EXIT_CHECK_FAILED
998
+ }
999
+ return 0
1000
+ }
1001
+
1002
+ assertCaptureIdentity(capture, contract, session)
1003
+
1004
+ const output = resolve(
1005
+ cwd,
1006
+ args.output ?? defaultCaptureName(contract, session),
1007
+ )
1008
+ try {
1009
+ await writeFile(output, captureBytes, {
1010
+ flag: args.force ? 'w' : 'wx',
1011
+ mode: 0o600,
1012
+ })
1013
+ } catch (error) {
1014
+ if (errorCode(error) === 'EEXIST') {
1015
+ throw new CliFailure(
1016
+ `'${output}' already exists; pass --force to replace it`,
1017
+ )
1018
+ }
1019
+ const message = error instanceof Error ? error.message : String(error)
1020
+ throw new CliFailure(`could not write '${output}': ${message}`)
1021
+ }
1022
+ const result = {
1023
+ path: output,
1024
+ contract: capture.contract,
1025
+ sessionId: capture.sessionId,
1026
+ eventCount: capture.events.length,
1027
+ snapshotCount: capture.snapshots.length,
1028
+ sha256: capture.footer.sha256,
1029
+ }
1030
+ emitResponse(
1031
+ args.format,
1032
+ result,
1033
+ [result],
1034
+ `Exported ${terminalText(capture.contract)}/${terminalText(capture.sessionId)}: ${capture.events.length} events, ${capture.snapshots.length} snapshots -> ${terminalText(output)}\n`,
1035
+ stdout,
1036
+ )
1037
+ return 0
1038
+ } catch (error) {
1039
+ const failure =
1040
+ error instanceof CliFailure
1041
+ ? error
1042
+ : new CliFailure(error instanceof Error ? error.message : String(error))
1043
+ stderr(`a2 devtools: ${terminalText(failure.message)}\n`)
1044
+ return failure.exitCode
1045
+ }
1046
+ }