@opencode-manager/ocm-cli 0.1.3 → 0.2.0

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.
Files changed (3) hide show
  1. package/README.md +17 -10
  2. package/dist/ocm.js +510 -127
  3. package/package.json +8 -2
package/README.md CHANGED
@@ -34,24 +34,31 @@ ocm
34
34
  ocm status
35
35
  ocm list
36
36
  ocm use <repoId|name>
37
- ocm push [--force] [--create] [--yes]
38
- ocm pull [--force]
37
+ ocm push [--force] [--create] [--yes] [--full]
38
+ ocm pull [--force] [--full]
39
39
  ocm logout
40
40
  ```
41
41
 
42
- Running `ocm` with no command tries to match the current git repo's `origin`
43
- against ready Manager repos. If one repo matches, it attaches OpenCode to that
44
- Manager repo. If no repo matches, it falls back to the last selected repo, then
45
- to local `opencode`.
42
+ Running `ocm` with no command computes the current git repo's OpenCode project
43
+ id (the same identity OpenCode uses: normalized origin remote hash, else the
44
+ cached id, else the root commit) and matches it against ready Manager repos. If
45
+ one repo matches, it attaches OpenCode to that Manager repo. If no repo matches,
46
+ it falls back to the last selected repo, then to local `opencode`.
46
47
 
47
48
  `ocm use <repoId|name>` selects a Manager repo, remembers it as the last repo,
48
49
  and attaches OpenCode to it.
49
50
 
50
- `ocm push` uploads the current git repo to the matching Manager repo. Use
51
- `--create` to create a Manager repo when no origin match exists, and `--yes` to
51
+ `ocm push` syncs the current git repo to the matching Manager repo using a fast
52
+ git bundle + working-tree patch by default. Pass `--full` to use the legacy
53
+ tarball mirror. If the fast path fails, `ocm` prompts before reverting to the
54
+ tarball mirror (and proceeds automatically when there is no TTY to prompt). Use
55
+ `--create` to create a Manager repo when no project match exists, and `--yes` to
52
56
  confirm creation in non-interactive shells.
53
57
 
54
- `ocm pull` replaces the current working tree with the matching Manager repo. It
58
+ `ocm pull` syncs the matching Manager repo over the current working tree using a
59
+ fast git bundle + working-tree patch by default. Pass `--full` to use the legacy
60
+ tarball mirror. If the fast path fails, `ocm` prompts before reverting to the
61
+ tarball mirror (and proceeds automatically when there is no TTY to prompt). It
55
62
  refuses to overwrite uncommitted local changes unless `--force` is passed.
56
63
 
57
64
  ## OpenCode plugin
@@ -69,6 +76,6 @@ export default [ocm]
69
76
  ## Requirements
70
77
 
71
78
  - `opencode` available on `PATH`
72
- - `git` and `tar` available on `PATH`
79
+ - `git` and `tar` (with gzip support, i.e. the `-z` flag) available on `PATH`
73
80
  - macOS `security` CLI for Keychain-backed token storage
74
81
  - An OpenCode Manager URL and bearer token
package/dist/ocm.js CHANGED
@@ -95,6 +95,9 @@ function deleteToken(account) {
95
95
  }
96
96
 
97
97
  // src/manager-api.ts
98
+ import { createReadStream } from "fs";
99
+ import { Readable } from "stream";
100
+
98
101
  class ManagerApiError extends Error {
99
102
  status;
100
103
  code;
@@ -165,12 +168,12 @@ class ManagerApi {
165
168
  if (!res.ok)
166
169
  throw await formatErrorResponse(res, `mirror part ${index}`);
167
170
  }
168
- async mirrorCommit(repoId, uploadId, totalParts) {
171
+ async mirrorCommit(repoId, uploadId, totalParts, gzip) {
169
172
  const url = `${this.baseUrl}/api/internal/repos/${repoId}/mirror/commit`;
170
173
  const res = await fetch(url, {
171
174
  method: "POST",
172
175
  headers: { ...this.headers(), "Content-Type": "application/json" },
173
- body: JSON.stringify({ uploadId, totalParts })
176
+ body: JSON.stringify({ uploadId, totalParts, gzip })
174
177
  });
175
178
  if (!res.ok)
176
179
  throw await formatErrorResponse(res, "mirror commit");
@@ -180,32 +183,112 @@ class ManagerApi {
180
183
  const url = `${this.baseUrl}/api/internal/repos/${repoId}/mirror/uploads/${uploadId}`;
181
184
  await fetch(url, { method: "DELETE", headers: this.headers() }).catch(() => {});
182
185
  }
183
- async mirrorDown(repoId) {
184
- const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror`, {
186
+ async mirrorDown(repoId, gzip) {
187
+ const query = gzip ? "?compress=gzip" : "";
188
+ const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror${query}`, {
185
189
  headers: this.headers()
186
190
  });
187
191
  if (!res.ok)
188
192
  throw await formatErrorResponse(res, "mirror download");
189
193
  return res.body;
190
194
  }
195
+ async mirrorPatch(repoId, body) {
196
+ const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/patch`, {
197
+ method: "POST",
198
+ headers: { ...this.headers(), "Content-Type": "application/json" },
199
+ body: JSON.stringify({ baseHead: body.baseHead, patch: body.patch, force: body.force === true })
200
+ });
201
+ if (!res.ok)
202
+ throw await formatErrorResponse(res, "mirror patch");
203
+ return await res.json();
204
+ }
205
+ async mirrorUploadBundle(repoId, bundlePath, opts) {
206
+ const query = opts.force === true ? "?force=1" : "";
207
+ const headers = { ...this.headers(), "Content-Type": "application/octet-stream" };
208
+ if (opts.branch)
209
+ headers["X-OCM-Branch"] = opts.branch;
210
+ const body = Readable.toWeb(createReadStream(bundlePath));
211
+ const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle${query}`, {
212
+ method: "POST",
213
+ headers,
214
+ body,
215
+ duplex: "half"
216
+ });
217
+ if (!res.ok)
218
+ throw await formatErrorResponse(res, "mirror bundle upload");
219
+ return await res.json();
220
+ }
221
+ async mirrorHead(repoId) {
222
+ const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/head`, {
223
+ headers: this.headers()
224
+ });
225
+ if (!res.ok)
226
+ throw await formatErrorResponse(res, "mirror head");
227
+ return await res.json();
228
+ }
229
+ async mirrorContains(repoId, sha) {
230
+ const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/contains/${sha}`, {
231
+ headers: this.headers()
232
+ });
233
+ if (!res.ok)
234
+ throw await formatErrorResponse(res, "mirror contains");
235
+ return await res.json();
236
+ }
237
+ async mirrorDownloadBundle(repoId) {
238
+ const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle`, {
239
+ headers: this.headers()
240
+ });
241
+ if (!res.ok)
242
+ throw await formatErrorResponse(res, "mirror bundle download");
243
+ return res.body;
244
+ }
245
+ async mirrorPatchSnapshot(repoId) {
246
+ const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/patch`, {
247
+ headers: this.headers()
248
+ });
249
+ if (!res.ok)
250
+ throw await formatErrorResponse(res, "mirror patch snapshot");
251
+ return await res.json();
252
+ }
191
253
  }
192
254
 
193
255
  // src/mirror.ts
194
256
  import { spawnSync as spawnSync3, spawn } from "child_process";
195
- import { existsSync as existsSync2, statSync } from "fs";
257
+ import { createWriteStream, existsSync as existsSync3 } from "fs";
196
258
  import * as fsp from "fs/promises";
197
- import { Readable } from "stream";
198
- import { join as join2, dirname as dirname2 } from "path";
199
- import { tmpdir } from "os";
259
+ import { Readable as Readable2 } from "stream";
260
+ import { pipeline } from "stream/promises";
261
+ import { join as join3, dirname as dirname2 } from "path";
262
+ import { tmpdir as tmpdir2 } from "os";
200
263
 
201
264
  // src/local-repo.ts
202
265
  import { spawnSync as spawnSync2 } from "child_process";
203
- function git(cwd, args) {
204
- const res = spawnSync2("git", args, { cwd, encoding: "utf-8" });
266
+ import { copyFileSync, existsSync as existsSync2, rmSync } from "fs";
267
+ import { join as join2 } from "path";
268
+ import { tmpdir } from "os";
269
+ function spawnGit(cwd, args, opts = {}) {
270
+ return spawnSync2("git", args, { cwd, input: opts.input, encoding: "utf-8", env: opts.env ?? process.env });
271
+ }
272
+ function git(cwd, args, env = process.env) {
273
+ const res = spawnGit(cwd, args, { env });
205
274
  if (res.status !== 0)
206
275
  return null;
207
276
  return (res.stdout ?? "").trim();
208
277
  }
278
+ function gitRaw(cwd, args, env = process.env) {
279
+ const res = spawnGit(cwd, args, { env });
280
+ if (res.status !== 0)
281
+ return null;
282
+ return res.stdout ?? "";
283
+ }
284
+ function runGit(cwd, args, input, env = process.env) {
285
+ const res = spawnGit(cwd, args, { input, env });
286
+ if (res.status !== 0) {
287
+ const stderr = (res.stderr ?? "").trim();
288
+ throw new Error(`git ${args.join(" ")} failed${stderr ? `: ${stderr}` : ""}`);
289
+ }
290
+ return res.stdout ?? "";
291
+ }
209
292
  function getRepoRoot(cwd) {
210
293
  return git(cwd, ["rev-parse", "--show-toplevel"]);
211
294
  }
@@ -226,22 +309,136 @@ function getDirtyPaths(dir) {
226
309
  }
227
310
  return paths;
228
311
  }
229
- function normalizeUrl(url) {
230
- return url.trim().replace(/\.git$/, "").replace(/^git@([^:]+):/, "ssh://git@$1/").replace(/\/+$/, "").toLowerCase();
231
- }
232
312
  function getBranchName(dir) {
233
- return git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
313
+ const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
314
+ return branch && branch !== "HEAD" ? branch : null;
234
315
  }
235
- function urlsEqual(a, b) {
236
- if (!a || !b)
237
- return false;
238
- return normalizeUrl(a) === normalizeUrl(b);
316
+ function getWorkingTreeDiff(dir) {
317
+ return gitRaw(dir, ["diff", "--binary", "HEAD", "--"]) ?? "";
318
+ }
319
+ function getHeadSha(dir) {
320
+ return git(dir, ["rev-parse", "HEAD"]);
321
+ }
322
+ function hasCommit(dir, sha) {
323
+ return git(dir, ["cat-file", "-e", `${sha}^{commit}`]) !== null;
324
+ }
325
+ function isAncestor(dir, ancestor, descendant) {
326
+ return spawnGit(dir, ["merge-base", "--is-ancestor", ancestor, descendant]).status === 0;
327
+ }
328
+ function countCommitsAhead(dir, from, to) {
329
+ const out = git(dir, ["rev-list", "--count", `${from}..${to}`]);
330
+ const n = out ? Number(out) : NaN;
331
+ return Number.isInteger(n) ? n : -1;
332
+ }
333
+ function getMirrorPatch(dir) {
334
+ const untracked = gitRaw(dir, ["ls-files", "--others", "--exclude-standard", "-z"])?.split("\x00").filter(Boolean) ?? [];
335
+ if (untracked.length === 0)
336
+ return getWorkingTreeDiff(dir);
337
+ const indexPath = git(dir, ["rev-parse", "--git-path", "index"]);
338
+ const tempIndex = join2(tmpdir(), `ocm-index-${Date.now()}-${Math.random().toString(36).slice(2)}`);
339
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
340
+ try {
341
+ if (indexPath && existsSync2(indexPath)) {
342
+ copyFileSync(indexPath, tempIndex);
343
+ }
344
+ const add = spawnSync2("git", ["add", "-N", "--", ...untracked], { cwd: dir, encoding: "utf-8", env });
345
+ if (add.status !== 0)
346
+ return getWorkingTreeDiff(dir);
347
+ return gitRaw(dir, ["diff", "--binary", "HEAD", "--"], env) ?? "";
348
+ } finally {
349
+ rmSync(tempIndex, { force: true });
350
+ }
351
+ }
352
+
353
+ // ../shared/src/project-id-resolver.ts
354
+ import { execFile } from "child_process";
355
+ import { createHash } from "crypto";
356
+ import { readFile } from "fs/promises";
357
+ import path from "path";
358
+ function git2(cwd, args) {
359
+ return new Promise((resolve) => {
360
+ execFile("git", args, { cwd }, (error, stdout) => {
361
+ resolve(error ? null : stdout.trim());
362
+ });
363
+ });
364
+ }
365
+ function trimSlashes(value, leading, trailing) {
366
+ let start = 0;
367
+ let end = value.length;
368
+ if (leading)
369
+ while (start < end && value[start] === "/")
370
+ start++;
371
+ if (trailing)
372
+ while (end > start && value[end - 1] === "/")
373
+ end--;
374
+ return value.slice(start, end);
375
+ }
376
+ function gitRemoteParts(host, name) {
377
+ let pathname = trimSlashes(name, true, false);
378
+ if (pathname.endsWith(".git"))
379
+ pathname = pathname.slice(0, -4);
380
+ else if (pathname.endsWith(".git/"))
381
+ pathname = pathname.slice(0, -5);
382
+ pathname = trimSlashes(pathname, false, true);
383
+ if (!host || !pathname)
384
+ return;
385
+ return `${host.toLowerCase()}/${pathname}`;
386
+ }
387
+ function normalizeGitRemote(input) {
388
+ const value = input.trim();
389
+ if (!value)
390
+ return;
391
+ try {
392
+ const parsed = new URL(value);
393
+ if (parsed.protocol === "file:")
394
+ return;
395
+ return gitRemoteParts(parsed.hostname, parsed.pathname);
396
+ } catch {
397
+ const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/);
398
+ if (scp)
399
+ return gitRemoteParts(scp[2], scp[3]);
400
+ return;
401
+ }
402
+ }
403
+ function gitRemoteProjectId(originUrl) {
404
+ const normalized = normalizeGitRemote(originUrl);
405
+ if (!normalized)
406
+ return;
407
+ return createHash("sha1").update(`git-remote:${normalized}`).digest("hex");
408
+ }
409
+ async function resolveOpenCodeProjectId(repoDir) {
410
+ const worktree = await git2(repoDir, ["rev-parse", "--show-toplevel"]);
411
+ if (!worktree)
412
+ return null;
413
+ const origin = await git2(worktree, ["remote", "get-url", "origin"]);
414
+ if (origin) {
415
+ const id = gitRemoteProjectId(origin);
416
+ if (id)
417
+ return id;
418
+ }
419
+ const commonDir = await git2(repoDir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
420
+ if (commonDir) {
421
+ try {
422
+ const cached = (await readFile(path.join(commonDir, "opencode"), "utf-8")).trim();
423
+ if (cached)
424
+ return cached;
425
+ } catch {}
426
+ }
427
+ const rootsOutput = await git2(worktree, ["rev-list", "--max-parents=0", "HEAD"]);
428
+ if (rootsOutput) {
429
+ const root = rootsOutput.split(`
430
+ `).map((line) => line.trim()).filter(Boolean).sort()[0];
431
+ if (root)
432
+ return root;
433
+ }
434
+ return null;
239
435
  }
240
436
 
241
437
  // src/mirror.ts
242
438
  var HARDCODED_EXCLUDES = ["node_modules", "dist", ".next", ".venv", "__pycache__", ".turbo", ".DS_Store", "._*"];
243
439
  var PART_RETRIES = 3;
244
440
  var PART_BACKOFF_MS = [500, 2000, 8000];
441
+ var MIRROR_GZIP = true;
245
442
  function getGitignoreExclusions(repoRoot) {
246
443
  const res = spawnSync3("git", ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"], {
247
444
  cwd: repoRoot,
@@ -253,44 +450,20 @@ function getGitignoreExclusions(repoRoot) {
253
450
  `).filter((line) => line.length > 0);
