@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.
@@ -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
+ }
@@ -1,44 +1,37 @@
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'
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. Loaded by the standalone binary's entry; expects
26
- the bundler-emitted manifest plus the raw argv tail. The binary is a
27
- thin remote client it carries no handler code, so it always talks to a
28
- running server over HTTP and APP_URL must be set. Flow:
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
- 1. Read .env next to the binary so APP_URL / APP_TOKEN are picked up
31
- for the common install-tarball case.
32
- 2. Pull the first positional as the subcommand.
33
- 3. --help and `<cmd> --help` print and exit zero.
34
- 4. Require APP_URL before dispatching a command.
35
- 5. Otherwise parse the rest of the argv against the manifest entry's
36
- JSON Schema and dispatch via createClient against APP_URL.
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
- Streaming responses are handled by sniffing the response Content-Type:
39
- sse/jsonl bodies (a streaming verb, or a socket `tail` command) are
40
- printed frame-by-frame as NDJSON to stdout; everything else is decoded
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
- if (!first || isHelpFlag(first)) {
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
- if (argv.some(isHelpFlag)) {
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
- const entry = manifest[first]
70
- if (!entry) {
71
- console.error(
72
- `${programName}: unknown command "${first}" — run \`${programName} --help\` for the list`,
73
- )
74
- return 1
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
- let args: Record<string, unknown> | undefined
78
- try {
79
- args = await parseArgvForRpc(argv.slice(1), entry.jsonSchema)
80
- } catch (error) {
81
- console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
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
- const appUrl = process.env.APP_URL
86
- if (!appUrl) {
87
- console.error(
88
- `${programName}: APP_URL is not set — the cli talks to a running server, so point it at one (e.g. APP_URL=http://localhost:3000)`,
89
- )
90
- return 1
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
- const fn = client[first]
96
- if (!fn) {
97
- console.error(`${programName}: command "${first}" not in client`)
98
- return 1
99
- }
100
- try {
101
- const response = await fn.raw(args)
102
- if (isStreamingResponse(response)) {
103
- /*
104
- Stream frame-by-frame to stdout as NDJSON. streamResponse
105
- throws a clear HttpError on a non-2xx body, caught below.
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
- printValue(await decodeResponse(response), true)
116
- return 0
117
- } catch (error) {
118
- console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
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 binaryPath = `${cwd}/dist/cli-thin/${platform}/${programName}`
56
+ const dir = `${cwd}/dist/cli-thin/${platform}`
57
+ const binaryPath = `${dir}/${programName}`
58
+ const serverPath = `${dir}/${serverBinaryName(platform)}`
50
59
  /*
51
- On-disk binary is fresh when it exists AND its mtime beats the
52
- newest rpc/socket source mtime. The mtime check catches the
53
- common dev iteration where the user edits an rpc handler but
54
- didn't run `belte cli` again. Other source paths (project lib,
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
- if (await binaryFile.exists()) {
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 thin cli for ${platform}…`)
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 binaryBytes = await Bun.file(binaryPath).bytes()
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, {