@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.
- package/README.md +16 -9
- package/dist/ocm.js +494 -65
- 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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
to
|
|
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`
|
|
51
|
-
|
|
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`
|
|
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
|
|
257
|
+
import { createWriteStream, existsSync as existsSync3 } from "fs";
|
|
197
258
|
import * as fsp from "fs/promises";
|
|
198
|
-
import { Readable } from "stream";
|
|
199
|
-
import {
|
|
200
|
-
import {
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 (!
|
|
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 =
|
|
265
|
-
const dest =
|
|
266
|
-
if (!
|
|
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
|
|
284
|
-
if (!
|
|
285
|
-
throw new MirrorAbort("
|
|
286
|
-
const matched = remotes.filter((r) =>
|
|
287
|
-
return { repoRoot,
|
|
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 =
|
|
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 =
|
|
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 (
|
|
668
|
+
if (existsSync3(repoRoot)) {
|
|
442
669
|
const entries = await fsp.readdir(repoRoot);
|
|
443
670
|
for (const entry of entries) {
|
|
444
|
-
await fsp.rename(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|