254
451
  }
255
452
  async function carryOverIgnored(fromDir, toDir) {
256
- if (!existsSync2(fromDir))
453
+ if (!existsSync3(fromDir))
257
454
  return;
258
455
  for (const rel of getGitignoreExclusions(fromDir)) {
259
456
  const clean = rel.replace(/\/+$/, "");
260
457
  if (!clean)
261
458
  continue;
262
- const src = join2(fromDir, clean);
263
- const dest = join2(toDir, clean);
264
- if (!existsSync2(src) || existsSync2(dest))
459
+ const src = join3(fromDir, clean);
460
+ const dest = join3(toDir, clean);
461
+ if (!existsSync3(src) || existsSync3(dest))
265
462
  continue;
266
463
  await fsp.mkdir(dirname2(dest), { recursive: true });
267
464
  await fsp.rename(src, dest).catch(() => {});
268
465
  }
269
466
  }
270
- function listIncludedFiles(repoRoot) {
271
- const tracked = spawnSync3("git", ["ls-files", "-z"], { cwd: repoRoot, encoding: "utf-8" });
272
- const untracked = spawnSync3("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: repoRoot, encoding: "utf-8" });
273
- const split = (out) => (out ?? "").split("\x00").filter((p) => p.length > 0);
274
- const all = [...split(tracked.stdout), ...split(untracked.stdout)];
275
- return all.filter((p) => {
276
- const top = p.split("/")[0] ?? p;
277
- return !HARDCODED_EXCLUDES.includes(top);
278
- });
279
- }
280
- function estimateTarSize(repoRoot) {
281
- const files = listIncludedFiles(repoRoot);
282
- let total = 0;
283
- for (const rel of files) {
284
- let size = 0;
285
- try {
286
- size = statSync(join2(repoRoot, rel)).size;
287
- } catch {
288
- continue;
289
- }
290
- total += 512 + Math.ceil(size / 512) * 512;
291
- }
292
- return total + 1024;
293
- }
294
467
 
295
468
  class MirrorAbort extends Error {
296
469
  constructor(message) {
@@ -298,15 +471,47 @@ class MirrorAbort extends Error {
298
471
  this.name = "MirrorAbort";
299
472
  }
300
473
  }
301
- function prepareMirror(cwd, remotes) {
474
+ async function prepareMirror(cwd, remotes) {
302
475
  const repoRoot = getRepoRoot(cwd);
303
476
  if (!repoRoot)
304
477
  throw new MirrorAbort("not in a git repository");
305
- const localOrigin = getOriginUrl(repoRoot);
306
- if (!localOrigin)
307
- throw new MirrorAbort("no origin URL found");
308
- const matched = remotes.filter((r) => urlsEqual(localOrigin, r.originUrl));
309
- return { repoRoot, localOrigin, matched };
478
+ const localProjectId = await resolveOpenCodeProjectId(repoRoot);
479
+ if (!localProjectId)
480
+ throw new MirrorAbort("could not resolve an OpenCode project id for this repository");
481
+ const matched = remotes.filter((r) => r.projectId && r.projectId === localProjectId);
482
+ return { repoRoot, localProjectId, matched };
483
+ }
484
+ async function checkPushDivergence(repoRoot, api, repoId) {
485
+ const info = await api.mirrorHead(repoId);
486
+ const { head: serverHead, branch: serverBranch, dirty: serverDirty } = info;
487
+ const localHead = getHeadSha(repoRoot);
488
+ if (!serverHead || serverHead === localHead) {
489
+ return { serverHead, serverBranch, serverDirty, diverged: false, lostCommits: 0 };
490
+ }
491
+ if (!hasCommit(repoRoot, serverHead)) {
492
+ return { serverHead, serverBranch, serverDirty, diverged: true, lostCommits: -1 };
493
+ }
494
+ if (localHead && isAncestor(repoRoot, serverHead, localHead)) {
495
+ return { serverHead, serverBranch, serverDirty, diverged: false, lostCommits: 0 };
496
+ }
497
+ const lostCommits = localHead ? countCommitsAhead(repoRoot, localHead, serverHead) : -1;
498
+ return { serverHead, serverBranch, serverDirty, diverged: true, lostCommits };
499
+ }
500
+ async function checkPullDivergence(repoRoot, api, repoId) {
501
+ const localHead = getHeadSha(repoRoot);
502
+ if (!localHead)
503
+ return { diverged: false, lostCommits: 0, serverHead: null };
504
+ const { contained } = await api.mirrorContains(repoId, localHead);
505
+ if (contained)
506
+ return { diverged: false, lostCommits: 0, serverHead: null };
507
+ let serverHead = null;
508
+ let lostCommits = -1;
509
+ try {
510
+ serverHead = (await api.mirrorHead(repoId)).head;
511
+ if (serverHead)
512
+ lostCommits = countCommitsAhead(repoRoot, serverHead, localHead);
513
+ } catch {}
514
+ return { diverged: true, lostCommits, serverHead };
310
515
  }
311
516
  function delay(ms) {
312
517
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -370,15 +575,16 @@ function createPartFlusher(api, repoId, uploadId, chunkSize) {
370
575
  async function mirrorUp(plan, opts) {
371
576
  const repoId = opts.create ? 0 : plan.matched[0].repoId;
372
577
  const begin = await opts.api.mirrorBegin(repoId, { force: opts.force, create: opts.create });
373
- const totalBytes = estimateTarSize(plan.repoRoot);
374
578
  let bytesSent = 0;
375
579
  const tarArgs = ["-c", "-C", plan.repoRoot];
580
+ if (MIRROR_GZIP)
581
+ tarArgs.unshift("-z");
376
582
  for (const dir of HARDCODED_EXCLUDES)
377
583
  tarArgs.push("--exclude", dir);
378
584
  const ignoredPaths = getGitignoreExclusions(plan.repoRoot);
379
585
  let excludeFile = null;
380
586
  if (ignoredPaths.length > 0) {
381
- excludeFile = join2(tmpdir(), `ocm-exclude-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
587
+ excludeFile = join3(tmpdir2(), `ocm-exclude-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
382
588
  await fsp.writeFile(excludeFile, ignoredPaths.join(`
383
589
  `));
384
590
  tarArgs.push("--exclude-from", excludeFile);
@@ -406,12 +612,12 @@ async function mirrorUp(plan, opts) {
406
612
  for await (const chunk of child.stdout) {
407
613
  await flusher.push(chunk);
408
614
  bytesSent += chunk.length;
409
- opts.onProgress?.({ phase: "uploading", bytesSent, totalBytes });
615
+ opts.onProgress?.({ bytesSent });
410
616
  }
411
617
  await tarExit;
412
618
  const totalParts = await flusher.finish();
413
- opts.onProgress?.({ phase: "committing", bytesSent, totalBytes });
414
- const result = await opts.api.mirrorCommit(begin.repoId, begin.uploadId, totalParts);
619
+ opts.onProgress?.({ bytesSent });
620
+ const result = await opts.api.mirrorCommit(begin.repoId, begin.uploadId, totalParts, MIRROR_GZIP);
415
621
  return result;
416
622
  } catch (err) {
417
623
  if (!child.killed)
@@ -431,8 +637,11 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
431
637
  const staging = `${repoRoot}.ocm-recv-${Date.now()}`;
432
638
  await fsp.mkdir(staging, { recursive: true });
433
639
  try {
434
- const tarball = await api.mirrorDown(repoId);
435
- const child = spawn("tar", ["-x", "-f", "-", "-C", staging], { stdio: ["pipe", "pipe", "pipe"] });
640
+ const tarball = await api.mirrorDown(repoId, MIRROR_GZIP);
641
+ const tarArgs = ["-x", "-f", "-", "-C", staging];
642
+ if (MIRROR_GZIP)
643
+ tarArgs.unshift("-z");
644
+ const child = spawn("tar", tarArgs, { stdio: ["pipe", "pipe", "pipe"] });
436
645
  const stderrChunks = [];
437
646
  child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
438
647
  const tarDone = new Promise((resolve, reject) => {
@@ -446,7 +655,7 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
446
655
  });
447
656
  child.on("error", reject);
448
657
  });
449
- const stdinWritable = Readable.fromWeb(tarball);
658
+ const stdinWritable = Readable2.fromWeb(tarball);
450
659
  let received = 0;
451
660
  stdinWritable.on("data", (chunk) => {
452
661
  received += chunk.length;
@@ -456,16 +665,16 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
456
665
  await tarDone;
457
666
  const backupDir = `${repoRoot}.ocm-backup-${Date.now()}`;
458
667
  await fsp.mkdir(backupDir, { recursive: true });
459
- if (existsSync2(repoRoot)) {
668
+ if (existsSync3(repoRoot)) {
460
669
  const entries = await fsp.readdir(repoRoot);
461
670
  for (const entry of entries) {
462
- await fsp.rename(join2(repoRoot, entry), join2(backupDir, entry));
671
+ await fsp.rename(join3(repoRoot, entry), join3(backupDir, entry));
463
672
  }
464
673
  }
465
674
  try {
466
675
  const stagingEntries = await fsp.readdir(staging);
467
676
  for (const entry of stagingEntries) {
468
- await fsp.rename(join2(staging, entry), join2(repoRoot, entry));
677
+ await fsp.rename(join3(staging, entry), join3(repoRoot, entry));
469
678
  }
470
679
  await carryOverIgnored(backupDir, repoRoot);
471
680
  await fsp.rm(backupDir, { recursive: true, force: true }).catch(() => {});
@@ -473,7 +682,7 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
473
682
  } catch (swapError) {
474
683
  const backupEntries = await fsp.readdir(backupDir).catch(() => []);
475
684
  for (const entry of backupEntries) {
476
- await fsp.rename(join2(backupDir, entry), join2(repoRoot, entry)).catch(() => {});
685
+ await fsp.rename(join3(backupDir, entry), join3(repoRoot, entry)).catch(() => {});
477
686
  }
478
687
  await fsp.rm(backupDir, { recursive: true, force: true }).catch(() => {});
479
688
  throw swapError;
@@ -483,6 +692,97 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
483
692
  throw error;
484
693
  }
485
694
  }
695
+ async function mirrorUpPatch(plan, opts) {
696
+ const repoId = plan.matched[0].repoId;
697
+ const patch = getMirrorPatch(plan.repoRoot);
698
+ return opts.api.mirrorPatch(repoId, { baseHead: getHeadSha(plan.repoRoot), patch, force: opts.force });
699
+ }
700
+ function applyPatch(repoRoot, patch) {
701
+ if (!patch)
702
+ return;
703
+ const child = spawnSync3("git", ["apply", "--binary", "--whitespace=nowarn", "-"], {
704
+ cwd: repoRoot,
705
+ input: patch,
706
+ encoding: "utf-8"
707
+ });
708
+ if (child.status !== 0) {
709
+ const stderr = (child.stderr ?? "").trim();
710
+ throw new Error(`git apply failed${stderr ? `: ${stderr}` : ""}`);
711
+ }
712
+ }
713
+ async function createLocalBundle(repoRoot) {
714
+ const bundlePath = join3(tmpdir2(), `ocm-bundle-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`);
715
+ runGit(repoRoot, ["bundle", "create", bundlePath, "--all"]);
716
+ return bundlePath;
717
+ }
718
+ function importLocalBundle(repoRoot, bundlePath, branch) {
719
+ runGit(repoRoot, ["fetch", bundlePath, "+refs/heads/*:refs/remotes/ocm-sync/*", "+refs/tags/*:refs/tags/*"]);
720
+ const refs = runGit(repoRoot, ["for-each-ref", "--format=%(refname:strip=3) %(objectname)", "refs/remotes/ocm-sync"]);
721
+ const updates = [];
722
+ for (const line of refs.split(`
723
+ `)) {
724
+ const trimmed = line.trim();
725
+ if (!trimmed)
726
+ continue;
727
+ const firstSpace = trimmed.indexOf(" ");
728
+ if (firstSpace === -1)
729
+ continue;
730
+ const name = trimmed.slice(0, firstSpace);
731
+ if (name === "HEAD")
732
+ continue;
733
+ const sha = trimmed.slice(firstSpace + 1);
734
+ updates.push(`update refs/heads/${name} ${sha}
735
+ `);
736
+ }
737
+ if (updates.length > 0) {
738
+ runGit(repoRoot, ["update-ref", "--stdin"], updates.join(""));
739
+ }
740
+ if (branch) {
741
+ runGit(repoRoot, ["checkout", branch]);
742
+ const head = runGit(repoRoot, ["rev-parse", `refs/remotes/ocm-sync/${branch}`]).trim();
743
+ if (head)
744
+ runGit(repoRoot, ["reset", "--hard", head]);
745
+ }
746
+ try {
747
+ const syncRefsOut = runGit(repoRoot, ["for-each-ref", "--format=%(refname)", "refs/remotes/ocm-sync"]);
748
+ const deletes = syncRefsOut.split(`
749
+ `).map((l) => l.trim()).filter(Boolean).map((ref) => `delete ${ref}
750
+ `);
751
+ if (deletes.length > 0) {
752
+ runGit(repoRoot, ["update-ref", "--stdin"], deletes.join(""));
753
+ }
754
+ } catch {}
755
+ }
756
+ async function writeBundleStream(repoId, api) {
757
+ const bundlePath = join3(tmpdir2(), `ocm-bundle-down-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`);
758
+ const stream = await api.mirrorDownloadBundle(repoId);
759
+ await pipeline(Readable2.fromWeb(stream), createWriteStream(bundlePath));
760
+ return bundlePath;
761
+ }
762
+ async function mirrorUpFast(plan, opts) {
763
+ const repoId = plan.matched[0].repoId;
764
+ const bundlePath = await createLocalBundle(plan.repoRoot);
765
+ try {
766
+ await opts.api.mirrorUploadBundle(repoId, bundlePath, { branch: getBranchName(plan.repoRoot), force: opts.force });
767
+ const patchResult = await mirrorUpPatch(plan, opts);
768
+ return { repoId: patchResult.repoId, branch: patchResult.branch, head: patchResult.head, created: false };
769
+ } finally {
770
+ await fsp.rm(bundlePath, { force: true }).catch(() => {});
771
+ }
772
+ }
773
+ async function mirrorDownFast(repoId, repoRoot, api, opts = { force: false }) {
774
+ if (!opts.force && getDirtyPaths(repoRoot).size > 0) {
775
+ throw new MirrorAbort("working tree has uncommitted changes; rerun with --force");
776
+ }
777
+ const snapshot = await api.mirrorPatchSnapshot(repoId);
778
+ const bundlePath = await writeBundleStream(repoId, api);
779
+ try {
780
+ importLocalBundle(repoRoot, bundlePath, snapshot.branch);
781
+ applyPatch(repoRoot, snapshot.patch);
782
+ } finally {
783
+ await fsp.rm(bundlePath, { force: true }).catch(() => {});
784
+ }
785
+ }
486
786
 
487
787
  // src/progress.ts
488
788
  var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -501,31 +801,10 @@ function formatBytes(bytes) {
501
801
  function createProgressReporter(label, out = process.stderr, now = Date.now) {
502
802
  let finished = false;
503
803
  let lastRenderAt = -Infinity;
504
- let lastBucket = -1;
505
804
  let lastNonTtyTickAt = -Infinity;
506
805
  let frameIndex = 0;
507
806
  const isTTY = out.isTTY === true;
508
807
  return {
509
- update(current, total) {
510
- if (finished)
511
- return;
512
- if (isTTY) {
513
- const t = now();
514
- if (t - lastRenderAt < 80)
515
- return;
516
- lastRenderAt = t;
517
- const pct = total > 0 ? Math.min(99, Math.floor(current / total * 100)) : 0;
518
- out.write(`\r\x1B[K${label}: ${pct}% (${formatBytes(current)} / ${formatBytes(total)})`);
519
- } else {
520
- const pct = total > 0 ? Math.min(99, Math.floor(current / total * 100)) : 0;
521
- const bucket = Math.floor(pct / 10);
522
- if (bucket === lastBucket)
523
- return;
524
- lastBucket = bucket;
525
- out.write(`${label}: ${pct}% (${formatBytes(current)} / ${formatBytes(total)})
526
- `);
527
- }
528
- },
529
808
  tick(bytes) {
530
809
  if (finished)
531
810
  return;
@@ -558,14 +837,15 @@ function createProgressReporter(label, out = process.stderr, now = Date.now) {
558
837
  // src/resolve-target.ts
559
838
  function resolveTarget(input) {
560
839
  const repoRoot = getRepoRoot(input.cwd);
561
- const localOrigin = repoRoot ? getOriginUrl(repoRoot) : null;
562
- if (repoRoot && localOrigin) {
563
- const matches = input.repos.filter((r) => urlsEqual(localOrigin, r.originUrl));
564
- if (matches.length === 1) {
565
- return { kind: "cwd-match", repo: matches[0], repoRoot };
566
- }
567
- if (matches.length > 1) {
568
- return { kind: "cwd-ambiguous", matches, localOrigin, repoRoot };
840
+ if (repoRoot) {
841
+ if (input.localProjectId) {
842
+ const matches = input.repos.filter((r) => r.projectId && r.projectId === input.localProjectId);
843
+ if (matches.length === 1) {
844
+ return { kind: "cwd-match", repo: matches[0], repoRoot };
845
+ }
846
+ if (matches.length > 1) {
847
+ return { kind: "cwd-ambiguous", matches, localProjectId: input.localProjectId, repoRoot };
848
+ }
569
849
  }
570
850
  return { kind: "local", reason: "no-match", repoRoot };
571
851
  }
@@ -585,7 +865,7 @@ function toTarget(last) {
585
865
  // package.json
586
866
  var package_default = {
587
867
  name: "@opencode-manager/ocm-cli",
588
- version: "0.1.3",
868
+ version: "0.2.0",
589
869
  description: "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
590
870
  license: "MIT",
591
871
  repository: {
@@ -611,21 +891,27 @@ var package_default = {
611
891
  build: "bun scripts/build.ts",
612
892
  postinstall: "node scripts/postinstall.mjs || true",
613
893
  typecheck: "tsc --noEmit",
894
+ lint: "eslint . --ext .ts",
895
+ "lint:fix": "eslint . --ext .ts --fix",
614
896
  test: "bun scripts/build.ts && vitest run",
615
897
  "test:watch": "vitest",
616
898
  prepublishOnly: "bun scripts/build.ts"
617
899
  },
618
900
  dependencies: {},
619
901
  devDependencies: {
902
+ "@eslint/js": "^9.36.0",
903
+ "@opencode-manager/shared": "workspace:*",
620
904
  "@types/node": "^22.0.0",
905
+ eslint: "^9.39.1",
621
906
  typescript: "^5.5.0",
907
+ "typescript-eslint": "^8.45.0",
622
908
  vitest: "^3.1.0"
623
909
  }
624
910
  };
625
911
 
626
912
  // bin/ocm.ts
627
913
  var VERSION = package_default.version;
628
- var USAGE = `ocm - OpenCode Manager workspace launcher
914
+ var USAGE = `ocm v${VERSION} - OpenCode Manager workspace launcher
629
915
 
630
916
  Usage:
631
917
  ocm Attach to the Manager repo matching $PWD's git origin,
@@ -636,8 +922,8 @@ Usage:
636
922
  ocm status Show current manager URL, repo, and whether token is set
637
923
  ocm list List ready repos from the manager
638
924
  ocm use <repoId|name> Attach to a specific repo and remember it as last
639
- ocm push [--force] [--create] [--yes] Mirror $PWD to the matching Manager repo (or create one)
640
- ocm pull [--force] Mirror the matching Manager repo over $PWD
925
+ ocm push [--force] [--create] [--yes] [--full] Mirror $PWD to the matching Manager repo (fast patch sync by default)
926
+ ocm pull [--force] [--full] Mirror the matching Manager repo over $PWD (fast patch sync by default)
641
927
  ocm --version Show the installed ocm version
642
928
  ocm --help Show this help
643
929
  `;
@@ -650,6 +936,52 @@ function info(msg) {
650
936
  process.stdout.write(`${msg}
651
937
  `);
652
938
  }
939
+ function promptYesNo(question) {
940
+ process.stderr.write(`${question} [y/N] `);
941
+ const res = spawnSync4("bash", ["-c", 'read LINE && printf "%s" "$LINE"'], {
942
+ stdio: ["inherit", "pipe", "inherit"],
943
+ encoding: "utf-8"
944
+ });
945
+ const answer = (res.stdout ?? "").trim().toLowerCase();
946
+ return answer === "y" || answer === "yes";
947
+ }
948
+ function confirmFullFallback() {
949
+ if (!process.stdin.isTTY)
950
+ return;
951
+ if (!promptYesNo("Fall back to a full mirror? This replaces the entire server working tree (no merge, no conflict resolution).")) {
952
+ die("aborted");
953
+ }
954
+ }
955
+ function confirmOverwrite(headline, reasons, question, note) {
956
+ process.stderr.write(`ocm: warning: ${headline}
957
+ `);
958
+ for (const reason of reasons)
959
+ process.stderr.write(` - ${reason}
960
+ `);
961
+ if (note)
962
+ process.stderr.write(` ${note}
963
+ `);
964
+ if (!process.stdin.isTTY) {
965
+ die("refusing to discard work; re-run with --force to override");
966
+ }
967
+ if (!promptYesNo(question)) {
968
+ die("aborted");
969
+ }
970
+ return true;
971
+ }
972
+ function guardDivergentPush(repoName, div) {
973
+ const reasons = [];
974
+ if (div.diverged) {
975
+ reasons.push(div.lostCommits >= 0 ? `the server is ${div.lostCommits} commit(s) ahead of your local branch` : "the server has commit(s) not present in your local branch");
976
+ }
977
+ if (div.serverDirty)
978
+ reasons.push("the server has uncommitted changes");
979
+ return confirmOverwrite(`pushing to ${repoName} will discard server-side work:`, reasons, "Overwrite server-side work and push anyway?", "This work is likely from OpenCode agent sessions on the manager.");
980
+ }
981
+ function guardDivergentPull(repoName, div) {
982
+ const reasons = [div.lostCommits >= 0 ? `your local branch is ${div.lostCommits} commit(s) ahead of ${repoName}` : `your local branch has commit(s) not present on ${repoName}`];
983
+ return confirmOverwrite(`pulling ${repoName} will discard local commits:`, reasons, "Discard local commits and pull anyway?");
984
+ }
653
985
  function requireState() {
654
986
  const state = readState();
655
987
  if (!state || !state.managerUrl) {
@@ -810,6 +1142,7 @@ async function cmdUse(args) {
810
1142
  attach(state.managerUrl, token, repo);
811
1143
  }
812
1144
  async function cmdDefault() {
1145
+ info(`ocm v${VERSION}`);
813
1146
  const state = requireState();
814
1147
  const token = requireToken(state);
815
1148
  const last = state.lastRepoId !== undefined && state.lastRepoDir ? {
@@ -818,12 +1151,14 @@ async function cmdDefault() {
818
1151
  directory: state.lastRepoDir,
819
1152
  branch: state.lastRepoBranch ?? null
820
1153
  } : undefined;
1154
+ info("connecting...");
821
1155
  const repos = await fetchRepos(state.managerUrl, token);
822
- const result = resolveTarget({ cwd: process.cwd(), repos, last });
1156
+ const localProjectId = await resolveOpenCodeProjectId(process.cwd());
1157
+ const result = resolveTarget({ cwd: process.cwd(), repos, localProjectId, last });
823
1158
  switch (result.kind) {
824
1159
  case "cwd-match": {
825
1160
  const repo = result.repo;
826
- info(`attaching to ${repo.name} (matched $PWD origin)`);
1161
+ info(`attaching to ${repo.name}`);
827
1162
  writeState({
828
1163
  ...state,
829
1164
  lastRepoId: repo.repoId,
@@ -834,12 +1169,16 @@ async function cmdDefault() {
834
1169
  attach(state.managerUrl, token, toManagerRepo(repo));
835
1170
  return;
836
1171
  }
837
- case "last":
838
- attach(state.managerUrl, token, toManagerRepo(result.repo));
1172
+ case "last": {
1173
+ const repo = result.repo;
1174
+ info(`attaching to ${repo.name} (last used)`);
1175
+ attach(state.managerUrl, token, toManagerRepo(repo));
839
1176
  return;
1177
+ }
840
1178
  case "cwd-ambiguous": {
841
1179
  const names = result.matches.map((r) => `${r.name} (id=${r.repoId})`).join(", ");
842
- die(`multiple Manager repos match origin ${result.localOrigin}: ${names}; disambiguate with \`ocm use <repoId>\``);
1180
+ die(`multiple Manager repos match project ${result.localProjectId}: ${names}; disambiguate with \`ocm use <repoId>\``);
1181
+ break;
843
1182
  }
844
1183
  case "local":
845
1184
  runLocalOpencode(result.reason);
@@ -869,6 +1208,7 @@ async function cmdPush(args) {
869
1208
  let force = false;
870
1209
  let create = false;
871
1210
  let yes = false;
1211
+ let full = false;
872
1212
  for (const arg of args) {
873
1213
  if (arg === "--force")
874
1214
  force = true;
@@ -876,68 +1216,87 @@ async function cmdPush(args) {
876
1216
  create = true;
877
1217
  else if (arg === "--yes")
878
1218
  yes = true;
1219
+ else if (arg === "--full")
1220
+ full = true;
879
1221
  }
880
1222
  const state = requireState();
881
1223
  const token = requireToken(state);
882
1224
  const api = new ManagerApi(state.managerUrl, token);
883
1225
  const repos = await fetchRepos(state.managerUrl, token);
884
- const progress = createProgressReporter("push");
885
- const onProgress = (p) => {
886
- if (p.phase === "committing")
887
- progress.tick(p.bytesSent);
888
- else if (p.totalBytes > 0)
889
- progress.update(p.bytesSent, p.totalBytes);
890
- else
891
- progress.tick(p.bytesSent);
892
- };
893
1226
  const remotes = repos.map((r) => ({
894
1227
  repoId: r.repoId,
895
1228
  name: r.name,
896
- originUrl: r.originUrl ?? null,
1229
+ projectId: r.projectId ?? null,
897
1230
  branch: r.branch
898
1231
  }));
899
- const plan = prepareMirror(process.cwd(), remotes);
1232
+ const plan = await prepareMirror(process.cwd(), remotes);
900
1233
  if (plan.matched.length === 0) {
901
1234
  if (!create) {
902
- die(`no matching Manager repo for origin ${plan.localOrigin}. Re-run with --create to create one.`);
1235
+ die(`no matching Manager repo for project ${plan.localProjectId}. Re-run with --create to create one.`);
903
1236
  }
904
1237
  const name = basename(plan.repoRoot);
905
1238
  const branch = getBranchName(plan.repoRoot);
1239
+ const originUrl = getOriginUrl(plan.repoRoot);
906
1240
  if (process.stdin.isTTY && !yes) {
907
- process.stderr.write(`Create Manager repo "${name}" by uploading ${plan.repoRoot} (origin: ${plan.localOrigin})? [y/N] `);
908
- const res = spawnSync4("bash", ["-c", 'read LINE && printf "%s" "$LINE"'], {
909
- stdio: ["inherit", "pipe", "inherit"],
910
- encoding: "utf-8"
911
- });
912
- const answer = (res.stdout ?? "").trim().toLowerCase();
913
- if (answer !== "y" && answer !== "yes") {
1241
+ if (!promptYesNo(`Create Manager repo "${name}" by uploading ${plan.repoRoot} (project: ${plan.localProjectId})?`)) {
914
1242
  die("aborted");
915
1243
  }
916
1244
  } else if (!process.stdin.isTTY && !yes) {
917
1245
  die("stdin is not a TTY; pass --yes to confirm creation");
918
1246
  }
1247
+ const progress = createProgressReporter("push");
1248
+ const onProgress = (p) => progress.tick(p.bytesSent);
919
1249
  const result = await mirrorUp(plan, {
920
1250
  api,
921
1251
  force,
922
- create: { name, originUrl: plan.localOrigin, branch },
1252
+ create: { name, originUrl, branch },
923
1253
  onProgress
924
1254
  });
925
1255
  progress.done();
926
1256
  info(`pushed ${plan.repoRoot} -> ${result.created ? "created" : "updated"} (repoId=${result.repoId}, branch=${result.branch})`);
927
1257
  } else if (plan.matched.length === 1) {
1258
+ if (!force) {
1259
+ try {
1260
+ const divergence = await checkPushDivergence(plan.repoRoot, api, plan.matched[0].repoId);
1261
+ if (divergence.diverged || divergence.serverDirty) {
1262
+ force = guardDivergentPush(plan.matched[0].name, divergence);
1263
+ }
1264
+ } catch (error) {
1265
+ if (!(error instanceof ManagerApiError && error.status === 404))
1266
+ throw error;
1267
+ }
1268
+ }
1269
+ if (!full) {
1270
+ try {
1271
+ const result2 = await mirrorUpFast(plan, { api, force });
1272
+ info(`pushed ${plan.repoRoot} -> ${plan.matched[0].name} via bundle (repoId=${result2.repoId}, branch=${result2.branch})`);
1273
+ return;
1274
+ } catch (error) {
1275
+ if (error instanceof MirrorAbort)
1276
+ throw error;
1277
+ process.stderr.write(`ocm: patch push failed: ${error instanceof Error ? error.message : String(error)}
1278
+ `);
1279
+ confirmFullFallback();
1280
+ }
1281
+ }
1282
+ const progress = createProgressReporter("push");
1283
+ const onProgress = (p) => progress.tick(p.bytesSent);
928
1284
  const result = await mirrorUp(plan, { api, force, onProgress });
929
1285
  progress.done();
930
1286
  info(`pushed ${plan.repoRoot} -> ${plan.matched[0].name} (repoId=${result.repoId}, branch=${result.branch})`);
931
1287
  } else {
932
1288
  const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(", ");
933
- die(`multiple Manager repos match origin ${plan.localOrigin}: ${names}; disambiguate with \`ocm push <repoId>\``);
1289
+ die(`multiple Manager repos match project ${plan.localProjectId}: ${names}; disambiguate with \`ocm push <repoId>\``);
934
1290
  }
935
1291
  }
936
1292
  async function cmdPull(args) {
937
1293
  let force = false;
1294
+ let full = false;
938
1295
  for (const arg of args) {
939
1296
  if (arg === "--force")
940
1297
  force = true;
1298
+ else if (arg === "--full")
1299
+ full = true;
941
1300
  }
942
1301
  const state = requireState();
943
1302
  const token = requireToken(state);
@@ -946,16 +1305,40 @@ async function cmdPull(args) {
946
1305
  const remotes = repos.map((r) => ({
947
1306
  repoId: r.repoId,
948
1307
  name: r.name,
949
- originUrl: r.originUrl ?? null,
1308
+ projectId: r.projectId ?? null,
950
1309
  branch: r.branch
951
1310
  }));
952
- const plan = prepareMirror(process.cwd(), remotes);
1311
+ const plan = await prepareMirror(process.cwd(), remotes);
953
1312
  if (plan.matched.length === 0) {
954
- die(`no matching Manager repo for origin ${plan.localOrigin}.`);
1313
+ die(`no matching Manager repo for project ${plan.localProjectId}.`);
955
1314
  }
956
1315
  if (plan.matched.length > 1) {
957
1316
  const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(", ");
958
- die(`multiple Manager repos match origin ${plan.localOrigin}: ${names}; disambiguate with \`ocm pull <repoId>\``);
1317
+ die(`multiple Manager repos match project ${plan.localProjectId}: ${names}; disambiguate with \`ocm pull <repoId>\``);
1318
+ }
1319
+ if (!force) {
1320
+ try {
1321
+ const divergence = await checkPullDivergence(plan.repoRoot, api, plan.matched[0].repoId);
1322
+ if (divergence.diverged) {
1323
+ force = guardDivergentPull(plan.matched[0].name, divergence);
1324
+ }
1325
+ } catch (error) {
1326
+ if (!(error instanceof ManagerApiError && error.status === 404))
1327
+ throw error;
1328
+ }
1329
+ }
1330
+ if (!full) {
1331
+ try {
1332
+ await mirrorDownFast(plan.matched[0].repoId, plan.repoRoot, api, { force });
1333
+ info(`pulled ${plan.matched[0].name} -> ${plan.repoRoot} via bundle`);
1334
+ return;
1335
+ } catch (error) {
1336
+ if (error instanceof MirrorAbort && !error.message.includes("falling back"))
1337
+ throw error;
1338
+ process.stderr.write(`ocm: patch pull failed: ${error instanceof Error ? error.message : String(error)}
1339
+ `);
1340
+ confirmFullFallback();
1341
+ }
959
1342
  }
960
1343
  const progress = createProgressReporter("pull");
961
1344
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-manager/ocm-cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,14 +24,20 @@
24
24
  ],
25
25
  "dependencies": {},
26
26
  "devDependencies": {
27
+ "@eslint/js": "^9.36.0",
27
28
  "@types/node": "^22.0.0",
29
+ "eslint": "^9.39.1",
28
30
  "typescript": "^5.5.0",
29
- "vitest": "^3.1.0"
31
+ "typescript-eslint": "^8.45.0",
32
+ "vitest": "^3.1.0",
33
+ "@opencode-manager/shared": "1.0.0"
30
34
  },
31
35
  "scripts": {
32
36
  "build": "bun scripts/build.ts",
33
37
  "postinstall": "node scripts/postinstall.mjs || true",
34
38
  "typecheck": "tsc --noEmit",
39
+ "lint": "eslint . --ext .ts",
40
+ "lint:fix": "eslint . --ext .ts --fix",
35
41
  "test": "bun scripts/build.ts && vitest run",
36
42
  "test:watch": "vitest"
37
43
  }