@briancray/belte 0.8.0 → 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 +10 -9
- package/package.json +1 -1
- package/src/buildCli.ts +46 -48
- package/src/bundleApp.ts +5 -3
- package/src/compile.ts +11 -1
- package/src/controlServerWorker.ts +14 -91
- package/src/lib/bundle/spawnEmbeddedServer.ts +61 -0
- package/src/lib/cli/connectToServer.ts +23 -0
- package/src/lib/cli/dispatchCommand.ts +71 -0
- package/src/lib/cli/loadEnvFromBinaryDir.ts +1 -1
- package/src/lib/cli/printHelp.ts +9 -3
- package/src/lib/cli/printSessionHelp.ts +27 -0
- package/src/lib/cli/printSessionStatus.ts +21 -0
- package/src/lib/cli/printTrimmed.ts +8 -0
- package/src/lib/cli/printValue.ts +10 -0
- package/src/lib/cli/resolveCliTarget.ts +48 -0
- package/src/lib/cli/runCli.ts +96 -78
- package/src/lib/cli/runSession.ts +105 -0
- package/src/lib/cli/startLocalInstance.ts +14 -0
- package/src/lib/cli/tokenizeLine.ts +51 -0
- package/src/lib/cli/types/CliTarget.ts +13 -0
- package/src/lib/server/cli/handleCliDownload.ts +26 -9
- package/src/lib/server/runtime/createServer.ts +143 -125
- package/src/lib/server/runtime/listenOnOpenPort.ts +36 -0
- package/src/lib/shared/clearLastConnection.ts +7 -0
- package/src/lib/shared/lastConnectionPath.ts +7 -0
- package/src/lib/shared/readLastConnection.ts +18 -0
- package/src/lib/shared/runningAsStandaloneBinary.ts +13 -0
- package/src/lib/shared/types/LastConnection.ts +9 -0
- package/src/lib/shared/writeLastConnection.ts +13 -0
- package/src/serverEntry.ts +12 -6
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
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// cross-compiles per target into
|
|
85
|
-
// /
|
|
86
|
-
//
|
|
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
|
-
'
|
|
133
|
-
'
|
|
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
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
114
|
-
|
|
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
|
|
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.
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
186
|
-
|
|
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
|
|
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
|
*/
|
package/src/lib/cli/printHelp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|