@bprp/flockcode 0.0.2

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,166 @@
1
+ /**
2
+ * Spawn and manage an `opencode serve` child process.
3
+ *
4
+ * When no explicit OpenCode URL is provided, this module finds an available
5
+ * port, starts `opencode serve` on it, waits for it to become ready, and
6
+ * returns the URL. The child process is killed when the parent process exits.
7
+ *
8
+ * The managed port is persisted to the crust store so other processes can
9
+ * discover the running opencode instance.
10
+ */
11
+
12
+ import { createStore, stateDir } from "@crustjs/store"
13
+
14
+ const OPENCODE_BIN = "opencode"
15
+ const DEFAULT_START_PORT = 4097
16
+ const MAX_PORT_ATTEMPTS = 100
17
+ const READY_TIMEOUT_MS = 30_000
18
+ const READY_POLL_INTERVAL_MS = 250
19
+
20
+ export const opencodeStore = createStore({
21
+ dirPath: stateDir("flockcode"),
22
+ name: "opencode",
23
+ fields: {
24
+ port: {
25
+ type: "number",
26
+ description: "Port of the flock-managed opencode serve process",
27
+ validate: (v) => {
28
+ if (v < 1 || v > 65535) throw new Error("port must be 1–65535")
29
+ },
30
+ },
31
+ },
32
+ })
33
+
34
+ /** Result of ensuring an OpenCode server is available. */
35
+ export interface EnsureOpenCodeResult {
36
+ /** The base URL of the OpenCode server. */
37
+ url: string
38
+ /** The spawned subprocess, if one was started. `null` when connecting to an existing server. */
39
+ child: Bun.Subprocess | null
40
+ }
41
+
42
+ /**
43
+ * Check if a TCP port is available by trying to listen on it.
44
+ */
45
+ async function isPortAvailable(port: number): Promise<boolean> {
46
+ try {
47
+ const server = Bun.serve({
48
+ port,
49
+ fetch() {
50
+ return new Response("probe")
51
+ },
52
+ })
53
+ server.stop()
54
+ return true
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Find the first available port starting from `startFrom`.
62
+ */
63
+ export async function findAvailablePort(startFrom: number = DEFAULT_START_PORT): Promise<number> {
64
+ for (let port = startFrom; port < startFrom + MAX_PORT_ATTEMPTS; port++) {
65
+ if (await isPortAvailable(port)) {
66
+ return port
67
+ }
68
+ }
69
+ throw new Error(`No available port found in range ${startFrom}–${startFrom + MAX_PORT_ATTEMPTS - 1}`)
70
+ }
71
+
72
+ /**
73
+ * Poll the OpenCode server until it responds or the timeout expires.
74
+ */
75
+ async function waitForReady(url: string, timeoutMs: number = READY_TIMEOUT_MS): Promise<void> {
76
+ const deadline = Date.now() + timeoutMs
77
+ const healthUrl = `${url}/health`
78
+
79
+ while (Date.now() < deadline) {
80
+ try {
81
+ const res = await fetch(healthUrl, { signal: AbortSignal.timeout(2000) })
82
+ if (res.ok) return
83
+ } catch {
84
+ // Not ready yet
85
+ }
86
+ await Bun.sleep(READY_POLL_INTERVAL_MS)
87
+ }
88
+
89
+ throw new Error(`OpenCode server at ${url} did not become ready within ${timeoutMs}ms`)
90
+ }
91
+
92
+ /**
93
+ * Spawn `opencode serve` as a child process on the given port.
94
+ */
95
+ function spawnProcess(port: number): Bun.Subprocess {
96
+ const child = Bun.spawn([OPENCODE_BIN, "serve", "--port", String(port)], {
97
+ stdout: "pipe",
98
+ stderr: "pipe",
99
+ })
100
+
101
+ // Forward stderr so startup errors are visible
102
+ const reader = child.stderr.getReader()
103
+ const decoder = new TextDecoder()
104
+ child.exited.then(async () => {
105
+ while (true) {
106
+ const { done, value } = await reader.read()
107
+ if (done) break
108
+ const text = decoder.decode(value, { stream: true })
109
+ for (const line of text.split("\n")) {
110
+ if (line) console.error(`[opencode] ${line}`)
111
+ }
112
+ }
113
+ })
114
+
115
+ return child
116
+ }
117
+
118
+ /**
119
+ * Ensure an OpenCode server is available.
120
+ *
121
+ * - If `explicitUrl` is a non-empty string, it is returned directly (assumes
122
+ * an OpenCode server is already running there).
123
+ * - Otherwise, finds an available port, spawns `opencode serve`, waits for
124
+ * it to become ready, and returns the URL plus the child process handle.
125
+ */
126
+ export async function ensureOpenCode(explicitUrl?: string): Promise<EnsureOpenCodeResult> {
127
+ if (explicitUrl) {
128
+ return { url: explicitUrl, child: null }
129
+ }
130
+
131
+ const port = await findAvailablePort()
132
+ console.log(`Spawning opencode serve on port ${port}...`)
133
+
134
+ const child = spawnProcess(port)
135
+ const url = `http://localhost:${port}`
136
+
137
+ await waitForReady(url)
138
+ console.log(`OpenCode server ready at ${url}`)
139
+
140
+ await opencodeStore.patch({ port })
141
+
142
+ return { url, child }
143
+ }
144
+
145
+ /**
146
+ * Register signal handlers to kill a spawned child process on exit.
147
+ * Clears the stored port before exiting.
148
+ */
149
+ export function cleanupOnExit(child: Bun.Subprocess): void {
150
+ const kill = async () => {
151
+ try {
152
+ child.kill()
153
+ } catch {
154
+ // Process may have already exited
155
+ }
156
+ try {
157
+ await opencodeStore.update(() => ({ port: undefined }))
158
+ } catch {
159
+ // Store write best-effort during shutdown
160
+ }
161
+ process.exit(0)
162
+ }
163
+
164
+ process.on("SIGINT", kill)
165
+ process.on("SIGTERM", kill)
166
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Sprite configure-services — registers `opencode-serve` and `flock-server`
3
+ * services on the Fly Sprite so they auto-start on Sprite wake.
4
+ *
5
+ * Also writes a `.flockenv` file to the Sprite's home directory containing
6
+ * the environment variables needed by the flock server (auth token, API keys).
7
+ *
8
+ * Intended to be called independently from sync. Run `sync` first to clone
9
+ * repos, then `configure-services` to set up background services.
10
+ */
11
+
12
+ import type { SpriteClient } from "./sprites"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Result for a single service registration. */
19
+ export interface ServiceResult {
20
+ /** Whether the service was created fresh. */
21
+ created: boolean
22
+ /** Whether an existing service was updated. */
23
+ updated: boolean
24
+ /** Whether the service was already configured and left unchanged. */
25
+ unchanged: boolean
26
+ }
27
+
28
+ /** Full result of a configure-services run. */
29
+ export interface ConfigureServicesResult {
30
+ /** Result for the `opencode-serve` service. */
31
+ opencodeServe: ServiceResult
32
+ /** Result for the `flock-server` service. */
33
+ flockServer: ServiceResult
34
+ /** Whether the `.flockenv` file was written to the Sprite. */
35
+ flockenvWritten: boolean
36
+ /** Whether the `.flockenv` file was already up-to-date. */
37
+ flockenvUnchanged: boolean
38
+ }
39
+
40
+ /** Options for {@link configureServices}. */
41
+ export interface ConfigureServicesOptions {
42
+ /** If true, report what would happen without making changes. */
43
+ dryRun?: boolean
44
+ /**
45
+ * The port opencode should listen on inside the Sprite.
46
+ * @default 4096
47
+ */
48
+ opencodePort?: number
49
+ /**
50
+ * Working directory for the opencode serve process on the Sprite.
51
+ * If not set, no `--dir` flag is passed (opencode uses its default).
52
+ */
53
+ opencodeDir?: string
54
+ /**
55
+ * The port the flock server should listen on inside the Sprite.
56
+ * @default 3000
57
+ */
58
+ flockServerPort?: number
59
+ /**
60
+ * Bearer token for authenticating mobile clients to the flock server.
61
+ * Written to `.flockenv` on the Sprite as `FLOCK_AUTH_TOKEN`.
62
+ */
63
+ flockAuthToken?: string
64
+ /**
65
+ * Google / Gemini API key for audio transcription.
66
+ * Written to `.flockenv` on the Sprite as `GEMINI_API_KEY`.
67
+ */
68
+ geminiApiKey?: string
69
+ /**
70
+ * Gemini model for audio transcription.
71
+ * Written to `.flockenv` on the Sprite as `TRANSCRIPTION_MODEL`.
72
+ */
73
+ transcriptionModel?: string
74
+ /**
75
+ * Callback for progress messages. If not provided, messages are printed to
76
+ * stdout via `console.log`.
77
+ */
78
+ onProgress?: (message: string) => void
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Constants
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const OPENCODE_SERVICE = "opencode-serve"
86
+ const FLOCK_SERVICE = "flock-server"
87
+ const FLOCKENV_FILENAME = ".flockenv"
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Core
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Configure services and environment on the Fly Sprite.
95
+ *
96
+ * 1. Writes a `.flockenv` file to `$HOME/.flockenv` with env vars the flock
97
+ * server needs (auth token, API keys, opencode URL).
98
+ * 2. Registers `opencode-serve` — runs `opencode serve --port <port>` as an
99
+ * internal service (no `http_port`, not externally proxied).
100
+ * 3. Registers `flock-server` — sources `.flockenv` and runs
101
+ * `bunx @bprp/flockcode start` as the externally-proxied HTTP service.
102
+ */
103
+ export async function configureServices(
104
+ sprite: SpriteClient,
105
+ options: ConfigureServicesOptions = {},
106
+ ): Promise<ConfigureServicesResult> {
107
+ const {
108
+ opencodePort = 4096,
109
+ opencodeDir,
110
+ flockServerPort = 3000,
111
+ flockAuthToken,
112
+ geminiApiKey,
113
+ dryRun = false,
114
+ } = options
115
+ const log = options.onProgress ?? console.log
116
+
117
+ const result: ConfigureServicesResult = {
118
+ opencodeServe: { created: false, updated: false, unchanged: false },
119
+ flockServer: { created: false, updated: false, unchanged: false },
120
+ flockenvWritten: false,
121
+ flockenvUnchanged: false,
122
+ }
123
+
124
+ // -- 1. Write .flockenv -------------------------------------------------
125
+
126
+ const homeDir = await sprite.homeDir()
127
+ const flockenvPath = `${homeDir}/${FLOCKENV_FILENAME}`
128
+ const desiredEnv = buildFlockenv({
129
+ opencodePort,
130
+ flockAuthToken,
131
+ geminiApiKey,
132
+ transcriptionModel: options.transcriptionModel,
133
+ })
134
+
135
+ // Check if the existing file already matches
136
+ let existingEnv: string | null = null
137
+ try {
138
+ const buf = await sprite.readFile(flockenvPath)
139
+ existingEnv = buf.toString("utf-8")
140
+ } catch {
141
+ // File doesn't exist yet
142
+ }
143
+
144
+ if (existingEnv === desiredEnv) {
145
+ log(` ${FLOCKENV_FILENAME} — already up-to-date`)
146
+ result.flockenvUnchanged = true
147
+ } else {
148
+ if (dryRun) {
149
+ log(` ${FLOCKENV_FILENAME} — would write to ${flockenvPath}`)
150
+ } else {
151
+ log(` ${FLOCKENV_FILENAME} — writing to ${flockenvPath}...`)
152
+ await sprite.writeFile(flockenvPath, Buffer.from(desiredEnv, "utf-8"))
153
+ log(` ${FLOCKENV_FILENAME} — written`)
154
+ }
155
+ result.flockenvWritten = true
156
+ }
157
+
158
+ // -- 2. opencode-serve (internal, no http_port) -------------------------
159
+
160
+ log("")
161
+ const opencodeArgs = buildOpencodeArgs(opencodePort, opencodeDir)
162
+ result.opencodeServe = await ensureService(sprite, {
163
+ serviceName: OPENCODE_SERVICE,
164
+ desiredCmd: "opencode",
165
+ desiredArgs: opencodeArgs,
166
+ desiredHttpPort: null,
167
+ needs: [],
168
+ dryRun,
169
+ log,
170
+ })
171
+
172
+ // -- 3. flock-server (externally proxied) --------------------------------
173
+
174
+ log("")
175
+ const flockCmd = `source ${flockenvPath} && exec bunx @bprp/flockcode start --port ${flockServerPort}`
176
+ result.flockServer = await ensureService(sprite, {
177
+ serviceName: FLOCK_SERVICE,
178
+ desiredCmd: "bash",
179
+ desiredArgs: ["-c", flockCmd],
180
+ desiredHttpPort: flockServerPort,
181
+ needs: [OPENCODE_SERVICE],
182
+ dryRun,
183
+ log,
184
+ })
185
+
186
+ return result
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Helpers
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** Build the `.flockenv` file contents. */
194
+ function buildFlockenv(opts: {
195
+ opencodePort: number
196
+ flockAuthToken?: string
197
+ geminiApiKey?: string
198
+ transcriptionModel?: string
199
+ }): string {
200
+ const lines: string[] = [
201
+ "# Flockcode environment — managed by `flock sprite configure-services`",
202
+ "# Do not edit manually; re-run configure-services to update.",
203
+ "",
204
+ `export OPENCODE_URL="http://localhost:${opts.opencodePort}"`,
205
+ ]
206
+ if (opts.flockAuthToken) {
207
+ lines.push(`export FLOCK_AUTH_TOKEN="${opts.flockAuthToken}"`)
208
+ }
209
+ if (opts.geminiApiKey) {
210
+ lines.push(`export GEMINI_API_KEY="${opts.geminiApiKey}"`)
211
+ }
212
+ if (opts.transcriptionModel) {
213
+ lines.push(`export TRANSCRIPTION_MODEL="${opts.transcriptionModel}"`)
214
+ }
215
+ lines.push("") // trailing newline
216
+ return lines.join("\n")
217
+ }
218
+
219
+ /** Build the args array for the opencode serve command. */
220
+ function buildOpencodeArgs(port: number, dir?: string): string[] {
221
+ const args = ["serve", "--port", String(port)]
222
+ if (dir) {
223
+ args.push("--dir", dir)
224
+ }
225
+ return args
226
+ }
227
+
228
+ /** Options for {@link ensureService}. */
229
+ interface EnsureServiceOptions {
230
+ serviceName: string
231
+ desiredCmd: string
232
+ desiredArgs: string[]
233
+ desiredHttpPort: number | null
234
+ needs: string[]
235
+ dryRun: boolean
236
+ log: (message: string) => void
237
+ }
238
+
239
+ /**
240
+ * Ensure a single service is registered on the Sprite with the desired
241
+ * configuration. Creates, updates, or leaves it unchanged as appropriate.
242
+ */
243
+ async function ensureService(
244
+ sprite: SpriteClient,
245
+ opts: EnsureServiceOptions,
246
+ ): Promise<ServiceResult> {
247
+ const { serviceName, desiredCmd, desiredArgs, desiredHttpPort, needs, dryRun, log } = opts
248
+ const result: ServiceResult = { created: false, updated: false, unchanged: false }
249
+
250
+ const existing = await sprite.getService(serviceName)
251
+
252
+ const putConfig = {
253
+ cmd: desiredCmd,
254
+ args: desiredArgs,
255
+ ...(desiredHttpPort != null ? { httpPort: desiredHttpPort } : {}),
256
+ ...(needs.length > 0 ? { needs } : {}),
257
+ }
258
+
259
+ if (existing) {
260
+ const matches =
261
+ existing.cmd === desiredCmd &&
262
+ arraysEqual(existing.args, desiredArgs) &&
263
+ (existing.http_port ?? null) === desiredHttpPort &&
264
+ arraysEqual(existing.needs, needs)
265
+
266
+ if (matches) {
267
+ log(` ${serviceName} — already configured (${existing.state?.status ?? "unknown"})`)
268
+ result.unchanged = true
269
+ } else {
270
+ if (dryRun) {
271
+ log(` ${serviceName} — would update (config changed)`)
272
+ log(` current: ${existing.cmd} ${existing.args.join(" ")} (port ${existing.http_port ?? "none"})`)
273
+ log(` desired: ${desiredCmd} ${desiredArgs.join(" ")} (port ${desiredHttpPort ?? "none"})`)
274
+ } else {
275
+ log(` ${serviceName} — updating...`)
276
+ await sprite.putService(serviceName, putConfig)
277
+ log(` ${serviceName} — updated`)
278
+ }
279
+ result.updated = true
280
+ }
281
+ } else {
282
+ if (dryRun) {
283
+ log(` ${serviceName} — would create: ${desiredCmd} ${desiredArgs.join(" ")} (port ${desiredHttpPort ?? "none"})`)
284
+ } else {
285
+ log(` ${serviceName} — creating...`)
286
+ await sprite.putService(serviceName, putConfig)
287
+ log(` ${serviceName} — created`)
288
+ }
289
+ result.created = true
290
+ }
291
+
292
+ return result
293
+ }
294
+
295
+ /** Shallow array equality. */
296
+ function arraysEqual(a: string[], b: string[]): boolean {
297
+ if (a.length !== b.length) return false
298
+ for (let i = 0; i < a.length; i++) {
299
+ if (a[i] !== b[i]) return false
300
+ }
301
+ return true
302
+ }