@neta-art/cohub-cli 2.2.1 → 2.2.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/dist/avatar.js +1 -1
- package/dist/commands/references.d.ts +2 -0
- package/dist/commands/references.js +153 -0
- package/dist/commands/sandbox.d.ts +2 -0
- package/dist/commands/sandbox.js +170 -0
- package/dist/commands/sandboxd-binary.d.ts +9 -0
- package/dist/commands/sandboxd-binary.js +217 -0
- package/dist/commands/spaces.js +22 -0
- package/dist/index.js +5 -0
- package/package.json +2 -2
package/dist/avatar.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function uploadAvatarAsset(input) {
|
|
|
18
18
|
filename: "avatar.webp",
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
|
-
const CHAT_IMAGE_MAX_EDGE =
|
|
21
|
+
const CHAT_IMAGE_MAX_EDGE = 1984;
|
|
22
22
|
const CHAT_IMAGE_QUALITY = 86;
|
|
23
23
|
export async function normalizeChatImageFile(path) {
|
|
24
24
|
return sharp(path)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createClient } from "../client.js";
|
|
2
|
+
import { table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
|
|
3
|
+
const RESOURCE_TYPES = new Set([
|
|
4
|
+
"space",
|
|
5
|
+
"session",
|
|
6
|
+
"checkpoint",
|
|
7
|
+
]);
|
|
8
|
+
const REFERENCE_KINDS = new Set([
|
|
9
|
+
"session_fork",
|
|
10
|
+
"space_fork",
|
|
11
|
+
"checkpoint_fork",
|
|
12
|
+
"mention",
|
|
13
|
+
"tool_call",
|
|
14
|
+
"mod",
|
|
15
|
+
"participant",
|
|
16
|
+
]);
|
|
17
|
+
const DIRECTIONS = new Set(["out", "in", "both"]);
|
|
18
|
+
const GROUP_BYS = new Set(["kind", "targetType", "sourceType", "day"]);
|
|
19
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
20
|
+
function parseSource(value) {
|
|
21
|
+
const idx = value.indexOf(":");
|
|
22
|
+
if (idx <= 0)
|
|
23
|
+
throw new Error("Source must be <type>:<id>, e.g. session:<uuid>");
|
|
24
|
+
const type = value.slice(0, idx);
|
|
25
|
+
const id = value.slice(idx + 1).trim();
|
|
26
|
+
if (!RESOURCE_TYPES.has(type))
|
|
27
|
+
throw new Error(`Invalid resource type: ${type}`);
|
|
28
|
+
if (!id)
|
|
29
|
+
throw new Error("Missing resource id");
|
|
30
|
+
return { type, id };
|
|
31
|
+
}
|
|
32
|
+
function parseKinds(value) {
|
|
33
|
+
const kinds = value?.split(",").map((k) => k.trim()).filter(Boolean);
|
|
34
|
+
if (!kinds?.length)
|
|
35
|
+
return undefined;
|
|
36
|
+
const invalid = kinds.find((k) => !REFERENCE_KINDS.has(k));
|
|
37
|
+
if (invalid)
|
|
38
|
+
throw new Error(`Invalid reference kind: ${invalid}`);
|
|
39
|
+
return [...new Set(kinds)];
|
|
40
|
+
}
|
|
41
|
+
function clampNumber(value, fallback, min, max) {
|
|
42
|
+
const parsed = Number(value ?? fallback);
|
|
43
|
+
if (!Number.isFinite(parsed))
|
|
44
|
+
return fallback;
|
|
45
|
+
return Math.min(Math.max(Math.floor(parsed), min), max);
|
|
46
|
+
}
|
|
47
|
+
export function registerReferences(program) {
|
|
48
|
+
const references = program
|
|
49
|
+
.command("references")
|
|
50
|
+
.description("Inspect resource references (forks, mentions, tool calls, mods, participants)");
|
|
51
|
+
references
|
|
52
|
+
.command("query")
|
|
53
|
+
.description("List references touching a resource")
|
|
54
|
+
.argument("<source>", "Resource selector, e.g. session:<uuid> or space:<uuid>")
|
|
55
|
+
.option("--direction <dir>", "out | in | both", "both")
|
|
56
|
+
.option("--kinds <kinds>", "Comma-separated kinds to include")
|
|
57
|
+
.option("--days <n>", "Only references seen within the last N days")
|
|
58
|
+
.option("--limit <n>", "Maximum rows, 1-500", "200")
|
|
59
|
+
.option("--json", "Output as JSON")
|
|
60
|
+
.addHelpText("after", `
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
cohub references query session:<uuid> --json
|
|
64
|
+
cohub references query space:<uuid> --direction in --kinds mention,tool_call
|
|
65
|
+
cohub references query space:<uuid> --kinds mod --days 30
|
|
66
|
+
`)
|
|
67
|
+
.action(async (source, opts) => {
|
|
68
|
+
const client = createClient();
|
|
69
|
+
try {
|
|
70
|
+
const ref = parseSource(source);
|
|
71
|
+
const direction = (opts.direction ?? "both");
|
|
72
|
+
if (!DIRECTIONS.has(direction))
|
|
73
|
+
return error(`Invalid direction: ${opts.direction}`);
|
|
74
|
+
const kinds = parseKinds(opts.kinds);
|
|
75
|
+
const days = opts.days ? clampNumber(opts.days, 30, 1, 365) : undefined;
|
|
76
|
+
const limit = clampNumber(opts.limit, 200, 1, 500);
|
|
77
|
+
const result = await client.references.query({
|
|
78
|
+
source: `${ref.type}:${ref.id}`,
|
|
79
|
+
direction,
|
|
80
|
+
kinds,
|
|
81
|
+
days,
|
|
82
|
+
limit,
|
|
83
|
+
});
|
|
84
|
+
if (jsonRequested(opts))
|
|
85
|
+
return outJson(result);
|
|
86
|
+
const rows = result.references.map((r) => ({
|
|
87
|
+
kind: r.kind,
|
|
88
|
+
source: `${r.sourceType}:${r.sourceId.slice(0, 8)}`,
|
|
89
|
+
target: `${r.targetType}:${r.targetId.slice(0, 8)}`,
|
|
90
|
+
count: r.count,
|
|
91
|
+
lastSeen: r.updatedAt.slice(0, 10),
|
|
92
|
+
}));
|
|
93
|
+
table(rows, [
|
|
94
|
+
{ key: "kind", label: "Kind" },
|
|
95
|
+
{ key: "source", label: "Source" },
|
|
96
|
+
{ key: "target", label: "Target" },
|
|
97
|
+
{ key: "count", label: "Count" },
|
|
98
|
+
{ key: "lastSeen", label: "Last Seen" },
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (e instanceof Error && (e.message.startsWith("Invalid") || e.message.startsWith("Source must") || e.message.startsWith("Missing"))) {
|
|
103
|
+
return error(e.message);
|
|
104
|
+
}
|
|
105
|
+
handleHttp(e);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
references
|
|
109
|
+
.command("aggregate")
|
|
110
|
+
.description("Grouped reference counts for a space")
|
|
111
|
+
.argument("<spaceId>", "Space id")
|
|
112
|
+
.option("--group-by <field>", "kind | targetType | sourceType | day", "kind")
|
|
113
|
+
.option("--kinds <kinds>", "Comma-separated kinds to include")
|
|
114
|
+
.option("--days <n>", "Only references seen within the last N days")
|
|
115
|
+
.option("--json", "Output as JSON")
|
|
116
|
+
.addHelpText("after", `
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
cohub references aggregate <spaceId> --json
|
|
120
|
+
cohub references aggregate <spaceId> --group-by targetType
|
|
121
|
+
cohub references aggregate <spaceId> --group-by day --days 30
|
|
122
|
+
`)
|
|
123
|
+
.action(async (spaceId, opts) => {
|
|
124
|
+
const client = createClient();
|
|
125
|
+
try {
|
|
126
|
+
if (!UUID_PATTERN.test(spaceId.trim()))
|
|
127
|
+
return error("Invalid space id");
|
|
128
|
+
const groupBy = (opts.groupBy ?? "kind");
|
|
129
|
+
if (!GROUP_BYS.has(groupBy))
|
|
130
|
+
return error(`Invalid group-by: ${opts.groupBy}`);
|
|
131
|
+
const kinds = parseKinds(opts.kinds);
|
|
132
|
+
const days = opts.days ? clampNumber(opts.days, 30, 1, 365) : undefined;
|
|
133
|
+
const result = await client.references.aggregate({ spaceId: spaceId.trim(), groupBy, kinds, days });
|
|
134
|
+
if (jsonRequested(opts))
|
|
135
|
+
return outJson(result);
|
|
136
|
+
const rows = result.groups.map((g) => ({
|
|
137
|
+
group: g.group,
|
|
138
|
+
references: g.references,
|
|
139
|
+
total: g.total,
|
|
140
|
+
}));
|
|
141
|
+
table(rows, [
|
|
142
|
+
{ key: "group", label: "Group" },
|
|
143
|
+
{ key: "references", label: "References" },
|
|
144
|
+
{ key: "total", label: "Total" },
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
if (e instanceof Error && e.message.startsWith("Invalid"))
|
|
149
|
+
return error(e.message);
|
|
150
|
+
handleHttp(e);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { resolveCohubEnvironment, resolveWebsocketUrl } from "@neta-art/cohub";
|
|
6
|
+
import { requireAccessToken } from "../auth.js";
|
|
7
|
+
import { createClient } from "../client.js";
|
|
8
|
+
import { error, json as outJson, jsonRequested, ok, spinner } from "../output.js";
|
|
9
|
+
import { resolveSpace } from "../space.js";
|
|
10
|
+
import { ensureSandboxdBinary, SandboxdDownloadError } from "./sandboxd-binary.js";
|
|
11
|
+
// Derive the gateway relay control endpoint from the realtime websocket URL,
|
|
12
|
+
// e.g. wss://gateway.cohub.run/ws -> wss://gateway.cohub.run/sandbox/relay.
|
|
13
|
+
const resolveRelayUrl = () => {
|
|
14
|
+
const explicit = process.env.COHUB_RELAY_URL?.trim();
|
|
15
|
+
if (explicit)
|
|
16
|
+
return explicit;
|
|
17
|
+
const wsUrl = resolveWebsocketUrl({ url: process.env.COHUB_WS_URL });
|
|
18
|
+
return wsUrl.replace(/\/ws$/, "/sandbox/relay");
|
|
19
|
+
};
|
|
20
|
+
const confirm = async (question) => {
|
|
21
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
22
|
+
try {
|
|
23
|
+
const answer = await new Promise((res) => rl.question(`${question} [y/N] `, res));
|
|
24
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
rl.close();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const webBaseUrl = () => resolveCohubEnvironment() === "prod" ? "https://cohub.run" : "https://dev.cohub.run";
|
|
31
|
+
// Consent copy is deliberately explicit: a local sandbox runs agent-issued
|
|
32
|
+
// shell commands as the current OS user. File RPCs are fenced to the folder,
|
|
33
|
+
// but shell commands are NOT — they can read/write anything the user can
|
|
34
|
+
// (SSH keys, other repos, etc). This mirrors the trust model of running an
|
|
35
|
+
// AI coding agent locally and must be surfaced clearly before starting.
|
|
36
|
+
const consentMessage = (rootDir, target) => [
|
|
37
|
+
`Start ${target} exposing ${rootDir}?`,
|
|
38
|
+
"",
|
|
39
|
+
"Agents in this space will be able to:",
|
|
40
|
+
` • read and write files under ${rootDir}`,
|
|
41
|
+
" • run shell commands as your user (full access to your machine, not just this folder)",
|
|
42
|
+
"",
|
|
43
|
+
"Only continue if you trust this space's collaborators.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
export function registerSandbox(program) {
|
|
46
|
+
const cmd = program.command("sandbox").description("Run a local folder as a space sandbox");
|
|
47
|
+
// ── sandbox up ──
|
|
48
|
+
cmd
|
|
49
|
+
.command("up [dir]")
|
|
50
|
+
.description("Expose a local folder to a space as its sandbox (foreground; Ctrl-C to stop)")
|
|
51
|
+
.option("-s, --space <id>", "Bind to an existing space instead of creating one")
|
|
52
|
+
.option("-n, --name <name>", "Name for the newly created space")
|
|
53
|
+
.option("-y, --yes", "Skip the confirmation prompt")
|
|
54
|
+
.option("--json", "Output as JSON")
|
|
55
|
+
.action(async (dir, opts) => {
|
|
56
|
+
const rootDir = resolve(dir ?? process.cwd());
|
|
57
|
+
const info = await stat(rootDir).catch(() => null);
|
|
58
|
+
if (!info?.isDirectory()) {
|
|
59
|
+
return error("Invalid directory", `${rootDir} is not a directory`);
|
|
60
|
+
}
|
|
61
|
+
// A single spinner: first status starts it, later statuses only update the
|
|
62
|
+
// label (calling start twice would leak the previous interval).
|
|
63
|
+
const spin = spinner();
|
|
64
|
+
let spinnerStarted = false;
|
|
65
|
+
let binary;
|
|
66
|
+
try {
|
|
67
|
+
binary = await ensureSandboxdBinary({
|
|
68
|
+
onStatus: (msg) => {
|
|
69
|
+
if (spinnerStarted) {
|
|
70
|
+
spin.update(msg);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
spin.start(msg);
|
|
74
|
+
spinnerStarted = true;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
if (spinnerStarted)
|
|
79
|
+
spin.stop("");
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (spinnerStarted)
|
|
83
|
+
spin.stop("");
|
|
84
|
+
if (err instanceof SandboxdDownloadError)
|
|
85
|
+
return error("Sandbox runtime unavailable", err.message);
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
const relayUrl = resolveRelayUrl();
|
|
89
|
+
const token = await requireAccessToken();
|
|
90
|
+
const client = createClient();
|
|
91
|
+
// Resolve or create the target space.
|
|
92
|
+
let spaceId = opts.space?.trim() || program.opts().space?.trim();
|
|
93
|
+
if (!spaceId) {
|
|
94
|
+
if (!opts.yes) {
|
|
95
|
+
const proceed = await confirm(consentMessage(rootDir, "a new local space"));
|
|
96
|
+
if (!proceed)
|
|
97
|
+
return error("Aborted", "No space was created");
|
|
98
|
+
}
|
|
99
|
+
const created = await client.spaces.create({
|
|
100
|
+
name: opts.name,
|
|
101
|
+
config: { sandbox: { provider: "local" } },
|
|
102
|
+
});
|
|
103
|
+
spaceId = created.space.id;
|
|
104
|
+
ok(`Created local space ${spaceId}`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// A local runner can only attach to a space whose sandbox provider is
|
|
108
|
+
// "local" (provider is fixed at space creation). Fail early with a clear
|
|
109
|
+
// message instead of letting the gateway reject the connection later.
|
|
110
|
+
const existing = await client.space(spaceId).sandbox.get().catch(() => null);
|
|
111
|
+
if (existing?.sandbox?.provider !== "local") {
|
|
112
|
+
return error("Not a local space", `Space ${spaceId} is not configured for a local sandbox. Create one with 'cohub sandbox up' (without --space).`);
|
|
113
|
+
}
|
|
114
|
+
if (!opts.yes) {
|
|
115
|
+
const proceed = await confirm(consentMessage(rootDir, `space ${spaceId}`));
|
|
116
|
+
if (!proceed)
|
|
117
|
+
return error("Aborted", "Sandbox was not started");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const url = `${webBaseUrl()}/spaces/${spaceId}`;
|
|
121
|
+
if (jsonRequested(opts)) {
|
|
122
|
+
outJson({ spaceId, rootDir, relayUrl, url });
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
ok(`Sandbox ready — open ${url}`);
|
|
126
|
+
console.log(` Serving: ${rootDir}`);
|
|
127
|
+
console.log(" Press Ctrl-C to stop.\n");
|
|
128
|
+
}
|
|
129
|
+
// Spawn the runner in the foreground. It dials the gateway relay and
|
|
130
|
+
// stays connected until the process is interrupted.
|
|
131
|
+
const child = spawn(binary, ["--local", "--space", spaceId, "--root", rootDir, "--relay", relayUrl], {
|
|
132
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
133
|
+
env: { ...process.env, COHUB_RELAY_TOKEN: token },
|
|
134
|
+
});
|
|
135
|
+
const stop = () => child.kill("SIGTERM");
|
|
136
|
+
process.on("SIGINT", stop);
|
|
137
|
+
process.on("SIGTERM", stop);
|
|
138
|
+
await new Promise((res) => {
|
|
139
|
+
child.on("exit", (code, signal) => {
|
|
140
|
+
if (signal)
|
|
141
|
+
console.log(`\nSandbox stopped (${signal}).`);
|
|
142
|
+
else if (code !== 0)
|
|
143
|
+
console.error(`Sandbox exited with code ${code}.`);
|
|
144
|
+
res();
|
|
145
|
+
});
|
|
146
|
+
child.on("error", (err) => error("Failed to start sandbox", err.message));
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// ── sandbox status ──
|
|
150
|
+
cmd
|
|
151
|
+
.command("status")
|
|
152
|
+
.description("Show the current sandbox status for a space")
|
|
153
|
+
.option("-s, --space <id>", "Target space ID")
|
|
154
|
+
.option("--json", "Output as JSON")
|
|
155
|
+
.action(async (opts) => {
|
|
156
|
+
const spaceId = opts.space?.trim() || resolveSpace(program);
|
|
157
|
+
const client = createClient();
|
|
158
|
+
const result = await client.space(spaceId).sandbox.get().catch(() => null);
|
|
159
|
+
const sandbox = result?.sandbox ?? null;
|
|
160
|
+
if (jsonRequested(opts))
|
|
161
|
+
return outJson({ spaceId, sandbox });
|
|
162
|
+
if (!sandbox) {
|
|
163
|
+
console.log(" (no sandbox)");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.log(` space: ${spaceId}`);
|
|
167
|
+
console.log(` provider: ${sandbox.provider ?? "cloud"}`);
|
|
168
|
+
console.log(` status: ${sandbox.status ?? "unknown"}`);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const SANDBOXD_VERSION = "v1.82.2";
|
|
2
|
+
export declare class SandboxdDownloadError extends Error {
|
|
3
|
+
name: string;
|
|
4
|
+
}
|
|
5
|
+
export type EnsureSandboxdOptions = {
|
|
6
|
+
version?: string;
|
|
7
|
+
onStatus?: (message: string) => void;
|
|
8
|
+
};
|
|
9
|
+
export declare const ensureSandboxdBinary: (options?: EnsureSandboxdOptions) => Promise<string>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
4
|
+
import { chmod, copyFile, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
// The sandboxd binary version is pinned independently of the CLI package: it
|
|
10
|
+
// only needs to change when apps/sandbox changes, and the agent-sandbox wire
|
|
11
|
+
// protocol ("1") guarantees backward compatibility.
|
|
12
|
+
//
|
|
13
|
+
// IMPORTANT: this must point at a tag whose CDN artifacts have already been
|
|
14
|
+
// published by .github/workflows/sandbox-binaries-build.yml. Only bump it AFTER
|
|
15
|
+
// that tag's publish-cdn job has succeeded, otherwise `sandbox up` 404s on the
|
|
16
|
+
// default download.
|
|
17
|
+
export const SANDBOXD_VERSION = "v1.82.2";
|
|
18
|
+
const BINARY_NAME = "cohub-sandboxd";
|
|
19
|
+
// Public CDN prefix hosting the release archives (the repo is private, so the
|
|
20
|
+
// GitHub Release assets are not publicly downloadable). Overridable for staging
|
|
21
|
+
// or self-hosting.
|
|
22
|
+
const cdnBaseUrl = () => (process.env.COHUB_SANDBOXD_CDN_BASE_URL?.trim() || "https://public.cohub.run/sandboxd").replace(/\/+$/, "");
|
|
23
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
24
|
+
const LOCK_STALE_MS = 5 * 60 * 1000;
|
|
25
|
+
// Map Node's platform/arch to the Go GOOS/GOARCH used in release asset names.
|
|
26
|
+
const GOOS_BY_PLATFORM = { darwin: "darwin", linux: "linux" };
|
|
27
|
+
const GOARCH_BY_ARCH = { x64: "amd64", arm64: "arm64" };
|
|
28
|
+
export class SandboxdDownloadError extends Error {
|
|
29
|
+
name = "SandboxdDownloadError";
|
|
30
|
+
}
|
|
31
|
+
const resolveTarget = () => {
|
|
32
|
+
const goos = GOOS_BY_PLATFORM[process.platform];
|
|
33
|
+
const goarch = GOARCH_BY_ARCH[process.arch];
|
|
34
|
+
if (!goos || !goarch) {
|
|
35
|
+
throw new SandboxdDownloadError(`Unsupported platform ${process.platform}/${process.arch}. Set COHUB_SANDBOXD_BIN to a locally built binary.`);
|
|
36
|
+
}
|
|
37
|
+
return { goos, goarch };
|
|
38
|
+
};
|
|
39
|
+
const cacheDir = (version) => join(homedir(), ".cache", "cohub", "sandboxd", version);
|
|
40
|
+
const cachedBinaryPath = (version) => join(cacheDir(version), BINARY_NAME);
|
|
41
|
+
const archiveName = (version, target) => `${BINARY_NAME}_${version}_${target.goos}_${target.goarch}.tar.gz`;
|
|
42
|
+
const isExecutableFile = async (path) => {
|
|
43
|
+
const info = await stat(path).catch(() => null);
|
|
44
|
+
return Boolean(info?.isFile());
|
|
45
|
+
};
|
|
46
|
+
const sha256File = async (path) => {
|
|
47
|
+
const hash = createHash("sha256");
|
|
48
|
+
await pipeline(createReadStream(path), hash);
|
|
49
|
+
return hash.digest("hex");
|
|
50
|
+
};
|
|
51
|
+
// Run `fn` under a single AbortController whose timeout spans the entire body
|
|
52
|
+
// read (not just the response headers), and normalize any network / timeout
|
|
53
|
+
// failure into a user-readable SandboxdDownloadError.
|
|
54
|
+
const withTimeout = async (what, fn) => {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
57
|
+
try {
|
|
58
|
+
return await fn(controller.signal);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err instanceof SandboxdDownloadError)
|
|
62
|
+
throw err;
|
|
63
|
+
if (controller.signal.aborted) {
|
|
64
|
+
throw new SandboxdDownloadError(`${what} timed out after ${DOWNLOAD_TIMEOUT_MS / 1000}s`);
|
|
65
|
+
}
|
|
66
|
+
throw new SandboxdDownloadError(`${what} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
// Download a URL straight to disk. The timeout covers the full stream, so a
|
|
73
|
+
// stalled body can never hang indefinitely.
|
|
74
|
+
const downloadToFile = (url, accept, destPath) => withTimeout(`Download of ${url}`, async (signal) => {
|
|
75
|
+
const response = await fetch(url, { signal, headers: { accept } });
|
|
76
|
+
if (!response.ok)
|
|
77
|
+
throw new SandboxdDownloadError(`Download failed (${response.status}) for ${url}`);
|
|
78
|
+
if (!response.body)
|
|
79
|
+
throw new SandboxdDownloadError(`Empty response body for ${url}`);
|
|
80
|
+
// Cast: Node's web-stream typings diverge from the DOM lib bundled by tsc.
|
|
81
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(destPath));
|
|
82
|
+
});
|
|
83
|
+
// Fetch a small text resource (the checksum manifest) under the same timeout.
|
|
84
|
+
const fetchText = (url, accept) => withTimeout(`Download of ${url}`, async (signal) => {
|
|
85
|
+
const response = await fetch(url, { signal, headers: { accept } });
|
|
86
|
+
if (!response.ok)
|
|
87
|
+
throw new SandboxdDownloadError(`Download failed (${response.status}) for ${url}`);
|
|
88
|
+
return (await response.text()).trim();
|
|
89
|
+
});
|
|
90
|
+
// List the entries of a `.tar.gz` without extracting, so we can reject archives
|
|
91
|
+
// that contain anything other than the expected single binary (path traversal,
|
|
92
|
+
// symlinks, extra entries) before touching the filesystem.
|
|
93
|
+
const listTarGz = (archivePath) => new Promise((res, rej) => {
|
|
94
|
+
const child = spawn("tar", ["-tzf", archivePath], { stdio: ["ignore", "pipe", "pipe"] });
|
|
95
|
+
let stdout = "";
|
|
96
|
+
let stderr = "";
|
|
97
|
+
child.stdout.on("data", (chunk) => {
|
|
98
|
+
stdout += chunk.toString();
|
|
99
|
+
});
|
|
100
|
+
child.stderr.on("data", (chunk) => {
|
|
101
|
+
stderr += chunk.toString();
|
|
102
|
+
});
|
|
103
|
+
child.on("error", rej);
|
|
104
|
+
child.on("close", (code) => code === 0
|
|
105
|
+
? res(stdout.split("\n").map((line) => line.trim()).filter(Boolean))
|
|
106
|
+
: rej(new SandboxdDownloadError(`tar listing failed: ${stderr.trim() || `exit ${code}`}`)));
|
|
107
|
+
});
|
|
108
|
+
// Extract a single-file `.tar.gz` using the system tar (universally present on
|
|
109
|
+
// macOS and Linux), keeping the CLI free of native archive dependencies.
|
|
110
|
+
const extractTarGz = (archivePath, cwd) => new Promise((res, rej) => {
|
|
111
|
+
const child = spawn("tar", ["-xzf", archivePath, "-C", cwd], { stdio: ["ignore", "ignore", "pipe"] });
|
|
112
|
+
let stderr = "";
|
|
113
|
+
child.stderr.on("data", (chunk) => {
|
|
114
|
+
stderr += chunk.toString();
|
|
115
|
+
});
|
|
116
|
+
child.on("error", rej);
|
|
117
|
+
child.on("close", (code) => code === 0 ? res() : rej(new SandboxdDownloadError(`tar extraction failed: ${stderr.trim() || `exit ${code}`}`)));
|
|
118
|
+
});
|
|
119
|
+
// Cross-process lock via atomic mkdir, mirroring the CLI self-update lock so two
|
|
120
|
+
// concurrent `sandbox up` invocations don't download the same archive twice.
|
|
121
|
+
const withLock = async (version, fn) => {
|
|
122
|
+
const lockPath = join(cacheDir(version), ".download.lock");
|
|
123
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
124
|
+
for (let attempt = 0; attempt < 600; attempt += 1) {
|
|
125
|
+
try {
|
|
126
|
+
await mkdir(lockPath);
|
|
127
|
+
try {
|
|
128
|
+
return await fn();
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
if (err.code !== "EEXIST")
|
|
136
|
+
throw err;
|
|
137
|
+
const info = await stat(lockPath).catch(() => null);
|
|
138
|
+
if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
139
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new SandboxdDownloadError("Timed out waiting for the sandboxd download lock");
|
|
146
|
+
};
|
|
147
|
+
const downloadAndVerify = async (version, target) => {
|
|
148
|
+
const name = archiveName(version, target);
|
|
149
|
+
const archiveUrl = `${cdnBaseUrl()}/${version}/${name}`;
|
|
150
|
+
const checksumUrl = `${archiveUrl}.sha256`;
|
|
151
|
+
const tempDir = await mkdtemp(join(tmpdir(), "cohub-sandboxd-"));
|
|
152
|
+
try {
|
|
153
|
+
const archivePath = join(tempDir, name);
|
|
154
|
+
// Fetch archive to disk (timeout covers the full body stream).
|
|
155
|
+
await downloadToFile(archiveUrl, "application/gzip", archivePath);
|
|
156
|
+
// Fetch and verify checksum. The .sha256 file is `<hash> <filename>`.
|
|
157
|
+
const checksumText = await fetchText(checksumUrl, "text/plain");
|
|
158
|
+
const expected = checksumText.split(/\s+/)[0]?.toLowerCase();
|
|
159
|
+
if (!expected || !/^[0-9a-f]{64}$/.test(expected)) {
|
|
160
|
+
throw new SandboxdDownloadError(`Invalid checksum manifest for ${name}`);
|
|
161
|
+
}
|
|
162
|
+
const actual = (await sha256File(archivePath)).toLowerCase();
|
|
163
|
+
if (actual !== expected) {
|
|
164
|
+
throw new SandboxdDownloadError(`Checksum mismatch for ${name} (expected ${expected}, got ${actual})`);
|
|
165
|
+
}
|
|
166
|
+
// Verify the archive contains exactly the expected binary before extracting,
|
|
167
|
+
// guarding against path traversal / unexpected entries from a tampered CDN.
|
|
168
|
+
const entries = await listTarGz(archivePath);
|
|
169
|
+
if (entries.length !== 1 || entries[0] !== BINARY_NAME) {
|
|
170
|
+
throw new SandboxdDownloadError(`Unexpected archive contents for ${name}: ${entries.join(", ") || "(empty)"}`);
|
|
171
|
+
}
|
|
172
|
+
// Extract the single binary from the archive.
|
|
173
|
+
await extractTarGz(archivePath, tempDir);
|
|
174
|
+
const extractedBinary = join(tempDir, BINARY_NAME);
|
|
175
|
+
if (!(await isExecutableFile(extractedBinary))) {
|
|
176
|
+
throw new SandboxdDownloadError(`Archive ${name} did not contain ${BINARY_NAME}`);
|
|
177
|
+
}
|
|
178
|
+
// Atomically move into the version cache (fall back to copy across devices).
|
|
179
|
+
const dest = cachedBinaryPath(version);
|
|
180
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
181
|
+
await rename(extractedBinary, dest).catch(async (err) => {
|
|
182
|
+
if (err.code !== "EXDEV")
|
|
183
|
+
throw err;
|
|
184
|
+
await copyFile(extractedBinary, dest);
|
|
185
|
+
});
|
|
186
|
+
await chmod(dest, 0o755);
|
|
187
|
+
return dest;
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
// Resolve a runnable sandboxd binary path, downloading (once, cached) if needed.
|
|
194
|
+
// COHUB_SANDBOXD_BIN always takes precedence for local development and offline use.
|
|
195
|
+
export const ensureSandboxdBinary = async (options = {}) => {
|
|
196
|
+
const override = process.env.COHUB_SANDBOXD_BIN?.trim();
|
|
197
|
+
if (override) {
|
|
198
|
+
if (!(await isExecutableFile(override))) {
|
|
199
|
+
throw new SandboxdDownloadError(`COHUB_SANDBOXD_BIN=${override} is not a file`);
|
|
200
|
+
}
|
|
201
|
+
return override;
|
|
202
|
+
}
|
|
203
|
+
const version = options.version?.trim() || SANDBOXD_VERSION;
|
|
204
|
+
const cached = cachedBinaryPath(version);
|
|
205
|
+
if (await isExecutableFile(cached))
|
|
206
|
+
return cached;
|
|
207
|
+
const target = resolveTarget();
|
|
208
|
+
return withLock(version, async () => {
|
|
209
|
+
// Re-check inside the lock: another process may have finished downloading.
|
|
210
|
+
if (await isExecutableFile(cached))
|
|
211
|
+
return cached;
|
|
212
|
+
options.onStatus?.(`Downloading sandbox runtime ${version} (${target.goos}/${target.goarch})`);
|
|
213
|
+
const path = await downloadAndVerify(version, target);
|
|
214
|
+
options.onStatus?.("Sandbox runtime ready");
|
|
215
|
+
return path;
|
|
216
|
+
});
|
|
217
|
+
};
|
package/dist/commands/spaces.js
CHANGED
|
@@ -678,6 +678,28 @@ function registerLabels(spacesCmd) {
|
|
|
678
678
|
handleHttp(e);
|
|
679
679
|
}
|
|
680
680
|
});
|
|
681
|
+
labelsCmd
|
|
682
|
+
.command("patch <resourceType> <resourceRef>")
|
|
683
|
+
.description("Patch resource labels")
|
|
684
|
+
.option("--add <refs>", "Comma-separated label refs to add")
|
|
685
|
+
.option("--remove <refs>", "Comma-separated label refs to remove")
|
|
686
|
+
.option("--json", "Output as JSON")
|
|
687
|
+
.action(async (resourceType, resourceRef, opts) => {
|
|
688
|
+
const spaceId = resolveSpace(spacesCmd);
|
|
689
|
+
const client = createClient();
|
|
690
|
+
try {
|
|
691
|
+
const result = await client.space(spaceId).labels.patchResourceLabels(parseLabelResourceType(resourceType), resourceRef, {
|
|
692
|
+
addLabelRefs: parseLabelRefs(opts.add),
|
|
693
|
+
removeLabelRefs: parseLabelRefs(opts.remove),
|
|
694
|
+
});
|
|
695
|
+
if (jsonRequested(opts))
|
|
696
|
+
return outJson(result);
|
|
697
|
+
ok("Resource labels patched");
|
|
698
|
+
}
|
|
699
|
+
catch (e) {
|
|
700
|
+
handleHttp(e);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
681
703
|
labelsCmd
|
|
682
704
|
.command("set <resourceType> <resourceRef> [labelRefs...]")
|
|
683
705
|
.description("Set resource labels")
|
package/dist/index.js
CHANGED
|
@@ -9,8 +9,10 @@ import { registerMe } from "./commands/me.js";
|
|
|
9
9
|
import { registerModels } from "./commands/models.js";
|
|
10
10
|
import { registerProfile } from "./commands/profile.js";
|
|
11
11
|
import { registerSearch } from "./commands/search.js";
|
|
12
|
+
import { registerReferences } from "./commands/references.js";
|
|
12
13
|
import { registerPrompt, registerSpaces } from "./commands/spaces.js";
|
|
13
14
|
import { maybeHandleRunCommand } from "./commands/run.js";
|
|
15
|
+
import { registerSandbox } from "./commands/sandbox.js";
|
|
14
16
|
import { registerTasks } from "./commands/tasks.js";
|
|
15
17
|
import { registerWorks } from "./commands/works.js";
|
|
16
18
|
import { ensureCliSelfUpdated } from "./self-update.js";
|
|
@@ -40,6 +42,7 @@ Common commands:
|
|
|
40
42
|
cohub spaces ls
|
|
41
43
|
cohub -s <space-id> prompt "Fix the failing tests"
|
|
42
44
|
cohub -s <space-id> run -- git status
|
|
45
|
+
cohub sandbox up ./my-project
|
|
43
46
|
cohub search "release notes"
|
|
44
47
|
cohub -s <space-id> spaces sessions turns ls <session-id>
|
|
45
48
|
cohub -s <space-id> spaces files ls
|
|
@@ -58,10 +61,12 @@ registerProfile(program);
|
|
|
58
61
|
registerMe(program);
|
|
59
62
|
registerPrompt(program);
|
|
60
63
|
registerSpaces(program);
|
|
64
|
+
registerSandbox(program);
|
|
61
65
|
registerChannels(program);
|
|
62
66
|
registerGenerations(program);
|
|
63
67
|
registerModels(program);
|
|
64
68
|
registerSearch(program);
|
|
69
|
+
registerReferences(program);
|
|
65
70
|
registerTasks(program);
|
|
66
71
|
registerCronJobs(program);
|
|
67
72
|
registerWorks(program);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub-cli",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
4
4
|
"description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@neta-art/generation": "^0.1.10",
|
|
17
17
|
"commander": "^14.0.3",
|
|
18
18
|
"sharp": "^0.34.5",
|
|
19
|
-
"@neta-art/cohub": "2.
|
|
19
|
+
"@neta-art/cohub": "2.3.0"
|
|
20
20
|
},
|
|
21
21
|
"publishConfig": {
|
|
22
22
|
"access": "public"
|