@opencode-manager/ocm-cli 0.1.4 → 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 +16 -9
  2. package/dist/ocm.js +494 -65
  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
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;
@@ -189,24 +192,103 @@ class ManagerApi {
189
192
  throw await formatErrorResponse(res, "mirror download");
190
193
  return res.body;
191
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
+ }
192
253
  }
193
254
 
194
255
  // src/mirror.ts
195
256
  import { spawnSync as spawnSync3, spawn } from "child_process";
196
- import { existsSync as existsSync2 } from "fs";
257
+ import { createWriteStream, existsSync as existsSync3 } from "fs";
197
258
  import * as fsp from "fs/promises";
198
- import { Readable } from "stream";
199
- import { join as join2, dirname as dirname2 } from "path";
200
- 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";
201
263
 
202
264
  // src/local-repo.ts
203
265
  import { spawnSync as spawnSync2 } from "child_process";
204
- function git(cwd, args) {
205
- 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 });
206
274
  if (res.status !== 0)
207
275
  return null;
208
276
  return (res.stdout ?? "").trim();
209
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
+ }
210
292
  function getRepoRoot(cwd) {
211
293
  return git(cwd, ["rev-parse", "--show-toplevel"]);
212
294
  }
@@ -227,16 +309,129 @@ function getDirtyPaths(dir) {
227
309
  }
228
310
  return paths;
229
311
  }
230
- function normalizeUrl(url) {
231
- return url.trim().replace(/\.git$/, "").replace(/^git@([^:]+):/, "ssh://git@$1/").replace(/\/+$/, "").toLowerCase();
232
- }
233
312
  function getBranchName(dir) {
234
- 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;
315
+ }
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);
235
375
  }
236
- function urlsEqual(a, b) {
237
- if (!a || !b)
238
- return false;
239
- return normalizeUrl(a) === normalizeUrl(b);
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;
240
435
  }
241
436
 
242
437
  // src/mirror.ts
@@ -255,15 +450,15 @@ function getGitignoreExclusions(repoRoot) {
255
450
  `).filter((line) => line.length > 0);
256
451
  }
257
452
  async function carryOverIgnored(fromDir, toDir) {
258
- if (!existsSync2(fromDir))
453
+ if (!existsSync3(fromDir))
259
454
  return;
260
455
  for (const rel of getGitignoreExclusions(fromDir)) {
261
456
  const clean = rel.replace(/\/+$/, "");
262
457
  if (!clean)
263
458
  continue;
264
- const src = join2(fromDir, clean);
265
- const dest = join2(toDir, clean);
266
- if (!existsSync2(src) || existsSync2(dest))
459
+ const src = join3(fromDir, clean);
460
+ const dest = join3(toDir, clean);
461
+ if (!existsSync3(src) || existsSync3(dest))
267
462
  continue;
268
463
  await fsp.mkdir(dirname2(dest), { recursive: true });
269
464
  await fsp.rename(src, dest).catch(() => {});
@@ -276,15 +471,47 @@ class MirrorAbort extends Error {
276
471
  this.name = "MirrorAbort";
277
472
  }
278
473
  }
279
- function prepareMirror(cwd, remotes) {
474
+ async function prepareMirror(cwd, remotes) {
280
475
  const repoRoot = getRepoRoot(cwd);
281
476
  if (!repoRoot)
282
477
  throw new MirrorAbort("not in a git repository");
283
- const localOrigin = getOriginUrl(repoRoot);
284
- if (!localOrigin)
285
- throw new MirrorAbort("no origin URL found");
286
- const matched = remotes.filter((r) => urlsEqual(localOrigin, r.originUrl));
287
- 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 };
288
515
  }
289
516
  function delay(ms) {
290
517
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -357,7 +584,7 @@ async function mirrorUp(plan, opts) {
357
584
  const ignoredPaths = getGitignoreExclusions(plan.repoRoot);
358
585
  let excludeFile = null;
359
586
  if (ignoredPaths.length > 0) {
360
- 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`);
361
588
  await fsp.writeFile(excludeFile, ignoredPaths.join(`
362
589
  `));
