@briancray/belte 0.3.1 → 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 (61) hide show
  1. package/bin/belte.ts +22 -13
  2. package/package.json +1 -1
  3. package/src/appEntry.ts +24 -8
  4. package/src/buildDisconnected.ts +3 -0
  5. package/src/bundleApp.ts +24 -2
  6. package/src/controlServerWorker.ts +205 -7
  7. package/src/discoveryEntry.ts +58 -11
  8. package/src/lib/browser/cache.ts +29 -6
  9. package/src/lib/browser/startClient.ts +24 -1
  10. package/src/lib/bundle/BundleWindow.ts +11 -0
  11. package/src/lib/bundle/disconnected.svelte +238 -42
  12. package/src/lib/bundle/onMenu.ts +20 -5
  13. package/src/lib/bundle/openWebview.ts +9 -2
  14. package/src/lib/bundle/signMacApp.ts +35 -0
  15. package/src/lib/cli/createClient.ts +65 -27
  16. package/src/lib/cli/loadEnvFromBinaryDir.ts +8 -38
  17. package/src/lib/cli/runCli.ts +37 -15
  18. package/src/lib/cli/types/CliManifestEntry.ts +7 -2
  19. package/src/lib/mcp/annotationsForMethod.ts +29 -0
  20. package/src/lib/mcp/createMcpServer.ts +10 -8
  21. package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
  22. package/src/lib/mcp/toolResultFromResponse.ts +66 -0
  23. package/src/lib/server/jsonl.ts +2 -1
  24. package/src/lib/server/rpc/defineVerb.ts +30 -17
  25. package/src/lib/server/rpc/parseArgs.ts +2 -1
  26. package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
  27. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
  28. package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
  29. package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
  30. package/src/lib/server/runtime/createServer.ts +57 -21
  31. package/src/lib/server/runtime/globToPathSet.ts +29 -0
  32. package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
  33. package/src/lib/server/runtime/parsePort.ts +16 -0
  34. package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
  35. package/src/lib/server/sockets/defineSocket.ts +7 -1
  36. package/src/lib/server/sockets/recentHistory.ts +11 -0
  37. package/src/lib/server/sockets/socketOperations.ts +35 -0
  38. package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
  39. package/src/lib/server/sse.ts +2 -1
  40. package/src/lib/shared/appDataDir.ts +22 -0
  41. package/src/lib/shared/buildRpcRequest.ts +2 -1
  42. package/src/lib/shared/carriesBodyArgs.ts +13 -0
  43. package/src/lib/shared/isReadOnlyMethod.ts +14 -0
  44. package/src/lib/shared/isStreamingResponse.ts +11 -0
  45. package/src/lib/shared/jsonlErrorFrame.ts +24 -0
  46. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  47. package/src/lib/shared/loadEnvFile.ts +17 -0
  48. package/src/lib/shared/loadEnvFromDataDir.ts +15 -0
  49. package/src/lib/shared/parseEnv.ts +30 -0
  50. package/src/lib/shared/readEnvFile.ts +15 -0
  51. package/src/lib/shared/resolveClientFlags.ts +8 -6
  52. package/src/lib/shared/responseErrorText.ts +9 -0
  53. package/src/lib/shared/serializeEnv.ts +18 -0
  54. package/src/lib/shared/sseErrorFrame.ts +29 -0
  55. package/src/lib/shared/streamResponse.ts +168 -0
  56. package/src/lib/shared/subscribableFromResponse.ts +1 -172
  57. package/src/lib/shared/types/CacheEntry.ts +6 -0
  58. package/src/serverEntry.ts +12 -0
  59. package/template/src/bundle/icon.png +0 -0
  60. package/template/src/server/rpc/getHello.ts +5 -3
  61. package/src/lib/shared/belteImportName.test.ts +0 -58
@@ -25,10 +25,11 @@ pass finds the data via cache() without issuing a network round-trip.
25
25
  */
