mirab 0.0.0-stage → 0.2.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 (105) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +226 -2
  3. package/package.json +58 -4
  4. package/src/agent/approved.ts +261 -0
  5. package/src/agent/define.ts +235 -0
  6. package/src/agent/guest.ts +138 -0
  7. package/src/agent/init.ts +150 -0
  8. package/src/agent/manifest.ts +141 -0
  9. package/src/agent/modules.ts +23 -0
  10. package/src/agent/runtime.ts +239 -0
  11. package/src/agent-profiles.ts +327 -0
  12. package/src/api/bootstrap-contract.ts +95 -0
  13. package/src/api/channel-contract.ts +19 -0
  14. package/src/api/client.ts +100 -0
  15. package/src/api/contract.ts +2454 -0
  16. package/src/api/events-contract.ts +103 -0
  17. package/src/api/import-contract.ts +22 -0
  18. package/src/api/orchestrator-contract.ts +73 -0
  19. package/src/api/runner-contract.ts +25 -0
  20. package/src/api/scopes.ts +41 -0
  21. package/src/api/sessions-contract.ts +71 -0
  22. package/src/api/settings-contract.ts +50 -0
  23. package/src/channel/bridge.ts +97 -0
  24. package/src/channel/local.ts +154 -0
  25. package/src/channel/state.ts +11 -0
  26. package/src/channel/wire.ts +104 -0
  27. package/src/dev-sync.ts +206 -0
  28. package/src/dev.ts +453 -0
  29. package/src/events-tail.ts +75 -0
  30. package/src/images.ts +498 -0
  31. package/src/index.ts +21 -0
  32. package/src/invocation.ts +34 -0
  33. package/src/isolation/anchored-fs.ts +284 -0
  34. package/src/isolation/colima.ts +159 -0
  35. package/src/isolation/container.ts +189 -0
  36. package/src/isolation/firecracker.ts +414 -0
  37. package/src/isolation/guest-protocol.json +1 -0
  38. package/src/isolation/host-user.ts +247 -0
  39. package/src/isolation/image.ts +336 -0
  40. package/src/isolation/index.ts +477 -0
  41. package/src/isolation/multipass.ts +541 -0
  42. package/src/isolation/network.ts +167 -0
  43. package/src/isolation/policy.ts +86 -0
  44. package/src/isolation/session.ts +385 -0
  45. package/src/isolation/sync.ts +329 -0
  46. package/src/isolation/vz-network-setup.ts +247 -0
  47. package/src/isolation/vz.ts +312 -0
  48. package/src/mirab-main.ts +536 -0
  49. package/src/mirab.ts +13 -0
  50. package/src/runner/define.ts +187 -0
  51. package/src/runner/image.ts +239 -0
  52. package/src/runner/index.ts +37 -0
  53. package/src/runner/serve.ts +491 -0
  54. package/src/runner/toolsets.ts +205 -0
  55. package/src/spawn-adapters/claude.ts +112 -0
  56. package/src/spawn-adapters/codex-logs.ts +130 -0
  57. package/src/spawn-adapters/codex-notify.ts +73 -0
  58. package/src/spawn-adapters/codex.ts +89 -0
  59. package/src/spawn-adapters/index.ts +40 -0
  60. package/src/spawn-adapters/types.ts +55 -0
  61. package/src/spawn-attach.ts +243 -0
  62. package/src/spawn-binary.ts +90 -0
  63. package/src/spawn-bridge.ts +504 -0
  64. package/src/spawn-bundle.ts +16 -0
  65. package/src/spawn-detach.ts +301 -0
  66. package/src/spawn-events.ts +35 -0
  67. package/src/spawn-hook.ts +86 -0
  68. package/src/spawn-input.ts +107 -0
  69. package/src/spawn-native.ts +49 -0
  70. package/src/spawn-nudge-worker.ts +61 -0
  71. package/src/spawn-nudge.ts +446 -0
  72. package/src/spawn-output.ts +45 -0
  73. package/src/spawn-queue.ts +135 -0
  74. package/src/spawn-quiet.ts +69 -0
  75. package/src/spawn-report.ts +114 -0
  76. package/src/spawn-sessions.ts +166 -0
  77. package/src/spawn-vm.ts +356 -0
  78. package/src/spawn-worker.ts +766 -0
  79. package/src/spawn.ts +468 -0
  80. package/src/station/config.ts +89 -0
  81. package/src/station/gateway.ts +94 -0
  82. package/src/station/vault.ts +96 -0
  83. package/src/test-cli.ts +90 -0
  84. package/src/tools.ts +3 -0
  85. package/types/agent/define.d.ts +76 -0
  86. package/types/agent/manifest.d.ts +53 -0
  87. package/types/agent/modules.d.ts +1 -0
  88. package/types/images.d.ts +124 -0
  89. package/types/index.d.ts +6 -0
  90. package/types/isolation/colima.d.ts +37 -0
  91. package/types/isolation/container.d.ts +25 -0
  92. package/types/isolation/firecracker.d.ts +55 -0
  93. package/types/isolation/host-user.d.ts +47 -0
  94. package/types/isolation/image.d.ts +158 -0
  95. package/types/isolation/index.d.ts +128 -0
  96. package/types/isolation/network.d.ts +33 -0
  97. package/types/isolation/policy.d.ts +29 -0
  98. package/types/isolation/session.d.ts +99 -0
  99. package/types/isolation/vz.d.ts +63 -0
  100. package/types/runner/define.d.ts +101 -0
  101. package/types/runner/image.d.ts +52 -0
  102. package/types/runner/index.d.ts +13 -0
  103. package/types/runner/serve.d.ts +69 -0
  104. package/types/runner/toolsets.d.ts +35 -0
  105. package/types/tools.d.ts +2 -0
