@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.
- package/README.md +74 -0
- package/dist/ocm.js +194 -29
- 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
|
|
109
|
-
const
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
141
|
+
body.create = true;
|
|
142
|
+
body.name = opts.create.name;
|
|
115
143
|
if (opts.create.originUrl)
|
|
116
|
-
|
|
144
|
+
body.originUrl = opts.create.originUrl;
|
|
117
145
|
if (opts.create.branch)
|
|
118
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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.
|
|
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": "
|
|
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",
|