@opencode-manager/ocm-cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +74 -0
  2. package/dist/ocm.js +194 -29
  3. package/package.json +3 -3
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # ocm-cli
2
+
3
+ OpenCode Manager CLI and plugin package.
4
+
5
+ `ocm` lets a local OpenCode TUI attach to repos hosted by OpenCode Manager. It
6
+ can also mirror a local git repo up to Manager or pull a Manager repo back down
7
+ to the local working tree.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add -g @opencode-manager/ocm-cli
13
+ ```
14
+
15
+ The package exposes the `ocm` binary and an OpenCode plugin entrypoint. Global
16
+ installs link the binary through the package manager. Local workspace installs
17
+ also create a best-effort `~/.local/bin/ocm` symlink.
18
+
19
+ ## Login
20
+
21
+ ```bash
22
+ ocm login <manager-url> [token]
23
+ ```
24
+
25
+ The token is stored in macOS Keychain under the `opencode-manager` service. CLI
26
+ state is stored at `~/.config/opencode-manager/state.json`.
27
+
28
+ If `[token]` is omitted, `ocm login` reads it from hidden TTY input or stdin.
29
+
30
+ ## Commands
31
+
32
+ ```bash
33
+ ocm
34
+ ocm status
35
+ ocm list
36
+ ocm use <repoId|name>
37
+ ocm push [--force] [--create] [--yes]
38
+ ocm pull [--force]
39
+ ocm logout
40
+ ```
41
+
42
+ Running `ocm` with no command tries to match the current git repo's `origin`
43
+ against ready Manager repos. If one repo matches, it attaches OpenCode to that
44
+ Manager repo. If no repo matches, it falls back to the last selected repo, then
45
+ to local `opencode`.
46
+
47
+ `ocm use <repoId|name>` selects a Manager repo, remembers it as the last repo,
48
+ and attaches OpenCode to it.
49
+
50
+ `ocm push` uploads the current git repo to the matching Manager repo. Use
51
+ `--create` to create a Manager repo when no origin match exists, and `--yes` to
52
+ confirm creation in non-interactive shells.
53
+
54
+ `ocm pull` replaces the current working tree with the matching Manager repo. It
55
+ refuses to overwrite uncommitted local changes unless `--force` is passed.
56
+
57
+ ## OpenCode plugin
58
+
59
+ The package default export is an OpenCode plugin entrypoint. Importing the
60
+ plugin performs a best-effort local `ocm` symlink install and then returns an
61
+ empty plugin object.
62
+
63
+ ```ts
64
+ import ocm from '@opencode-manager/ocm-cli'
65
+
66
+ export default [ocm]
67
+ ```
68
+
69
+ ## Requirements
70
+
71
+ - `opencode` available on `PATH`
72
+ - `git` and `tar` available on `PATH`
73
+ - macOS `security` CLI for Keychain-backed token storage
74
+ - An OpenCode Manager URL and bearer token
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,39 +134,58 @@ 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
  }
@@ -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,
@@ -219,7 +269,68 @@ function prepareMirror(cwd, remotes) {
219
269
  const matched = remotes.filter((r) => urlsEqual(localOrigin, r.originUrl));
220
270
  return { repoRoot, localOrigin, matched };
221
271
  }
272
+ function delay(ms) {
273
+ return new Promise((resolve) => setTimeout(resolve, ms));
274
+ }
275
+ function isRetryablePartError(err) {
276
+ if (err instanceof ManagerApiError) {
277
+ return err.status >= 500 || err.status === 408 || err.status === 429;
278
+ }
279
+ return true;
280
+ }
281
+ async function uploadPartWithRetry(api, repoId, uploadId, index, chunk) {
282
+ let lastError;
283
+ for (let attempt = 0;attempt < PART_RETRIES; attempt++) {
284
+ try {
285
+ await api.mirrorUploadPart(repoId, uploadId, index, chunk);
286
+ return;
287
+ } catch (err) {
288
+ lastError = err;
289
+ if (!isRetryablePartError(err))
290
+ break;
291
+ if (attempt < PART_RETRIES - 1) {
292
+ await delay(PART_BACKOFF_MS[attempt]);
293
+ }
294
+ }
295
+ }
296
+ throw lastError instanceof Error ? lastError : new Error(`part ${index} failed: ${String(lastError)}`);
297
+ }
298
+ function createPartFlusher(api, repoId, uploadId, chunkSize) {
299
+ let acc = [];
300
+ let accLen = 0;
301
+ let index = 0;
302
+ const flush = async () => {
303
+ if (accLen === 0)
304
+ return;
305
+ const buf = Buffer.concat(acc, accLen);
306
+ acc = [];
307
+ accLen = 0;
308
+ await uploadPartWithRetry(api, repoId, uploadId, index, buf);
309
+ index += 1;
310
+ };
311
+ return {
312
+ async push(buf) {
313
+ let offset = 0;
314
+ while (offset < buf.length) {
315
+ const room = chunkSize - accLen;
316
+ const take = Math.min(room, buf.length - offset);
317
+ acc.push(buf.subarray(offset, offset + take));
318
+ accLen += take;
319
+ offset += take;
320
+ if (accLen >= chunkSize) {
321
+ await flush();
322
+ }
323
+ }
324
+ },
325
+ async finish() {
326
+ await flush();
327
+ return index;
328
+ }
329
+ };
330
+ }
222
331
  async function mirrorUp(plan, opts) {
332
+ const repoId = opts.create ? 0 : plan.matched[0].repoId;
333
+ const begin = await opts.api.mirrorBegin(repoId, { force: opts.force, create: opts.create });
223
334
  const tarArgs = ["-c", "-C", plan.repoRoot];
224
335
  for (const dir of HARDCODED_EXCLUDES)
225
336
  tarArgs.push("--exclude", dir);
@@ -232,7 +343,10 @@ async function mirrorUp(plan, opts) {
232
343
  tarArgs.push("--exclude-from", excludeFile);
233
344
  }
234
345
  tarArgs.push(".");
235
- const child = spawn("tar", tarArgs, { stdio: ["pipe", "pipe", "pipe"] });
346
+ const child = spawn("tar", tarArgs, {
347
+ stdio: ["pipe", "pipe", "pipe"],
348
+ env: { ...process.env, COPYFILE_DISABLE: "1" }
349
+ });
236
350
  const stderrChunks = [];
237
351
  child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
238
352
  const tarExit = new Promise((resolve, reject) => {
@@ -246,17 +360,20 @@ async function mirrorUp(plan, opts) {
246
360
  });
247
361
  child.on("error", reject);
248
362
  });
249
- const body = Readable.toWeb(child.stdout);
250
- const repoId = opts.create ? 0 : plan.matched[0].repoId;
363
+ const flusher = createPartFlusher(opts.api, begin.repoId, begin.uploadId, begin.chunkSize);
251
364
  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
- ]);
365
+ for await (const chunk of child.stdout) {
366
+ await flusher.push(chunk);
367
+ }
368
+ await tarExit;
369
+ const totalParts = await flusher.finish();
370
+ const result = await opts.api.mirrorCommit(begin.repoId, begin.uploadId, totalParts);
259
371
  return result;
372
+ } catch (err) {
373
+ if (!child.killed)
374
+ child.kill("SIGKILL");
375
+ await opts.api.mirrorAbort(begin.repoId, begin.uploadId);
376
+ throw err;
260
377
  } finally {
261
378
  if (excludeFile) {
262
379
  await fsp.rm(excludeFile, { force: true }).catch(() => {});
@@ -344,8 +461,49 @@ function toTarget(last) {
344
461
  directory: last.directory
345
462
  };
346
463
  }
464
+ // package.json
465
+ var package_default = {
466
+ name: "@opencode-manager/ocm-cli",
467
+ version: "0.1.2",
468
+ description: "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
469
+ license: "MIT",
470
+ repository: {
471
+ type: "git",
472
+ url: "https://github.com/chriswritescode-dev/opencode-manager.git",
473
+ directory: "ocm-cli"
474
+ },
475
+ type: "module",
476
+ main: "./dist/plugin.js",
477
+ exports: {
478
+ ".": {
479
+ import: "./dist/plugin.js"
480
+ }
481
+ },
482
+ bin: {
483
+ ocm: "./dist/ocm.js"
484
+ },
485
+ files: [
486
+ "dist",
487
+ "README.md"
488
+ ],
489
+ scripts: {
490
+ build: "bun scripts/build.ts",
491
+ postinstall: "node scripts/postinstall.mjs || true",
492
+ typecheck: "tsc --noEmit",
493
+ test: "bun scripts/build.ts && vitest run",
494
+ "test:watch": "vitest",
495
+ prepublishOnly: "bun scripts/build.ts"
496
+ },
497
+ dependencies: {},
498
+ devDependencies: {
499
+ "@types/node": "^22.0.0",
500
+ typescript: "^5.5.0",
501
+ vitest: "^3.1.0"
502
+ }
503
+ };
347
504
 
348
505
  // bin/ocm.ts
506
+ var VERSION = package_default.version;
349
507
  var USAGE = `ocm - OpenCode Manager workspace launcher
