@comonad/cocode 0.0.0-stage → 0.1.8
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 +15 -2
- package/bin/cocode.mjs +284 -0
- package/dist/bin.mjs +2 -0
- package/package.json +23 -4
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# CoCode CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CoCode runs coding agents on your computer and lets you control them from its
|
|
4
|
+
desktop, web, or mobile app.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx @comonad/cocode
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
CoCode is invite-only. The first run of each version asks you to sign in at
|
|
11
|
+
https://code.comonad.co.uk with a one-time code, then downloads the CoCode
|
|
12
|
+
executable for this platform. To download without the sign-in, set
|
|
13
|
+
`COCODE_ACCESS_TOKEN` for one run, such as `cocode --version`. Do not keep it set
|
|
14
|
+
for a server, because the token is short-lived.
|
|
15
|
+
|
|
16
|
+
It supports macOS on Apple silicon and Linux on x64 and arm64 with glibc.
|
package/bin/cocode.mjs
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as NodeChildProcess from "node:child_process";
|
|
3
|
+
import * as NodeCrypto from "node:crypto";
|
|
4
|
+
import * as NodeFS from "node:fs";
|
|
5
|
+
import * as NodeOS from "node:os";
|
|
6
|
+
import * as NodePath from "node:path";
|
|
7
|
+
|
|
8
|
+
const manifest = JSON.parse(
|
|
9
|
+
NodeFS.readFileSync(NodePath.join(import.meta.dirname, "../package.json"), "utf8"),
|
|
10
|
+
);
|
|
11
|
+
const { version } = manifest;
|
|
12
|
+
|
|
13
|
+
const ARCHIVE_KEYS = ["darwin-arm64", "linux-arm64", "linux-x64"];
|
|
14
|
+
const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
15
|
+
// RFC 8628: 5 seconds when the server names no interval, and 5 more after each slow_down.
|
|
16
|
+
const DEFAULT_POLL_SECONDS = 5;
|
|
17
|
+
const SLOW_DOWN_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
function cocodeHome() {
|
|
20
|
+
const raw = (process.env.COCODE_HOME || "").trim();
|
|
21
|
+
if (raw === "") return NodePath.join(NodeOS.homedir(), ".cocode");
|
|
22
|
+
if (raw === "~") return NodeOS.homedir();
|
|
23
|
+
if (raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
24
|
+
return NodePath.join(NodeOS.homedir(), raw.slice(2));
|
|
25
|
+
}
|
|
26
|
+
return NodePath.resolve(raw);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
30
|
+
|
|
31
|
+
function secureUrlSetting(name, fallback) {
|
|
32
|
+
const value = (process.env[name] || fallback).replace(/\/+$/, "");
|
|
33
|
+
const url = URL.canParse(value) ? new URL(value) : undefined;
|
|
34
|
+
const isLoopbackHttp = url?.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
35
|
+
if (url?.protocol !== "https:" && !isLoopbackHttp) {
|
|
36
|
+
throw new Error(`${name} must be an https URL, or http on localhost, not "${value}".`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function archiveKey() {
|
|
42
|
+
const key = `${process.platform}-${process.arch}`;
|
|
43
|
+
const installScript = new URL("/install.sh", manifest.cocode.releasesUrl);
|
|
44
|
+
if (key.startsWith("win32-")) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`CoCode has no Windows CLI. Run it inside WSL with \`npx ${manifest.name}\` or ${installScript}.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (key === "darwin-x64") {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"CoCode ships a macOS CLI for Apple silicon only. On an Apple silicon Mac, use an arm64 build of Node.js.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (!ARCHIVE_KEYS.includes(key)) {
|
|
55
|
+
throw new Error(`CoCode has no CLI for ${key}. It ships for ${ARCHIVE_KEYS.join(", ")}.`);
|
|
56
|
+
}
|
|
57
|
+
if (key.startsWith("linux-") && !process.report.getReport().header.glibcVersionRuntime) {
|
|
58
|
+
throw new Error("the Linux CLI needs glibc. musl systems such as Alpine are not supported.");
|
|
59
|
+
}
|
|
60
|
+
return key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function accessToken() {
|
|
64
|
+
const token = (process.env.COCODE_ACCESS_TOKEN || "").trim();
|
|
65
|
+
return token === "" ? await signIn() : token;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function signIn() {
|
|
69
|
+
const relayUrl = secureUrlSetting("COCODE_RELAY_URL", manifest.cocode.relayUrl);
|
|
70
|
+
const clientId = manifest.cocode.oauthClientId;
|
|
71
|
+
const post = (route, params) =>
|
|
72
|
+
fetch(`${relayUrl}${route}`, { method: "POST", body: new URLSearchParams(params) });
|
|
73
|
+
|
|
74
|
+
const started = await post("/cli/device", { client_id: clientId });
|
|
75
|
+
if (!started.ok) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`could not start the CoCode sign-in: ${relayUrl} returned HTTP ${started.status}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const grant = await started.json();
|
|
81
|
+
if (
|
|
82
|
+
typeof grant.device_code !== "string" ||
|
|
83
|
+
typeof grant.user_code !== "string" ||
|
|
84
|
+
typeof grant.verification_uri !== "string" ||
|
|
85
|
+
typeof grant.expires_in !== "number"
|
|
86
|
+
) {
|
|
87
|
+
throw new Error("the CoCode sign-in service sent an unexpected response.");
|
|
88
|
+
}
|
|
89
|
+
process.stderr.write(
|
|
90
|
+
[
|
|
91
|
+
`Sign in to CoCode to download version ${version}. Open on any device:`,
|
|
92
|
+
` ${grant.verification_uri_complete || grant.verification_uri}`,
|
|
93
|
+
`Code: ${grant.user_code}`,
|
|
94
|
+
`Waiting for approval. Expires in ${Math.max(1, Math.round(grant.expires_in / 60))} min. Ctrl+C cancels.`,
|
|
95
|
+
"",
|
|
96
|
+
].join("\n"),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const deadline = Date.now() + grant.expires_in * 1000;
|
|
100
|
+
let intervalMs = (grant.interval ?? DEFAULT_POLL_SECONDS) * 1000;
|
|
101
|
+
while (Date.now() < deadline) {
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
103
|
+
const response = await post("/cli/oauth/token", {
|
|
104
|
+
grant_type: DEVICE_CODE_GRANT,
|
|
105
|
+
device_code: grant.device_code,
|
|
106
|
+
client_id: clientId,
|
|
107
|
+
}).catch(() => undefined);
|
|
108
|
+
if (response === undefined || response.status >= 500) {
|
|
109
|
+
await response?.body?.cancel();
|
|
110
|
+
intervalMs += SLOW_DOWN_MS;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const body = await response.json().catch(() => ({}));
|
|
114
|
+
if (response.ok && typeof body.access_token === "string") return body.access_token;
|
|
115
|
+
if (body.error === "authorization_pending") continue;
|
|
116
|
+
if (body.error === "slow_down") {
|
|
117
|
+
intervalMs += SLOW_DOWN_MS;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (body.error === "access_denied") throw new Error("the CoCode sign-in was denied.");
|
|
121
|
+
if (body.error === "expired_token") break;
|
|
122
|
+
throw new Error(`the CoCode sign-in failed (${body.error || `HTTP ${response.status}`}).`);
|
|
123
|
+
}
|
|
124
|
+
throw new Error("the CoCode sign-in code expired. Run the command again for a new code.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function get(url, token) {
|
|
128
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
129
|
+
if (response.status === 401) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
process.env.COCODE_ACCESS_TOKEN
|
|
132
|
+
? "the CoCode release feed rejected COCODE_ACCESS_TOKEN. It may have expired, or your account is not on the allow list. Unset it to sign in with a code."
|
|
133
|
+
: "the CoCode release feed rejected your sign-in. Your account is not on the allow list.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (response.status === 404) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${url} does not exist yet. If ${version} was just released, try again in a few minutes.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (!response.ok) throw new Error(`GET ${url} returned HTTP ${response.status}`);
|
|
142
|
+
return response;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function download(url, token, file) {
|
|
146
|
+
const response = await get(url, token);
|
|
147
|
+
const total = Number(response.headers.get("content-length")) || 0;
|
|
148
|
+
const hash = NodeCrypto.createHash("sha256");
|
|
149
|
+
const out = NodeFS.openSync(file, "w");
|
|
150
|
+
const tty = process.stderr.isTTY;
|
|
151
|
+
let received = 0;
|
|
152
|
+
let shown = -1;
|
|
153
|
+
try {
|
|
154
|
+
for await (const chunk of response.body) {
|
|
155
|
+
hash.update(chunk);
|
|
156
|
+
NodeFS.writeSync(out, chunk);
|
|
157
|
+
received += chunk.byteLength;
|
|
158
|
+
const step = total ? Math.floor((received * 100) / total) : Math.floor(received / 1048576);
|
|
159
|
+
if (tty && step !== shown) {
|
|
160
|
+
shown = step;
|
|
161
|
+
const percent = total ? ` ${step}%` : "";
|
|
162
|
+
process.stderr.write(
|
|
163
|
+
`\r\x1b[2Kcocode: downloading CoCode ${version}${percent} (${(received / 1048576).toFixed(1)} MB)`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
NodeFS.closeSync(out);
|
|
169
|
+
if (tty) process.stderr.write("\r\x1b[2K");
|
|
170
|
+
}
|
|
171
|
+
return hash.digest("hex");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function install(dir, key) {
|
|
175
|
+
const releasesUrl = secureUrlSetting("COCODE_RELEASES_URL", manifest.cocode.releasesUrl);
|
|
176
|
+
const token = await accessToken();
|
|
177
|
+
const archive = `cocode-${version}-${key}.tar.gz`;
|
|
178
|
+
const base = `${releasesUrl}/download/v${version}`;
|
|
179
|
+
const sums = await (await get(`${base}/SHA256SUMS`, token)).text();
|
|
180
|
+
const expected = /^([0-9a-f]{64})\s+\*?(.+)$/im;
|
|
181
|
+
const line = sums
|
|
182
|
+
.split(/\r?\n/)
|
|
183
|
+
.map((l) => expected.exec(l.trim()))
|
|
184
|
+
.find((m) => m && m[2] === archive);
|
|
185
|
+
if (!line) throw new Error(`${archive} is not listed in the release's SHA256SUMS`);
|
|
186
|
+
|
|
187
|
+
// Stage beside the destination and rename atomically to avoid half-installed versions.
|
|
188
|
+
NodeFS.mkdirSync(NodePath.dirname(dir), { recursive: true });
|
|
189
|
+
const staging = NodeFS.mkdtempSync(`${dir}.staging-`);
|
|
190
|
+
try {
|
|
191
|
+
const file = NodePath.join(staging, archive);
|
|
192
|
+
if ((await download(`${base}/${archive}`, token, file)) !== line[1].toLowerCase()) {
|
|
193
|
+
throw new Error(`checksum mismatch for ${archive}`);
|
|
194
|
+
}
|
|
195
|
+
const extract = NodeChildProcess.spawnSync(
|
|
196
|
+
"tar",
|
|
197
|
+
["-xf", file, "-C", staging, "--strip-components=1"],
|
|
198
|
+
{ stdio: "inherit" },
|
|
199
|
+
);
|
|
200
|
+
if (extract.status !== 0) throw new Error(`could not extract ${archive}`);
|
|
201
|
+
NodeFS.rmSync(file);
|
|
202
|
+
NodeFS.writeFileSync(NodePath.join(staging, ".install-complete"), `${version}\n`);
|
|
203
|
+
publish(staging, dir);
|
|
204
|
+
} finally {
|
|
205
|
+
NodeFS.rmSync(staging, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const isComplete = (dir) => {
|
|
210
|
+
try {
|
|
211
|
+
return NodeFS.readFileSync(NodePath.join(dir, ".install-complete"), "utf8").trim() === version;
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** Preserve concurrently published complete installs; replace only incomplete folders without the completion marker. */
|
|
218
|
+
function publish(staging, dir) {
|
|
219
|
+
try {
|
|
220
|
+
NodeFS.renameSync(staging, dir);
|
|
221
|
+
return;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (isComplete(dir)) return;
|
|
224
|
+
if (!NodeFS.existsSync(dir)) throw error;
|
|
225
|
+
}
|
|
226
|
+
const lock = `${dir}.lock`;
|
|
227
|
+
try {
|
|
228
|
+
NodeFS.mkdirSync(lock);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error.code !== "EEXIST") throw error;
|
|
231
|
+
throw new Error(
|
|
232
|
+
`another cocode is installing ${version}. If none is running, delete ${lock} and try again.`,
|
|
233
|
+
{ cause: error },
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const stale = `${staging}.stale`;
|
|
237
|
+
try {
|
|
238
|
+
// A peer may have published between our marker check and taking the lock.
|
|
239
|
+
if (isComplete(dir)) return;
|
|
240
|
+
// Move it aside rather than deleting in place: once the folder is empty, a
|
|
241
|
+
// peer's first rename can land in it.
|
|
242
|
+
NodeFS.renameSync(dir, stale);
|
|
243
|
+
try {
|
|
244
|
+
NodeFS.renameSync(staging, dir);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (!isComplete(dir)) throw error;
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
NodeFS.rmSync(lock, { recursive: true, force: true });
|
|
250
|
+
NodeFS.rmSync(stale, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function main() {
|
|
255
|
+
const dir = NodePath.join(cocodeHome(), "runtime", "versions", version);
|
|
256
|
+
if (!isComplete(dir)) await install(dir, archiveKey());
|
|
257
|
+
|
|
258
|
+
// Service IPC and signals must reach the child; terminal Ctrl-C already does, so do not forward SIGINT twice.
|
|
259
|
+
const ipc = process.send !== undefined;
|
|
260
|
+
const child = NodeChildProcess.spawn(NodePath.join(dir, "cocode"), process.argv.slice(2), {
|
|
261
|
+
stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
|
|
262
|
+
// The executable lives in the CoCode home, so this is how it knows npm launched it.
|
|
263
|
+
env: { ...process.env, COCODE_NPM_LAUNCHER_PATH: import.meta.filename },
|
|
264
|
+
});
|
|
265
|
+
if (ipc) {
|
|
266
|
+
process.on("message", (message) => child.connected && child.send(message));
|
|
267
|
+
child.on("message", (message) => process.connected && process.send(message));
|
|
268
|
+
process.on("disconnect", () => child.connected && child.disconnect());
|
|
269
|
+
}
|
|
270
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
271
|
+
process.on(signal, () => (ipc || signal !== "SIGINT") && child.kill(signal));
|
|
272
|
+
}
|
|
273
|
+
child.on("error", (error) => fail(error));
|
|
274
|
+
child.on("exit", (code, signal) =>
|
|
275
|
+
process.exit(code ?? 128 + (NodeOS.constants.signals[signal] || 1)),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function fail(error) {
|
|
280
|
+
process.stderr.write(`cocode: ${error.message}\n`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
await main().catch(fail);
|
package/dist/bin.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comonad/cocode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "CoCode CLI. Signs you in, then downloads the self-contained executable for this platform.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/vahaah/cocode.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"cocode": "./bin/cocode.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20.11"
|
|
19
|
+
},
|
|
20
|
+
"cocode": {
|
|
21
|
+
"releasesUrl": "https://code.comonad.co.uk/releases",
|
|
22
|
+
"relayUrl": "https://relay.code.comonad.co.uk",
|
|
23
|
+
"oauthClientId": "cocode-cli"
|
|
24
|
+
}
|
|
25
|
+
}
|