@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.
package/src/sprites.ts ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Thin wrapper around the @fly/sprites SDK providing the specific operations
3
+ * needed by sprite-sync: command execution, file existence checks, file
4
+ * read/write, and home directory discovery.
5
+ *
6
+ * File operations use the Sprites REST filesystem API directly since the JS
7
+ * SDK only exposes command execution.
8
+ */
9
+
10
+ import { SpritesClient, ExecError } from "@fly/sprites"
11
+ import type { Sprite, ExecResult } from "@fly/sprites"
12
+ import { env } from "./env"
13
+
14
+ export { ExecError }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Service types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** State of a running (or stopped) service on a Sprite. */
21
+ export interface SpriteServiceState {
22
+ name: string
23
+ pid?: number
24
+ started_at?: string
25
+ status: string
26
+ }
27
+
28
+ /** A service registered on a Sprite. */
29
+ export interface SpriteService {
30
+ name: string
31
+ cmd: string
32
+ args: string[]
33
+ http_port: number | null
34
+ needs: string[]
35
+ state: SpriteServiceState | null
36
+ }
37
+
38
+ /** Configuration for creating or updating a service via {@link SpriteClient.putService}. */
39
+ export interface PutServiceConfig {
40
+ /** The command to run (e.g. `"opencode"`). */
41
+ cmd: string
42
+ /** Arguments to pass to the command. */
43
+ args?: string[]
44
+ /** HTTP port the service listens on (enables Sprite URL proxying). */
45
+ httpPort?: number
46
+ /** Names of other services this one depends on. */
47
+ needs?: string[]
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Client options
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** Options for creating a {@link SpriteClient}. */
55
+ export interface SpriteClientOptions {
56
+ /** Sprite name (e.g. "my-dev-sprite"). */
57
+ spriteName: string
58
+ /** Sprites API token. */
59
+ token: string
60
+ /** Base URL for the Sprites API. Defaults to https://api.sprites.dev. */
61
+ baseURL?: string
62
+ }
63
+
64
+ /**
65
+ * Client for interacting with a single Fly Sprite.
66
+ *
67
+ * Wraps the `@fly/sprites` SDK for command execution and uses the Sprites
68
+ * REST filesystem API for file read/write operations.
69
+ */
70
+ export class SpriteClient {
71
+ #sprite: Sprite
72
+ #client: SpritesClient
73
+ #spriteName: string
74
+ #baseURL: string
75
+ #token: string
76
+ #cachedHomeDir: string | null = null
77
+
78
+ constructor(options: SpriteClientOptions) {
79
+ this.#spriteName = options.spriteName
80
+ this.#token = options.token
81
+ this.#baseURL = options.baseURL ?? "https://api.sprites.dev"
82
+ this.#client = new SpritesClient(options.token, { baseURL: this.#baseURL })
83
+ this.#sprite = this.#client.sprite(options.spriteName)
84
+ }
85
+
86
+ /** The underlying Sprite handle from the SDK. */
87
+ get sprite(): Sprite {
88
+ return this.#sprite
89
+ }
90
+
91
+ /**
92
+ * Run a command on the Sprite and return stdout as a string.
93
+ *
94
+ * **Note:** The Sprites SDK splits the command string on whitespace and
95
+ * executes directly — no shell is involved. Shell features like `$VAR`,
96
+ * pipes, `&&`/`||` will NOT work. Use {@link execBash} if you need a shell.
97
+ *
98
+ * Throws {@link ExecError} on non-zero exit.
99
+ */
100
+ async exec(command: string, options?: { cwd?: string }): Promise<string> {
101
+ const result: ExecResult = await this.#sprite.exec(command, {
102
+ ...(options?.cwd ? { cwd: options.cwd } : {}),
103
+ })
104
+ return typeof result.stdout === "string" ? result.stdout : result.stdout.toString("utf-8")
105
+ }
106
+
107
+ /**
108
+ * Run a command with explicit arguments on the Sprite. Unlike {@link exec},
109
+ * arguments are NOT split on whitespace — each element in `args` is passed
110
+ * as a separate argument. No shell is involved.
111
+ *
112
+ * Returns stdout as a string. Throws {@link ExecError} on non-zero exit.
113
+ */
114
+ async execFile(file: string, args: string[] = [], options?: { cwd?: string }): Promise<string> {
115
+ const result: ExecResult = await this.#sprite.execFile(file, args, {
116
+ ...(options?.cwd ? { cwd: options.cwd } : {}),
117
+ })
118
+ return typeof result.stdout === "string" ? result.stdout : result.stdout.toString("utf-8")
119
+ }
120
+
121
+ /**
122
+ * Run a shell command on the Sprite via `bash -c`.
123
+ *
124
+ * Use this when you need shell features (variable expansion, pipes,
125
+ * `&&`/`||`, redirects, etc.). Returns stdout as a string.
126
+ */
127
+ async execBash(command: string, options?: { cwd?: string }): Promise<string> {
128
+ const result = await this.#sprite.execFile("bash", ["-c", command], {
129
+ ...(options?.cwd ? { cwd: options.cwd } : {}),
130
+ })
131
+ return typeof result.stdout === "string" ? result.stdout : result.stdout.toString("utf-8")
132
+ }
133
+
134
+ /**
135
+ * Run a command with explicit arguments, returning stdout on success or
136
+ * `null` on non-zero exit (instead of throwing).
137
+ */
138
+ async tryExecFile(file: string, args: string[] = [], options?: { cwd?: string }): Promise<string | null> {
139
+ try {
140
+ return await this.execFile(file, args, options)
141
+ } catch {
142
+ return null
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Run a command on the Sprite, returning stdout on success or `null` on
148
+ * non-zero exit (instead of throwing).
149
+ *
150
+ * @deprecated Prefer {@link tryExecFile} — this method splits on whitespace.
151
+ */
152
+ async tryExec(command: string, options?: { cwd?: string }): Promise<string | null> {
153
+ try {
154
+ return await this.exec(command, options)
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
160
+ /** Check if a path exists on the Sprite. */
161
+ async exists(remotePath: string): Promise<boolean> {
162
+ try {
163
+ await this.#sprite.execFile("test", ["-e", remotePath])
164
+ return true
165
+ } catch {
166
+ return false
167
+ }
168
+ }
169
+
170
+ /** Check if a path is a directory on the Sprite. */
171
+ async isDirectory(remotePath: string): Promise<boolean> {
172
+ try {
173
+ await this.#sprite.execFile("test", ["-d", remotePath])
174
+ return true
175
+ } catch {
176
+ return false
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Write a file to the Sprite via the REST filesystem API.
182
+ *
183
+ * Uses `PUT /v1/sprites/{name}/fs/write?path=<path>` with raw bytes body.
184
+ */
185
+ async writeFile(remotePath: string, contents: Buffer | Uint8Array): Promise<void> {
186
+ const url = `${this.#baseURL}/v1/sprites/${encodeURIComponent(this.#spriteName)}/fs/write?path=${encodeURIComponent(remotePath)}`
187
+ const res = await fetch(url, {
188
+ method: "PUT",
189
+ headers: {
190
+ Authorization: `Bearer ${this.#token}`,
191
+ "Content-Type": "application/octet-stream",
192
+ },
193
+ body: Buffer.from(contents) as BodyInit,
194
+ })
195
+ if (!res.ok) {
196
+ const text = await res.text().catch(() => "")
197
+ throw new Error(`Failed to write file ${remotePath}: ${res.status} ${text}`)
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Read a file from the Sprite via the REST filesystem API.
203
+ *
204
+ * Uses `GET /v1/sprites/{name}/fs/read?path=<path>`. Returns raw bytes.
205
+ */
206
+ async readFile(remotePath: string): Promise<Buffer> {
207
+ const url = `${this.#baseURL}/v1/sprites/${encodeURIComponent(this.#spriteName)}/fs/read?path=${encodeURIComponent(remotePath)}`
208
+ const res = await fetch(url, {
209
+ method: "GET",
210
+ headers: {
211
+ Authorization: `Bearer ${this.#token}`,
212
+ },
213
+ })
214
+ if (!res.ok) {
215
+ const text = await res.text().catch(() => "")
216
+ throw new Error(`Failed to read file ${remotePath}: ${res.status} ${text}`)
217
+ }
218
+ return Buffer.from(await res.arrayBuffer())
219
+ }
220
+
221
+ /**
222
+ * Get the Sprite's home directory. Cached after the first call.
223
+ */
224
+ async homeDir(): Promise<string> {
225
+ if (this.#cachedHomeDir) return this.#cachedHomeDir
226
+ const result = await this.exec("printenv HOME")
227
+ this.#cachedHomeDir = result.trim()
228
+ return this.#cachedHomeDir
229
+ }
230
+
231
+ // -------------------------------------------------------------------------
232
+ // Services API — manage background services on the Sprite
233
+ // -------------------------------------------------------------------------
234
+
235
+ /**
236
+ * List all registered services and their current state.
237
+ *
238
+ * Uses `GET /v1/sprites/{name}/services`.
239
+ */
240
+ async listServices(): Promise<SpriteService[]> {
241
+ const url = `${this.#baseURL}/v1/sprites/${encodeURIComponent(this.#spriteName)}/services`
242
+ const res = await fetch(url, {
243
+ method: "GET",
244
+ headers: { Authorization: `Bearer ${this.#token}` },
245
+ })
246
+ if (!res.ok) {
247
+ const text = await res.text().catch(() => "")
248
+ throw new Error(`Failed to list services: ${res.status} ${text}`)
249
+ }
250
+ return (await res.json()) as SpriteService[]
251
+ }
252
+
253
+ /**
254
+ * Get a single service by name. Returns `null` if not found.
255
+ *
256
+ * Uses `GET /v1/sprites/{name}/services/{service_name}`.
257
+ */
258
+ async getService(serviceName: string): Promise<SpriteService | null> {
259
+ const url = `${this.#baseURL}/v1/sprites/${encodeURIComponent(this.#spriteName)}/services/${encodeURIComponent(serviceName)}`
260
+ const res = await fetch(url, {
261
+ method: "GET",
262
+ headers: { Authorization: `Bearer ${this.#token}` },
263
+ })
264
+ if (res.status === 404) return null
265
+ if (!res.ok) {
266
+ const text = await res.text().catch(() => "")
267
+ throw new Error(`Failed to get service ${serviceName}: ${res.status} ${text}`)
268
+ }
269
+ return (await res.json()) as SpriteService
270
+ }
271
+
272
+ /**
273
+ * Create or update a service on the Sprite.
274
+ *
275
+ * Uses `PUT /v1/sprites/{name}/services/{service_name}`.
276
+ */
277
+ async putService(serviceName: string, config: PutServiceConfig): Promise<SpriteService | null> {
278
+ const url = `${this.#baseURL}/v1/sprites/${encodeURIComponent(this.#spriteName)}/services/${encodeURIComponent(serviceName)}`
279
+ const body: Record<string, unknown> = {
280
+ cmd: config.cmd,
281
+ }
282
+ if (config.args) body.args = config.args
283
+ if (config.httpPort != null) body.http_port = config.httpPort
284
+ if (config.needs) body.needs = config.needs
285
+
286
+ const res = await fetch(url, {
287
+ method: "PUT",
288
+ headers: {
289
+ Authorization: `Bearer ${this.#token}`,
290
+ "Content-Type": "application/json",
291
+ },
292
+ body: JSON.stringify(body),
293
+ })
294
+ const text = await res.text()
295
+ if (!res.ok) {
296
+ throw new Error(`Failed to put service ${serviceName}: ${res.status} ${text}`)
297
+ }
298
+ try {
299
+ return JSON.parse(text) as SpriteService
300
+ } catch {
301
+ // Some API versions may return empty or non-JSON bodies on success
302
+ return null
303
+ }
304
+ }
305
+ }
306
+
307
+ /** Escape a string for safe use in a shell command. */
308
+ function shellQuote(s: string): string {
309
+ return `'${s.replace(/'/g, "'\\''")}'`
310
+ }
311
+
312
+ /**
313
+ * Create a {@link SpriteClient} from validated environment variables.
314
+ *
315
+ * Uses `SPRITE_NAME`, `SPRITES_TOKEN`, and `SPRITES_API_URL` from {@link env}.
316
+ *
317
+ * @throws if `SPRITE_NAME` or `SPRITES_TOKEN` are empty.
318
+ */
319
+ export function createSpriteClientFromEnv(): SpriteClient {
320
+ if (!env.SPRITE_NAME) throw new Error("SPRITE_NAME environment variable is required")
321
+ if (!env.SPRITES_TOKEN) throw new Error("SPRITES_TOKEN environment variable is required")
322
+
323
+ return new SpriteClient({
324
+ spriteName: env.SPRITE_NAME,
325
+ token: env.SPRITES_TOKEN,
326
+ baseURL: env.SPRITES_API_URL,
327
+ })
328
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared server bootstrap — used by both the CLI `start` command and
3
+ * the standalone `server.ts` entrypoint.
4
+ */
5
+
6
+ import { createApp } from "./app"
7
+ import { ensureOpenCode, cleanupOnExit } from "./spawn-opencode"
8
+
9
+ /** Options for {@link startServer}. */
10
+ export interface StartServerOptions {
11
+ /**
12
+ * OpenCode server URL to bridge. Empty string or undefined means
13
+ * "spawn one automatically".
14
+ */
15
+ opencodeUrl?: string
16
+ /** Port for the Bun HTTP server. */
17
+ port: number
18
+ }
19
+
20
+ /**
21
+ * Create the Hono app and start the Bun HTTP server.
22
+ *
23
+ * When no `opencodeUrl` is provided, an `opencode serve` child process
24
+ * is spawned on an available port. It is killed when the flock server exits.
25
+ *
26
+ * Returns the app internals alongside the Bun `Server` handle so the
27
+ * caller can inspect or stop the server if needed.
28
+ */
29
+ export async function startServer(options: StartServerOptions) {
30
+ const { port } = options
31
+
32
+ const { url: opencodeUrl, child: opencodeChild } = await ensureOpenCode(options.opencodeUrl)
33
+
34
+ if (opencodeChild) {
35
+ cleanupOnExit(opencodeChild)
36
+ }
37
+
38
+ const { app, instanceDs, ephemeralDs, appDs, stateStream, instanceId } = await createApp(opencodeUrl)
39
+
40
+ console.log(`Server starting on port ${port} (opencode: ${opencodeUrl})`)
41
+
42
+ const server = Bun.serve({
43
+ port,
44
+ idleTimeout: 255, // seconds — must exceed durable streams long-poll timeout (30s)
45
+ fetch: app.fetch,
46
+ })
47
+
48
+ return { app, instanceDs, ephemeralDs, appDs, stateStream, instanceId, server }
49
+ }