350
508
 
351
509
  Usage:
@@ -359,6 +517,7 @@ Usage:
359
517
  ocm use <repoId|name> Attach to a specific repo and remember it as last
360
518
  ocm push [--force] [--create] [--yes] Mirror $PWD to the matching Manager repo (or create one)
361
519
  ocm pull [--force] Mirror the matching Manager repo over $PWD
520
+ ocm --version Show the installed ocm version
362
521
  ocm --help Show this help
363
522
  `;
364
523
  function die(msg, code = 1) {
@@ -478,9 +637,11 @@ async function cmdLogout() {
478
637
  async function cmdStatus() {
479
638
  const state = readState();
480
639
  if (!state) {
640
+ info(`version: ${VERSION}`);
481
641
  info("no state. run: ocm login <url>");
482
642
  return;
483
643
  }
644
+ info(`version: ${VERSION}`);
484
645
  info(`manager url: ${state.managerUrl}`);
485
646
  info(`token in kc: ${getToken(state.managerUrl) ? "yes" : "no"}`);
486
647
  if (state.lastRepoId !== undefined) {
@@ -672,6 +833,10 @@ async function main() {
672
833
  process.stdout.write(USAGE);
673
834
  return;
674
835
  }
836
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
837
+ info(VERSION);
838
+ return;
839
+ }
675
840
  try {
676
841
  switch (cmd) {
677
842
  case undefined:
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@opencode-manager/ocm-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/chriswritescode-dev/opencode-manager.git",
8
+ "url": "https://github.com/chriswritescode-dev/opencode-manager.git",
9
9
  "directory": "ocm-cli"
10
10
  },
11
11
  "type": "module",
@@ -16,7 +16,7 @@
16
16
  }
17
17
  },
18
18
  "bin": {
19
- "ocm": "dist/ocm.js"
19
+ "ocm": "./dist/ocm.js"
20
20
  },
21
21
  "files": [
22
22
  "dist",