@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,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
+ }