@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.
- package/README.md +17 -10
- package/dist/ocm.js +510 -127
- 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
|
|
@@ -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
|
|
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
|
|
257
|
+
import { createWriteStream, existsSync as existsSync3 } from "fs";
|
|
196
258
|
import * as fsp from "fs/promises";
|
|
197
|
-
import { Readable } from "stream";
|
|
198
|
-
import {
|
|
199
|
-
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";
|
|
200
263
|
|
|
201
264
|
// src/local-repo.ts
|
|
202
265
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
313
|
+
const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
314
|
+
return branch && branch !== "HEAD" ? branch : null;
|
|
234
315
|
}
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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 (!
|
|
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 =
|
|
263
|
-
const dest =
|
|
264
|
-
if (!
|
|
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
|
|
306
|
-
if (!
|
|
307
|
-
throw new MirrorAbort("
|
|
308
|
-
const matched = remotes.filter((r) =>
|
|
309
|
-
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 };
|
|
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 =
|
|
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?.({
|
|
615
|
+
opts.onProgress?.({ bytesSent });
|
|
410
616
|
}
|
|
411
617
|
await tarExit;
|
|
412
618
|
const totalParts = await flusher.finish();
|
|
413
|
-
opts.onProgress?.({
|
|
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
|
|
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 =
|
|
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 (
|
|
668
|
+
if (existsSync3(repoRoot)) {
|
|
460
669
|
const entries = await fsp.readdir(repoRoot);
|
|
461
670
|
for (const entry of entries) {
|
|
462
|
-
await fsp.rename(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
}
|