@@ -0,0 +1,327 @@
1
+ import {
2
+ chmod,
3
+ lstat,
4
+ mkdir,
5
+ open,
6
+ readdir,
7
+ readFile,
8
+ rename,
9
+ unlink,
10
+ } from 'node:fs/promises'
11
+ import { constants } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { isAbsolute, join, resolve } from 'node:path'
14
+ import { randomBytes } from 'node:crypto'
15
+ import { Command } from 'commander'
16
+ import { z } from 'zod'
17
+ import { nudgeSpec } from './spawn-nudge'
18
+
19
+ // Runtime settings only. Scoped tokens stay in the existing identity store.
20
+ const nameSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,23}$/)
21
+ const text = z
22
+ .string()
23
+ .min(1)
24
+ .refine((s) => !s.includes('\0'))
25
+ /** `<event>.<action>[:<label>]` against the forge event stream (gild-site#55). */
26
+ export const triggerPattern = /^([a-z][a-z_]*)\.([a-z][a-z_]*)(?::([^\n]+))?$/
27
+ const triggerSchema = z
28
+ .string()
29
+ .regex(
30
+ triggerPattern,
31
+ 'Use <event>.<action>[:<label>], e.g. issues.labeled:triage',
32
+ )
33
+ export type AgentTrigger = { event: string; action: string; label?: string }
34
+ /** Split a validated trigger spec into its parts; null when the grammar fails. */
35
+ export function parseTrigger(spec: string): AgentTrigger | null {
36
+ const match = spec.match(triggerPattern)
37
+ if (!match) return null
38
+ return {
39
+ event: match[1],
40
+ action: match[2],
41
+ ...(match[3] !== undefined ? { label: match[3] } : {}),
42
+ }
43
+ }
44
+ export const profileSchema = z.strictObject({
45
+ name: nameSchema,
46
+ runtime: text,
47
+ model: text.optional(),
48
+ effort: text.optional(),
49
+ directory: text.refine(isAbsolute),
50
+ args: z.array(z.string().refine((s) => !s.includes('\0'))).default([]),
51
+ env: z.array(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)).optional(),
52
+ channels: z
53
+ .array(z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/))
54
+ .optional(),
55
+ nudges: z.array(nudgeSpec).optional(),
56
+ on: z.array(triggerSchema).optional(),
57
+ })
58
+ export type AgentProfile = z.infer<typeof profileSchema>
59
+ export const profilesDirectory = () => join(homedir(), '.mirab', 'agents')
60
+ function profilePath(name: string) {
61
+ if (!nameSchema.safeParse(name).success)
62
+ throw Error(
63
+ 'Agent profile name must be 1–24 letters, digits, underscores or hyphens',
64
+ )
65
+ return join(profilesDirectory(), name + '.json')
66
+ }
67
+ async function privateDirectory() {
68
+ for (const path of [join(homedir(), '.mirab'), profilesDirectory()]) {
69
+ await mkdir(path, { mode: 0o700 }).catch((e: NodeJS.ErrnoException) => {
70
+ if (e.code !== 'EEXIST') throw e
71
+ })
72
+ const info = await lstat(path)
73
+ if (
74
+ !info.isDirectory() ||
75
+ info.isSymbolicLink() ||
76
+ info.uid !== process.getuid?.()
77
+ )
78
+ throw Error('Unsafe local agent profile directory')
79
+ await chmod(path, 0o700)
80
+ }
81
+ }
82
+ function nudgeError(issues: z.core.$ZodIssue[]) {
83
+ const issue = issues.find((i) => i.path[0] === 'nudges')
84
+ return issue && `Invalid nudge rule: ${issue.message}`
85
+ }
86
+ export function parseProfile(value: unknown): AgentProfile {
87
+ const result = profileSchema.safeParse(value)
88
+ if (!result.success)
89
+ throw Error(
90
+ nudgeError(result.error.issues) ??
91
+ 'Invalid agent profile: only name, runtime, model, effort, directory, args, env, channels, on and nudges are allowed; credentials belong in the identity store' +
92
+ (result.error.issues[0]
93
+ ? ` (${result.error.issues[0].path.join('.') || 'profile'}: ${result.error.issues[0].message})`
94
+ : ''),
95
+ )
96
+ return result.data
97
+ }
98
+ export async function loadProfile(name: string): Promise<AgentProfile> {
99
+ const path = profilePath(name)
100
+ await privateDirectory()
101
+ const file = await open(
102
+ path,
103
+ constants.O_RDONLY | constants.O_NOFOLLOW,
104
+ ).catch((e: NodeJS.ErrnoException) => {
105
+ if (e.code === 'ENOENT')
106
+ throw Error(`No local agent profile ${name}; use mirab agent add ${name}`)
107
+ throw e
108
+ })
109
+ try {
110
+ const info = await file.stat()
111
+ if (
112
+ !info.isFile() ||
113
+ info.uid !== process.getuid?.() ||
114
+ info.size > 64 * 1024
115
+ )
116
+ throw Error('Unsafe local agent profile file')
117
+ await file.chmod(0o600)
118
+ let value: unknown
119
+ try {
120
+ value = JSON.parse(await file.readFile('utf8'))
121
+ } catch {
122
+ throw Error('Invalid agent profile JSON')
123
+ }
124
+ const profile = parseProfile(value)
125
+ if (profile.name !== name)
126
+ throw Error('Agent profile name differs from its filename')
127
+ return profile
128
+ } finally {
129
+ await file.close()
130
+ }
131
+ }
132
+ export async function saveProfile(value: unknown, create = false) {
133
+ const profile = parseProfile(value)
134
+ const path = profilePath(profile.name)
135
+ await privateDirectory()
136
+ const body = JSON.stringify(profile, null, 2) + '\n'
137
+ if (Buffer.byteLength(body) > 64 * 1024)
138
+ throw Error('Agent profile exceeds 64 KiB')
139
+ if (create) {
140
+ const file = await open(path, 'wx', 0o600).catch(
141
+ (e: NodeJS.ErrnoException) => {
142
+ if (e.code === 'EEXIST')
143
+ throw Error(
144
+ `Agent profile ${profile.name} already exists; use mirab agent edit`,
145
+ )
146
+ throw e
147
+ },
148
+ )
149
+ try {
150
+ await file.writeFile(body)
151
+ } finally {
152
+ await file.close()
153
+ }
154
+ } else {
155
+ await loadProfile(profile.name)
156
+ const temp = path + '.' + randomBytes(6).toString('hex') + '.tmp'
157
+ try {
158
+ const file = await open(temp, 'wx', 0o600)
159
+ try {
160
+ await file.writeFile(body)
161
+ } finally {
162
+ await file.close()
163
+ }
164
+ await rename(temp, path)
165
+ } finally {
166
+ await unlink(temp).catch(() => {})
167
+ }
168
+ }
169
+ }
170
+ export async function resolveProfile(name: string, extra: string[]) {
171
+ const profile = await loadProfile(name)
172
+ const { stat } = await import('node:fs/promises')
173
+ const directory = await stat(profile.directory).catch(() => null)
174
+ if (!directory?.isDirectory())
175
+ throw Error(
176
+ `Agent profile ${name} directory does not exist or is not a directory: ${profile.directory}`,
177
+ )
178
+ const { profileArguments } = await import('./spawn-adapters')
179
+ return {
180
+ profile,
181
+ args: [
182
+ ...profileArguments(profile.runtime, profile),
183
+ ...profile.args,
184
+ ...extra,
185
+ ],
186
+ }
187
+ }
188
+ const collect = (value: string, previous: string[] = []) => [...previous, value]
189
+ export function profileCommands(agent: Command) {
190
+ for (const verb of ['add', 'edit'] as const) {
191
+ agent
192
+ .command(`${verb} <name>`)
193
+ .description(
194
+ `${verb} a local runtime profile (credentials are stored separately)`,
195
+ )
196
+ .option('--runtime <command>', 'native runtime or executable')
197
+ .option('--model <model>', 'native model ID; empty clears it')
198
+ .option('--effort <effort>', 'native reasoning effort; empty clears it')
199
+ .option(
200
+ '--dir <directory>',
201
+ 'working directory, resolved to an absolute path',
202
+ )
203
+ .option(
204
+ '--arg <argument>',
205
+ 'extra native argument; repeat to replace args',
206
+ collect,
207
+ )
208
+ .option(
209
+ '--env <name>',
210
+ 'inherit only these environment variable names; repeat',
211
+ collect,
212
+ )
213
+ .option(
214
+ '--channel <owner/repo>',
215
+ 'reserved channel mention subscription; repeat',
216
+ collect,
217
+ )
218
+ .option(
219
+ '--nudge <rule>',
220
+ 'watchdog rule: idle:<duration>[:<limit>][:<message>], waiting:<duration>, ci:<owner/repo>, mention-unanswered:<duration>; repeat',
221
+ collect,
222
+ )
223
+ .option(
224
+ '--on <event.action[:label]>',
225
+ 'wake this agent on a forge event, e.g. issues.labeled:triage; repeat',
226
+ collect,
227
+ )
228
+ .option('--clear-args', 'clear native arguments')
229
+ .option('--clear-env', 'restore normal environment inheritance')
230
+ .option('--clear-channels', 'clear reserved channels')
231
+ .option('--clear-nudges', 'clear watchdog rules')
232
+ .option('--clear-on', 'clear event triggers')
233
+ .option('--file <json>', 'read a complete, credential-free profile JSON')
234
+ .action(async (name: string, opts) => {
235
+ profilePath(name)
236
+ if (opts.file) {
237
+ if (Object.keys(opts).some((key) => key !== 'file'))
238
+ throw Error('--file cannot be combined with profile field options')
239
+ let value: unknown
240
+ try {
241
+ value = JSON.parse(await readFile(opts.file, 'utf8'))
242
+ } catch {
243
+ throw Error('Cannot read profile JSON')
244
+ }
245
+ const profile = parseProfile(value)
246
+ if (profile.name !== name)
247
+ throw Error('Agent profile name differs from the command name')
248
+ await saveProfile(profile, verb === 'add')
249
+ return
250
+ }
251
+ const previous =
252
+ verb === 'edit' ? await loadProfile(name) : { name, args: [] }
253
+ await saveProfile(
254
+ {
255
+ ...previous,
256
+ ...(opts.runtime !== undefined ? { runtime: opts.runtime } : {}),
257
+ ...(opts.dir !== undefined ? { directory: resolve(opts.dir) } : {}),
258
+ ...(opts.model !== undefined
259
+ ? { model: opts.model || undefined }
260
+ : {}),
261
+ ...(opts.effort !== undefined
262
+ ? { effort: opts.effort || undefined }
263
+ : {}),
264
+ ...(opts.arg
265
+ ? { args: opts.arg }
266
+ : opts.clearArgs
267
+ ? { args: [] }
268
+ : {}),
269
+ ...(opts.env
270
+ ? { env: opts.env }
271
+ : opts.clearEnv
272
+ ? { env: undefined }
273
+ : {}),
274
+ ...(opts.channel
275
+ ? { channels: opts.channel }
276
+ : opts.clearChannels
277
+ ? { channels: undefined }
278
+ : {}),
279
+ ...(opts.nudge
280
+ ? { nudges: opts.nudge }
281
+ : opts.clearNudges
282
+ ? { nudges: undefined }
283
+ : {}),
284
+ ...(opts.on
285
+ ? { on: opts.on }
286
+ : opts.clearOn
287
+ ? { on: undefined }
288
+ : {}),
289
+ },
290
+ verb === 'add',
291
+ )
292
+ })
293
+ }
294
+ agent
295
+ .command('rm <name>')
296
+ .description('remove a local runtime profile; keep approved credentials')
297
+ .action(async (name: string) => {
298
+ await loadProfile(name)
299
+ await unlink(profilePath(name))
300
+ })
301
+ agent
302
+ .command('ls')
303
+ .description('list local runtime profiles')
304
+ .option('--json', 'print profiles as JSON')
305
+ .action(async (opts) => {
306
+ await privateDirectory()
307
+ const profiles: AgentProfile[] = []
308
+ for (const entry of (await readdir(profilesDirectory())).sort()) {
309
+ if (entry.endsWith('.json'))
310
+ profiles.push(await loadProfile(entry.slice(0, -5)))
311
+ }
312
+ if (opts.json) console.log(JSON.stringify(profiles))
313
+ else {
314
+ console.log('NAME\tRUNTIME\tMODEL\tEFFORT\tDIRECTORY')
315
+ for (const p of profiles)
316
+ console.log(
317
+ [
318
+ p.name,
319
+ p.runtime,
320
+ p.model ?? '',
321
+ p.effort ?? '',
322
+ p.directory,
323
+ ].join('\t'),
324
+ )
325
+ }
326
+ })
327
+ }
@@ -0,0 +1,95 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ // Identity establishment precedes a gf_ token. These legacy browser/terminal
3
+ // routes keep their key-proof protocol and are deliberately outside API v1.
4
+ import { z } from 'zod'
5
+ import { ApiRequestError } from './client.ts'
6
+ const proof = z.object({
7
+ challenge: z.string(),
8
+ publicKey: z.string(),
9
+ signature: z.string(),
10
+ })
11
+ export const bootstrapRoutes = {
12
+ challenge: {
13
+ path: '/api/auth/challenge',
14
+ input: z.object({}),
15
+ output: z.object({ challenge: z.string() }),
16
+ },
17
+ answer: {
18
+ path: '/api/auth/answer',
19
+ input: proof,
20
+ output: z.object({ ok: z.boolean() }),
21
+ },
22
+ claim: {
23
+ path: '/api/claim/answer',
24
+ input: proof,
25
+ output: z.object({ ok: z.boolean(), name: z.string() }),
26
+ },
27
+ token: {
28
+ path: '/api/tokens',
29
+ input: proof,
30
+ output: z.object({ token: z.string(), name: z.string().nullable() }),
31
+ },
32
+ join: {
33
+ path: '/api/agents/requests',
34
+ input: z.object({
35
+ sponsor: z.string(),
36
+ label: z.string(),
37
+ publicKey: z.string(),
38
+ repo: z.string().optional(),
39
+ grants: z.array(z.string()),
40
+ note: z.string().optional(),
41
+ }),
42
+ output: z.object({
43
+ id: z.string(),
44
+ status: z.string(),
45
+ agent: z.string(),
46
+ approveUrl: z.string(),
47
+ collect: z.string(),
48
+ }),
49
+ },
50
+ collect: {
51
+ path: '/api/agents/requests/{id}/token',
52
+ input: z.object({ signature: z.string() }),
53
+ output: z.object({
54
+ status: z.string(),
55
+ name: z.string().optional(),
56
+ token: z.string().optional(),
57
+ repo: z.string().nullable().optional(),
58
+ grants: z.array(z.string()).optional(),
59
+ }),
60
+ },
61
+ } as const
62
+ export class ProofClient {
63
+ constructor(
64
+ readonly origin: string,
65
+ readonly fetcher: typeof fetch = fetch,
66
+ ) {}
67
+ async request<I extends keyof typeof bootstrapRoutes>(
68
+ id: I,
69
+ input: z.input<(typeof bootstrapRoutes)[I]['input']>,
70
+ params: Record<string, string> = {},
71
+ ) {
72
+ const route = bootstrapRoutes[id],
73
+ path = route.path.replace(/\{(\w+)\}/g, (_, name) =>
74
+ encodeURIComponent(params[name]),
75
+ )
76
+ const response = await this.fetcher(this.origin.replace(/\/$/, '') + path, {
77
+ method: 'POST',
78
+ headers: { 'content-type': 'application/json' },
79
+ body: JSON.stringify(route.input.parse(input)),
80
+ redirect: 'error',
81
+ signal: AbortSignal.timeout(30000),
82
+ })
83
+ const data = await response
84
+ .json()
85
+ .catch(() => ({ error: `HTTP ${response.status}` }))
86
+ if (!response.ok)
87
+ throw new ApiRequestError(
88
+ response.status,
89
+ data.message ?? data.error ?? `HTTP ${response.status}`,
90
+ )
91
+ return route.output.parse(data) as z.output<
92
+ (typeof bootstrapRoutes)[I]['output']
93
+ >
94
+ }
95
+ }
@@ -0,0 +1,19 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ import { z } from 'zod'
3
+ import { channelState } from '../channel/state.ts'
4
+ export const channelCursor = z.string().regex(/^(0|[1-9][0-9]{0,14})$/)
5
+ export const channelAuthor = z.object({ name: z.string(), kind: z.enum(['human', 'agent']) })
6
+ export const channelMessage = z.object({
7
+ cursor: channelCursor, id: z.string(), created_at: z.string(),
8
+ reply_to: channelCursor.nullable(), author: channelAuthor, kind: z.enum(['message', 'system']), body: z.string(),
9
+ link: z.object({ href: z.string(), label: z.string() }).nullable(),
10
+ })
11
+ export const channelPage = z.object({ messages: z.array(channelMessage), cursor: channelCursor, before: channelCursor.nullable(), after: channelCursor.nullable() })
12
+ export const channelQuery = z.object({ before: channelCursor.optional(), after: channelCursor.optional(), limit: z.coerce.number().int().min(1).max(200).default(50) }).refine(q => !(q.before && q.after), 'Use before or after, not both')
13
+ export const channelInput = z.object({ body: z.string().trim().min(1).max(4000), reply_to: channelCursor.optional() })
14
+ export const channelParticipant = channelAuthor.extend({ prefix: z.enum(['@', '%', '+', '']), sponsor: z.string().nullable(), scopes: z.array(z.string()), online: z.boolean(), state: channelState.nullable().optional() })
15
+ export const channelParticipants = z.object({ participants: z.array(channelParticipant), can_post: z.boolean(), viewer: z.string().nullable() })
16
+ export type ChannelMessage = z.output<typeof channelMessage>
17
+ export type ChannelParticipant = z.output<typeof channelParticipant>
18
+ export type ChannelPage = z.output<typeof channelPage>
19
+ export type ChannelQuery = z.output<typeof channelQuery>
@@ -0,0 +1,100 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ import {
3
+ routes,
4
+ type Operation,
5
+ type Input,
6
+ type Output,
7
+ type Query,
8
+ } from './contract.ts'
9
+ export class ApiRequestError extends Error {
10
+ readonly status: number
11
+ constructor(status: number, message: string) {
12
+ super(message)
13
+ this.status = status
14
+ }
15
+ }
16
+ export class GildClient {
17
+ constructor(
18
+ readonly baseURL: string,
19
+ readonly token: string,
20
+ readonly fetcher: (
21
+ input: RequestInfo | URL,
22
+ init?: RequestInit,
23
+ ) => Promise<Response> = fetch,
24
+ ) {}
25
+ async request<I extends Operation>(
26
+ operation: I,
27
+ params: Record<string, string | number> = {},
28
+ body?: Input<I>,
29
+ query?: Query<I>,
30
+ options?: { idempotencyKey?: string; signal?: AbortSignal },
31
+ ): Promise<Output<I>> {
32
+ const route = routes.find((r) => r.id === operation)!
33
+ const path = route.path.replace(/\{(\w+)\}/g, (_, name: string) => {
34
+ if (params[name] === undefined)
35
+ throw new Error(`Missing path parameter: ${name}`)
36
+ const value = String(params[name])
37
+ return name === 'path' || name === 'ref'
38
+ ? value.split('/').map(encodeURIComponent).join('/')
39
+ : encodeURIComponent(value)
40
+ })
41
+ const url = new URL(this.baseURL.replace(/\/$/, '') + path)
42
+ if (query)
43
+ for (const [name, value] of Object.entries(query))
44
+ if (value !== undefined) url.searchParams.set(name, String(value))
45
+ const res = await this.fetcher(url, {
46
+ method: route.method,
47
+ headers: {
48
+ authorization: `Bearer ${this.token}`,
49
+ 'content-type': 'application/json',
50
+ ...(options?.idempotencyKey
51
+ ? { 'idempotency-key': options.idempotencyKey }
52
+ : {}),
53
+ },
54
+ body: ['GET', 'DELETE'].includes(route.method)
55
+ ? undefined
56
+ : JSON.stringify(route.input.parse(body ?? {})),
57
+ signal: options?.signal ?? AbortSignal.timeout(30000),
58
+ redirect: 'error',
59
+ })
60
+ const data = res.status === 204 ? null : await res
61
+ .json()
62
+ .catch(() => ({ message: `HTTP ${res.status}` }))
63
+ if (!res.ok)
64
+ throw new ApiRequestError(
65
+ res.status,
66
+ data.message ?? `HTTP ${res.status}`,
67
+ )
68
+ return route.output.parse(data) as Output<I>
69
+ }
70
+ }
71
+ // Protocol adapters still resolve through canonical definitions.
72
+ export async function requestPath(
73
+ client: GildClient,
74
+ method: string,
75
+ path: string,
76
+ body?: unknown,
77
+ ) {
78
+ for (const route of routes) {
79
+ if (route.method !== method) continue
80
+ const names = [...route.path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]),
81
+ pattern = new RegExp(
82
+ '^' +
83
+ route.path
84
+ .split(/\{\w+\}/)
85
+ .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
86
+ .join('([^/]+)') +
87
+ '$',
88
+ ),
89
+ matched = pattern.exec(path)
90
+ if (matched)
91
+ return client.request(
92
+ route.id,
93
+ Object.fromEntries(
94
+ names.map((n, i) => [n, decodeURIComponent(matched[i + 1])]),
95
+ ),
96
+ body as Input<Operation>,
97
+ )
98
+ }
99
+ throw new Error(`Unknown API operation: ${method} ${path}`)
100
+ }