@briancray/belte 0.8.1 → 0.9.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.
package/bin/belte.ts CHANGED
@@ -78,12 +78,12 @@ async function compileCmd(): Promise<void> {
78
78
  })
79
79
  }
80
80
 
81
- // Builds the standalone CLI binary — a thin remote client that talks to a
82
- // running server (manifest baked in, needs APP_URL at runtime). Discovery
83
- // walks the rpc registry to bake the manifest in. `--platforms a,b,c`
84
- // cross-compiles per target into dist/cli-thin/<platform>/ — the layout the
85
- // /__belte/cli download endpoint streams. For an embedded backend, use
86
- // `belte compile` (standalone server binary) instead.
81
+ // Builds the standalone CLI binary — a thin remote client (manifest baked in)
82
+ // that ships the compiled server beside it, so it can talk to a remote server
83
+ // or spawn a local instance (`<name> start`). Discovery walks the rpc registry
84
+ // to bake the manifest in. `--platforms a,b,c` cross-compiles per target into
85
+ // dist/cli-thin/<platform>/ (cli + server) the layout the /__belte/cli
86
+ // download endpoint streams. For just the server, use `belte compile`.
87
87
  async function cliCmd(): Promise<void> {
88
88
  const targetFlag = parseFlag('target')
89
89
  const outFlag = parseFlag('out')
@@ -128,9 +128,10 @@ function usage(): never {
128
128
  ' belte compile [--target=<bun-...>] [--out=<path>]\n' +
129
129
  ' build a standalone server executable\n' +
130
130
  ' belte cli [--target=<bun-...>] [--out=<path>] [--platforms=<a,b,c>]\n' +
131
- ' build the cli binary — a thin remote client\n' +
132
- ' that talks to a running server (needs APP_URL;\n' +
133
- ' --platforms cross-compiles per platform)\n' +
131
+ ' build the cli binary — a thin remote client that\n' +
132
+ ' ships the server beside it (connect to a remote\n' +
133
+ ' server or `start` a local instance; --platforms\n' +
134
+ ' cross-compiles per platform)\n' +
134
135
  ' belte bundle build a movable, self-contained app\n' +
135
136
  ' bundle for this platform (unsigned). Boots\n' +
136
137
  ' into a connect screen — start the embedded\n' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@briancray/belte",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
6
6
  "license": "MIT",
package/src/buildCli.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { build } from './build.ts'
3
+ import { compile } from './compile.ts'
1
4
  import type { CompileTarget } from './lib/server/runtime/types/CompileTarget.ts'
2
5
  import { detectTarget } from './lib/shared/detectTarget.ts'
3
6
  import { exeSuffix } from './lib/shared/exeSuffix.ts'
@@ -12,21 +15,21 @@ const DISCOVERY_ENTRY = new URL('./discoveryEntry.ts', import.meta.url).pathname
12
15
  const CLI_ENTRY = new URL('./cliEntry.ts', import.meta.url).pathname
13
16
 
14
17
  /*
15
- Two-pass CLI binary build. The CLI is always a thin remote client — it
16
- bakes in the per-rpc manifest and talks to a running server over HTTP
17
- (APP_URL at runtime); no handler code is bundled. For an embedded
18
- backend, `belte compile` produces the standalone server binary instead.
18
+ CLI binary build. The CLI is a thin remote client — it bakes in the per-rpc
19
+ manifest and talks to a running server over HTTP — but the **full** binary ships
20
+ the compiled server beside it, so `/start` can spawn a local instance.
19
21
 
20
- 1. Discovery: build the discovery entry into a temporary JS bundle and
21
- run it. It imports every rpc/socket module so defineVerb /
22
- defineSocket populate the registries, then prints the CLI manifest
23
- to stdout. The manifest is written to `dist/cli-manifest.json`.
24
- 2. Compile: build the CLI binary via `Bun.build({ compile })`. The
25
- resolver plugin's `belte:cli-manifest` virtual reads the manifest
26
- JSON written in step 1 and splices it into the bundle.
22
+ 1. Client build (once): `build` produces the platform-independent `dist/_app`
23
+ the server binaries embed. It clears dist first, so it runs before everything.
24
+ 2. Discovery: build the discovery entry into a temporary JS bundle and run it. It
25
+ imports every rpc/socket module so defineVerb / defineSocket populate the
26
+ registries, then prints the CLI manifest to stdout → `dist/cli-manifest.json`.
27
+ 3. Per target: a server binary (`compile`, reusing the shared `dist/_app` via
28
+ `buildClient:false`) plus the CLI binary, written side by side. The resolver's
29
+ `belte:cli-manifest` virtual splices in the manifest from step 2.
27
30
 
28
- `platforms` cross-compiles per target into `dist/cli-thin/<platform>/`
29
- the layout the /__belte/cli download endpoint serves.
31
+ `platforms` cross-compiles per target into `dist/cli-thin/<platform>/` (the layout
32
+ the /__belte/cli download endpoint serves): `<programName>` + `server` together.
30
33
  */
31
34
  export async function buildCli({
32
35
  cwd = process.cwd(),
@@ -40,17 +43,20 @@ export async function buildCli({
40
43
  platforms?: CompileTarget[]
41
44
  } = {}): Promise<string[]> {
42
45
  const distDir = `${cwd}/dist`
43
- await Bun.$`mkdir -p ${distDir}`.quiet()
44
46
  const manifestPath = `${distDir}/cli-manifest.json`
45
47
  const discoveryOut = `${distDir}/_discovery.js`
46
48
 
47
49
  const svelteConfig = await loadSvelteConfig(cwd)
48
50
  const plugins = serverBuildPlugins({ cwd, svelteConfig })
49
51
 
52
+ // Step 1 — client build once (clears dist, writes _app). Every server binary
53
+ // embeds it, so it must precede discovery and the per-target compiles.
54
+ await build({ cwd, svelteConfig })
55
+
50
56
  /*
51
- Step 1 — discovery. Build a runnable bundle, execute it under bun,
52
- capture stdout. We don't `bun build --compile` here because the
53
- discovery output is throwaway; a plain JS bundle runs faster.
57
+ Step 2 — discovery. Build a runnable bundle, execute it under bun, capture
58
+ stdout. We don't `bun build --compile` here because the discovery output is
59
+ throwaway; a plain JS bundle runs faster. Additive — does not clear dist.
54
60
  */
55
61
  const discoveryResult = await Bun.build({
56
62
  entrypoints: [DISCOVERY_ENTRY],
@@ -80,49 +86,41 @@ export async function buildCli({
80
86
  const entryCount = Object.keys(JSON.parse(stdout) as Record<string, unknown>).length
81
87
  log.info(`discovered ${entryCount} cli commands → ${manifestPath}`)
82
88
 
89
+ const programName = await readProgramName(cwd)
90
+
83
91
  /*
84
- Step 2 — compile. The cliEntry imports the now-populated
85
- belte:cli-manifest virtual; bun build --compile emits the standalone
86
- binary. When `platforms` is set, loops once per target and writes
87
- binaries into `dist/cli-thin/<platform>/<programName>` — the layout
88
- the download route expects.
92
+ Step 3 — compile a CLI binary + sibling server binary for a single target,
93
+ written side by side so `resolveServerBinary()` finds the server next to the
94
+ running CLI. The server reuses the shared client build (buildClient:false).
89
95
  */
90
- const programName = await readProgramName(cwd)
96
+ async function buildTargetPair(platformTarget: CompileTarget, cliOut: string): Promise<string> {
97
+ const serverOut = join(dirname(cliOut), `server${exeSuffix(platformTarget)}`)
98
+ await compile({ cwd, target: platformTarget, outfile: serverOut, buildClient: false })
99
+ const result = await Bun.build({
100
+ entrypoints: [CLI_ENTRY],
101
+ target: 'bun',
102
+ compile: { target: platformTarget, outfile: cliOut },
103
+ plugins,
104
+ })
105
+ exitOnBuildFailure(result)
106
+ log.success(`compiled cli + server: ${cliOut}`)
107
+ return cliOut
108
+ }
91
109
 
92
110
  if (platforms && platforms.length > 0) {
93
- // Cross-compile every target in parallel — each build is independent.
111
+ // Cross-compile every target in parallel — each pair is independent.
94
112
  return Promise.all(
95
113
  platforms.map(async (platformTarget) => {
96
114
  const shortName = platformTarget.replace(/^bun-/, '')
97
- const suffix = exeSuffix(platformTarget)
98
- const platformOut = `${distDir}/cli-thin/${shortName}/${programName}${suffix}`
115
+ const cliOut = `${distDir}/cli-thin/${shortName}/${programName}${exeSuffix(platformTarget)}`
99
116
  await Bun.$`mkdir -p ${`${distDir}/cli-thin/${shortName}`}`.quiet()
100
- const result = await Bun.build({
101
- entrypoints: [CLI_ENTRY],
102
- target: 'bun',
103
- compile: { target: platformTarget, outfile: platformOut },
104
- plugins,
105
- })
106
- exitOnBuildFailure(result)
107
- log.success(`compiled thin cli binary: ${platformOut}`)
108
- return platformOut
117
+ return buildTargetPair(platformTarget, cliOut)
109
118
  }),
110
119
  )
111
120
  }
112
121
 
113
- const suffix = exeSuffix(target)
114
- const outPath = outfile ?? `${distDir}/cli${suffix}`
115
-
116
- const cliResult = await Bun.build({
117
- entrypoints: [CLI_ENTRY],
118
- target: 'bun',
119
- compile: { target, outfile: outPath },
120
- plugins,
121
- })
122
- exitOnBuildFailure(cliResult)
123
-
124
- log.success(`compiled thin cli binary: ${outPath} (target: ${target})`)
125
- return [outPath]
122
+ const cliOut = outfile ?? `${distDir}/cli${exeSuffix(target)}`
123
+ return [await buildTargetPair(target, cliOut)]
126
124
  }
127
125
 
128
126
  async function readProgramName(cwd: string): Promise<string> {
package/src/bundleApp.ts CHANGED
@@ -61,15 +61,17 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
61
61
  await compile({ cwd, target, outfile: `${binDir}/${serverBinaryFilename()}` })
62
62
 
63
63
  /*
64
- Opt-in: ship the project's `.env.bundle` as the shipped `.env`, which the
64
+ Opt-in: ship the project's `bundle.env` as the shipped `.env`, which the
65
65
  server loads at boot (loadEnvFromBinaryDir) as its default config layer. A
66
66
  dedicated file, never the working `.env` — a compiled bundle is extractable,
67
67
  so only ship-safe defaults belong here; user-specific/secret values come from
68
- the data-dir `.env` instead. bundleLayout places it under Contents/Resources
68
+ the data-dir `.env` instead. Named outside Bun's `.env.*` autoload family on
69
+ purpose: it's a build input, not a runtime overlay, so `bun dev`/`bun start`
70
+ never pick it up. bundleLayout places it under Contents/Resources
69
71
  in a macOS `.app` (sealed as a resource, so it survives codesign) and beside
70
72
  the binaries otherwise. Skipped when absent.
71
73
  */
72
- const bundleEnv = Bun.file(`${cwd}/.env.bundle`)
74
+ const bundleEnv = Bun.file(`${cwd}/bundle.env`)
73
75
  if (await bundleEnv.exists()) {
74
76
  await Bun.$`mkdir -p ${dirname(envPath)}`.quiet()
75
77
  await Bun.write(envPath, bundleEnv)
package/src/compile.ts CHANGED
@@ -21,13 +21,23 @@ export async function compile({
21
21
  cwd = process.cwd(),
22
22
  target = detectTarget(),
23
23
  outfile,
24
+ buildClient = true,
24
25
  }: {
25
26
  cwd?: string
26
27
  target?: CompileTarget
27
28
  outfile?: string
29
+ /*
30
+ Skip the client `build` (which clears dist). Set false when the caller already
31
+ built the platform-independent client once and is compiling several server
32
+ binaries against it — e.g. `belte cli` co-shipping a per-platform server beside
33
+ each CLI binary — so the shared `dist/_app` isn't wiped between targets.
34
+ */
35
+ buildClient?: boolean
28
36
  } = {}): Promise<string> {
29
37
  const svelteConfig = await loadSvelteConfig(cwd)
30
- await build({ cwd, svelteConfig })
38
+ if (buildClient) {
39
+ await build({ cwd, svelteConfig })
40
+ }
31
41
 
32
42
  const outPath = outfile ?? `${cwd}/dist/app${exeSuffix(target)}`
33
43
 
@@ -1,20 +1,20 @@
1
- import { mkdir, rm } from 'node:fs/promises'
1
+ import { mkdir } from 'node:fs/promises'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { bindConnectedFlag } from './lib/bundle/bindConnectedFlag.ts'
4
4
  import { bindRequestNavigate } from './lib/bundle/bindRequestNavigate.ts'
5
5
  import { listenLocalControlServer } from './lib/bundle/listenLocalControlServer.ts'
6
6
  import { probeBelteServer } from './lib/bundle/probeBelteServer.ts'
7
- import { resolveServerBinary } from './lib/bundle/resolveServerBinary.ts'
8
7
  import { resolveWebviewLib } from './lib/bundle/resolveWebviewLib.ts'
8
+ import { spawnEmbeddedServer } from './lib/bundle/spawnEmbeddedServer.ts'
9
9
  import { stableLocalPort } from './lib/bundle/stableLocalPort.ts'
10
- import { waitForServer } from './lib/bundle/waitForServer.ts'
11
- import { findOpenPort } from './lib/server/runtime/findOpenPort.ts'
12
- import { parsePort } from './lib/server/runtime/parsePort.ts'
13
10
  import { appDataDir } from './lib/shared/appDataDir.ts'
14
11
  import { bundleLayout } from './lib/shared/bundleLayout.ts'
12
+ import { clearLastConnection } from './lib/shared/clearLastConnection.ts'
15
13
  import { log } from './lib/shared/log.ts'
16
14
  import { readEnvFile } from './lib/shared/readEnvFile.ts'
15
+ import { readLastConnection } from './lib/shared/readLastConnection.ts'
17
16
  import { serializeEnv } from './lib/shared/serializeEnv.ts'
17
+ import { writeLastConnection } from './lib/shared/writeLastConnection.ts'
18
18
 
19
19
  /*
20
20
  The bundle's control server, run in a Worker so it owns its own thread.
@@ -158,57 +158,14 @@ function handleConnectionLost(url: string): void {
158
158
  }
159
159
 
160
160
  /*
161
- Spawns the sibling server binary on a free port and waits for it to answer,
162
- returning the URL to point the window at. Any previous child is reaped first so
163
- only one embedded server runs at a time.
161
+ Boots the embedded server via the shared spawn helper and keeps the child so
162
+ killServerChild can reap it. Any previous child is reaped first so only one
163
+ embedded server runs at a time; returns the URL to point the window at.
164
164
  */
165
- /*
166
- The port the embedded server binds. A `PORT` configured in the data-dir `.env`
167
- (where the config form writes), the shipped binary-dir `.env`, or the launcher's
168
- own env is honored — so the server answers at a fixed, known address another
169
- machine can reliably connect to. With none set, the first open port at/above
170
- 3000 is chosen (matching the standalone server's default). Precedence matches
171
- the server's own env stack: shell > data-dir > binary-dir. A configured port is
172
- used as-is and not second-guessed — if it's taken, the bind failure surfaces
173
- rather than silently moving.
174
- */
175
- async function resolveEmbeddedPort(): Promise<number> {
176
- const [dataDirEnv, binaryDirEnv] = await Promise.all([
177
- readEnvFile(dataDirEnvPath()),
178
- readEnvFile(binaryDirEnvPath()),
179
- ])
180
- return parsePort(process.env.PORT ?? dataDirEnv.PORT ?? binaryDirEnv.PORT) ?? findOpenPort(3000)
181
- }
182
-
183
165
  async function startEmbeddedServer(timeoutMs?: number): Promise<string> {
184
166
  killServerChild()
185
- const port = await resolveEmbeddedPort()
186
- const url = `http://localhost:${port}`
187
- serverChild = Bun.spawn({
188
- cmd: [resolveServerBinary()],
189
- // BELTE_PARENT_PID lets the child exit if the launcher is force-quit
190
- // (a clean window close reaps it directly; see exitWithParent). The
191
- // server resolves its own config from its data-dir/binary-dir .env at
192
- // boot (see serverEntry), so the launcher injects nothing else.
193
- env: { ...process.env, PORT: String(port), BELTE_PARENT_PID: String(process.pid) },
194
- stdio: ['inherit', 'inherit', 'inherit'],
195
- })
196
- /*
197
- Race readiness against the child's exit. A misconfigured bundle (missing env
198
- the server needs to bind) crashes immediately; without this the launcher
199
- would wait out waitForServer's full timeout and report a generic stall
200
- instead of the actual crash. The exit branch resolves (never rejects) so the
201
- race loser still pending after a successful boot can't surface as an
202
- unhandled rejection when the child is later reaped on disconnect.
203
- */
204
- const exited = serverChild.exited
205
- const outcome = await Promise.race([
206
- waitForServer(url, timeoutMs ? { timeoutMs } : undefined).then(() => undefined),
207
- exited,
208
- ])
209
- if (outcome !== undefined) {
210
- throw new Error(`[belte] embedded server exited (code ${outcome}) before binding`)
211
- }
167
+ const { url, child } = await spawnEmbeddedServer({ programName, timeoutMs })
168
+ serverChild = child
212
169
  return url
213
170
  }
214
171
 
@@ -229,7 +186,7 @@ so a half-started server doesn't hold its port.
229
186
  */
230
187
  const AUTO_START_CEILING_MS = 3000
231
188
  async function resolveLaunchTarget(): Promise<string> {
232
- const last = await readLastConnection()
189
+ const last = await readLastConnection(programName)
233
190
  if (!last) {
234
191
  return controlOrigin
235
192
  }
@@ -332,40 +289,6 @@ async function writeConfig(values: Record<string, string>): Promise<void> {
332
289
  await Bun.write(path, serializeEnv(merged))
333
290
  }
334
291
 
335
- /*
336
- The launcher-owned record of the last connection, in the data dir so it survives
337
- relaunch and is readable before the window opens — unlike the webview's
338
- localStorage, and unlike the embedded server's URL, which can't be persisted
339
- because it picks a fresh port each launch (so we record the intent, not the URL).
340
- resolveLaunchTarget reads it; /connect and /start write it; /disconnect clears it.
341
- */
342
- type LastConnection = { kind: 'embedded' } | { kind: 'url'; url: string }
343
-
344
- function lastConnectionPath(): string {
345
- return join(appDataDir(programName), 'last-connection.json')
346
- }
347
-
348
- async function readLastConnection(): Promise<LastConnection | undefined> {
349
- const file = Bun.file(lastConnectionPath())
350
- if (!(await file.exists())) {
351
- return undefined
352
- }
353
- try {
354
- return (await file.json()) as LastConnection
355
- } catch {
356
- return undefined
357
- }
358
- }
359
-
360
- async function writeLastConnection(value: LastConnection): Promise<void> {
361
- await mkdir(appDataDir(programName), { recursive: true })
362
- await Bun.write(lastConnectionPath(), JSON.stringify(value))
363
- }
364
-
365
- async function clearLastConnection(): Promise<void> {
366
- await rm(lastConnectionPath(), { force: true })
367
- }
368
-
369
292
  // GET /__belte/config — the form's schema + current values, or null schema to skip the gate.
370
293
  async function handleConfigGet(): Promise<Response> {
371
294
  if (!configSchema) {
@@ -393,7 +316,7 @@ async function handleConnect(request: Request): Promise<Response> {
393
316
  flag?.setConnected(true)
394
317
  startLivenessWatch(target)
395
318
  // Record the choice so the next launch reconnects here before opening.
396
- await writeLastConnection({ kind: 'url', url: target })
319
+ await writeLastConnection(programName, { kind: 'url', url: target })
397
320
  log.info(`connecting to ${identity.name} at ${target}`)
398
321
  return Response.json({ redirect: target })
399
322
  }
@@ -405,7 +328,7 @@ async function handleStart(): Promise<Response> {
405
328
  flag?.setConnected(true)
406
329
  startLivenessWatch(localUrl)
407
330
  // Record the choice so the next launch boots the embedded server first.
408
- await writeLastConnection({ kind: 'embedded' })
331
+ await writeLastConnection(programName, { kind: 'embedded' })
409
332
  log.info(`started embedded server at ${localUrl}`)
410
333
  return Response.json({ redirect: localUrl })
411
334
  } catch (error) {
@@ -419,7 +342,7 @@ async function handleDisconnect(): Promise<Response> {
419
342
  stopLivenessWatch()
420
343
  killServerChild()
421
344
  flag?.setConnected(false)
422
- await clearLastConnection()
345
+ await clearLastConnection(programName)
423
346
  return new Response(undefined, { status: 204 })
424
347
  }
425
348
 
@@ -0,0 +1,61 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { findOpenPort } from '../server/runtime/findOpenPort.ts'
3
+ import { parsePort } from '../server/runtime/parsePort.ts'
4
+ import { appDataDir } from '../shared/appDataDir.ts'
5
+ import { bundleLayout } from '../shared/bundleLayout.ts'
6
+ import { readEnvFile } from '../shared/readEnvFile.ts'
7
+ import { resolveServerBinary } from './resolveServerBinary.ts'
8
+ import { waitForServer } from './waitForServer.ts'
9
+
10
+ /*
11
+ The port the embedded server binds. A `PORT` from the shell, the data-dir `.env`
12
+ (where the config form writes), or the shipped binary-dir `.env` is honored — so
13
+ the server answers at a fixed, known address another machine can reliably connect
14
+ to. With none set, the first open port at/above 3000 is chosen (matching the
15
+ standalone server's default). Precedence matches the server's own env stack:
16
+ shell > data-dir > binary-dir. A configured port is used as-is — if it's taken,
17
+ the bind failure surfaces rather than silently moving.
18
+ */
19
+ async function resolveEmbeddedPort(programName: string): Promise<number> {
20
+ const [dataDirEnv, binaryDirEnv] = await Promise.all([
21
+ readEnvFile(join(appDataDir(programName), '.env')),
22
+ readEnvFile(bundleLayout(dirname(process.execPath)).envPath),
23
+ ])
24
+ return parsePort(process.env.PORT ?? dataDirEnv.PORT ?? binaryDirEnv.PORT) ?? findOpenPort(3000)
25
+ }
26
+
27
+ /*
28
+ Spawns the sibling server binary on a free port and waits for it to answer,
29
+ returning the live URL plus the child so the caller owns its lifetime (reaping on
30
+ disconnect/exit). Readiness is raced against the child's exit so a server that
31
+ crashes on boot (missing config) surfaces immediately instead of stalling out
32
+ waitForServer's full timeout; the loser branch resolves (never rejects) so it
33
+ can't surface as an unhandled rejection once the child is later reaped. Does not
34
+ reap a previous child — the caller owns that.
35
+ */
36
+ export async function spawnEmbeddedServer({
37
+ programName,
38
+ timeoutMs,
39
+ }: {
40
+ programName: string
41
+ timeoutMs?: number
42
+ }): Promise<{ url: string; child: ReturnType<typeof Bun.spawn> }> {
43
+ const port = await resolveEmbeddedPort(programName)
44
+ const url = `http://localhost:${port}`
45
+ const child = Bun.spawn({
46
+ cmd: [resolveServerBinary()],
47
+ // BELTE_PARENT_PID lets the child exit if the parent is force-quit (a clean
48
+ // shutdown reaps it directly). The server resolves its own config from its
49
+ // data-dir/binary-dir .env at boot, so nothing else is injected.
50
+ env: { ...process.env, PORT: String(port), BELTE_PARENT_PID: String(process.pid) },
51
+ stdio: ['inherit', 'inherit', 'inherit'],
52
+ })
53
+ const outcome = await Promise.race([
54
+ waitForServer(url, timeoutMs ? { timeoutMs } : undefined).then(() => undefined),
55
+ child.exited,
56
+ ])
57
+ if (outcome !== undefined) {
58
+ throw new Error(`[belte] embedded server exited (code ${outcome}) before binding`)
59
+ }
60
+ return { url, child }
61
+ }
@@ -0,0 +1,23 @@
1
+ import { probeBelteServer } from '../bundle/probeBelteServer.ts'
2
+ import { log } from '../shared/log.ts'
3
+ import { writeLastConnection } from '../shared/writeLastConnection.ts'
4
+ import type { CliTarget } from './types/CliTarget.ts'
5
+
6
+ /*
7
+ Connects to a remote belte server: probes its identity endpoint first so we never
8
+ record or talk to a non-belte URL, then persists the intent so the next bare run
9
+ resumes here. Carries the env bearer token (baked or shell) for authed servers.
10
+ Returns the target, or undefined when nothing belte answers.
11
+ */
12
+ export async function connectToServer(
13
+ programName: string,
14
+ url: string,
15
+ ): Promise<CliTarget | undefined> {
16
+ const identity = await probeBelteServer(url)
17
+ if (!identity) {
18
+ log.warn(`no belte server responded at ${url}`)
19
+ return undefined
20
+ }
21
+ await writeLastConnection(programName, { kind: 'url', url })
22
+ return { url, token: process.env.APP_TOKEN, name: identity.name }
23
+ }
@@ -0,0 +1,71 @@
1
+ import { decodeResponse } from '../shared/decodeResponse.ts'
2
+ import { isStreamingResponse } from '../shared/isStreamingResponse.ts'
3
+ import { responseErrorText } from '../shared/responseErrorText.ts'
4
+ import { streamResponse } from '../shared/streamResponse.ts'
5
+ import { createClient } from './createClient.ts'
6
+ import { parseArgvForRpc } from './parseArgvForRpc.ts'
7
+ import { printValue } from './printValue.ts'
8
+ import type { CliManifest } from './types/CliManifest.ts'
9
+
10
+ /*
11
+ Runs one RPC command against a target server and prints the result, returning a
12
+ process exit code. Shared by the one-shot path (runCli) and the interactive
13
+ session (runSession) so a command behaves identically typed at the shell or at
14
+ the session prompt. Streaming responses (sse/jsonl — a streaming verb or a socket
15
+ `tail`) print frame-by-frame as NDJSON; everything else decodes and prints once.
16
+ */
17
+ export async function dispatchCommand({
18
+ programName,
19
+ manifest,
20
+ command,
21
+ argvTail,
22
+ url,
23
+ token,
24
+ }: {
25
+ programName: string
26
+ manifest: CliManifest
27
+ command: string
28
+ argvTail: string[]
29
+ url: string
30
+ token?: string
31
+ }): Promise<number> {
32
+ const entry = manifest[command]
33
+ if (!entry) {
34
+ console.error(
35
+ `${programName}: unknown command "${command}" — run \`${programName} --help\` for the list`,
36
+ )
37
+ return 1
38
+ }
39
+
40
+ let args: Record<string, unknown> | undefined
41
+ try {
42
+ args = await parseArgvForRpc(argvTail, entry.jsonSchema)
43
+ } catch (error) {
44
+ console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
45
+ return 1
46
+ }
47
+
48
+ const client = createClient({ url, token, manifest })
49
+ const fn = client[command]
50
+ if (!fn) {
51
+ console.error(`${programName}: command "${command}" not in client`)
52
+ return 1
53
+ }
54
+ try {
55
+ const response = await fn.raw(args)
56
+ if (isStreamingResponse(response)) {
57
+ for await (const frame of streamResponse(response)) {
58
+ printValue(frame, false)
59
+ }
60
+ return 0
61
+ }
62
+ if (!response.ok) {
63
+ throw new Error(await responseErrorText(response))
64
+ }
65
+ printValue(await decodeResponse(response), true)
66
+ return 0
67
+ } catch (error) {
68
+ console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
69
+ return 1
70
+ }
71
+ }
@@ -7,7 +7,7 @@ Loads the bundle's shipped `.env` into `process.env`, resolved from the running
7
7
  binary's directory (`process.execPath`) via bundleLayout — beside the binary in
8
8
  the flat layout, under `Contents/Resources` in a macOS `.app`. This is the file the
9
9
  install tarball ships beside the executable — and, for a bundle, the one `bundleApp`
10
- copies from the project's `.env.bundle`. It carries the app's shipped defaults; the
10
+ copies from the project's `bundle.env`. It carries the app's shipped defaults; the
11
11
  fill-when-unset merge (see loadEnvFile) lets per-shell exports, Bun's CWD
12
12
  `.env`, and the user's data-dir config all override it.
13
13
  */
@@ -1,3 +1,4 @@
1
+ import { printTrimmed } from './printTrimmed.ts'
1
2
  import type { CliManifest } from './types/CliManifest.ts'
2
3
  import type { CliManifestEntry } from './types/CliManifestEntry.ts'
3
4
 
@@ -37,7 +38,7 @@ export function printTopLevelHelp(
37
38
  footer = '',
38
39
  ): void {
39
40
  if (banner.trim()) {
40
- console.log(banner.replace(/\n$/, ''))
41
+ printTrimmed(banner)
41
42
  console.log('')
42
43
  }
43
44
  const names = Object.keys(manifest).toSorted()
@@ -65,14 +66,19 @@ export function printTopLevelHelp(
65
66
  console.log(` ${' '.repeat(20)} ${detail}`)
66
67
  }
67
68
  }
69
+ console.log(`\nconnection (\`/\` manages the connection, a bare word runs a command):`)
70
+ console.log(` ${programName} /connect <url> connect to a remote server`)
71
+ console.log(` ${programName} /start start a local instance`)
72
+ console.log(` ${programName} /disconnect forget the saved connection`)
73
+ console.log(` ${programName} resume the saved connection (session)`)
68
74
  console.log(`\n --help, -h show this help`)
69
75
  console.log(` <command> --help show help for a specific command`)
70
76
  console.log(`\nenv:`)
71
- console.log(` APP_URL remote server URL (required)`)
77
+ console.log(` APP_URL default server URL (baked at install; shell-overridable)`)
72
78
  console.log(` APP_TOKEN sent as Authorization: Bearer <value>`)
73
79
  if (footer.trim()) {
74
80
  console.log('')
75
- console.log(footer.replace(/\n$/, ''))
81
+ printTrimmed(footer)
76
82
  }
77
83
  }
78
84
 
@@ -0,0 +1,27 @@
1
+ import { printCommandHelp, printTopLevelHelp } from './printHelp.ts'
2
+ import type { CliManifest } from './types/CliManifest.ts'
3
+
4
+ /*
5
+ Session `/help`: with a command name, the per-command flag help; otherwise the
6
+ meta-command list followed by the RPC command listing (no banner — already shown
7
+ at session start).
8
+ */
9
+ export function printSessionHelp(
10
+ programName: string,
11
+ manifest: CliManifest,
12
+ command?: string,
13
+ ): void {
14
+ if (command) {
15
+ printCommandHelp(programName, command, manifest)
16
+ return
17
+ }
18
+ console.log('session commands:')
19
+ console.log(' /connect <url> connect to a remote server')
20
+ console.log(' /start start a local instance')
21
+ console.log(' /disconnect disconnect and forget the saved connection')
22
+ console.log(' /help [command] show this help, or help for one command')
23
+ console.log(' /clear clear the screen')
24
+ console.log(' /exit leave the session')
25
+ console.log('')
26
+ printTopLevelHelp(programName, manifest)
27
+ }
@@ -0,0 +1,21 @@
1
+ import { probeBelteServer } from '../bundle/probeBelteServer.ts'
2
+ import type { CliTarget } from './types/CliTarget.ts'
3
+
4
+ /*
5
+ Prints the session's connection line. A local instance (spawned child) reads as
6
+ "running a local instance"; a remote one reads as "connected to <name>", using the
7
+ name the target already carries from its resolve-time probe and only re-probing
8
+ when it doesn't. No target → the not-connected hint listing the verbs.
9
+ */
10
+ export async function printSessionStatus(target: CliTarget | undefined): Promise<void> {
11
+ if (!target) {
12
+ console.log('(not connected — /connect <url> or /start)')
13
+ return
14
+ }
15
+ if (target.child) {
16
+ console.log(`running a local instance at ${target.url}`)
17
+ return
18
+ }
19
+ const name = target.name ?? (await probeBelteServer(target.url))?.name ?? target.url
20
+ console.log(`connected to ${name} at ${target.url}`)
21
+ }