@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 +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/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
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Prints a block of chrome (banner/footer) with its trailing newline stripped,
|
|
2
|
+
// or nothing when the text is blank. Shared by the help, banner, and session
|
|
3
|
+
// footer so the trim-and-skip idiom lives in one place.
|
|
4
|
+
export function printTrimmed(text: string): void {
|
|
5
|
+
if (text.trim()) {
|
|
6
|
+
console.log(text.replace(/\n$/, ''))
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// String results print verbatim (with a trailing newline); everything else as a JSON line.
|
|
2
|
+
export function printValue(value: unknown, pretty: boolean): void {
|
|
3
|
+
if (typeof value === 'string') {
|
|
4
|
+
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
|
|
5
|
+
return
|
|
6
|
+
}
|
|
7
|
+
if (value !== undefined) {
|
|
8
|
+
process.stdout.write(`${JSON.stringify(value, null, pretty ? 2 : undefined)}\n`)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { probeBelteServer } from '../bundle/probeBelteServer.ts'
|
|
2
|
+
import { spawnEmbeddedServer } from '../bundle/spawnEmbeddedServer.ts'
|
|
3
|
+
import { log } from '../shared/log.ts'
|
|
4
|
+
import { readLastConnection } from '../shared/readLastConnection.ts'
|
|
5
|
+
import type { CliTarget } from './types/CliTarget.ts'
|
|
6
|
+
|
|
7
|
+
// Bound a resume boot so a slow/failed local start falls back to not-connected
|
|
8
|
+
// rather than hanging the CLI before the prompt appears.
|
|
9
|
+
const AUTO_START_CEILING_MS = 3000
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
Resolves the connection to resume when the CLI runs without an explicit
|
|
13
|
+
connection verb — the terminal analogue of the bundle's resolveLaunchTarget.
|
|
14
|
+
Reads the saved intent:
|
|
15
|
+
- embedded → boot a fresh local instance (bounded; undefined on failure)
|
|
16
|
+
- url, still alive → connect to it
|
|
17
|
+
- url, now dead → warn, undefined (caller shows the not-connected prompt)
|
|
18
|
+
- nothing recorded → the baked/shell APP_URL default, else undefined
|
|
19
|
+
Returns undefined when there's nothing live to talk to.
|
|
20
|
+
*/
|
|
21
|
+
export async function resolveCliTarget(programName: string): Promise<CliTarget | undefined> {
|
|
22
|
+
const last = await readLastConnection(programName)
|
|
23
|
+
if (last?.kind === 'embedded') {
|
|
24
|
+
try {
|
|
25
|
+
const { url, child } = await spawnEmbeddedServer({
|
|
26
|
+
programName,
|
|
27
|
+
timeoutMs: AUTO_START_CEILING_MS,
|
|
28
|
+
})
|
|
29
|
+
return { url, child }
|
|
30
|
+
} catch (error) {
|
|
31
|
+
log.warn(
|
|
32
|
+
`could not start local instance: ${error instanceof Error ? error.message : String(error)}`,
|
|
33
|
+
)
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (last?.kind === 'url') {
|
|
38
|
+
const identity = await probeBelteServer(last.url)
|
|
39
|
+
if (identity) {
|
|
40
|
+
return { url: last.url, token: process.env.APP_TOKEN, name: identity.name }
|
|
41
|
+
}
|
|
42
|
+
log.warn(`last server at ${last.url} is not responding`)
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
// Nothing recorded — fall back to the baked default / shell override.
|
|
46
|
+
const appUrl = process.env.APP_URL
|
|
47
|
+
return appUrl ? { url: appUrl, token: process.env.APP_TOKEN } : undefined
|
|
48
|
+
}
|
package/src/lib/cli/runCli.ts
CHANGED
|
@@ -1,44 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { createClient } from './createClient.ts'
|
|
1
|
+
import { clearLastConnection } from '../shared/clearLastConnection.ts'
|
|
2
|
+
import { loadEnvFromDataDir } from '../shared/loadEnvFromDataDir.ts'
|
|
3
|
+
import { connectToServer } from './connectToServer.ts'
|
|
4
|
+
import { dispatchCommand } from './dispatchCommand.ts'
|
|
6
5
|
import { loadEnvFromBinaryDir } from './loadEnvFromBinaryDir.ts'
|
|
7
|
-
import { parseArgvForRpc } from './parseArgvForRpc.ts'
|
|
8
6
|
import { printCommandHelp, printTopLevelHelp } from './printHelp.ts'
|
|
7
|
+
import { printTrimmed } from './printTrimmed.ts'
|
|
8
|
+
import { resolveCliTarget } from './resolveCliTarget.ts'
|
|
9
|
+
import { runSession } from './runSession.ts'
|
|
10
|
+
import { startLocalInstance } from './startLocalInstance.ts'
|
|
9
11
|
import type { CliManifest } from './types/CliManifest.ts'
|
|
12
|
+
import type { CliTarget } from './types/CliTarget.ts'
|
|
10
13
|
|
|
11
14
|
const isHelpFlag = (arg: string): boolean => arg === '--help' || arg === '-h'
|
|
12
15
|
|
|
13
|
-
// String results print verbatim (with a trailing newline); everything else as a JSON line.
|
|
14
|
-
function printValue(value: unknown, pretty: boolean): void {
|
|
15
|
-
if (typeof value === 'string') {
|
|
16
|
-
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
|
|
17
|
-
return
|
|
18
|
-
}
|
|
19
|
-
if (value !== undefined) {
|
|
20
|
-
process.stdout.write(`${JSON.stringify(value, null, pretty ? 2 : undefined)}\n`)
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
16
|
/*
|
|
25
|
-
Top-level CLI driver
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
Top-level CLI driver for the standalone binary. The binary is a thin remote client
|
|
18
|
+
— it carries no handler code, so it always talks to a running server over HTTP, but
|
|
19
|
+
it can boot one: the full binary ships the server beside it, so `/start` spawns a
|
|
20
|
+
local instance. One rule governs the first positional — `/` manages the connection,
|
|
21
|
+
a bare word runs a command:
|
|
29
22
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
23
|
+
--help / -h → top-level help
|
|
24
|
+
/help [cmd] → help (per-command with an arg)
|
|
25
|
+
(none) + TTY → interactive session, resuming the saved connection
|
|
26
|
+
(none) + non-TTY → top-level help (scripts use `<cmd>` one-shot)
|
|
27
|
+
/connect <url> → connect to a remote server, open a session
|
|
28
|
+
/start → boot a local instance, open a session
|
|
29
|
+
/disconnect → forget the saved connection, exit
|
|
30
|
+
<cmd> [--flags] → one-shot RPC against the resumed target
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
and pretty-printed once.
|
|
32
|
+
The connection verbs are `/`-prefixed only — no bare aliases — so a bare word is
|
|
33
|
+
always an RPC command and never collides. Env layers APP_URL/APP_TOKEN (shell >
|
|
34
|
+
data-dir > binary-dir) supply the baked default a fresh download resumes against.
|
|
42
35
|
*/
|
|
43
36
|
export async function runCli({
|
|
44
37
|
programName,
|
|
@@ -53,69 +46,94 @@ export async function runCli({
|
|
|
53
46
|
footer?: string
|
|
54
47
|
argv: string[]
|
|
55
48
|
}): Promise<number> {
|
|
49
|
+
await loadEnvFromDataDir(programName)
|
|
56
50
|
await loadEnvFromBinaryDir()
|
|
57
51
|
|
|
58
52
|
const first = argv[0]
|
|
59
|
-
|
|
53
|
+
|
|
54
|
+
// Explicit help, top-level and per-command.
|
|
55
|
+
if (first && isHelpFlag(first)) {
|
|
60
56
|
printTopLevelHelp(programName, manifest, banner, footer)
|
|
61
57
|
return 0
|
|
62
58
|
}
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
if (first === '/help') {
|
|
60
|
+
if (argv[1]) {
|
|
61
|
+
printCommandHelp(programName, argv[1], manifest)
|
|
62
|
+
} else {
|
|
63
|
+
printTopLevelHelp(programName, manifest, banner, footer)
|
|
64
|
+
}
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
if (first && argv.some(isHelpFlag)) {
|
|
65
68
|
printCommandHelp(programName, first, manifest)
|
|
66
69
|
return 0
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
if (!
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// No command: interactive session on a TTY, help otherwise (scripts/pipes).
|
|
73
|
+
if (!first) {
|
|
74
|
+
if (!process.stdin.isTTY) {
|
|
75
|
+
printTopLevelHelp(programName, manifest, banner, footer)
|
|
76
|
+
return 0
|
|
77
|
+
}
|
|
78
|
+
printTrimmed(banner)
|
|
79
|
+
const target = await resolveCliTarget(programName)
|
|
80
|
+
return runSession({ programName, manifest, footer, target })
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return 1
|
|
83
|
+
// Disconnect (reset): clear the saved connection and exit.
|
|
84
|
+
if (first === '/disconnect') {
|
|
85
|
+
await clearLastConnection(programName)
|
|
86
|
+
console.log('disconnected')
|
|
87
|
+
return 0
|
|
83
88
|
}
|
|
84
89
|
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
// Connect to a remote server, then open a session.
|
|
91
|
+
if (first === '/connect') {
|
|
92
|
+
const url = argv[1]
|
|
93
|
+
if (!url) {
|
|
94
|
+
console.error(`${programName}: /connect requires a url`)
|
|
95
|
+
return 1
|
|
96
|
+
}
|
|
97
|
+
printTrimmed(banner)
|
|
98
|
+
const target = await connectToServer(programName, url)
|
|
99
|
+
if (!target) {
|
|
100
|
+
return 1
|
|
101
|
+
}
|
|
102
|
+
return runSession({ programName, manifest, footer, target })
|
|
91
103
|
}
|
|
92
|
-
const appToken = process.env.APP_TOKEN
|
|
93
|
-
const client = createClient({ url: appUrl, token: appToken, manifest })
|
|
94
104
|
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
*/
|
|
107
|
-
for await (const frame of streamResponse(response)) {
|
|
108
|
-
printValue(frame, false)
|
|
109
|
-
}
|
|
110
|
-
return 0
|
|
111
|
-
}
|
|
112
|
-
if (!response.ok) {
|
|
113
|
-
throw new Error(await responseErrorText(response))
|
|
105
|
+
// Start a local instance, then open a session.
|
|
106
|
+
if (first === '/start') {
|
|
107
|
+
printTrimmed(banner)
|
|
108
|
+
let target: CliTarget
|
|
109
|
+
try {
|
|
110
|
+
target = await startLocalInstance(programName)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(
|
|
113
|
+
`${programName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
114
|
+
)
|
|
115
|
+
return 1
|
|
114
116
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
return runSession({ programName, manifest, footer, target })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One-shot RPC dispatch (scripting): resolve the target without a session.
|
|
121
|
+
const target = await resolveCliTarget(programName)
|
|
122
|
+
if (!target) {
|
|
123
|
+
console.error(
|
|
124
|
+
`${programName}: not connected — run \`${programName} /connect <url>\` or \`${programName} /start\``,
|
|
125
|
+
)
|
|
119
126
|
return 1
|
|
120
127
|
}
|
|
128
|
+
const code = await dispatchCommand({
|
|
129
|
+
programName,
|
|
130
|
+
manifest,
|
|
131
|
+
command: first,
|
|
132
|
+
argvTail: argv.slice(1),
|
|
133
|
+
url: target.url,
|
|
134
|
+
token: target.token,
|
|
135
|
+
})
|
|
136
|
+
// Reap any local instance booted just to resolve the target.
|
|
137
|
+
target.child?.kill()
|
|
138
|
+
return code
|
|
121
139
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { clearLastConnection } from '../shared/clearLastConnection.ts'
|
|
2
|
+
import { connectToServer } from './connectToServer.ts'
|
|
3
|
+
import { dispatchCommand } from './dispatchCommand.ts'
|
|
4
|
+
import { printSessionHelp } from './printSessionHelp.ts'
|
|
5
|
+
import { printSessionStatus } from './printSessionStatus.ts'
|
|
6
|
+
import { printTrimmed } from './printTrimmed.ts'
|
|
7
|
+
import { startLocalInstance } from './startLocalInstance.ts'
|
|
8
|
+
import { tokenizeLine } from './tokenizeLine.ts'
|
|
9
|
+
import type { CliManifest } from './types/CliManifest.ts'
|
|
10
|
+
import type { CliTarget } from './types/CliTarget.ts'
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
Interactive session (REPL). The banner is printed once by the caller; this prints
|
|
14
|
+
the status line, then loops reading stdin lines via Bun's async-iterable console:
|
|
15
|
+
- bare words → dispatch the RPC against the current target
|
|
16
|
+
- /connect <url>, /start, /disconnect, /help, /clear, /exit → meta commands
|
|
17
|
+
The session owns the current target's child (a local instance) and reaps it when
|
|
18
|
+
the connection is swapped or the loop ends — including on SIGINT.
|
|
19
|
+
*/
|
|
20
|
+
export async function runSession({
|
|
21
|
+
programName,
|
|
22
|
+
manifest,
|
|
23
|
+
footer,
|
|
24
|
+
target,
|
|
25
|
+
}: {
|
|
26
|
+
programName: string
|
|
27
|
+
manifest: CliManifest
|
|
28
|
+
footer: string
|
|
29
|
+
target: CliTarget | undefined
|
|
30
|
+
}): Promise<number> {
|
|
31
|
+
let current = target
|
|
32
|
+
// Reap any local instance on Ctrl+C — the closure reads the latest `current`.
|
|
33
|
+
process.on('SIGINT', () => {
|
|
34
|
+
current?.child?.kill()
|
|
35
|
+
process.exit(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Swap the active connection: reap the previous local instance (only one runs
|
|
39
|
+
// at a time), adopt the next target, and reprint the status line.
|
|
40
|
+
async function swap(next: CliTarget | undefined): Promise<void> {
|
|
41
|
+
current?.child?.kill()
|
|
42
|
+
current = next
|
|
43
|
+
await printSessionStatus(current)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await printSessionStatus(current)
|
|
47
|
+
const promptText = `${programName}> `
|
|
48
|
+
process.stdout.write(promptText)
|
|
49
|
+
|
|
50
|
+
for await (const line of console) {
|
|
51
|
+
const tokens = tokenizeLine(line.trim())
|
|
52
|
+
const head = tokens[0]
|
|
53
|
+
if (head === undefined) {
|
|
54
|
+
process.stdout.write(promptText)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (head === '/exit' || head === '/quit') {
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
if (head === '/clear') {
|
|
61
|
+
console.clear()
|
|
62
|
+
} else if (head === '/help') {
|
|
63
|
+
printSessionHelp(programName, manifest, tokens[1])
|
|
64
|
+
} else if (head === '/connect') {
|
|
65
|
+
const url = tokens[1]
|
|
66
|
+
if (!url) {
|
|
67
|
+
console.error('/connect requires a url')
|
|
68
|
+
} else {
|
|
69
|
+
const next = await connectToServer(programName, url)
|
|
70
|
+
if (next) {
|
|
71
|
+
await swap(next)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else if (head === '/start') {
|
|
75
|
+
try {
|
|
76
|
+
await swap(await startLocalInstance(programName))
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error(
|
|
79
|
+
`could not start local instance: ${error instanceof Error ? error.message : String(error)}`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
} else if (head === '/disconnect') {
|
|
83
|
+
await clearLastConnection(programName)
|
|
84
|
+
await swap(undefined)
|
|
85
|
+
} else if (head.startsWith('/')) {
|
|
86
|
+
console.error(`unknown command "${head}" — /help for the list`)
|
|
87
|
+
} else if (!current) {
|
|
88
|
+
console.error('not connected — /connect <url> or /start')
|
|
89
|
+
} else {
|
|
90
|
+
await dispatchCommand({
|
|
91
|
+
programName,
|
|
92
|
+
manifest,
|
|
93
|
+
command: head,
|
|
94
|
+
argvTail: tokens.slice(1),
|
|
95
|
+
url: current.url,
|
|
96
|
+
token: current.token,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
process.stdout.write(promptText)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
current?.child?.kill()
|
|
103
|
+
printTrimmed(footer)
|
|
104
|
+
return 0
|
|
105
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { spawnEmbeddedServer } from '../bundle/spawnEmbeddedServer.ts'
|
|
2
|
+
import { writeLastConnection } from '../shared/writeLastConnection.ts'
|
|
3
|
+
import type { CliTarget } from './types/CliTarget.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Boots a local (embedded) instance for this session and records the intent so the
|
|
7
|
+
next bare run resumes a local instance. The caller owns the returned child and
|
|
8
|
+
reaps it on disconnect/exit. Throws if the server crashes before binding.
|
|
9
|
+
*/
|
|
10
|
+
export async function startLocalInstance(programName: string): Promise<CliTarget> {
|
|
11
|
+
const { url, child } = await spawnEmbeddedServer({ programName })
|
|
12
|
+
await writeLastConnection(programName, { kind: 'embedded' })
|
|
13
|
+
return { url, child }
|
|
14
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Splits a session input line into argv tokens, honouring single and double quotes
|
|
3
|
+
so values with spaces survive (e.g. `post --title "hello world"`). Quotes group;
|
|
4
|
+
a backslash escapes the next character (outside single quotes). Unterminated
|
|
5
|
+
quotes consume to end of line. Pure; no shell features beyond quoting — no
|
|
6
|
+
globbing, no variable expansion.
|
|
7
|
+
*/
|
|
8
|
+
export function tokenizeLine(line: string): string[] {
|
|
9
|
+
const tokens: string[] = []
|
|
10
|
+
let current = ''
|
|
11
|
+
let hasToken = false
|
|
12
|
+
let quote: '"' | "'" | undefined
|
|
13
|
+
for (let index = 0; index < line.length; index++) {
|
|
14
|
+
const char = line[index]
|
|
15
|
+
if (char === '\\' && quote !== "'") {
|
|
16
|
+
const next = line[++index]
|
|
17
|
+
if (next !== undefined) {
|
|
18
|
+
current += next
|
|
19
|
+
hasToken = true
|
|
20
|
+
}
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
if (quote) {
|
|
24
|
+
if (char === quote) {
|
|
25
|
+
quote = undefined
|
|
26
|
+
} else {
|
|
27
|
+
current += char
|
|
28
|
+
}
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
if (char === '"' || char === "'") {
|
|
32
|
+
quote = char
|
|
33
|
+
hasToken = true
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if (char === ' ' || char === '\t') {
|
|
37
|
+
if (hasToken) {
|
|
38
|
+
tokens.push(current)
|
|
39
|
+
current = ''
|
|
40
|
+
hasToken = false
|
|
41
|
+
}
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
current += char
|
|
45
|
+
hasToken = true
|
|
46
|
+
}
|
|
47
|
+
if (hasToken) {
|
|
48
|
+
tokens.push(current)
|
|
49
|
+
}
|
|
50
|
+
return tokens
|
|
51
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
A resolved CLI connection: the server URL plus an optional bearer token, and the
|
|
3
|
+
child process when this is a locally-spawned instance — the session owns that
|
|
4
|
+
child and reaps it on disconnect/exit.
|
|
5
|
+
*/
|
|
6
|
+
export type CliTarget = {
|
|
7
|
+
url: string
|
|
8
|
+
token?: string
|
|
9
|
+
child?: ReturnType<typeof Bun.spawn>
|
|
10
|
+
// The app's name from its identity probe, when already fetched while resolving
|
|
11
|
+
// the target — lets the status line print it without re-probing.
|
|
12
|
+
name?: string
|
|
13
|
+
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { NO_STORE } from '../../shared/cacheControlValues.ts'
|
|
2
|
+
import { exeSuffix } from '../../shared/exeSuffix.ts'
|
|
2
3
|
import { log } from '../../shared/log.ts'
|
|
3
4
|
import { normalizeTarget } from '../../shared/normalizeTarget.ts'
|
|
4
5
|
import { buildEnvContent } from './buildEnvContent.ts'
|
|
5
6
|
import { createTarGz } from './createTarGz.ts'
|
|
6
7
|
import { maxSourceMtime } from './maxSourceMtime.ts'
|
|
7
8
|
|
|
9
|
+
// The sibling server binary's name for a platform — `server` / `server.exe` — must
|
|
10
|
+
// match what resolveServerBinary() looks for next to the unpacked CLI binary.
|
|
11
|
+
function serverBinaryName(platform: string): string {
|
|
12
|
+
return `server${exeSuffix(normalizeTarget(platform))}`
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
/*
|
|
9
16
|
Process-wide per-platform build coalescing. Two concurrent curls for
|
|
10
17
|
the same /__belte/cli/<platform> share one promise; the later requests
|
|
@@ -46,16 +53,20 @@ async function computeBinary(
|
|
|
46
53
|
programName: string,
|
|
47
54
|
cwd: string,
|
|
48
55
|
): Promise<string | undefined> {
|
|
49
|
-
const
|
|
56
|
+
const dir = `${cwd}/dist/cli-thin/${platform}`
|
|
57
|
+
const binaryPath = `${dir}/${programName}`
|
|
58
|
+
const serverPath = `${dir}/${serverBinaryName(platform)}`
|
|
50
59
|
/*
|
|
51
|
-
On-disk
|
|
52
|
-
newest rpc/socket source mtime. The mtime check
|
|
53
|
-
common dev iteration where the user edits an rpc handler but
|
|
54
|
-
|
|
60
|
+
On-disk binaries are fresh when both the CLI and its sibling server exist AND
|
|
61
|
+
the CLI's mtime beats the newest rpc/socket source mtime. The mtime check
|
|
62
|
+
catches the common dev iteration where the user edits an rpc handler but didn't
|
|
63
|
+
run `belte cli` again; the server-exists check forces a rebuild for a dist
|
|
64
|
+
produced before the CLI co-shipped a server. Other source paths (project lib,
|
|
55
65
|
transitive imports) fall back to manual rebuild.
|
|
56
66
|
*/
|
|
57
67
|
const binaryFile = Bun.file(binaryPath)
|
|
58
|
-
|
|
68
|
+
const serverFile = Bun.file(serverPath)
|
|
69
|
+
if ((await binaryFile.exists()) && (await serverFile.exists())) {
|
|
59
70
|
const binaryMtime = (await binaryFile.stat()).mtimeMs
|
|
60
71
|
const sourceMtime = await maxSourceMtime(cwd)
|
|
61
72
|
if (binaryMtime >= sourceMtime) {
|
|
@@ -64,7 +75,7 @@ async function computeBinary(
|
|
|
64
75
|
log.info(`thin cli for ${platform} is stale — rebuilding`)
|
|
65
76
|
}
|
|
66
77
|
try {
|
|
67
|
-
log.info(`lazy-building
|
|
78
|
+
log.info(`lazy-building cli + server for ${platform}…`)
|
|
68
79
|
// Lazy-import buildCli so the build pipeline isn't pulled into
|
|
69
80
|
// production processes that never serve a download.
|
|
70
81
|
const { buildCli } = await import('../../../buildCli.ts')
|
|
@@ -72,7 +83,7 @@ async function computeBinary(
|
|
|
72
83
|
cwd,
|
|
73
84
|
platforms: [normalizeTarget(platform)],
|
|
74
85
|
})
|
|
75
|
-
return (await binaryFile.exists()) ? binaryPath : undefined
|
|
86
|
+
return (await binaryFile.exists()) && (await serverFile.exists()) ? binaryPath : undefined
|
|
76
87
|
} catch (error) {
|
|
77
88
|
log.error(error)
|
|
78
89
|
return undefined
|
|
@@ -107,9 +118,15 @@ export async function handleCliDownload(
|
|
|
107
118
|
auth && auth.toLowerCase().startsWith('bearer ') ? auth.slice('bearer '.length) : undefined
|
|
108
119
|
const envContent = buildEnvContent(appUrl, bearer)
|
|
109
120
|
|
|
110
|
-
const
|
|
121
|
+
const serverPath = `${cwd}/dist/cli-thin/${platform}/${serverBinaryName(platform)}`
|
|
122
|
+
const [binaryBytes, serverBytes] = await Promise.all([
|
|
123
|
+
Bun.file(binaryPath).bytes(),
|
|
124
|
+
Bun.file(serverPath).bytes(),
|
|
125
|
+
])
|
|
126
|
+
// Ship the server beside the CLI so `/start` can spawn a local instance.
|
|
111
127
|
const archive = createTarGz([
|
|
112
128
|
{ name: programName, content: binaryBytes, mode: 0o755 },
|
|
129
|
+
{ name: serverBinaryName(platform), content: serverBytes, mode: 0o755 },
|
|
113
130
|
{ name: '.env', content: new TextEncoder().encode(envContent), mode: 0o644 },
|
|
114
131
|
])
|
|
115
132
|
return new Response(archive, {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises'
|
|
2
|
+
import { lastConnectionPath } from './lastConnectionPath.ts'
|
|
3
|
+
|
|
4
|
+
// Forgets the saved connection (the `/disconnect` reset). Missing file is a no-op.
|
|
5
|
+
export async function clearLastConnection(programName: string): Promise<void> {
|
|
6
|
+
await rm(lastConnectionPath(programName), { force: true })
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { appDataDir } from './appDataDir.ts'
|
|
3
|
+
|
|
4
|
+
// Path to the per-program last-connection record, beside the data-dir `.env`.
|
|
5
|
+
export function lastConnectionPath(programName: string): string {
|
|
6
|
+
return join(appDataDir(programName), 'last-connection.json')
|
|
7
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { lastConnectionPath } from './lastConnectionPath.ts'
|
|
2
|
+
import type { LastConnection } from './types/LastConnection.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Reads the saved connection intent, or undefined when none is recorded or the file
|
|
6
|
+
is unreadable/corrupt — callers treat undefined as "nothing to resume".
|
|
7
|
+
*/
|
|
8
|
+
export async function readLastConnection(programName: string): Promise<LastConnection | undefined> {
|
|
9
|
+
const file = Bun.file(lastConnectionPath(programName))
|
|
10
|
+
if (!(await file.exists())) {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return (await file.json()) as LastConnection
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
True when this code runs inside a `bun build --compile` standalone executable
|
|
3
|
+
(the bundle's embedded server, or an install-tarball server binary) rather than
|
|
4
|
+
under the `bun` CLI. Bun mounts a compiled binary's embedded modules under a
|
|
5
|
+
synthetic root — `/$bunfs/…` on posix, `…~BUN…` on Windows — so `Bun.main`
|
|
6
|
+
carries that marker only in a standalone binary; under `bun dev`/`bun start`
|
|
7
|
+
it's a real on-disk path. Used to scope the bundle's data-dir/binary-dir `.env`
|
|
8
|
+
loading to the shipped app, so `bun dev`/`bun start` keep to their project-local
|
|
9
|
+
CWD `.env` alone.
|
|
10
|
+
*/
|
|
11
|
+
export function runningAsStandaloneBinary(): boolean {
|
|
12
|
+
return Bun.main.includes('$bunfs') || Bun.main.includes('~BUN')
|
|
13
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The launcher/CLI-owned record of the last connection, kept in the data dir so it
|
|
3
|
+
survives relaunch and is readable before any window or session opens. It records
|
|
4
|
+
the *intent*, not a concrete embedded URL — an embedded server picks a fresh port
|
|
5
|
+
each launch, so only `{ kind: 'embedded' }` is durable; a remote connection keeps
|
|
6
|
+
its url. resolveLaunchTarget / resolveCliTarget read it; /connect and /start write
|
|
7
|
+
it; /disconnect clears it.
|
|
8
|
+
*/
|
|
9
|
+
export type LastConnection = { kind: 'embedded' } | { kind: 'url'; url: string }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import { appDataDir } from './appDataDir.ts'
|
|
3
|
+
import { lastConnectionPath } from './lastConnectionPath.ts'
|
|
4
|
+
import type { LastConnection } from './types/LastConnection.ts'
|
|
5
|
+
|
|
6
|
+
// Persists the connection intent, creating the data dir on first write.
|
|
7
|
+
export async function writeLastConnection(
|
|
8
|
+
programName: string,
|
|
9
|
+
value: LastConnection,
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
await mkdir(appDataDir(programName), { recursive: true })
|
|
12
|
+
await Bun.write(lastConnectionPath(programName), JSON.stringify(value))
|
|
13
|
+
}
|