@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
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprite sync — ensures all local OpenCode projects are cloned on a shared
|
|
3
|
+
* Fly Sprite, and that `.sprite-keep` files are uploaded and current.
|
|
4
|
+
*
|
|
5
|
+
* The sync function is self-contained and can be called from a CLI command
|
|
6
|
+
* or an API endpoint.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { homedir } from "node:os"
|
|
10
|
+
import { relative, join, dirname, basename } from "node:path"
|
|
11
|
+
import { createHash } from "node:crypto"
|
|
12
|
+
import { ExecError } from "@fly/sprites"
|
|
13
|
+
import type { SpriteClient } from "./sprites"
|
|
14
|
+
import type { OpencodeClient } from "./opencode"
|
|
15
|
+
import type { Project } from "@opencode-ai/sdk/v2"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** A single cloned project. */
|
|
22
|
+
export interface SyncedProject {
|
|
23
|
+
projectId: string
|
|
24
|
+
localPath: string
|
|
25
|
+
spritePath: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A file that was uploaded to the Sprite. */
|
|
29
|
+
export interface SyncedFile {
|
|
30
|
+
projectId: string
|
|
31
|
+
/** Path relative to the project root. */
|
|
32
|
+
file: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A file that was skipped during sync. */
|
|
36
|
+
export interface SkippedFile {
|
|
37
|
+
projectId: string
|
|
38
|
+
file: string
|
|
39
|
+
reason: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Full result of a sync run. */
|
|
43
|
+
export interface SyncResult {
|
|
44
|
+
cloned: SyncedProject[]
|
|
45
|
+
alreadyExists: SyncedProject[]
|
|
46
|
+
filesUploaded: SyncedFile[]
|
|
47
|
+
filesSkipped: SkippedFile[]
|
|
48
|
+
warnings: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Options for {@link sync}. */
|
|
52
|
+
export interface SyncOptions {
|
|
53
|
+
/** If true, report what would happen without making changes. */
|
|
54
|
+
dryRun?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Callback for progress messages. If not provided, messages are printed to
|
|
57
|
+
* stdout via `console.log`.
|
|
58
|
+
*/
|
|
59
|
+
onProgress?: (message: string) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Path mapping
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Map a local absolute path to the corresponding Sprite path by stripping
|
|
68
|
+
* the local home directory prefix and re-rooting under the Sprite home.
|
|
69
|
+
*
|
|
70
|
+
* Returns `null` if the path is outside the home directory.
|
|
71
|
+
*
|
|
72
|
+
* ```
|
|
73
|
+
* localPathToSpritePath("/Users/ben/job1/project", "/Users/ben", "/home/sprite")
|
|
74
|
+
* // → "/home/sprite/job1/project"
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function localPathToSpritePath(localPath: string, localHome: string, spriteHome: string): string | null {
|
|
78
|
+
const rel = relative(localHome, localPath)
|
|
79
|
+
if (rel.startsWith("..")) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
return join(spriteHome, rel)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// .sprite-keep parsing
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse a `.sprite-keep` file into a list of file path patterns.
|
|
91
|
+
* Blank lines and `#` comments are ignored. Patterns are returned as-is
|
|
92
|
+
* (they may contain globs).
|
|
93
|
+
*/
|
|
94
|
+
export function parseSpriteKeep(contents: string): string[] {
|
|
95
|
+
return contents
|
|
96
|
+
.split("\n")
|
|
97
|
+
.map((line) => line.trim())
|
|
98
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Core sync
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sync all OpenCode projects to the Sprite.
|
|
107
|
+
*
|
|
108
|
+
* For each project returned by `client.project.list()`:
|
|
109
|
+
* 1. If the repo is not yet cloned on the Sprite, clone it.
|
|
110
|
+
* 2. If a `.sprite-keep` file exists locally, upload any referenced files
|
|
111
|
+
* that are missing or out of date on the Sprite.
|
|
112
|
+
*/
|
|
113
|
+
export async function sync(
|
|
114
|
+
sprite: SpriteClient,
|
|
115
|
+
opencode: OpencodeClient,
|
|
116
|
+
options: SyncOptions = {},
|
|
117
|
+
): Promise<SyncResult> {
|
|
118
|
+
const { dryRun = false } = options
|
|
119
|
+
const log = options.onProgress ?? console.log
|
|
120
|
+
|
|
121
|
+
const result: SyncResult = {
|
|
122
|
+
cloned: [],
|
|
123
|
+
alreadyExists: [],
|
|
124
|
+
filesUploaded: [],
|
|
125
|
+
filesSkipped: [],
|
|
126
|
+
warnings: [],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 1. List projects
|
|
130
|
+
const projectsRes = await opencode.project.list()
|
|
131
|
+
const projects = projectsRes.data ?? []
|
|
132
|
+
if (projects.length === 0) {
|
|
133
|
+
log("No projects found.")
|
|
134
|
+
return result
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. Discover home directories
|
|
138
|
+
const localHome = homedir()
|
|
139
|
+
const spriteHome = await sprite.homeDir()
|
|
140
|
+
log(`Local home: ${localHome}`)
|
|
141
|
+
log(`Sprite home: ${spriteHome}`)
|
|
142
|
+
log(`Syncing ${projects.length} project(s)...\n`)
|
|
143
|
+
|
|
144
|
+
// 3. Build a map of remote URL → sprite path for orphan/move detection
|
|
145
|
+
const remoteUrlToSpritePath = new Map<string, string>()
|
|
146
|
+
|
|
147
|
+
// 4. Sync each project
|
|
148
|
+
for (const project of projects) {
|
|
149
|
+
const localPath = project.worktree
|
|
150
|
+
const projectId = project.id
|
|
151
|
+
const spritePath = localPathToSpritePath(localPath, localHome, spriteHome)
|
|
152
|
+
const relPath = relative(localHome, localPath)
|
|
153
|
+
|
|
154
|
+
if (!spritePath) {
|
|
155
|
+
const warning = ` ⚠ ${localPath} — outside home directory, skipping`
|
|
156
|
+
log(warning)
|
|
157
|
+
result.warnings.push(warning)
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
log(` ${relPath}`)
|
|
162
|
+
|
|
163
|
+
// Get the local git remote URL
|
|
164
|
+
const remoteUrl = await getLocalGitRemoteUrl(localPath)
|
|
165
|
+
if (!remoteUrl) {
|
|
166
|
+
const warning = ` ⚠ could not determine git remote URL, skipping`
|
|
167
|
+
log(warning)
|
|
168
|
+
result.warnings.push(`${relPath} — could not determine git remote URL`)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check if already cloned on the Sprite
|
|
173
|
+
const existsOnSprite = await sprite.isDirectory(spritePath)
|
|
174
|
+
|
|
175
|
+
if (existsOnSprite) {
|
|
176
|
+
// Verify the remote URL matches
|
|
177
|
+
const spriteRemoteUrl = await sprite.tryExecFile(
|
|
178
|
+
"git", ["-C", spritePath, "remote", "get-url", "origin"],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if (spriteRemoteUrl && normalizeRemoteUrl(spriteRemoteUrl.trim()) !== normalizeRemoteUrl(remoteUrl)) {
|
|
182
|
+
const warning =
|
|
183
|
+
` ⚠ remote URL mismatch on Sprite ` +
|
|
184
|
+
`(local: ${remoteUrl}, sprite: ${spriteRemoteUrl.trim()}), skipping`
|
|
185
|
+
log(warning)
|
|
186
|
+
result.warnings.push(`${relPath} — remote URL mismatch`)
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
log(` ✓ already cloned`)
|
|
191
|
+
result.alreadyExists.push({ projectId, localPath, spritePath })
|
|
192
|
+
} else {
|
|
193
|
+
// Clone the repo
|
|
194
|
+
if (dryRun) {
|
|
195
|
+
log(` → would clone ${remoteUrl}`)
|
|
196
|
+
} else {
|
|
197
|
+
const parentDir = dirname(spritePath)
|
|
198
|
+
await sprite.execFile("mkdir", ["-p", parentDir])
|
|
199
|
+
|
|
200
|
+
log(` cloning ${remoteUrl}...`)
|
|
201
|
+
try {
|
|
202
|
+
await sprite.execFile("git", ["clone", remoteUrl, spritePath])
|
|
203
|
+
log(` ✓ cloned`)
|
|
204
|
+
} catch (err: unknown) {
|
|
205
|
+
const stderr = err instanceof ExecError
|
|
206
|
+
? (typeof err.stderr === "string" ? err.stderr : err.stderr.toString("utf-8")).trim()
|
|
207
|
+
: ""
|
|
208
|
+
const detail = stderr || (err instanceof Error ? err.message : String(err))
|
|
209
|
+
const warning = ` ⚠ clone failed: ${detail}`
|
|
210
|
+
log(warning)
|
|
211
|
+
result.warnings.push(`${relPath} — clone failed: ${detail}`)
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
result.cloned.push({ projectId, localPath, spritePath })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
remoteUrlToSpritePath.set(normalizeRemoteUrl(remoteUrl), spritePath)
|
|
219
|
+
|
|
220
|
+
// Sync .sprite-keep files
|
|
221
|
+
await syncSpriteKeepFiles(sprite, projectId, localPath, spritePath, dryRun, log, result)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 5. Detect orphaned projects on the Sprite
|
|
225
|
+
await detectOrphans(sprite, projects, localHome, spriteHome, remoteUrlToSpritePath, log, result)
|
|
226
|
+
|
|
227
|
+
return result
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// .sprite-keep file sync
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
async function syncSpriteKeepFiles(
|
|
235
|
+
sprite: SpriteClient,
|
|
236
|
+
projectId: string,
|
|
237
|
+
localPath: string,
|
|
238
|
+
spritePath: string,
|
|
239
|
+
dryRun: boolean,
|
|
240
|
+
log: (msg: string) => void,
|
|
241
|
+
result: SyncResult,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
const keepFilePath = join(localPath, ".sprite-keep")
|
|
244
|
+
const keepFile = Bun.file(keepFilePath)
|
|
245
|
+
const keepExists = await keepFile.exists()
|
|
246
|
+
|
|
247
|
+
if (!keepExists) {
|
|
248
|
+
log(` (no .sprite-keep)`)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const keepContents = await keepFile.text()
|
|
253
|
+
const patterns = parseSpriteKeep(keepContents)
|
|
254
|
+
|
|
255
|
+
if (patterns.length === 0) {
|
|
256
|
+
log(` (empty .sprite-keep)`)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
log(` syncing .sprite-keep files...`)
|
|
261
|
+
|
|
262
|
+
for (const pattern of patterns) {
|
|
263
|
+
const glob = new Bun.Glob(pattern)
|
|
264
|
+
const matches = glob.scanSync({ cwd: localPath, absolute: false, dot: true })
|
|
265
|
+
let matchCount = 0
|
|
266
|
+
|
|
267
|
+
for (const relFile of matches) {
|
|
268
|
+
matchCount++
|
|
269
|
+
const localFilePath = join(localPath, relFile)
|
|
270
|
+
const spriteFilePath = join(spritePath, relFile)
|
|
271
|
+
|
|
272
|
+
const localFile = Bun.file(localFilePath)
|
|
273
|
+
const localExists = await localFile.exists()
|
|
274
|
+
if (!localExists) {
|
|
275
|
+
result.filesSkipped.push({ projectId, file: relFile, reason: "not found locally" })
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const localBytes = new Uint8Array(await localFile.arrayBuffer())
|
|
280
|
+
const localHash = md5(localBytes)
|
|
281
|
+
|
|
282
|
+
// Check if the file on the Sprite is already up to date via md5sum.
|
|
283
|
+
// This needs a pipe so we use execBash.
|
|
284
|
+
const spriteHash = await sprite.execBash(
|
|
285
|
+
`md5sum '${spriteFilePath.replace(/'/g, "'\\''")}' 2>/dev/null | awk '{print $1}'`,
|
|
286
|
+
).then((s) => s.trim()).catch(() => null)
|
|
287
|
+
|
|
288
|
+
if (spriteHash && spriteHash === localHash) {
|
|
289
|
+
log(` ✓ ${relFile} — up to date`)
|
|
290
|
+
result.filesSkipped.push({ projectId, file: relFile, reason: "up to date" })
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (dryRun) {
|
|
295
|
+
log(` → would upload ${relFile}`)
|
|
296
|
+
result.filesUploaded.push({ projectId, file: relFile })
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Ensure parent directory exists on Sprite
|
|
301
|
+
const spriteFileDir = dirname(spriteFilePath)
|
|
302
|
+
await sprite.execFile("mkdir", ["-p", spriteFileDir])
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
await sprite.writeFile(spriteFilePath, localBytes)
|
|
306
|
+
log(` ✓ ${relFile} — uploaded`)
|
|
307
|
+
result.filesUploaded.push({ projectId, file: relFile })
|
|
308
|
+
} catch (err: any) {
|
|
309
|
+
const reason = `upload failed: ${err.message ?? err}`
|
|
310
|
+
log(` ⚠ ${relFile} — ${reason}`)
|
|
311
|
+
result.filesSkipped.push({ projectId, file: relFile, reason })
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (matchCount === 0) {
|
|
316
|
+
result.filesSkipped.push({ projectId, file: pattern, reason: "no matches" })
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Orphan detection
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
async function detectOrphans(
|
|
326
|
+
sprite: SpriteClient,
|
|
327
|
+
projects: Project[],
|
|
328
|
+
localHome: string,
|
|
329
|
+
spriteHome: string,
|
|
330
|
+
remoteUrlToSpritePath: Map<string, string>,
|
|
331
|
+
log: (msg: string) => void,
|
|
332
|
+
result: SyncResult,
|
|
333
|
+
): Promise<void> {
|
|
334
|
+
// Build set of expected Sprite paths
|
|
335
|
+
const expectedPaths = new Set(
|
|
336
|
+
projects
|
|
337
|
+
.map((p) => localPathToSpritePath(p.worktree, localHome, spriteHome))
|
|
338
|
+
.filter((p): p is string => p !== null),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
// Find git repos on the Sprite up to 4 levels deep
|
|
342
|
+
const findResult = await sprite.tryExecFile(
|
|
343
|
+
"find", [spriteHome, "-maxdepth", "4", "-name", ".git", "-type", "d"],
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if (!findResult) return
|
|
347
|
+
|
|
348
|
+
const gitDirs = findResult
|
|
349
|
+
.trim()
|
|
350
|
+
.split("\n")
|
|
351
|
+
.filter(Boolean)
|
|
352
|
+
.map((gitDir) => dirname(gitDir))
|
|
353
|
+
|
|
354
|
+
for (const dir of gitDirs) {
|
|
355
|
+
if (expectedPaths.has(dir)) continue
|
|
356
|
+
|
|
357
|
+
const remoteUrl = await sprite.tryExecFile(
|
|
358
|
+
"git", ["-C", dir, "remote", "get-url", "origin"],
|
|
359
|
+
)
|
|
360
|
+
const normalizedUrl = remoteUrl ? normalizeRemoteUrl(remoteUrl.trim()) : null
|
|
361
|
+
const knownPath = normalizedUrl ? remoteUrlToSpritePath.get(normalizedUrl) : null
|
|
362
|
+
|
|
363
|
+
if (knownPath) {
|
|
364
|
+
const warning = `⚠ Orphaned: ${dir} (same repo now at ${knownPath}, safe to remove)`
|
|
365
|
+
log(`\n ${warning}`)
|
|
366
|
+
result.warnings.push(warning)
|
|
367
|
+
} else {
|
|
368
|
+
const warning = `⚠ Orphaned: ${dir} (exists on Sprite but not in local projects)`
|
|
369
|
+
log(`\n ${warning}`)
|
|
370
|
+
result.warnings.push(warning)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Helpers
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/** Get the `origin` remote URL for a local git repo. */
|
|
380
|
+
async function getLocalGitRemoteUrl(repoPath: string): Promise<string | null> {
|
|
381
|
+
try {
|
|
382
|
+
const proc = Bun.spawn(["git", "-C", repoPath, "remote", "get-url", "origin"], {
|
|
383
|
+
stdout: "pipe",
|
|
384
|
+
stderr: "pipe",
|
|
385
|
+
})
|
|
386
|
+
const [exitCode, stdout] = await Promise.all([
|
|
387
|
+
proc.exited,
|
|
388
|
+
new Response(proc.stdout).text(),
|
|
389
|
+
])
|
|
390
|
+
if (exitCode !== 0) return null
|
|
391
|
+
return stdout.trim() || null
|
|
392
|
+
} catch {
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Normalize a git remote URL for comparison.
|
|
399
|
+
* Strips trailing `.git`, protocol differences, and trailing slashes.
|
|
400
|
+
*/
|
|
401
|
+
function normalizeRemoteUrl(url: string): string {
|
|
402
|
+
return url
|
|
403
|
+
.replace(/\.git$/, "")
|
|
404
|
+
.replace(/\/$/, "")
|
|
405
|
+
.replace(/^https?:\/\//, "")
|
|
406
|
+
.replace(/^git@/, "")
|
|
407
|
+
.replace(/^ssh:\/\/git@/, "")
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Compute the MD5 hex digest of a byte array. */
|
|
411
|
+
function md5(data: Uint8Array): string {
|
|
412
|
+
return createHash("md5").update(data).digest("hex")
|
|
413
|
+
}
|