26
26
  function hydrateCacheFromSnapshot(store: CacheStore, snapshot: CacheSnapshotEntry[]): void {
27
27
  for (const entry of snapshot) {
28
+ const headers = new Headers(entry.headers)
28
29
  const response = new Response(entry.body, {
29
30
  status: entry.status,
30
31
  statusText: entry.statusText,
31
- headers: new Headers(entry.headers),
32
+ headers,
32
33
  })
33
34
  store.entries.set(entry.key, {
34
35
  key: entry.key,
@@ -36,10 +37,32 @@ function hydrateCacheFromSnapshot(store: CacheStore, snapshot: CacheSnapshotEntr
36
37
  request: new Request(entry.url, { method: entry.method }),
37
38
  ttl: undefined,
38
39
  expiresAt: undefined,
40
+ value: warmValueFromSnapshot(entry.status, headers, entry.body),
39
41
  })
40
42
  }
41
43
  }
42
44
 
45
+ /*
46
+ Synchronously decodes a snapshot body so the warm entry reads without a
47
+ microtask hop on first render. Mirrors decodeResponse for the textual cases
48
+ the snapshot ships; non-2xx and 204 yield no warm value and fall back to the
49
+ async path, which throws HttpError / returns undefined exactly as a live call
50
+ would. Binary/xml bodies also skip the warm path and decode asynchronously.
51
+ */
52
+ function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
53
+ if (status === 204 || status < 200 || status >= 300) {
54
+ return undefined
55
+ }
56
+ const contentType = (headers.get('content-type') ?? '').toLowerCase()
57
+ if (contentType.includes('json')) {
58
+ return JSON.parse(body)
59
+ }
60
+ if (contentType.startsWith('text/')) {
61
+ return body
62
+ }
63
+ return undefined
64
+ }
65
+
43
66
  function isInternalLinkEvent(event: MouseEvent): HTMLAnchorElement | undefined {
44
67
  if (event.defaultPrevented) {
45
68
  return undefined
@@ -1,3 +1,4 @@
1
+ import type { StandardSchemaV1 } from '../server/rpc/types/StandardSchemaV1.ts'
1
2
  import type { BundleMenu } from './BundleMenu.ts'
2
3
 
3
4
  /*
@@ -17,4 +18,14 @@ export type BundleWindow = {
17
18
  width?: number
18
19
  height?: number
19
20
  menu?: BundleMenu[]
21
+ /*
22
+ Config the embedded server needs before it can run, as a Standard Schema (the
23
+ same kind belte accepts for RPC/MCP). Its JSON Schema drives the connect
24
+ screen's first-run form, shown as a modal when Start is clicked with a required
25
+ key still unset; the user's answers persist to the data-dir `.env` the server
26
+ loads at boot. Each property maps to one env var of the same name; `title` is
27
+ the field label, `description` the hint, `format: 'password'` masks the input,
28
+ and `default` pre-fills it.
29
+ */
30
+ config?: StandardSchemaV1
20
31
  }
@@ -11,20 +11,9 @@ connection so the native File menu's enabled state stays authoritative.
11
11
  */
12
12
 
13
13
  /*
14
- localStorage key holding the last connection so a relaunch repeats it: either a
15
- remote server URL, or the START_EMBEDDED sentinel meaning "boot the embedded
16
- server". The embedded server's own URL can't be persisted — it picks a fresh
17
- port each launch — so we persist the intent and re-run start() instead.
18
- Disconnect clears it so a relaunch never auto-retries a forgotten server.
19
- */
20
- const STORAGE_KEY = 'belte:server-url'
21
- const START_EMBEDDED = 'belte:start-embedded'
22
-
23
- /*
24
- The last remote URL that successfully connected, kept separate from STORAGE_KEY
25
- so it survives disconnect (and the app quitting): it only prefills the form, it
26
- never drives an auto-reconnect, so reconnecting to the same server stays one
27
- click away even after an explicit disconnect.
14
+ The last remote URL that successfully connected only prefills the form's input
15
+ on a later visit; it never drives a reconnect (the launcher owns auto-resume now,
16
+ deciding before the window even opens). Survives disconnect and quitting.
28
17
  */
29
18
  const LAST_URL_KEY = 'belte:last-server-url'
30
19
 
@@ -36,9 +25,51 @@ const placeholder = 'https://example.com'
36
25
 
37
26
  // Prefill the form with the last server we connected to, from any prior launch.
38
27
  let url = $state(localStorage.getItem(LAST_URL_KEY) ?? '')
39
- let starting = $state(false)
40
28
  let error = $state<string | undefined>(undefined)
41
29
 
30
+ /*
31
+ `?action=` set by the File menu (Start/Disconnect) or the launcher when a live
32
+ connection dies (`lost`). Auto-resume of a saved connection now happens in the
33
+ launcher before the window opens, so this screen only ever loads as a real
34
+ destination — there's no no-action auto-resume left to handle here.
35
+ */
36
+ const launchAction = new URLSearchParams(location.search).get('action')
37
+
38
+ /*
39
+ Two phases so the screen never flashes before a redirect. A menu Start may boot
40
+ straight through, so it opens on a neutral splash; every other entry is a genuine
41
+ destination, so the connect screen shows immediately. Boot/connect re-enter the
42
+ splash so a redirect (including after saving config) never flashes the form.
43
+ */
44
+ let phase = $state<'splash' | 'connect'>(launchAction === 'start' ? 'splash' : 'connect')
45
+
46
+ /*
47
+ First-run config form, surfaced as a modal only when Start is clicked (or
48
+ auto-start fires) with a required key still unset. Fields are derived from the
49
+ app's config JSON Schema served by the launcher; answers post back to the
50
+ data-dir `.env` the embedded server loads at boot.
51
+ */
52
+ type ConfigField = {
53
+ key: string
54
+ label: string
55
+ description?: string
56
+ inputType: 'text' | 'password' | 'number' | 'checkbox'
57
+ required: boolean
58
+ }
59
+ let configFields = $state<ConfigField[]>([])
60
+ let configValues = $state<Record<string, string>>({})
61
+ let showConfig = $state(false)
62
+ let savingConfig = $state(false)
63
+ // Every required field has a value — gates the modal's Save button.
64
+ const canSaveConfig = $derived(
65
+ configFields.every(
66
+ (field) =>
67
+ !field.required ||
68
+ field.inputType === 'checkbox' ||
69
+ (configValues[field.key] ?? '').trim() !== '',
70
+ ),
71
+ )
72
+
42
73
  /*
43
74
  Interpret the boot intent once on load. `?action=` is set by the native File
44
75
  menu's navigate items (or the launcher when a live connection dies); absent it, a
@@ -51,30 +82,19 @@ remembered server reconnects automatically:
51
82
  - (none) → reconnect to the saved server if there is one.
52
83
  */
53
84
  $effect(() => {
54
- const action = new URLSearchParams(location.search).get('action')
55
- const saved = localStorage.getItem(STORAGE_KEY) ?? undefined
56
- if (action === 'start') {
85
+ if (launchAction === 'start') {
86
+ // File-menu Start Server is an explicit click → run the Start flow.
57
87
  void start()
58
88
  return
59
89
  }
60
- if (action === 'lost') {
90
+ if (launchAction === 'lost') {
61
91
  error = 'The server stopped responding.'
62
92
  return
63
93
  }
64
- if (action === 'disconnect') {
65
- // Forget the auto-reconnect intent but keep LAST_URL_KEY, so the form
66
- // stays prefilled with the server we just left for a one-click return.
67
- localStorage.removeItem(STORAGE_KEY)
94
+ if (launchAction === 'disconnect') {
95
+ // Have the launcher forget the auto-resume choice and reap any embedded
96
+ // server; LAST_URL_KEY stays so the form is still prefilled to reconnect.
68
97
  void fetch('/__belte/disconnect').catch(() => {})
69
- return
70
- }
71
- // No action: repeat the last choice — re-boot the embedded server, or reconnect.
72
- if (saved === START_EMBEDDED) {
73
- void start()
74
- return
75
- }
76
- if (saved) {
77
- void connect(saved)
78
98
  }
79
99
  })
80
100
 
@@ -92,6 +112,8 @@ async function connect(target: string = url.trim()): Promise<void> {
92
112
  return
93
113
  }
94
114
  error = undefined
115
+ // Hide the form while connecting so a successful redirect doesn't flash it.
116
+ phase = 'splash'
95
117
  try {
96
118
  const response = await fetch('/connect', {
97
119
  method: 'POST',
@@ -103,21 +125,44 @@ async function connect(target: string = url.trim()): Promise<void> {
103
125
  throw new Error(body.error ?? `connect failed (${response.status})`)
104
126
  }
105
127
  const { redirect } = (await response.json()) as { redirect: string }
106
- localStorage.setItem(STORAGE_KEY, cleaned)
107
- // Remember it separately so it outlives a later disconnect and prefills the form.
128
+ // Prefill the form with this server on a later visit (the launcher records
129
+ // the auto-resume choice itself, on the /connect it just handled).
108
130
  localStorage.setItem(LAST_URL_KEY, cleaned)
109
131
  location.href = redirect
110
132
  } catch (cause) {
111
133
  error = `Could not connect: ${String(cause)}`
134
+ // Failed — bring the form back so the error and a retry are visible.
135
+ phase = 'connect'
112
136
  }
113
137
  }
114
138
 
115
- // Boot the embedded server via the launcher, then follow it once it answers.
139
+ /*
140
+ Start, always an explicit click (button or File-menu) — auto-resume happens in
141
+ the launcher before the window opens, so this is never a launch path. Asks the
142
+ launcher what config the app needs: if it declares any, open the modal (prefilled
143
+ with the last-used values) so the user can review or change settings before
144
+ booting — re-running Start after a disconnect is how you reconfigure. With no
145
+ config schema, boot straight through. The modal's save path resumes the boot.
146
+ */
116
147
  async function start(): Promise<void> {
117
148
  error = undefined
118
- starting = true
119
- // Remember the embedded-server choice so the next launch boots it automatically.
120
- localStorage.setItem(STORAGE_KEY, START_EMBEDDED)
149
+ const config = await loadConfig().catch(() => undefined)
150
+ if (config) {
151
+ configFields = config.fields
152
+ configValues = { ...config.values }
153
+ // Reveal the connect screen as the modal's backdrop.
154
+ phase = 'connect'
155
+ showConfig = true
156
+ return
157
+ }
158
+ await boot()
159
+ }
160
+
161
+ // Boot the embedded server via the launcher, then follow it once it answers.
162
+ async function boot(): Promise<void> {
163
+ // Splash while booting so the connect screen doesn't flash before the redirect
164
+ // (including straight after saving config).
165
+ phase = 'splash'
121
166
  try {
122
167
  const response = await fetch('/start', { method: 'POST' })
123
168
  if (!response.ok) {
@@ -128,11 +173,92 @@ async function start(): Promise<void> {
128
173
  location.href = redirect
129
174
  } catch (cause) {
130
175
  error = `Could not start the server: ${String(cause)}`
131
- starting = false
176
+ // Boot failed — bring the connect screen back to show the error.
177
+ phase = 'connect'
178
+ }
179
+ }
180
+
181
+ /*
182
+ Fetches the app's config schema + resolved current values from the launcher and
183
+ turns the JSON Schema into render-ready fields. Returns undefined when no schema
184
+ is declared, so Start never gates.
185
+ */
186
+ async function loadConfig(): Promise<
187
+ { fields: ConfigField[]; values: Record<string, string> } | undefined
188
+ > {
189
+ const response = await fetch('/__belte/config')
190
+ const { schema, values } = (await response.json()) as {
191
+ schema: Record<string, unknown> | null
192
+ values: Record<string, string>
193
+ }
194
+ if (!schema) {
195
+ return undefined
196
+ }
197
+ return { fields: fieldsFromSchema(schema), values: values ?? {} }
198
+ }
199
+
200
+ // Derives one render-ready field per JSON Schema property, reusing the standard
201
+ // slots: `title` → label, `description` → hint, `format`/`type` → input kind.
202
+ function fieldsFromSchema(schema: Record<string, unknown>): ConfigField[] {
203
+ const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>
204
+ const required = new Set((schema.required as string[]) ?? [])
205
+ return Object.entries(properties).map(([key, property]) => ({
206
+ key,
207
+ label: (property.title as string) ?? key,
208
+ description: property.description as string | undefined,
209
+ inputType: inputType(property),
210
+ required: required.has(key),
211
+ }))
212
+ }
213
+
214
+ // Maps a JSON Schema property to an HTML input kind (directory falls back to text
215
+ // until a native picker exists).
216
+ function inputType(property: Record<string, unknown>): ConfigField['inputType'] {
217
+ if (property.type === 'boolean') {
218
+ return 'checkbox'
219
+ }
220
+ if (property.type === 'number' || property.type === 'integer') {
221
+ return 'number'
222
+ }
223
+ if (property.format === 'password') {
224
+ return 'password'
225
+ }
226
+ return 'text'
227
+ }
228
+
229
+ // Persist the form's answers to the data-dir `.env`, then resume the boot.
230
+ async function saveConfig(): Promise<void> {
231
+ error = undefined
232
+ savingConfig = true
233
+ try {
234
+ const response = await fetch('/__belte/config', {
235
+ method: 'POST',
236
+ headers: { 'content-type': 'application/json' },
237
+ body: JSON.stringify({ values: configValues }),
238
+ })
239
+ if (!response.ok) {
240
+ throw new Error(`save failed (${response.status})`)
241
+ }
242
+ showConfig = false
243
+ savingConfig = false
244
+ await boot()
245
+ } catch (cause) {
246
+ error = `Could not save settings: ${String(cause)}`
247
+ savingConfig = false
132
248
  }
133
249
  }
134
250
  </script>
135
251
 
252
+ {#if phase === 'splash'}
253
+ <!-- Neutral splash shown while an auto-start/auto-reconnect resolves, so the
254
+ connect screen never flashes before it redirects. Same background as the card. -->
255
+ <div
256
+ class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-950">
257
+ {#if logo}
258
+ <img src={logo} alt="" class="h-16 w-16 rounded-xl object-contain opacity-90">
259
+ {/if}
260
+ </div>
261
+ {:else}
136
262
  <main
137
263
  class="flex min-h-screen items-center justify-center bg-gray-50 p-6 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
138
264
  <div
@@ -170,9 +296,8 @@ async function start(): Promise<void> {
170
296
  <button
171
297
  type="button"
172
298
  onclick={() => void start()}
173
- disabled={starting}
174
- class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-60 dark:border-gray-700 dark:hover:bg-gray-800">
175
- {starting ? 'Starting…' : 'Start server'}
299
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
300
+ Start server
176
301
  </button>
177
302
 
178
303
  {#if error}
@@ -189,3 +314,74 @@ async function start(): Promise<void> {
189
314
  </p>
190
315
  </div>
191
316
  </main>
317
+ {/if}
318
+
319
+ {#if showConfig}
320
+ <!-- First-run config modal — shown only when Start needs settings the app lacks. -->
321
+ <div
322
+ class="fixed inset-0 z-10 flex items-center justify-center bg-black/40 p-6 text-gray-900 dark:text-gray-100">
323
+ <div
324
+ class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-lg ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
325
+ <h2 class="mb-5 text-lg font-semibold tracking-tight">Set up {heading}</h2>
326
+
327
+ <form
328
+ class="flex flex-col gap-4"
329
+ onsubmit={(event) => {
330
+ event.preventDefault()
331
+ void saveConfig()
332
+ }}>
333
+ {#each configFields as field (field.key)}
334
+ <label class="flex flex-col gap-1 text-sm">
335
+ <span class="font-medium">
336
+ {field.label}
337
+ {#if field.required}
338
+ <span class="text-red-500">*</span>
339
+ {/if}
340
+ </span>
341
+ {#if field.inputType === 'checkbox'}
342
+ <input
343
+ type="checkbox"
344
+ checked={configValues[field.key] === 'true'}
345
+ onchange={(event) =>
346
+ (configValues[field.key] = event.currentTarget.checked
347
+ ? 'true'
348
+ : 'false')}
349
+ class="mt-1 size-4 self-start rounded border-gray-300 dark:border-gray-700">
350
+ {:else}
351
+ <input
352
+ type={field.inputType}
353
+ value={configValues[field.key] ?? ''}
354
+ oninput={(event) =>
355
+ (configValues[field.key] = event.currentTarget.value)}
356
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900 focus:ring-1 focus:ring-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:focus:border-gray-100 dark:focus:ring-gray-100">
357
+ {/if}
358
+ {#if field.description}
359
+ <span class="text-xs text-gray-400 dark:text-gray-500">
360
+ {field.description}
361
+ </span>
362
+ {/if}
363
+ </label>
364
+ {/each}
365
+
366
+ <div class="mt-1 flex gap-3">
367
+ <button
368
+ type="button"
369
+ onclick={() => (showConfig = false)}
370
+ class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
371
+ Cancel
372
+ </button>
373
+ <button
374
+ type="submit"
375
+ disabled={!canSaveConfig || savingConfig}
376
+ class="flex-1 rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300">
377
+ {savingConfig ? 'Saving…' : 'Save & start'}
378
+ </button>
379
+ </div>
380
+ </form>
381
+
382
+ {#if error}
383
+ <p class="mt-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
384
+ {/if}
385
+ </div>
386
+ </div>
387
+ {/if}
@@ -1,25 +1,40 @@
1
1
  /*
2
2
  Subscribes to bundle menu clicks. Each custom menu item declared in the bundle
3
- window config dispatches a `belte:menu` CustomEvent into the page when clicked;
4
- this registers `handler`, called with the item's `emit` name. Returns an
5
- unsubscribe function, so it drops straight into a Svelte `$effect`:
3
+ window config dispatches a `belte:menu` CustomEvent into the page when clicked.
4
+ Two forms, both returning an unsubscribe so they drop straight into a Svelte
5
+ `$effect`:
6
6
 
7
+ // catch-all — every emit name flows through one handler
7
8
  $effect(() =>
8
9
  onMenu((name) => {
9
10
  if (name === 'reload') location.reload()
10
11
  }),
11
12
  )
12
13
 
14
+ // filtered — handler fires only for the named item
15
+ $effect(() => onMenu('reload', () => location.reload()))
16
+
13
17
  Inert during SSR and in a plain browser tab — `$effect` only runs client-side,
14
18
  the native menu that fires the event exists only in the bundled desktop app,
15
19
  and `window` is guarded so importing the module never assumes a DOM.
16
20
  */
17
- export function onMenu(handler: (name: string) => void): () => void {
21
+ export function onMenu(handler: (name: string) => void): () => void
22
+ export function onMenu(name: string, handler: () => void): () => void
23
+ export function onMenu(
24
+ nameOrHandler: string | ((name: string) => void),
25
+ maybeHandler?: () => void,
26
+ ): () => void {
18
27
  if (typeof window === 'undefined') {
19
28
  return () => {}
20
29
  }
30
+ // String first arg = filter to that emit name; otherwise a catch-all handler.
31
+ const filter = typeof nameOrHandler === 'string' ? nameOrHandler : undefined
32
+ const handler = typeof nameOrHandler === 'string' ? maybeHandler : nameOrHandler
21
33
  function listener(event: Event) {
22
- handler((event as CustomEvent<{ name: string }>).detail.name)
34
+ const name = (event as CustomEvent<{ name: string }>).detail.name
35
+ if (filter === undefined || filter === name) {
36
+ handler?.(name)
37
+ }
23
38
  }
24
39
  window.addEventListener('belte:menu', listener)
25
40
  return () => window.removeEventListener('belte:menu', listener)
@@ -62,8 +62,15 @@ export async function openWebview({
62
62
  webview_destroy: { args: [FFIType.ptr], returns: FFIType.void },
63
63
  })
64
64
 
65
- // The second arg is an optional parent window handle; null means a fresh window.
66
- const handle = symbols.webview_create(0, null)
65
+ /*
66
+ First arg is the webview's `debug` flag: 1 enables the native inspector
67
+ (WKWebView's Web Inspector, WebView2 DevTools, WebKitGTK inspector) so a JS
68
+ error on the loaded page — otherwise silent in a bare bundle window — can be
69
+ read via right-click → Inspect. Gated behind BELTE_INSPECT so release bundles
70
+ ship without it. The second arg is an optional parent handle; null = fresh window.
71
+ */
72
+ const debug = process.env.BELTE_INSPECT ? 1 : 0
73
+ const handle = symbols.webview_create(debug, null)
67
74
  symbols.webview_set_title(handle, cString(title))
68
75
  symbols.webview_set_size(handle, width, height, WEBVIEW_HINT_NONE)
69
76
  /*
@@ -0,0 +1,35 @@
1
+ import { log } from '../shared/log.ts'
2
+
3
+ /*
4
+ Ad-hoc code-signs an assembled macOS `.app` so it launches on other Macs.
5
+
6
+ Apple Silicon mandates a valid code signature for every executable. `bun
7
+ build --compile` emits an ad-hoc, linker-signed binary, but assembling the
8
+ `.app` around it (writing Info.plist, dropping in the lib) leaves the bundle
9
+ unsealed — `codesign --verify` then reports the signature as modified, and a
10
+ copy that picks up a quarantine flag (AirDrop, USB, download) gets silently
11
+ killed by Gatekeeper/AMFI: the icon bounces once and nothing opens.
12
+
13
+ Re-signing inside-out fixes that. Nested Mach-O code (the webview dylib, the
14
+ embedded server binary, the launcher) is signed first, then the bundle as a
15
+ whole, which seals Resources and binds Info.plist. The identity is `-`,
16
+ ad-hoc: no certificate, no Developer account, no network — as far as signing
17
+ goes without a paid Developer ID. Recipients copying a quarantined bundle
18
+ still need `xattr -cr <app>` once, but the app no longer fails to launch.
19
+
20
+ Best-effort: if `codesign` is missing or fails, warn and return rather than
21
+ abort the bundle, which is otherwise complete and usable on the build host.
22
+ */
23
+ export async function signMacApp(bundleRoot: string, innerPaths: string[]): Promise<void> {
24
+ try {
25
+ // Inner Mach-O code inside-out, then the bundle, which re-signs the
26
+ // main executable as part of sealing — order matters for nested seals.
27
+ for (const path of innerPaths) {
28
+ await Bun.$`codesign --force --sign - ${path}`.quiet()
29
+ }
30
+ await Bun.$`codesign --force --sign - ${bundleRoot}`.quiet()
31
+ } catch (error) {
32
+ log.warn(`could not code-sign ${bundleRoot} — it may not launch when copied to another Mac`)
33
+ log.error(error)
34
+ }
35
+ }
@@ -5,7 +5,21 @@ import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
5
5
  import { decodeResponse } from '../shared/decodeResponse.ts'
6
6
  import type { CliManifest } from './types/CliManifest.ts'
7
7
 
8
- type AnyApi = Record<string, (args?: unknown) => Promise<unknown>>
8
+ /*
9
+ Each property of the client is a callable: invoking it decodes the body
10
+ (plain call), while `.raw(args)` returns the underlying Response without
11
+ decoding or throwing on non-2xx — the escape hatch the CLI uses to sniff
12
+ the Content-Type and stream sse/jsonl bodies frame-by-frame instead of
13
+ buffering through decodeResponse.
14
+ */
15
+ type ClientInvoker = ((args?: unknown) => Promise<unknown>) & {
16
+ raw: (args?: unknown) => Promise<Response>
17
+ }
18
+
19
+ type AnyApi = Record<string, ClientInvoker>
20
+
21
+ // A command resolved to its HTTP shape — the manifest/registry lookup result.
22
+ type ResolvedCommand = { method: HttpVerb; url: string; accept?: string }
9
23
 
10
24
  /*
11
25
  Builds a typed proxy over the project's RPCs for use in scripts, tests,
@@ -42,35 +56,56 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
42
56
  baked-in source of truth); registry is the in-process fallback for
43
57
  use in same-project code where defineVerb has run.
44
58
  */
45
- function resolve(name: string): { method: HttpVerb; url: string } | undefined {
59
+ function resolve(name: string): ResolvedCommand | undefined {
46
60
  const entry = manifest?.[name]
47
61
  if (entry) {
48
- return { method: entry.method, url: entry.url }
62
+ return { method: entry.method, url: entry.url, accept: entry.accept }
49
63
  }
50
64
  const found = findVerbByCommandName(name)
51
65
  return found ? { method: found.remote.method, url: found.remote.url } : undefined
52
66
  }
53
67
 
54
68
  /*
55
- Single call path for both modes — only the base URL and how the Request
56
- is dispatched differ. Remote mode fetches over the network; in-process
57
- mode looks the verb up in the registry and runs verb.fetch (no hop).
69
+ Single dispatch path for both modes — only the base URL and how the
70
+ Request is sent differ. Remote mode fetches over the network;
71
+ in-process mode looks the verb up in the registry and runs verb.fetch
72
+ (no hop). Returns the raw Response; callers decode or stream it.
58
73
  */
59
- async function call(
60
- method: HttpVerb,
61
- path: string,
74
+ function send(
75
+ resolved: ResolvedCommand,
62
76
  args: unknown,
63
77
  baseUrl: string,
64
78
  dispatch: (request: Request) => Promise<Response>,
65
- ): Promise<unknown> {
79
+ ): Promise<Response> {
66
80
  const headers = new Headers()
67
81
  if (token) {
68
82
  headers.set('authorization', `Bearer ${token}`)
69
83
  }
70
- const request = buildRpcRequest({ method, url: path, args, baseUrl, headers })
71
- const response = await dispatch(request)
84
+ if (resolved.accept) {
85
+ headers.set('accept', resolved.accept)
86
+ }
87
+ const request = buildRpcRequest({
88
+ method: resolved.method,
89
+ url: resolved.url,
90
+ args,
91
+ baseUrl,
92
+ headers,
93
+ })
94
+ return dispatch(request)
95
+ }
96
+
97
+ // Decoding plain-call path: throws on non-2xx, returns the decoded body.
98
+ async function call(
99
+ resolved: ResolvedCommand,
100
+ args: unknown,
101
+ baseUrl: string,
102
+ dispatch: (request: Request) => Promise<Response>,
103
+ ): Promise<unknown> {
104
+ const response = await send(resolved, args, baseUrl, dispatch)
72
105
  if (!response.ok) {
73
- throw new Error(`${method} ${path} failed: ${response.status} ${response.statusText}`)
106
+ throw new Error(
107
+ `${resolved.method} ${resolved.url} failed: ${response.status} ${response.statusText}`,
108
+ )
74
109
  }
75
110
  return decodeResponse(response)
76
111
  }
@@ -94,10 +129,24 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
94
129
  manifest + registry are fixed for a client's lifetime, so a resolved
95
130
  invoker (or its absence) never changes.
96
131
  */
97
- const invokerCache = new Map<string, ((args?: unknown) => Promise<unknown>) | undefined>()
132
+ const invokerCache = new Map<string, ClientInvoker | undefined>()
133
+
134
+ /*
135
+ Build a memoised invoker for a resolved command. The plain call and
136
+ `.raw` share one dispatch — remote mode hits the network, in-process
137
+ mode runs verb.fetch — so the two can't diverge on URL/headers.
138
+ */
139
+ function buildInvoker(resolved: ResolvedCommand): ClientInvoker {
140
+ const baseUrl = url ?? 'http://localhost/'
141
+ const dispatch = url ? fetch : inProcessDispatch(resolved.url)
142
+ const invoker = ((args?: unknown) =>
143
+ call(resolved, args, baseUrl, dispatch)) as ClientInvoker
144
+ invoker.raw = (args?: unknown) => send(resolved, args, baseUrl, dispatch)
145
+ return invoker
146
+ }
98
147
 
99
148
  return new Proxy({} as Api, {
100
- get(_target, prop): ((args?: unknown) => Promise<unknown>) | undefined {
149
+ get(_target, prop): ClientInvoker | undefined {
101
150
  if (typeof prop !== 'string') {
102
151
  return undefined
103
152
  }
@@ -105,18 +154,7 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
105
154
  return invokerCache.get(prop)
106
155
  }
107
156
  const resolved = resolve(prop)
108
- const invoker = resolved
109
- ? (args?: unknown) =>
110
- url
111
- ? call(resolved.method, resolved.url, args, url, fetch)
112
- : call(
113
- resolved.method,
114
- resolved.url,
115
- args,
116
- 'http://localhost/',
117
- inProcessDispatch(resolved.url),
118
- )
119
- : undefined
157
+ const invoker = resolved ? buildInvoker(resolved) : undefined
120
158
  invokerCache.set(prop, invoker)
121
159
  return invoker
122
160
  },