@opencode-manager/ocm-cli 0.1.1 → 0.1.3

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 (2) hide show
  1. package/dist/ocm.js +290 -35
  2. package/package.json +1 -1
package/dist/ocm.js CHANGED
@@ -95,6 +95,35 @@ function deleteToken(account) {
95
95
  }
96
96
 
97
97
  // src/manager-api.ts
98
+ class ManagerApiError extends Error {
99
+ status;
100
+ code;
101
+ operation;
102
+ constructor(message, status, code, operation) {
103
+ super(message);
104
+ this.status = status;
105
+ this.code = code;
106
+ this.operation = operation;
107
+ this.name = "ManagerApiError";
108
+ }
109
+ }
110
+ async function formatErrorResponse(res, operation) {
111
+ const text = await res.text().catch(() => "");
112
+ let code = null;
113
+ let detail = text;
114
+ if (text) {
115
+ try {
116
+ const parsed = JSON.parse(text);
117
+ const errField = typeof parsed.error === "string" ? parsed.error : null;
118
+ const msgField = typeof parsed.message === "string" ? parsed.message : null;
119
+ code = errField;
120
+ detail = msgField ?? errField ?? text;
121
+ } catch {}
122
+ }
123
+ const message = detail ? `${operation} failed (${res.status}): ${detail}` : `${operation} failed (${res.status})`;
124
+ return new ManagerApiError(message, res.status, code, operation);
125
+ }
126
+
98
127
  class ManagerApi {
99
128
  baseUrl;
100
129
  token;
@@ -105,49 +134,68 @@ class ManagerApi {
105
134
  headers(extra = {}) {
106
135
  return { Authorization: `Bearer ${this.token}`, ...extra };
107
136
  }
108
- async mirrorUp(repoId, body, opts) {
109
- const params = new URLSearchParams;
110
- if (opts.force)
111
- params.set("force", "1");
137
+ async mirrorBegin(repoId, opts) {
138
+ const url = `${this.baseUrl}/api/internal/repos/${repoId}/mirror/begin`;
139
+ const body = { force: opts.force === true };
112
140
  if (opts.create) {
113
- params.set("create", "1");
114
- params.set("name", opts.create.name);
141
+ body.create = true;
142
+ body.name = opts.create.name;
115
143
  if (opts.create.originUrl)
116
- params.set("originUrl", opts.create.originUrl);
144
+ body.originUrl = opts.create.originUrl;
117
145
  if (opts.create.branch)
118
- params.set("branch", opts.create.branch);
146
+ body.branch = opts.create.branch;
119
147
  }
120
- const qs = params.toString() ? `?${params.toString()}` : "";
121
- const url = `${this.baseUrl}/api/internal/repos/${repoId}/mirror${qs}`;
122
148
  const res = await fetch(url, {
123
149
  method: "POST",
124
- headers: {
125
- ...this.headers(),
126
- "Content-Type": "application/x-tar"
127
- },
128
- body,
129
- duplex: "half"
150
+ headers: { ...this.headers(), "Content-Type": "application/json" },
151
+ body: JSON.stringify(body)
152
+ });
153
+ if (!res.ok)
154
+ throw await formatErrorResponse(res, "mirror begin");
155
+ return await res.json();
156
+ }
157
+ async mirrorUploadPart(repoId, uploadId, index, chunk) {
158
+ const url = `${this.baseUrl}/api/internal/repos/${repoId}/mirror/parts/${uploadId}/${index}`;
159
+ const ab = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
160
+ const res = await fetch(url, {
161
+ method: "PUT",
162
+ headers: { ...this.headers(), "Content-Type": "application/octet-stream" },
163
+ body: ab
164
+ });
165
+ if (!res.ok)
166
+ throw await formatErrorResponse(res, `mirror part ${index}`);
167
+ }
168
+ async mirrorCommit(repoId, uploadId, totalParts) {
169
+ const url = `${this.baseUrl}/api/internal/repos/${repoId}/mirror/commit`;
170
+ const res = await fetch(url, {
171
+ method: "POST",
172
+ headers: { ...this.headers(), "Content-Type": "application/json" },
173
+ body: JSON.stringify({ uploadId, totalParts })
130
174
  });
131
175
  if (!res.ok)
132
- throw new Error(`mirror ${res.status}: ${await res.text()}`);
176
+ throw await formatErrorResponse(res, "mirror commit");
133
177
  return await res.json();
134
178
  }
179
+ async mirrorAbort(repoId, uploadId) {
180
+ const url = `${this.baseUrl}/api/internal/repos/${repoId}/mirror/uploads/${uploadId}`;
181
+ await fetch(url, { method: "DELETE", headers: this.headers() }).catch(() => {});
182
+ }
135
183
  async mirrorDown(repoId) {
136
184
  const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror`, {
137
185
  headers: this.headers()
138
186
  });
139
187
  if (!res.ok)
140
- throw new Error(`mirror ${res.status}: ${await res.text()}`);
188
+ throw await formatErrorResponse(res, "mirror download");
141
189
  return res.body;
142
190
  }
143
191
  }
144
192
 
145
193
  // src/mirror.ts
146
194
  import { spawnSync as spawnSync3, spawn } from "child_process";
147
- import { existsSync as existsSync2 } from "fs";
195
+ import { existsSync as existsSync2, statSync } from "fs";
148
196
  import * as fsp from "fs/promises";
149
197
  import { Readable } from "stream";
150
- import { join as join2 } from "path";
198
+ import { join as join2, dirname as dirname2 } from "path";
151
199
  import { tmpdir } from "os";
152
200
 
153
201
  // src/local-repo.ts
@@ -191,7 +239,9 @@ function urlsEqual(a, b) {
191
239
  }
192
240
 
193
241
  // src/mirror.ts
194
- var HARDCODED_EXCLUDES = ["node_modules", "dist", ".next", ".venv", "__pycache__", ".turbo"];
242
+ var HARDCODED_EXCLUDES = ["node_modules", "dist", ".next", ".venv", "__pycache__", ".turbo", ".DS_Store", "._*"];
243
+ var PART_RETRIES = 3;
244
+ var PART_BACKOFF_MS = [500, 2000, 8000];
195
245
  function getGitignoreExclusions(repoRoot) {
196
246
  const res = spawnSync3("git", ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"], {
197
247
  cwd: repoRoot,
@@ -202,6 +252,45 @@ function getGitignoreExclusions(repoRoot) {
202
252
  return (res.stdout ?? "").split(`
203
253
  `).filter((line) => line.length > 0);
204
254
  }
255
+ async function carryOverIgnored(fromDir, toDir) {
256
+ if (!existsSync2(fromDir))
257
+ return;
258
+ for (const rel of getGitignoreExclusions(fromDir)) {
259
+ const clean = rel.replace(/\/+$/, "");
260
+ if (!clean)
261
+ continue;
262
+ const src = join2(fromDir, clean);
263
+ const dest = join2(toDir, clean);
264
+ if (!existsSync2(src) || existsSync2(dest))
265
+ continue;
266
+ await fsp.mkdir(dirname2(dest), { recursive: true });
267
+ await fsp.rename(src, dest).catch(() => {});
268
+ }
269
+ }
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
+ }
205
294
 
206
295
  class MirrorAbort extends Error {
207
296
  constructor(message) {
@@ -219,7 +308,70 @@ function prepareMirror(cwd, remotes) {
219
308
  const matched = remotes.filter((r) => urlsEqual(localOrigin, r.originUrl));
220
309
  return { repoRoot, localOrigin, matched };
221
310
  }
311
+ function delay(ms) {
312
+ return new Promise((resolve) => setTimeout(resolve, ms));
313
+ }
314
+ function isRetryablePartError(err) {
315
+ if (err instanceof ManagerApiError) {
316
+ return err.status >= 500 || err.status === 408 || err.status === 429;
317
+ }
318
+ return true;
319
+ }
320
+ async function uploadPartWithRetry(api, repoId, uploadId, index, chunk) {
321
+ let lastError;
322
+ for (let attempt = 0;attempt < PART_RETRIES; attempt++) {
323
+ try {
324
+ await api.mirrorUploadPart(repoId, uploadId, index, chunk);
325
+ return;
326
+ } catch (err) {
327
+ lastError = err;
328
+ if (!isRetryablePartError(err))
329
+ break;
330
+ if (attempt < PART_RETRIES - 1) {
331
+ await delay(PART_BACKOFF_MS[attempt]);
332
+ }
333
+ }
334
+ }
335
+ throw lastError instanceof Error ? lastError : new Error(`part ${index} failed: ${String(lastError)}`);
336
+ }
337
+ function createPartFlusher(api, repoId, uploadId, chunkSize) {
338
+ let acc = [];
339
+ let accLen = 0;
340
+ let index = 0;
341
+ const flush = async () => {
342
+ if (accLen === 0)
343
+ return;
344
+ const buf = Buffer.concat(acc, accLen);
345
+ acc = [];
346
+ accLen = 0;
347
+ await uploadPartWithRetry(api, repoId, uploadId, index, buf);
348
+ index += 1;
349
+ };
350
+ return {
351
+ async push(buf) {
352
+ let offset = 0;
353
+ while (offset < buf.length) {
354
+ const room = chunkSize - accLen;
355
+ const take = Math.min(room, buf.length - offset);
356
+ acc.push(buf.subarray(offset, offset + take));
357
+ accLen += take;
358
+ offset += take;
359
+ if (accLen >= chunkSize) {
360
+ await flush();
361
+ }
362
+ }
363
+ },
364
+ async finish() {
365
+ await flush();
366
+ return index;
367
+ }
368
+ };
369
+ }
222
370
  async function mirrorUp(plan, opts) {
371
+ const repoId = opts.create ? 0 : plan.matched[0].repoId;
372
+ const begin = await opts.api.mirrorBegin(repoId, { force: opts.force, create: opts.create });
373
+ const totalBytes = estimateTarSize(plan.repoRoot);
374
+ let bytesSent = 0;
223
375
  const tarArgs = ["-c", "-C", plan.repoRoot];
224
376
  for (const dir of HARDCODED_EXCLUDES)
225
377
  tarArgs.push("--exclude", dir);
@@ -232,7 +384,10 @@ async function mirrorUp(plan, opts) {
232
384
  tarArgs.push("--exclude-from", excludeFile);
233
385
  }
234
386
  tarArgs.push(".");
235
- const child = spawn("tar", tarArgs, { stdio: ["pipe", "pipe", "pipe"] });
387
+ const child = spawn("tar", tarArgs, {
388
+ stdio: ["pipe", "pipe", "pipe"],
389
+ env: { ...process.env, COPYFILE_DISABLE: "1" }
390
+ });
236
391
  const stderrChunks = [];
237
392
  child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
238
393
  const tarExit = new Promise((resolve, reject) => {
@@ -246,17 +401,23 @@ async function mirrorUp(plan, opts) {
246
401
  });
247
402
  child.on("error", reject);
248
403
  });
249
- const body = Readable.toWeb(child.stdout);
250
- const repoId = opts.create ? 0 : plan.matched[0].repoId;
404
+ const flusher = createPartFlusher(opts.api, begin.repoId, begin.uploadId, begin.chunkSize);
251
405
  try {
252
- const [result] = await Promise.all([
253
- opts.api.mirrorUp(repoId, body, {
254
- force: opts.force,
255
- create: opts.create
256
- }),
257
- tarExit
258
- ]);
406
+ for await (const chunk of child.stdout) {
407
+ await flusher.push(chunk);
408
+ bytesSent += chunk.length;
409
+ opts.onProgress?.({ phase: "uploading", bytesSent, totalBytes });
410
+ }
411
+ await tarExit;
412
+ const totalParts = await flusher.finish();
413
+ opts.onProgress?.({ phase: "committing", bytesSent, totalBytes });
414
+ const result = await opts.api.mirrorCommit(begin.repoId, begin.uploadId, totalParts);
259
415
  return result;
416
+ } catch (err) {
417
+ if (!child.killed)
418
+ child.kill("SIGKILL");
419
+ await opts.api.mirrorAbort(begin.repoId, begin.uploadId);
420
+ throw err;
260
421
  } finally {
261
422
  if (excludeFile) {
262
423
  await fsp.rm(excludeFile, { force: true }).catch(() => {});
@@ -286,6 +447,11 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
286
447
  child.on("error", reject);
287
448
  });
288
449
  const stdinWritable = Readable.fromWeb(tarball);
450
+ let received = 0;
451
+ stdinWritable.on("data", (chunk) => {
452
+ received += chunk.length;
453
+ opts.onProgress?.(received);
454
+ });
289
455
  stdinWritable.pipe(child.stdin);
290
456
  await tarDone;
291
457
  const backupDir = `${repoRoot}.ocm-backup-${Date.now()}`;
@@ -301,6 +467,7 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
301
467
  for (const entry of stagingEntries) {
302
468
  await fsp.rename(join2(staging, entry), join2(repoRoot, entry));
303
469
  }
470
+ await carryOverIgnored(backupDir, repoRoot);
304
471
  await fsp.rm(backupDir, { recursive: true, force: true }).catch(() => {});
305
472
  await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
306
473
  } catch (swapError) {
@@ -317,6 +484,77 @@ async function mirrorDown(repoId, repoRoot, api, opts = { force: false }) {
317
484
  }
318
485
  }
319
486
 
487
+ // src/progress.ts
488
+ var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
489
+ function formatBytes(bytes) {
490
+ if (bytes < 1024) {
491
+ return `${bytes} B`;
492
+ }
493
+ if (bytes < 1024 * 1024) {
494
+ return `${(bytes / 1024).toFixed(1)} KB`;
495
+ }
496
+ if (bytes < 1024 * 1024 * 1024) {
497
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
498
+ }
499
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
500
+ }
501
+ function createProgressReporter(label, out = process.stderr, now = Date.now) {
502
+ let finished = false;
503
+ let lastRenderAt = -Infinity;
504
+ let lastBucket = -1;
505
+ let lastNonTtyTickAt = -Infinity;
506
+ let frameIndex = 0;
507
+ const isTTY = out.isTTY === true;
508
+ 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
+ tick(bytes) {
530
+ if (finished)
531
+ return;
532
+ const t = now();
533
+ if (isTTY) {
534
+ if (t - lastRenderAt < 80)
535
+ return;
536
+ lastRenderAt = t;
537
+ out.write(`\r\x1B[K${label}: ${FRAMES[frameIndex]} ${formatBytes(bytes)}`);
538
+ frameIndex = (frameIndex + 1) % FRAMES.length;
539
+ } else {
540
+ if (t - lastNonTtyTickAt < 1000)
541
+ return;
542
+ lastNonTtyTickAt = t;
543
+ out.write(`${label}: ${formatBytes(bytes)}
544
+ `);
545
+ }
546
+ },
547
+ done() {
548
+ if (finished)
549
+ return;
550
+ finished = true;
551
+ if (isTTY) {
552
+ out.write("\r\x1B[K");
553
+ }
554
+ }
555
+ };
556
+ }
557
+
320
558
  // src/resolve-target.ts
321
559
  function resolveTarget(input) {
322
560
  const repoRoot = getRepoRoot(input.cwd);
@@ -347,7 +585,7 @@ function toTarget(last) {
347
585
  // package.json
348
586
  var package_default = {
349
587
  name: "@opencode-manager/ocm-cli",
350
- version: "0.1.1",
588
+ version: "0.1.3",
351
589
  description: "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
352
590
  license: "MIT",
353
591
  repository: {
@@ -643,6 +881,15 @@ async function cmdPush(args) {
643
881
  const token = requireToken(state);
644
882
  const api = new ManagerApi(state.managerUrl, token);
645
883
  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
+ };
646
893
  const remotes = repos.map((r) => ({
647
894
  repoId: r.repoId,
648
895
  name: r.name,
@@ -672,11 +919,14 @@ async function cmdPush(args) {
672
919
  const result = await mirrorUp(plan, {
673
920
  api,
674
921
  force,
675
- create: { name, originUrl: plan.localOrigin, branch }
922
+ create: { name, originUrl: plan.localOrigin, branch },
923
+ onProgress
676
924
  });
925
+ progress.done();
677
926
  info(`pushed ${plan.repoRoot} -> ${result.created ? "created" : "updated"} (repoId=${result.repoId}, branch=${result.branch})`);
678
927
  } else if (plan.matched.length === 1) {
679
- const result = await mirrorUp(plan, { api, force });
928
+ const result = await mirrorUp(plan, { api, force, onProgress });
929
+ progress.done();
680
930
  info(`pushed ${plan.repoRoot} -> ${plan.matched[0].name} (repoId=${result.repoId}, branch=${result.branch})`);
681
931
  } else {
682
932
  const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(", ");
@@ -707,7 +957,12 @@ async function cmdPull(args) {
707
957
  const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(", ");
708
958
  die(`multiple Manager repos match origin ${plan.localOrigin}: ${names}; disambiguate with \`ocm pull <repoId>\``);
709
959
  }
710
- await mirrorDown(plan.matched[0].repoId, plan.repoRoot, api, { force });
960
+ const progress = createProgressReporter("pull");
961
+ try {
962
+ await mirrorDown(plan.matched[0].repoId, plan.repoRoot, api, { force, onProgress: (bytes) => progress.tick(bytes) });
963
+ } finally {
964
+ progress.done();
965
+ }
711
966
  info(`pulled ${plan.matched[0].name} -> ${plan.repoRoot}`);
712
967
  }
713
968
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-manager/ocm-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
5
5
  "license": "MIT",
6
6
  "repository": {