363
590
  tarArgs.push("--exclude-from", excludeFile);
@@ -428,7 +655,7 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
428
655
  });
429
656
  child.on("error", reject);
430
657
  });
431
- const stdinWritable = Readable.fromWeb(tarball);
658
+ const stdinWritable = Readable2.fromWeb(tarball);
432
659
  let received = 0;
433
660
  stdinWritable.on("data", (chunk) => {
434
661
  received += chunk.length;
@@ -438,16 +665,16 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
438
665
  await tarDone;
439
666
  const backupDir = `${repoRoot}.ocm-backup-${Date.now()}`;
440
667
  await fsp.mkdir(backupDir, { recursive: true });
441
- if (existsSync2(repoRoot)) {
668
+ if (existsSync3(repoRoot)) {
442
669
  const entries = await fsp.readdir(repoRoot);
443
670
  for (const entry of entries) {
444
- await fsp.rename(join2(repoRoot, entry), join2(backupDir, entry));
671
+ await fsp.rename(join3(repoRoot, entry), join3(backupDir, entry));
445
672
  }
446
673
  }
447
674
  try {
448
675
  const stagingEntries = await fsp.readdir(staging);
449
676
  for (const entry of stagingEntries) {
450
- await fsp.rename(join2(staging, entry), join2(repoRoot, entry));
677
+ await fsp.rename(join3(staging, entry), join3(repoRoot, entry));
451
678
  }
452
679
  await carryOverIgnored(backupDir, repoRoot);
453
680
  await fsp.rm(backupDir, { recursive: true, force: true }).catch(() => {});
@@ -455,7 +682,7 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
455
682
  } catch (swapError) {
456
683
  const backupEntries = await fsp.readdir(backupDir).catch(() => []);
457
684
  for (const entry of backupEntries) {
458
- await fsp.rename(join2(backupDir, entry), join2(repoRoot, entry)).catch(() => {});
685
+ await fsp.rename(join3(backupDir, entry), join3(repoRoot, entry)).catch(() => {});
459
686
  }
460
687
  await fsp.rm(backupDir, { recursive: true, force: true }).catch(() => {});
461
688
  throw swapError;
@@ -465,6 +692,97 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
465
692
  throw error;
466
693
  }
467
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
+ }
468
786
 
469
787
  // src/progress.ts
470
788
  var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -519,14 +837,15 @@ function createProgressReporter(label, out = process.stderr, now = Date.now) {
519
837
  // src/resolve-target.ts
520
838
  function resolveTarget(input) {
521
839
  const repoRoot = getRepoRoot(input.cwd);
522
- const localOrigin = repoRoot ? getOriginUrl(repoRoot) : null;
523
- if (repoRoot && localOrigin) {
524
- const matches = input.repos.filter((r) => urlsEqual(localOrigin, r.originUrl));
525
- if (matches.length === 1) {
526
- return { kind: "cwd-match", repo: matches[0], repoRoot };
527
- }
528
- if (matches.length > 1) {
529
- 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
+ }
530
849
  }
531
850
  return { kind: "local", reason: "no-match", repoRoot };
532
851
  }
@@ -546,7 +865,7 @@ function toTarget(last) {
546
865
  // package.json
547
866
  var package_default = {
548
867
  name: "@opencode-manager/ocm-cli",
549
- version: "0.1.4",
868
+ version: "0.2.0",
550
869
  description: "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
551
870
  license: "MIT",
552
871
  repository: {
@@ -572,21 +891,27 @@ var package_default = {
572
891
  build: "bun scripts/build.ts",
573
892
  postinstall: "node scripts/postinstall.mjs || true",
574
893
  typecheck: "tsc --noEmit",
894
+ lint: "eslint . --ext .ts",
895
+ "lint:fix": "eslint . --ext .ts --fix",
575
896
  test: "bun scripts/build.ts && vitest run",
576
897
  "test:watch": "vitest",
577
898
  prepublishOnly: "bun scripts/build.ts"
578
899
  },
579
900
  dependencies: {},
580
901
  devDependencies: {
902
+ "@eslint/js": "^9.36.0",
903
+ "@opencode-manager/shared": "workspace:*",
581
904
  "@types/node": "^22.0.0",
905
+ eslint: "^9.39.1",
582
906
  typescript: "^5.5.0",
907
+ "typescript-eslint": "^8.45.0",
583
908
  vitest: "^3.1.0"
584
909
  }
585
910
  };
586
911
 
587
912
  // bin/ocm.ts
588
913
  var VERSION = package_default.version;
589
- var USAGE = `ocm - OpenCode Manager workspace launcher
914
+ var USAGE = `ocm v${VERSION} - OpenCode Manager workspace launcher
590
915
 
591
916
  Usage:
592
917
  ocm Attach to the Manager repo matching $PWD's git origin,
@@ -597,8 +922,8 @@ Usage:
597
922
  ocm status Show current manager URL, repo, and whether token is set
598
923
  ocm list List ready repos from the manager
599
924
  ocm use <repoId|name> Attach to a specific repo and remember it as last
600
- ocm push [--force] [--create] [--yes] Mirror $PWD to the matching Manager repo (or create one)
601
- 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)
602
927
  ocm --version Show the installed ocm version
603
928
  ocm --help Show this help
604
929
  `;
@@ -611,6 +936,52 @@ function info(msg) {
611
936
  process.stdout.write(`${msg}
612
937
  `);
613
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
+ }
614
985
  function requireState() {
615
986
  const state = readState();
616
987
  if (!state || !state.managerUrl) {
@@ -771,6 +1142,7 @@ async function cmdUse(args) {
771
1142
  attach(state.managerUrl, token, repo);
772
1143
  }
773
1144
  async function cmdDefault() {
1145
+ info(`ocm v${VERSION}`);
774
1146
  const state = requireState();
775
1147
  const token = requireToken(state);
776
1148
  const last = state.lastRepoId !== undefined && state.lastRepoDir ? {
@@ -779,12 +1151,14 @@ async function cmdDefault() {
779
1151
  directory: state.lastRepoDir,
780
1152
  branch: state.lastRepoBranch ?? null
781
1153
  } : undefined;
1154
+ info("connecting...");
782
1155
  const repos = await fetchRepos(state.managerUrl, token);
783
- 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 });
784
1158
  switch (result.kind) {
785
1159
  case "cwd-match": {
786
1160
  const repo = result.repo;
787
- info(`attaching to ${repo.name} (matched $PWD origin)`);
1161
+ info(`attaching to ${repo.name}`);
788
1162
  writeState({
789
1163
  ...state,
790
1164
  lastRepoId: repo.repoId,
@@ -795,12 +1169,16 @@ async function cmdDefault() {
795
1169
  attach(state.managerUrl, token, toManagerRepo(repo));
796
1170
  return;
797
1171
  }
798
- case "last":
799
- 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));
800
1176
  return;
1177
+ }
801
1178
  case "cwd-ambiguous": {
802
1179
  const names = result.matches.map((r) => `${r.name} (id=${r.repoId})`).join(", ");
803
- 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;
804
1182
  }
805
1183
  case "local":
806
1184
  runLocalOpencode(result.reason);
@@ -830,6 +1208,7 @@ async function cmdPush(args) {
830
1208
  let force = false;
831
1209
  let create = false;
832
1210
  let yes = false;
1211
+ let full = false;
833
1212
  for (const arg of args) {
834
1213
  if (arg === "--force")
835
1214
  force = true;
@@ -837,61 +1216,87 @@ async function cmdPush(args) {
837
1216
  create = true;
838
1217
  else if (arg === "--yes")
839
1218
  yes = true;
1219
+ else if (arg === "--full")
1220
+ full = true;
840
1221
  }
841
1222
  const state = requireState();
842
1223
  const token = requireToken(state);
843
1224
  const api = new ManagerApi(state.managerUrl, token);
844
1225
  const repos = await fetchRepos(state.managerUrl, token);
845
- const progress = createProgressReporter("push");
846
- const onProgress = (p) => progress.tick(p.bytesSent);
847
1226
  const remotes = repos.map((r) => ({
848
1227
  repoId: r.repoId,
849
1228
  name: r.name,
850
- originUrl: r.originUrl ?? null,
1229
+ projectId: r.projectId ?? null,
851
1230
  branch: r.branch
852
1231
  }));
853
- const plan = prepareMirror(process.cwd(), remotes);
1232
+ const plan = await prepareMirror(process.cwd(), remotes);
854
1233
  if (plan.matched.length === 0) {
855
1234
  if (!create) {
856
- 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.`);
857
1236
  }
858
1237
  const name = basename(plan.repoRoot);
859
1238
  const branch = getBranchName(plan.repoRoot);
1239
+ const originUrl = getOriginUrl(plan.repoRoot);
860
1240
  if (process.stdin.isTTY && !yes) {
861
- process.stderr.write(`Create Manager repo "${name}" by uploading ${plan.repoRoot} (origin: ${plan.localOrigin})? [y/N] `);
862
- const res = spawnSync4("bash", ["-c", 'read LINE && printf "%s" "$LINE"'], {
863
- stdio: ["inherit", "pipe", "inherit"],
864
- encoding: "utf-8"
865
- });
866
- const answer = (res.stdout ?? "").trim().toLowerCase();
867
- if (answer !== "y" && answer !== "yes") {
1241
+ if (!promptYesNo(`Create Manager repo "${name}" by uploading ${plan.repoRoot} (project: ${plan.localProjectId})?`)) {
868
1242
  die("aborted");
869
1243
  }
870
1244
  } else if (!process.stdin.isTTY && !yes) {
871
1245
  die("stdin is not a TTY; pass --yes to confirm creation");
872
1246
  }
1247
+ const progress = createProgressReporter("push");
1248
+ const onProgress = (p) => progress.tick(p.bytesSent);
873
1249
  const result = await mirrorUp(plan, {
874
1250
  api,
875
1251
  force,
876
- create: { name, originUrl: plan.localOrigin, branch },
1252
+ create: { name, originUrl, branch },
877
1253
  onProgress
878
1254
  });
879
1255
  progress.done();
880
1256
  info(`pushed ${plan.repoRoot} -> ${result.created ? "created" : "updated"} (repoId=${result.repoId}, branch=${result.branch})`);
881
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);
882
1284
  const result = await mirrorUp(plan, { api, force, onProgress });
883
1285
  progress.done();
884
1286
  info(`pushed ${plan.repoRoot} -> ${plan.matched[0].name} (repoId=${result.repoId}, branch=${result.branch})`);
885
1287
  } else {
886
1288
  const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(", ");
887
- 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>\``);
888
1290
  }
