@neta-art/cohub-cli 6.12.0 → 7.0.1

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.
@@ -1,175 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { stat } from "node:fs/promises";
3
- import { createInterface } from "node:readline";
4
- import { basename, 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, handleHttp, 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.live/ws -> wss://gateway.cohub.live/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.live" : "https://dev.cohub.live";
31
- export const resolveLocalSpaceName = (rootDir, requestedName) => requestedName?.trim() || basename(rootDir) || "local-space";
32
- // Consent copy is deliberately explicit: a local sandbox runs agent-issued
33
- // shell commands as the current OS user. File RPCs are fenced to the folder,
34
- // but shell commands are NOT — they can read/write anything the user can
35
- // (SSH keys, other repos, etc). This mirrors the trust model of running an
36
- // AI coding agent locally and must be surfaced clearly before starting.
37
- const consentMessage = (rootDir, target) => [
38
- `Start ${target} exposing ${rootDir}?`,
39
- "",
40
- "Agents in this space will be able to:",
41
- ` • read and write files under ${rootDir}`,
42
- " • run shell commands as your user (full access to your machine, not just this folder)",
43
- "",
44
- "Only continue if you trust this space's collaborators.",
45
- ].join("\n");
46
- export function registerSandbox(program) {
47
- const cmd = program.command("sandbox").description("Run a local folder as a space sandbox");
48
- // ── sandbox up ──
49
- cmd
50
- .command("up [dir]")
51
- .description("Expose a local folder to a space as its sandbox (foreground; Ctrl-C to stop)")
52
- .option("-s, --space <id>", "Bind to an existing space instead of creating one")
53
- .option("-n, --name <name>", "Name for the newly created space")
54
- .option("-y, --yes", "Skip the confirmation prompt")
55
- .option("--json", "Output as JSON")
56
- .action(async (dir, opts) => {
57
- const rootDir = resolve(dir ?? process.cwd());
58
- const info = await stat(rootDir).catch(() => null);
59
- if (!info?.isDirectory()) {
60
- return error("Invalid directory", `${rootDir} is not a directory`);
61
- }
62
- // A single spinner: first status starts it, later statuses only update the
63
- // label (calling start twice would leak the previous interval).
64
- const spin = spinner();
65
- let spinnerStarted = false;
66
- let binary;
67
- try {
68
- binary = await ensureSandboxdBinary({
69
- onStatus: (msg) => {
70
- if (spinnerStarted) {
71
- spin.update(msg);
72
- }
73
- else {
74
- spin.start(msg);
75
- spinnerStarted = true;
76
- }
77
- },
78
- });
79
- if (spinnerStarted)
80
- spin.stop("");
81
- }
82
- catch (err) {
83
- if (spinnerStarted)
84
- spin.stop("");
85
- if (err instanceof SandboxdDownloadError)
86
- return error("Sandbox runtime unavailable", err.message);
87
- throw err;
88
- }
89
- const relayUrl = resolveRelayUrl();
90
- const token = await requireAccessToken();
91
- const client = createClient();
92
- // Resolve or create the target space.
93
- let spaceId = opts.space?.trim() || program.opts().space?.trim();
94
- if (!spaceId) {
95
- if (!opts.yes) {
96
- const proceed = await confirm(consentMessage(rootDir, "a new local space"));
97
- if (!proceed)
98
- return error("Aborted", "No space was created");
99
- }
100
- const created = await client.spaces.create({
101
- name: resolveLocalSpaceName(rootDir, opts.name),
102
- config: { sandbox: { provider: "local" } },
103
- });
104
- spaceId = created.space.id;
105
- ok(`Created local space ${spaceId}`);
106
- }
107
- else {
108
- // A local runner can only attach to a space whose sandbox provider is
109
- // "local" (provider is fixed at space creation). Fail early with a clear
110
- // message instead of letting the gateway reject the connection later.
111
- const existing = await client.space(spaceId).sandbox.get().catch(() => null);
112
- if (existing?.sandbox?.provider !== "local") {
113
- return error("Not a local space", `Space ${spaceId} is not configured for a local sandbox. Create one with 'cohub sandbox up' (without --space).`);
114
- }
115
- if (!opts.yes) {
116
- const proceed = await confirm(consentMessage(rootDir, `space ${spaceId}`));
117
- if (!proceed)
118
- return error("Aborted", "Sandbox was not started");
119
- }
120
- }
121
- const url = `${webBaseUrl()}/spaces/${spaceId}`;
122
- if (jsonRequested(opts)) {
123
- outJson({ spaceId, rootDir, relayUrl, url });
124
- }
125
- else {
126
- ok(`Sandbox ready — open ${url}`);
127
- console.log(` Serving: ${rootDir}`);
128
- console.log(" Press Ctrl-C to stop.\n");
129
- }
130
- // Spawn the runner in the foreground. It dials the gateway relay and
131
- // stays connected until the process is interrupted.
132
- const child = spawn(binary, ["--local", "--space", spaceId, "--root", rootDir, "--relay", relayUrl], {
133
- stdio: ["ignore", "inherit", "inherit"],
134
- env: { ...process.env, COHUB_RELAY_TOKEN: token },
135
- });
136
- const stop = () => child.kill("SIGTERM");
137
- process.on("SIGINT", stop);
138
- process.on("SIGTERM", stop);
139
- await new Promise((res) => {
140
- child.on("exit", (code, signal) => {
141
- if (signal)
142
- console.log(`\nSandbox stopped (${signal}).`);
143
- else if (code !== 0)
144
- console.error(`Sandbox exited with code ${code}.`);
145
- res();
146
- });
147
- child.on("error", (err) => error("Failed to start sandbox", err.message));
148
- });
149
- });
150
- // ── sandbox status ──
151
- cmd
152
- .command("status")
153
- .description("Show the current sandbox status for a space")
154
- .option("-s, --space <id>", "Target space ID")
155
- .option("--json", "Output as JSON")
156
- .action(async (opts) => {
157
- const spaceId = opts.space?.trim() || await resolveSpace(program);
158
- const client = createClient();
159
- try {
160
- const sandbox = (await client.space(spaceId).sandbox.get()).sandbox ?? null;
161
- if (jsonRequested(opts))
162
- return outJson({ spaceId, sandbox });
163
- if (!sandbox) {
164
- console.log(" (no sandbox)");
165
- return;
166
- }
167
- console.log(` space: ${spaceId}`);
168
- console.log(` provider: ${sandbox.provider ?? "cloud"}`);
169
- console.log(` status: ${sandbox.status ?? "unknown"}`);
170
- }
171
- catch (cause) {
172
- handleHttp(cause);
173
- }
174
- });
175
- }