@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/package.json +45 -0
- package/src/app.ts +153 -0
- package/src/diagnose-stream.ts +305 -0
- package/src/env.ts +35 -0
- package/src/event-discovery.ts +355 -0
- package/src/event-driven-test.ts +72 -0
- package/src/index.ts +223 -0
- package/src/opencode.ts +278 -0
- package/src/prompt.ts +127 -0
- package/src/router/agents.ts +57 -0
- package/src/router/base.ts +10 -0
- package/src/router/commands.ts +57 -0
- package/src/router/context.ts +22 -0
- package/src/router/diffs.ts +46 -0
- package/src/router/index.ts +24 -0
- package/src/router/models.ts +55 -0
- package/src/router/permissions.ts +28 -0
- package/src/router/projects.ts +175 -0
- package/src/router/sessions.ts +316 -0
- package/src/router/snapshot.ts +9 -0
- package/src/server.ts +15 -0
- package/src/spawn-opencode.ts +166 -0
- package/src/sprite-configure-services.ts +302 -0
- package/src/sprite-sync.ts +413 -0
- package/src/sprites.ts +328 -0
- package/src/start-server.ts +49 -0
- package/src/state-stream.ts +711 -0
- package/src/transcribe.ts +100 -0
- package/src/types.ts +430 -0
- package/src/voice-prompt.ts +222 -0
- package/src/worktree-name.ts +62 -0
- package/src/worktree.ts +549 -0
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
|
+
}
|