889
1291
  }
890
1292
  async function cmdPull(args) {
891
1293
  let force = false;
1294
+ let full = false;
892
1295
  for (const arg of args) {
893
1296
  if (arg === "--force")
894
1297
  force = true;
1298
+ else if (arg === "--full")
1299
+ full = true;
895
1300
  }
896
1301
  const state = requireState();
897
1302
  const token = requireToken(state);
@@ -900,16 +1305,40 @@ async function cmdPull(args) {
900
1305
  const remotes = repos.map((r) => ({
901
1306
  repoId: r.repoId,
902
1307
  name: r.name,
903
- originUrl: r.originUrl ?? null,
1308
+ projectId: r.projectId ?? null,
904
1309
  branch: r.branch
905
1310
  }));
906
- const plan = prepareMirror(process.cwd(), remotes);
1311
+ const plan = await prepareMirror(process.cwd(), remotes);
907
1312
  if (plan.matched.length === 0) {
908
- die(`no matching Manager repo for origin ${plan.localOrigin}.`);
1313
+ die(`no matching Manager repo for project ${plan.localProjectId}.`);
909
1314
  }
910
1315
  if (plan.matched.length > 1) {
911
1316
  const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(", ");
912
- 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
+ }
913
1342
  }
914
1343
  const progress = createProgressReporter("pull");
915
1344
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-manager/ocm-cli",
3
- "version": "0.1.4",
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
  }