@abuaboud/box 0.7.7

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/cbox.mjs ADDED
@@ -0,0 +1,1386 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { styleText as styleText5 } from "node:util";
5
+ import { Command } from "commander";
6
+ import { input as input2, select as select4 } from "@inquirer/prompts";
7
+
8
+ // src/api.ts
9
+ import { spawn } from "node:child_process";
10
+ import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
11
+ import { join as join2 } from "node:path";
12
+ import { styleText as styleText2 } from "node:util";
13
+
14
+ // src/laptop.ts
15
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
16
+ import { gzip } from "node:zlib";
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
18
+ import { homedir as homedir2, userInfo } from "node:os";
19
+ import { join, sep } from "node:path";
20
+ import ssh2 from "ssh2";
21
+
22
+ // src/ui.ts
23
+ import { homedir } from "node:os";
24
+ import { styleText } from "node:util";
25
+ var dim = (s) => styleText("dim", s);
26
+ var ok = (s) => console.log(`${styleText("green", "\u2713")} ${s}`);
27
+ var miss = (s) => console.log(`${styleText("yellow", "!")} ${s}`);
28
+ var fail = (s) => {
29
+ active?.();
30
+ console.error(`${styleText("red", "\u2717")} ${s}`);
31
+ process.exit(1);
32
+ };
33
+ var active;
34
+ var stopProgress = () => active?.();
35
+ function progress(steps, { pace = 5e3 } = {}) {
36
+ const tty = process.stdout.isTTY;
37
+ if (!tty) {
38
+ console.log(dim(`${steps[0][1]}\u2026`));
39
+ return Object.assign((done) => {
40
+ if (done) ok(done);
41
+ }, { say: (label2) => console.log(dim(`${label2}\u2026`)) });
42
+ }
43
+ const spin = "\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F", t0 = Date.now(), width = 16;
44
+ let i = 0, said;
45
+ process.stdout.write("\x1B[?25l");
46
+ const restore = () => process.stdout.write("\x1B[?25h");
47
+ process.once("exit", restore);
48
+ const draw = () => {
49
+ const ms = Date.now() - t0;
50
+ const label2 = said ?? steps.filter(([at2]) => at2 <= ms).at(-1)[1];
51
+ const at = Math.min(width - 1, Math.floor((1 - Math.exp(-ms / pace)) * width));
52
+ const track = [...Array(width)].map((_, k) => k < at ? styleText("cyan", "\u2501") : k === at ? "\u{1F4E6}" : styleText("dim", "\u2500")).join("");
53
+ const secs = ms / 1e3, clock = secs < 60 ? `${secs.toFixed(1)}s` : `${Math.floor(secs / 60)}m ${String(Math.floor(secs % 60)).padStart(2, "0")}s`;
54
+ process.stdout.write(`\r\x1B[2K${styleText("cyan", spin[i++ % spin.length])} ${track} ${label2.slice(0, Math.max(20, (process.stdout.columns ?? 100) - width - 16))}\u2026 ${dim(clock)}`);
55
+ };
56
+ draw();
57
+ const timer = setInterval(draw, 80);
58
+ const stop = (done) => {
59
+ clearInterval(timer);
60
+ process.stdout.write("\r\x1B[2K");
61
+ restore();
62
+ active = void 0;
63
+ if (done) ok(done);
64
+ };
65
+ active = stop;
66
+ return Object.assign(stop, { say: (label2) => {
67
+ said = label2;
68
+ } });
69
+ }
70
+ var span = (ms) => {
71
+ const m = Math.max(0, Math.round(ms / 6e4));
72
+ return m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${Math.round(m / 1440)} days`;
73
+ };
74
+ var tilde = (p) => p.startsWith(homedir()) ? `~${p.slice(homedir().length)}` : p;
75
+ function label(b) {
76
+ if (b.state === "paused") return styleText("blue", b.asleepSince ? `asleep ${span(Date.now() - new Date(b.asleepSince).getTime())}` : "asleep");
77
+ if (b.state !== "running") return styleText("yellow", b.state);
78
+ if (b.busy) return styleText("green", "\u25CF working");
79
+ if (!b.idleMinutes) return styleText("green", "awake");
80
+ const left = b.idleMinutes * 6e4 - (Date.now() - new Date(b.lastActiveAt ?? Date.now()).getTime());
81
+ return styleText("yellow", `\u263E sleeps in ${span(Math.max(6e4, left))}`);
82
+ }
83
+
84
+ // src/laptop.ts
85
+ var OLD_CONFIG = join(homedir2(), ".config", "craftboxes");
86
+ var OLD_BOXES = join(homedir2(), ".craftboxes");
87
+ var NEW_CONFIG = join(homedir2(), ".config", "box");
88
+ var NEW_BOXES = join(homedir2(), ".box");
89
+ var CONFIG_DIR = existsSync(OLD_CONFIG) ? OLD_CONFIG : NEW_CONFIG;
90
+ var BOXES = existsSync(OLD_BOXES) ? OLD_BOXES : NEW_BOXES;
91
+ var SERVICES = { 5432: "Postgres", 6379: "Redis", 3306: "MySQL", 27017: "MongoDB", 8123: "ClickHouse" };
92
+ function moveHome() {
93
+ const moves = [[OLD_CONFIG, NEW_CONFIG], [OLD_BOXES, NEW_BOXES]].filter(([from]) => existsSync(from));
94
+ if (!moves.length) return;
95
+ if (otherBoxRunning()) return miss(`Close your other box windows so box can move ~/.craftboxes to ~/.box`);
96
+ const clash = moves.flatMap(([from, to]) => existsSync(to) ? readdirSync(from).filter((f) => existsSync(join(to, f))).map((f) => join(to, f)) : []);
97
+ if (clash.length) return miss(`box can't move ~/.craftboxes to ~/.box while ${clash.join(", ")} exists`);
98
+ try {
99
+ for (const [from, to] of moves) {
100
+ if (!existsSync(to)) {
101
+ renameSync(from, to);
102
+ continue;
103
+ }
104
+ for (const f of readdirSync(from)) renameSync(join(from, f), join(to, f));
105
+ rmdirSync(from);
106
+ }
107
+ } catch (e) {
108
+ return miss(`Couldn't move ~/.craftboxes to ~/.box yet: ${e.message}`);
109
+ }
110
+ if (moves.some(([from]) => from === OLD_BOXES)) {
111
+ repairWorktrees();
112
+ moveClaudeProjects();
113
+ }
114
+ ok(`Moved ~/.craftboxes to ~/.box ${dim("\xB7 reopen VS Code windows with box code")}`);
115
+ const again = spawnSync(process.execPath, process.argv.slice(1), { stdio: "inherit", env: { ...process.env, CBOX_UPDATED: "1" } });
116
+ process.exit(again.status ?? 0);
117
+ }
118
+ function otherBoxRunning() {
119
+ try {
120
+ return execFileSync("ps", ["-A", "-o", "pid=,args="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 }).split("\n").some((line) => {
121
+ const [pid, ...args] = line.trim().split(/\s+/);
122
+ const command = args.join(" ");
123
+ return ![process.pid, process.ppid].includes(Number(pid)) && /(^|[\s/])c?box(\.mjs)?(\s|$)/.test(command) && !/\s(proxy|sync)(\s|$)/.test(command);
124
+ });
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+ function repairWorktrees() {
130
+ for (const env of readdirSync(NEW_BOXES)) {
131
+ const clone = join(NEW_BOXES, env, ".clone");
132
+ if (env.startsWith(".") || !existsSync(join(clone, ".git"))) continue;
133
+ const worktrees = readdirSync(join(NEW_BOXES, env)).map((d) => join(NEW_BOXES, env, d, "repo")).filter((r) => existsSync(join(r, ".git")) && statSync(join(r, ".git")).isFile());
134
+ try {
135
+ if (worktrees.length) execFileSync("git", ["-C", clone, "worktree", "repair", ...worktrees], { stdio: "ignore", timeout: 3e4 });
136
+ } catch {
137
+ miss(`Couldn't repair the ${env} worktrees. Run: git -C ${clone} worktree repair`);
138
+ }
139
+ }
140
+ }
141
+ function moveClaudeProjects() {
142
+ const projects = join(homedir2(), ".claude", "projects");
143
+ const [from, to] = [OLD_BOXES, NEW_BOXES].map((p) => p.replace(/[^a-zA-Z0-9]/g, "-"));
144
+ for (const dir of existsSync(projects) ? readdirSync(projects) : []) {
145
+ const target = join(projects, to + dir.slice(from.length));
146
+ if ((dir === from || dir.startsWith(`${from}-`)) && !existsSync(target)) renameSync(join(projects, dir), target);
147
+ }
148
+ const file2 = join(homedir2(), ".claude.json");
149
+ try {
150
+ const config = JSON.parse(readFileSync(file2, "utf8"));
151
+ const keys = Object.keys(config.projects ?? {});
152
+ if (!keys.some((k) => k.startsWith(OLD_BOXES + sep))) return;
153
+ config.projects = Object.fromEntries(Object.entries(config.projects).map(([k, v]) => [k.startsWith(OLD_BOXES + sep) ? NEW_BOXES + k.slice(OLD_BOXES.length) : k, v]));
154
+ writeFileSync(file2, JSON.stringify(config, null, 2));
155
+ } catch {
156
+ }
157
+ }
158
+ function localMcp() {
159
+ const claudeJson = join(homedir2(), ".claude.json");
160
+ const servers = existsSync(claudeJson) ? JSON.parse(readFileSync(claudeJson, "utf8")).mcpServers ?? {} : {};
161
+ const skipped = [];
162
+ const mcpServers = {};
163
+ for (const [name, s] of Object.entries(servers)) {
164
+ if (s.command && /^(\/|\.|~)/.test(s.command)) skipped.push(name);
165
+ else mcpServers[name] = s;
166
+ }
167
+ const mcpOAuth = Object.fromEntries(Object.entries(localCreds().mcpOAuth ?? {}).map(([k, v]) => {
168
+ const { refreshToken, ...rest } = v;
169
+ void refreshToken;
170
+ return [k, rest];
171
+ }));
172
+ return { mcpServers, mcpOAuth, skipped };
173
+ }
174
+ function localClaudeHome() {
175
+ const root = join(homedir2(), ".claude");
176
+ const json = (p) => {
177
+ try {
178
+ return JSON.parse(readFileSync(join(root, p), "utf8"));
179
+ } catch {
180
+ return {};
181
+ }
182
+ };
183
+ const settings = json("settings.json");
184
+ const enabled = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
185
+ const installed = json("plugins/installed_plugins.json").plugins ?? {};
186
+ const known = json("plugins/known_marketplaces.json");
187
+ const plugins = {}, marketplaces = {};
188
+ for (const id of enabled) {
189
+ const mine = (installed[id] ?? []).filter((e) => e.scope === "user" && e.installPath?.startsWith(root));
190
+ const m = known[id.split("@")[1]];
191
+ if (!mine.length || !m?.installLocation?.startsWith(root)) continue;
192
+ plugins[id] = mine;
193
+ marketplaces[id.split("@")[1]] = m;
194
+ }
195
+ const dirs = ["skills", "agents", "commands", "output-styles"].filter((d) => existsSync(join(root, d)));
196
+ const paths = [...dirs, ...Object.values(plugins).flat().map((e) => e.installPath), ...Object.values(marketplaces).map((m) => m.installLocation)].map((p) => p.startsWith(root) ? p.slice(root.length + 1) : p);
197
+ const tar = !paths.length ? Promise.resolve(void 0) : new Promise((done) => execFile("tar", ["-chf", "-", "--no-xattrs", "--exclude=.git", "--exclude=node_modules", "--exclude=.DS_Store", "-C", root, ...new Set(paths)], { encoding: "buffer", maxBuffer: 256 << 20, env: { ...process.env, COPYFILE_DISABLE: "1" } }, (err, out) => err ? done(void 0) : gzip(out, (e, gz) => done(e ? void 0 : gz))));
198
+ const memory = existsSync(join(root, "CLAUDE.md")) ? readFileSync(join(root, "CLAUDE.md"), "utf8") : "";
199
+ return { from: root, pending: paths.length > 0, tar, plugins, marketplaces, enabled: Object.keys(plugins), memory, skills: existsSync(join(root, "skills")) ? readdirSync(join(root, "skills")).filter((f) => !f.startsWith(".")).length : 0 };
200
+ }
201
+ function laptopKey() {
202
+ const priv = join(CONFIG_DIR, "id_ed25519");
203
+ if (!existsSync(priv)) {
204
+ const k = ssh2.utils.generateKeyPairSync("ed25519", { comment: "box" });
205
+ mkdirSync(CONFIG_DIR, { recursive: true });
206
+ writeFileSync(priv, k.private, { mode: 384 });
207
+ writeFileSync(priv + ".pub", k.public.trim() + "\n");
208
+ }
209
+ return { privateKey: readFileSync(priv), publicKey: readFileSync(priv + ".pub", "utf8").trim() };
210
+ }
211
+ function sshKeys(boxKey) {
212
+ const home2 = join(homedir2(), ".ssh");
213
+ const mine = existsSync(home2) ? readdirSync(home2).filter((f) => f.endsWith(".pub")).map((f) => readFileSync(join(home2, f), "utf8").trim().split(/\s+/).slice(0, 2).join(" ")) : [];
214
+ return [boxKey, ...mine.filter((k) => /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) [A-Za-z0-9+/=]+$/.test(k))].join("\n");
215
+ }
216
+ function localCreds() {
217
+ const file2 = join(homedir2(), ".claude", ".credentials.json");
218
+ if (process.platform !== "darwin") return existsSync(file2) ? JSON.parse(readFileSync(file2, "utf8")) : {};
219
+ for (const account of [["-a", userInfo().username], []]) {
220
+ try {
221
+ return JSON.parse(execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", ...account, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }));
222
+ } catch {
223
+ }
224
+ }
225
+ return {};
226
+ }
227
+ function localGitHubToken() {
228
+ const out = (cmd, args, input3) => {
229
+ try {
230
+ return execFileSync(cmd, args, { encoding: "utf8", input: input3, stdio: [input3 ? "pipe" : "ignore", "pipe", "ignore"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).trim() || void 0;
231
+ } catch {
232
+ return void 0;
233
+ }
234
+ };
235
+ return out("gh", ["auth", "token", "--hostname", "github.com"]) ?? out("git", ["credential", "fill"], "protocol=https\nhost=github.com\n\n")?.match(/^password=(.+)$/m)?.[1];
236
+ }
237
+ function gitRepo() {
238
+ const git2 = (...args) => {
239
+ try {
240
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 }).trim();
241
+ } catch {
242
+ return "";
243
+ }
244
+ };
245
+ const dir = git2("rev-parse", "--show-toplevel");
246
+ if (!dir) return void 0;
247
+ const url = git2("remote", "get-url", "origin") || git2("remote", "get-url", "upstream");
248
+ const m = /github\.com[:/]+([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url);
249
+ if (!m) return void 0;
250
+ const branch = git2("branch", "--show-current");
251
+ const pushed = branch && git2("rev-parse", "--abbrev-ref", `${branch}@{upstream}`);
252
+ const defaultBranch = git2("symbolic-ref", "--short", "refs/remotes/origin/HEAD").replace(/^origin\//, "");
253
+ return { dir, repo: `${m[1]}/${m[2]}`, branch: pushed && branch !== defaultBranch ? branch : void 0 };
254
+ }
255
+ function localCopy() {
256
+ const git2 = (...args) => {
257
+ try {
258
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3, maxBuffer: 16 << 20 }).trim();
259
+ } catch {
260
+ return "";
261
+ }
262
+ };
263
+ const top = git2("rev-parse", "--show-toplevel");
264
+ if (!top) return void 0;
265
+ const repo = /github\.com[:/]+([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(git2("-C", top, "remote", "get-url", "origin"))?.[1];
266
+ const files = git2("-C", top, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory").split("\n").filter((f) => /(^|\/)(\.env[^/]*|[^/]+\.local|docker-compose\.override\.ya?ml)$/.test(f) && !/(^|\/)(node_modules|\.claude)\//.test(f)).slice(0, 8);
267
+ let listening = "";
268
+ try {
269
+ listening = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 });
270
+ } catch {
271
+ }
272
+ const ports = new Set([...listening.matchAll(/:(\d+) \(LISTEN\)/g)].map((m) => Number(m[1])));
273
+ const services = Object.entries(SERVICES).filter(([port]) => ports.has(Number(port))).map(([port, name]) => `${name} :${port}`);
274
+ return { dir: top, repo, branch: git2("-C", top, "branch", "--show-current") || void 0, files, services };
275
+ }
276
+
277
+ // src/api.ts
278
+ var file = join2(CONFIG_DIR, "config.json");
279
+ var load = () => {
280
+ if (!existsSync2(file)) return { server: process.env.CB_SERVER ?? "https://box.abuaboud.me" };
281
+ const c = JSON.parse(readFileSync2(file, "utf8"));
282
+ return { ...c, server: c.server.replace("://craftboxes.abuaboud.me", "://box.abuaboud.me") };
283
+ };
284
+ var save = (c) => {
285
+ mkdirSync2(CONFIG_DIR, { recursive: true });
286
+ writeFileSync2(file, JSON.stringify(c, null, 2));
287
+ chmodSync(file, 384);
288
+ };
289
+ async function api(path, init = {}, retried = false) {
290
+ if (!load().token) await login();
291
+ const c = load();
292
+ const res = await fetch(new URL(path, c.server), { ...init, headers: { authorization: `Bearer ${c.token}`, ...init.body ? { "content-type": "application/json" } : {} } });
293
+ const body = await res.json().catch(() => ({}));
294
+ if (res.status === 401 && !retried) {
295
+ save({ ...c, token: void 0 });
296
+ return api(path, init, true);
297
+ }
298
+ if (!res.ok) fail(body.error ?? `Request failed (${res.status})`);
299
+ return body;
300
+ }
301
+ async function login(server) {
302
+ stopProgress();
303
+ const c = { ...load(), ...server && { server } };
304
+ const post = (path, body) => fetch(new URL(path, c.server), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((r) => r.json());
305
+ const code2 = await post("/api/auth/device/code", { client_id: "cb" });
306
+ if (!code2.device_code) fail(`The server refused: ${code2.error_description ?? code2.message ?? "no device code"}`);
307
+ console.log(`Open ${styleText2("underline", code2.verification_uri_complete)} and approve ${styleText2("bold", code2.user_code)}`);
308
+ if (process.stdout.isTTY) spawn(process.platform === "darwin" ? "open" : "xdg-open", [code2.verification_uri_complete], { stdio: "ignore", detached: true }).on("error", () => {
309
+ }).unref();
310
+ for (let wait = code2.interval ?? 5; ; ) {
311
+ await new Promise((r) => setTimeout(r, wait * 1e3));
312
+ const t = await post("/api/auth/device/token", { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: code2.device_code, client_id: "cb" });
313
+ if (t.access_token) {
314
+ c.token = t.access_token;
315
+ break;
316
+ }
317
+ if (t.error === "slow_down") wait += 5;
318
+ else if (t.error !== "authorization_pending") fail(t.error_description ?? t.error);
319
+ }
320
+ save(c);
321
+ const me = await fetch(new URL("/api/me", c.server), { headers: { authorization: `Bearer ${c.token}` } }).then((r) => r.json());
322
+ save({ ...load(), email: me.user.email });
323
+ ok(`Signed in as ${me.user.email} ${dim(`\xB7 ${me.org.name}`)}`);
324
+ console.log();
325
+ }
326
+ async function findBox(name) {
327
+ const box = (await api("/api/boxes")).find((b) => b.name === name || b.id === name);
328
+ return box ?? fail(`No box named ${name}. box ls lists them.`);
329
+ }
330
+
331
+ // src/environments.ts
332
+ import { execFileSync as execFileSync2, spawn as spawn2 } from "node:child_process";
333
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
334
+ import { join as join3 } from "node:path";
335
+ import { styleText as styleText3 } from "node:util";
336
+ import { input, select } from "@inquirer/prompts";
337
+ async function chooseEnvironment(envName, here) {
338
+ const envs = await api("/api/environments");
339
+ if (envName) return waitReady(envs.find((e) => e.name === envName) ?? fail(`No environment named ${envName}.`));
340
+ const mine = here ? envs.filter((e) => e.repo.toLowerCase() === here.repo.toLowerCase()) : [];
341
+ const setUp = async () => setUpEnvironment(here?.repo ?? await askRepo(), here?.dir, envs);
342
+ if (!envs.length) return waitReady(await setUp());
343
+ if (!process.stdin.isTTY && mine.length === 1) return waitReady(mine[0]);
344
+ const label2 = (e) => `${e.name.padEnd(16)}${dim(`${mine.includes(e) ? "this repo" : e.repo} \xB7 ${e.size ?? "small"}${e.status === "ready" ? "" : ` \xB7 ${e.status}`}`)}`;
345
+ const create = { name: styleText3("cyan", here ? `+ Set up a new environment for ${here.repo}` : "+ Set up a new environment"), value: "" };
346
+ const pick = await select({
347
+ message: here ? `Environment for ${here.repo}${here.branch ? dim(` \xB7 branch ${here.branch}`) : ""}` : `Environment ${dim("(you're not in a repo)")}`,
348
+ choices: [...mine.map((e) => ({ name: label2(e), value: e.name })), ...here && !mine.length ? [create] : [], ...envs.filter((e) => !mine.includes(e)).map((e) => ({ name: label2(e), value: e.name })), ...here && !mine.length ? [] : [create]]
349
+ });
350
+ return waitReady(pick ? envs.find((e) => e.name === pick) : await setUp());
351
+ }
352
+ var askRepo = () => input({ message: "GitHub repo (owner/name)", validate: (v) => /^[\w.-]+\/[\w.-]+$/.test(v.trim()) || "Write it as owner/name" }).then((v) => v.trim());
353
+ async function waitReady(env) {
354
+ if (env.status === "ready") return env;
355
+ if (env.status === "failed") fail(`The ${env.name} environment failed to build: ${(env.error ?? "").split("\n")[0]}. Rebuild it from the dashboard.`);
356
+ const t0 = Date.now();
357
+ const bar = progress([[0, env.progress ?? "Building the snapshot"]], { pace: 9e4 });
358
+ for (; ; ) {
359
+ await new Promise((r) => setTimeout(r, 2e3));
360
+ const now = await api(`/api/environments/${env.id}`);
361
+ if (now.progress) bar.say(now.progress);
362
+ if (now.status === "ready") {
363
+ const secs = Math.round((Date.now() - t0) / 1e3);
364
+ bar(`Snapshot ready ${dim(`${secs >= 60 ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, "0")}s` : `${secs}s`} \xB7 next boxes start in about 2s`)}`);
365
+ return now;
366
+ }
367
+ if (now.status === "failed") {
368
+ bar();
369
+ fail(`The build failed: ${(now.error ?? "").trim().split("\n").slice(-3).join(" ").slice(0, 300)}`);
370
+ }
371
+ }
372
+ }
373
+ function detectSetup(dir) {
374
+ if (!dir) return void 0;
375
+ const has = (f) => existsSync3(join3(dir, f));
376
+ let scripts = {};
377
+ try {
378
+ scripts = JSON.parse(readFileSync3(join3(dir, "package.json"), "utf8")).scripts ?? {};
379
+ } catch {
380
+ }
381
+ const pm = has("bun.lock") || has("bun.lockb") ? "bun" : has("pnpm-lock.yaml") ? "pnpm" : has("yarn.lock") ? "yarn" : has("package-lock.json") ? "npm" : void 0;
382
+ const own = ["setup", "bootstrap"].find((k) => scripts[k]);
383
+ if (pm && own) return { setup: `${pm} run ${own}`, from: `the ${own} script in package.json` };
384
+ const byLock = [
385
+ ["bun.lock", "bun install"],
386
+ ["bun.lockb", "bun install"],
387
+ ["pnpm-lock.yaml", "pnpm install --frozen-lockfile"],
388
+ ["yarn.lock", "yarn install --immutable"],
389
+ ["package-lock.json", "npm ci"],
390
+ ["uv.lock", "uv sync"],
391
+ ["requirements.txt", "pip install -r requirements.txt"],
392
+ ["go.mod", "go mod download"],
393
+ ["Cargo.lock", "cargo fetch"],
394
+ ["Gemfile.lock", "bundle install"]
395
+ ];
396
+ const hit = byLock.find(([f]) => has(f));
397
+ return hit && { setup: hit[1], from: hit[0] };
398
+ }
399
+ function detectSize(dir) {
400
+ if (!dir) return { size: "small", why: "the default" };
401
+ let files = 0, kb = 0;
402
+ try {
403
+ files = Number(execFileSync2("sh", ["-c", "git ls-files | wc -l"], { cwd: dir, encoding: "utf8", timeout: 5e3 }).trim());
404
+ } catch {
405
+ }
406
+ try {
407
+ kb = Number(execFileSync2("du", ["-sk", ".git"], { cwd: dir, encoding: "utf8", timeout: 5e3 }).split(" ")[0]);
408
+ } catch {
409
+ }
410
+ const why = `${files.toLocaleString()} files, ${(kb / 1024 / 1024).toFixed(1)} GB of history`;
411
+ return files > 2e4 || kb > 1024 * 1024 ? { size: "medium", why } : { size: "small", why };
412
+ }
413
+ function secretNames(dir) {
414
+ for (const f of [".env.example", ".env.sample", ".env.template"]) {
415
+ try {
416
+ return readFileSync3(join3(dir ?? "", f), "utf8").split("\n").map((l) => /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/.exec(l)?.[1]).filter((k) => !!k);
417
+ } catch {
418
+ }
419
+ }
420
+ return [];
421
+ }
422
+ async function setUpEnvironment(repo, dir, envs) {
423
+ miss(`${repo} ${dim("has no environment yet. Setting one up:")}`);
424
+ const detected = detectSetup(dir), sized = detectSize(dir), names = secretNames(dir);
425
+ let name = repo.split("/")[1].toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").slice(0, 28) || "env";
426
+ for (let i = 2, base = name; envs.some((e) => e.name === name); i++) name = `${base}-${i}`;
427
+ let setup = detected?.setup ?? "", size = sized.size, secrets = "";
428
+ const sizes = { small: "2 vCPU, 4 GB", medium: "4 vCPU, 8 GB", large: "8 vCPU, 16 GB" };
429
+ for (; ; ) {
430
+ console.log();
431
+ console.log(` ${dim("Name ")} ${name}`);
432
+ console.log(` ${dim("Repo ")} ${repo}`);
433
+ console.log(` ${dim("Machine ")} ${size[0].toUpperCase() + size.slice(1)} ${dim(`\xB7 ${sizes[size]}${size === sized.size ? ` (${sized.why})` : ""}`)}`);
434
+ console.log(` ${dim("Setup ")} ${setup ? styleText3("cyan", setup) : dim("none")} ${dim(detected && setup === detected.setup ? `\xB7 from ${detected.from}` : setup ? "" : "\xB7 boxes start from a plain clone")}`);
435
+ if (names.length) console.log(` ${dim("Secrets ")} ${names.join(", ")} ${dim(secrets ? "\xB7 values copied from .env" : "\xB7 names from .env.example, values empty")}`);
436
+ console.log();
437
+ const choice = await select({ message: "Create it?", choices: [
438
+ { name: "Create and start a box", value: "go" },
439
+ ...names.length && !secrets && dir && existsSync3(join3(dir, ".env")) ? [{ name: "Copy the secret values from my .env first", value: "secrets" }] : [],
440
+ { name: "Change something", value: "edit" },
441
+ { name: "Cancel", value: "cancel" }
442
+ ] });
443
+ if (choice === "cancel") process.exit(0);
444
+ if (choice === "secrets") {
445
+ const values = Object.fromEntries(readFileSync3(join3(dir, ".env"), "utf8").split("\n").map((l) => /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(l)).filter((m) => !!m).map((m) => [m[1], m[2].replace(/^(['"])(.*)\1$/, "$2")]));
446
+ secrets = names.filter((n) => values[n] !== void 0).map((n) => `${n}=${values[n]}`).join("\n");
447
+ ok(`${secrets.split("\n").filter(Boolean).length} of ${names.length} secret values copied`);
448
+ continue;
449
+ }
450
+ if (choice === "edit") {
451
+ name = await input({ message: "Name", default: name, validate: (v) => /^[a-z0-9][a-z0-9-]{0,30}$/.test(v) || "Lowercase letters, digits and dashes" });
452
+ size = await select({ message: "Machine", default: size, choices: Object.entries(sizes).map(([value, spec]) => ({ name: `${value.padEnd(8)}${dim(spec)}`, value })) });
453
+ setup = (await input({ message: "Setup script (runs once in the snapshot; empty for none)", default: setup })).trim();
454
+ continue;
455
+ }
456
+ break;
457
+ }
458
+ await ensureGitHub(repo);
459
+ const { id } = await api("/api/environments", { method: "POST", body: JSON.stringify({ name, repo, size, setup, secrets }) });
460
+ ok(`Environment ${styleText3("cyan", name)} created`);
461
+ return { id, name, repo, status: "building", size, progress: "Starting a build machine" };
462
+ }
463
+ async function ensureGitHub(repo) {
464
+ const open = (url) => {
465
+ if (process.stdout.isTTY) spawn2(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore", detached: true }).on("error", () => {
466
+ }).unref();
467
+ };
468
+ const status = () => api("/api/github/repos");
469
+ let r = await status();
470
+ if (!r.linked) {
471
+ const url = new URL("/connect/github", load().server).toString();
472
+ console.log(`
473
+ ${styleText3("yellow", "!")} Connect GitHub so Box can clone ${repo} and your boxes can push as you.`);
474
+ console.log(dim(` Opening ${url}`));
475
+ open(url);
476
+ const bar = progress([[0, "Waiting for you to connect GitHub in the browser"]], { pace: 6e4 });
477
+ for (const end = Date.now() + 10 * 6e4; !r.linked; r = await status()) {
478
+ if (Date.now() > end) {
479
+ bar();
480
+ fail("Gave up waiting after 10 minutes. Run box again when it's done.");
481
+ }
482
+ await new Promise((resolve) => setTimeout(resolve, 2500));
483
+ }
484
+ bar();
485
+ ok("GitHub connected");
486
+ }
487
+ const isPublic = await fetch(`https://api.github.com/repos/${repo}`, { signal: AbortSignal.timeout(5e3) }).then((res) => res.ok ? res.json() : null).then((j) => j && !j.private).catch(() => false);
488
+ if (!isPublic && !r.repos.some((p) => p.fullName.toLowerCase() === repo.toLowerCase())) fail(`Your GitHub login can't reach ${repo}.`);
489
+ }
490
+
491
+ // src/local.ts
492
+ import { execFileSync as execFileSync3, spawn as spawn3 } from "node:child_process";
493
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync as rmSync2, statSync as statSync2, watch, writeFileSync as writeFileSync4 } from "node:fs";
494
+ import { homedir as homedir4 } from "node:os";
495
+ import { basename, dirname, join as join5 } from "node:path";
496
+ import { select as select2 } from "@inquirer/prompts";
497
+
498
+ // src/ssh.ts
499
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
500
+ import { connect } from "node:net";
501
+ import { homedir as homedir3 } from "node:os";
502
+ import { join as join4 } from "node:path";
503
+ var SELF = [process.execPath, process.argv[1]];
504
+ var selfCommand = (...args) => [...SELF, ...args].map((a) => `"${a}"`).join(" ");
505
+ function ensureSshConfig() {
506
+ const sshDir = join4(homedir3(), ".ssh");
507
+ mkdirSync3(sshDir, { recursive: true, mode: 448 });
508
+ laptopKey();
509
+ writeFileSync3(join4(sshDir, "box"), [
510
+ "Host *.box",
511
+ " User user",
512
+ ` IdentityFile "${join4(CONFIG_DIR, "id_ed25519")}"`,
513
+ ` ProxyCommand ${selfCommand("proxy", "%h")}`,
514
+ " StrictHostKeyChecking no",
515
+ " UserKnownHostsFile /dev/null",
516
+ " LogLevel ERROR",
517
+ " ServerAliveInterval 30",
518
+ ""
519
+ ].join("\n"));
520
+ const config = join4(sshDir, "config");
521
+ const now = existsSync4(config) ? readFileSync4(config, "utf8") : "";
522
+ const renamed = now.replace(/^Include ~\/\.ssh\/craftboxes$/m, "Include ~/.ssh/box");
523
+ const next = /^Include ~\/\.ssh\/box$/m.test(renamed) ? renamed : `Include ~/.ssh/box
524
+
525
+ ${renamed}`;
526
+ if (next !== now) writeFileSync3(config, next, { mode: 384 });
527
+ rmSync(join4(sshDir, "craftboxes"), { force: true });
528
+ }
529
+ async function proxy(host) {
530
+ const name = host.replace(/\.box$/, "");
531
+ const got = await api(`/api/boxes/${name}/connect`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(laptopKey().publicKey) }) });
532
+ const targets = [...got.ssh.hosts].reverse();
533
+ for (let attempt = 0; attempt < 20; attempt++) {
534
+ for (const [h, port] of targets) {
535
+ const sock = await new Promise((done) => {
536
+ const s = connect({ host: h, port, timeout: 5e3 });
537
+ s.once("connect", () => {
538
+ s.setTimeout(0);
539
+ done(s);
540
+ }).once("error", () => done(void 0)).once("timeout", () => {
541
+ s.destroy();
542
+ done(void 0);
543
+ });
544
+ });
545
+ if (!sock) continue;
546
+ process.stdin.pipe(sock);
547
+ sock.pipe(process.stdout);
548
+ sock.on("close", () => process.exit(0));
549
+ return;
550
+ }
551
+ await new Promise((r) => setTimeout(r, 1e3));
552
+ }
553
+ fail(`Couldn't reach ${name} over SSH.`);
554
+ }
555
+
556
+ // src/local.ts
557
+ async function newLocalBox(env, branch, name, o) {
558
+ const stop = progress([[0, "Finding a machine"], [900, `Unpacking the ${env} snapshot`], [2500, "Connecting GitHub"], [9e3, "Bigger machines take a few extra seconds"]]);
559
+ const box = await api("/api/boxes", { method: "POST", body: JSON.stringify({ env, branch, name, agent: "local", size: o.size, idleMinutes: o.alwaysOn ? 0 : void 0, sshKey: sshKeys(laptopKey().publicKey) }) }).finally(() => stop());
560
+ ok(`${box.name} is ready ${dim(`\xB7 ssh ${box.name}.box`)}`);
561
+ await openLocal({ name: box.name, env, here: localCopy(), branch, fresh: true });
562
+ }
563
+ var CLONE = ".clone";
564
+ var MIRROR_SKIP = [".git", "node_modules", "dist", "build", ".turbo", ".nx", ".next", ".cache", "coverage", ".DS_Store"];
565
+ function claudeMd(name, here) {
566
+ const lines = [
567
+ `# Machine: ${name}.box`,
568
+ `Run every command there with \`ssh ${name}.box '<command>'\`, never on this laptop. ./repo is a git worktree on this laptop and its files are mirrored to ~/repo on the machine: read and edit files in ./repo.`,
569
+ `Run git here, never on the machine: \`git -C repo status\`, commit and push from ./repo.`
570
+ ];
571
+ if (here) {
572
+ const where = tilde(here.dir);
573
+ lines.push("", `# The user's local copy: ${where}${here.repo ? ` (${here.repo}${here.branch ? `, ${here.branch}` : ""})` : ""}`);
574
+ const mine = here.files.map((f) => `${where}/${f}`);
575
+ if (mine.length) lines.push(`"My .env", "my data" or "my setup" mean their laptop: ${mine.join(", ")}.`);
576
+ if (here.services.length) lines.push(`Running on their laptop: ${here.services.join(", ")}.`);
577
+ }
578
+ lines.push("", "How to do anything else: @../../llms.txt");
579
+ return lines.join("\n") + "\n";
580
+ }
581
+ function boxFolder(env, name) {
582
+ return join5(BOXES, env, name.startsWith(`${env}-`) ? name.slice(env.length + 1) : name);
583
+ }
584
+ function localBoxes() {
585
+ if (!existsSync5(BOXES)) return [];
586
+ const found = [];
587
+ for (const top of readdirSync2(BOXES, { withFileTypes: true })) {
588
+ if (!top.isDirectory() || top.name.startsWith(".")) continue;
589
+ const env = top.name, envDir = join5(BOXES, env);
590
+ for (const entry of readdirSync2(envDir, { withFileTypes: true })) {
591
+ const file2 = join5(envDir, entry.name, ".box.json");
592
+ if (!entry.isDirectory() || !existsSync5(file2)) continue;
593
+ const state = JSON.parse(readFileSync5(file2, "utf8"));
594
+ if (state.name) found.push({ name: state.name, env, folder: join5(envDir, entry.name), owner: state.owner });
595
+ }
596
+ }
597
+ return found;
598
+ }
599
+ function prepareFolder(name, env, here, mcp) {
600
+ const folder = boxFolder(env, name);
601
+ const legacy = join5(BOXES, name);
602
+ if (!existsSync5(folder) && existsSync5(join5(legacy, ".box.json"))) {
603
+ mkdirSync4(dirname(folder), { recursive: true });
604
+ renameSync2(legacy, folder);
605
+ }
606
+ mkdirSync4(join5(folder, ".claude"), { recursive: true });
607
+ const stateFile = join5(folder, ".box.json");
608
+ const state = existsSync5(stateFile) ? JSON.parse(readFileSync5(stateFile, "utf8")) : {};
609
+ const local = here ?? state.local;
610
+ const next = { ...state, name, env, owner: load().email, local };
611
+ writeFileSync4(join5(folder, "CLAUDE.md"), claudeMd(name, local));
612
+ const hook = (direction) => [{ matcher: "Bash", hooks: [{ type: "command", command: selfCommand("sync", name, direction, folder), timeout: 600 }] }];
613
+ writeFileSync4(join5(folder, ".claude", "settings.json"), JSON.stringify({ enableAllProjectMcpServers: true, permissions: { allow: [`Bash(ssh ${name}.box:*)`, `Bash(scp:*)`, `Bash(rsync:*)`, "mcp__box"] }, hooks: { PreToolUse: hook("push"), PostToolUse: hook("pull") } }, null, 2));
614
+ if (mcp) writeFileSync4(join5(folder, ".mcp.json"), JSON.stringify({ mcpServers: { box: { type: "http", url: mcp.url, headers: { authorization: `Box ${mcp.token}` } } } }, null, 2), { mode: 384 });
615
+ writeFileSync4(stateFile, JSON.stringify(next, null, 2));
616
+ return folder;
617
+ }
618
+ async function ensureClone(env) {
619
+ const clone = join5(BOXES, env.name, CLONE);
620
+ const token = localGitHubToken();
621
+ const auth = token ? ["-c", `http.https://github.com/.extraheader=AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`] : [];
622
+ if (existsSync5(join5(clone, ".git"))) {
623
+ await git([...auth, "-C", clone, "fetch", "-q", "--prune", "origin"]);
624
+ return clone;
625
+ }
626
+ const partial = `${clone}.partial`;
627
+ rmSync2(partial, { recursive: true, force: true });
628
+ mkdirSync4(dirname(clone), { recursive: true });
629
+ const bar = progress([[0, `Cloning ${env.repo} once for every ${env.name} box`]], { pace: 6e4 });
630
+ const cloned = await git([...auth, "clone", "-q", "--no-checkout", "--filter=blob:none", token ? `https://github.com/${env.repo}.git` : `git@github.com:${env.repo}.git`, partial]);
631
+ if (!cloned.ok) {
632
+ bar();
633
+ fail(`Couldn't clone ${env.repo}: ${cloned.err.split("\n").at(-1)}. Run gh auth login and try again.`);
634
+ }
635
+ await git(["-C", partial, "update-ref", "--no-deref", "HEAD", "HEAD"]);
636
+ renameSync2(partial, clone);
637
+ bar(`Cloned ${env.repo} into ${tilde(clone)}`);
638
+ return clone;
639
+ }
640
+ async function ensureWorktree({ clone, folder, name, branch }) {
641
+ const repo = join5(folder, "repo");
642
+ if (existsSync5(join5(repo, ".git")) && statSync2(join5(repo, ".git")).isFile()) return false;
643
+ if (existsSync5(repo)) renameSync2(repo, `${repo}.mirror`);
644
+ await git(["-C", clone, "worktree", "prune"]);
645
+ const onOrigin = branch && (await git(["-C", clone, "rev-parse", "-q", "--verify", `refs/remotes/origin/${branch}`])).ok;
646
+ const tries = onOrigin ? [["worktree", "add", "-q", repo, branch], ["worktree", "add", "-q", "--detach", repo, `origin/${branch}`]] : [["worktree", "add", "-q", "--no-track", "-b", `cb/${name}`, repo, "origin/HEAD"], ["worktree", "add", "-q", repo, `cb/${name}`]];
647
+ for (const args of tries) {
648
+ const r = await git(["-C", clone, ...args]);
649
+ if (r.ok) return true;
650
+ rmSync2(repo, { recursive: true, force: true });
651
+ }
652
+ if (existsSync5(`${repo}.mirror`)) renameSync2(`${repo}.mirror`, repo);
653
+ return fail(`Couldn't make a worktree for ${name} in ${tilde(clone)}.`);
654
+ }
655
+ async function setUpWorktree({ clone, folder, name, branch, fresh }) {
656
+ if (!await ensureWorktree({ clone, folder, name, branch })) return false;
657
+ const repo = join5(folder, "repo");
658
+ const bar = progress([[0, fresh ? `Sending ${tilde(repo)} to ${name}` : `Copying ${name}'s work into ${tilde(repo)}`]], { pace: 6e4 });
659
+ let copied = false;
660
+ for (let attempt = 0; attempt < 4 && !copied; attempt++) copied = await (fresh ? push(name, folder, false, ["--delete"]) : pull(name, folder)) || (await new Promise((r) => setTimeout(r, 3e3)), false);
661
+ if (copied && fresh) await alignBox(name, repo);
662
+ bar(copied ? `${tilde(repo)} is a worktree of ${tilde(clone)}` : void 0);
663
+ if (!copied) fail(`Couldn't sync ${tilde(repo)} with ${name}. Check that ssh ${name}.box works.`);
664
+ rmSync2(`${repo}.mirror`, { recursive: true, force: true });
665
+ return true;
666
+ }
667
+ async function alignBox(name, repo) {
668
+ const sha = (await git(["-C", repo, "rev-parse", "HEAD"])).out;
669
+ const branch = (await git(["-C", repo, "branch", "--show-current"])).out;
670
+ if (!/^[0-9a-f]{40}$/.test(sha)) return;
671
+ const point = branch && /^[\w./-]+$/.test(branch) ? `git symbolic-ref HEAD refs/heads/${branch}` : "git checkout -q --detach";
672
+ await new Promise((done) => spawn3("ssh", [`${name}.box`, `cd repo && { git cat-file -e ${sha}^{commit} 2>/dev/null || git fetch -q origin ${sha} 2>/dev/null; } && git cat-file -e ${sha}^{commit} && ${point} && git reset -q ${sha}`], { stdio: "ignore" }).on("close", done).on("error", done));
673
+ }
674
+ function newestTranscript(folder) {
675
+ const sessions = join5(homedir4(), ".claude", "projects", folder.replace(/[^a-zA-Z0-9]/g, "-"));
676
+ return existsSync5(sessions) ? readdirSync2(sessions).filter((f) => f.endsWith(".jsonl")).map((f) => join5(sessions, f)).sort((a, b) => statSync2(b).mtimeMs - statSync2(a).mtimeMs)[0] : void 0;
677
+ }
678
+ function backgroundSession(folder) {
679
+ try {
680
+ const sessions = JSON.parse(execFileSync3("claude", ["agents", "--json", "--cwd", folder], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e4 }));
681
+ return sessions.filter((s) => s.kind === "background").sort((a, b) => b.startedAt - a.startedAt)[0]?.id;
682
+ } catch {
683
+ return void 0;
684
+ }
685
+ }
686
+ function laptopState(folder) {
687
+ const branch = (() => {
688
+ try {
689
+ return execFileSync3("git", ["-C", join5(folder, "repo"), "branch", "--show-current"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 }).trim();
690
+ } catch {
691
+ return "";
692
+ }
693
+ })();
694
+ const newest = newestTranscript(folder);
695
+ const titles = newest ? [...readFileSync5(newest, "utf8").matchAll(/"aiTitle":"((?:[^"\\]|\\.)*)"/g)] : [];
696
+ const task = titles.length ? (() => {
697
+ try {
698
+ return JSON.parse(`"${titles.at(-1)[1]}"`);
699
+ } catch {
700
+ return void 0;
701
+ }
702
+ })() : void 0;
703
+ return { branch: branch || void 0, task, working: !!newest && Date.now() - statSync2(newest).mtimeMs < 9e4 };
704
+ }
705
+ var git = (args) => new Promise((done) => {
706
+ const p = spawn3("git", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
707
+ let out = "", err = "";
708
+ p.stdout.on("data", (d) => {
709
+ out += d;
710
+ });
711
+ p.stderr.on("data", (d) => {
712
+ err += d;
713
+ });
714
+ p.on("close", (code2) => done({ ok: code2 === 0, out: out.trim(), err: err.trim() }));
715
+ p.on("error", (e) => done({ ok: false, out: "", err: e.message }));
716
+ });
717
+ function watchRepo(name, folder) {
718
+ const repo = join5(folder, "repo");
719
+ let timer, poll, running = false, again = false;
720
+ const run = async () => {
721
+ if (running) {
722
+ again = true;
723
+ return;
724
+ }
725
+ running = true;
726
+ do {
727
+ again = false;
728
+ await push(name, folder, true);
729
+ } while (again);
730
+ running = false;
731
+ };
732
+ const schedule = () => {
733
+ clearTimeout(timer);
734
+ timer = setTimeout(() => void run(), 800);
735
+ };
736
+ const pollInstead = () => {
737
+ poll ??= setInterval(() => void run(), 5e3);
738
+ };
739
+ const close = process.platform === "linux" ? watchEachDir(repo, schedule, pollInstead) : watchTree(repo, schedule, pollInstead);
740
+ return async () => {
741
+ clearTimeout(timer);
742
+ clearInterval(poll);
743
+ close();
744
+ while (running) await new Promise((r) => setTimeout(r, 200));
745
+ };
746
+ }
747
+ function watchTree(repo, changed, failed) {
748
+ try {
749
+ const watcher = watch(repo, { recursive: true }, (_event, file2) => {
750
+ if (file2 && !String(file2).split(/[\\/]/).some((part) => MIRROR_SKIP.includes(part))) changed();
751
+ }).on("error", () => {
752
+ watcher.close();
753
+ failed();
754
+ });
755
+ return () => watcher.close();
756
+ } catch {
757
+ failed();
758
+ return () => void 0;
759
+ }
760
+ }
761
+ function watchEachDir(repo, changed, failed) {
762
+ const watchers = /* @__PURE__ */ new Map();
763
+ let broken = false;
764
+ const closeAll = () => {
765
+ for (const w of watchers.values()) w.close();
766
+ watchers.clear();
767
+ };
768
+ const fail2 = () => {
769
+ if (broken) return;
770
+ broken = true;
771
+ closeAll();
772
+ failed();
773
+ };
774
+ const add = (dir) => {
775
+ if (broken || watchers.has(dir)) return;
776
+ try {
777
+ watchers.set(dir, watch(dir, (_event, file2) => {
778
+ if (file2) track(join5(dir, String(file2)));
779
+ changed();
780
+ }).on("error", fail2));
781
+ } catch {
782
+ fail2();
783
+ }
784
+ };
785
+ const track = (path) => {
786
+ const known = watchers.get(path);
787
+ if (known && !existsSync5(path)) {
788
+ known.close();
789
+ watchers.delete(path);
790
+ } else if (!known && basename(path) !== ".git" && isDirectory(path) && !ignored(repo, path)) add(path);
791
+ };
792
+ const dirs = trackedDirs(repo);
793
+ if (dirs) for (const d of dirs) add(d);
794
+ else fail2();
795
+ return closeAll;
796
+ }
797
+ function trackedDirs(repo) {
798
+ try {
799
+ const files = execFileSync3("git", ["-C", repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { encoding: "utf8", maxBuffer: 256 << 20, stdio: ["ignore", "pipe", "ignore"] }).split("\0");
800
+ const dirs = /* @__PURE__ */ new Set([repo]);
801
+ for (const f of files) for (let d = dirname(f); d !== "." && !dirs.has(join5(repo, d)); d = dirname(d)) dirs.add(join5(repo, d));
802
+ return [...dirs].filter(isDirectory);
803
+ } catch {
804
+ return void 0;
805
+ }
806
+ }
807
+ function ignored(repo, path) {
808
+ try {
809
+ execFileSync3("git", ["-C", repo, "check-ignore", "-q", path], { stdio: "ignore" });
810
+ return true;
811
+ } catch {
812
+ return false;
813
+ }
814
+ }
815
+ function isDirectory(path) {
816
+ try {
817
+ return statSync2(path).isDirectory();
818
+ } catch {
819
+ return false;
820
+ }
821
+ }
822
+ async function prune(boxes) {
823
+ const email = load().email;
824
+ const mine = localBoxes().filter((b) => b.owner && b.owner === email);
825
+ if (!mine.length) return;
826
+ const alive = new Set((boxes ?? await api("/api/boxes")).filter((b) => b.state !== "gone").map((b) => b.name));
827
+ for (const b of mine) {
828
+ if (alive.has(b.name)) continue;
829
+ await dropFolder(b);
830
+ console.log(dim(`Removed ${tilde(b.folder)}: ${b.name} no longer exists`));
831
+ }
832
+ }
833
+ async function dropFolder(b) {
834
+ rmSync2(b.folder, { recursive: true, force: true });
835
+ const clone = join5(BOXES, b.env, CLONE);
836
+ if (existsSync5(clone)) await git(["-C", clone, "worktree", "prune"]);
837
+ }
838
+ var rsync = (from, to, extra = [], quiet2 = false) => new Promise((done) => {
839
+ const args = ["-az", ...extra, ...MIRROR_SKIP.flatMap((x) => ["--exclude", x]), from, to];
840
+ const p = spawn3("rsync", args, { stdio: ["ignore", "ignore", "pipe"] });
841
+ let err = "";
842
+ p.stderr.on("data", (d) => {
843
+ err += d;
844
+ });
845
+ p.on("close", (code2) => {
846
+ if (code2 && !quiet2) console.error(dim(`rsync: ${err.trim().split("\n").slice(-2).join(" ")}`));
847
+ done(code2 === 0);
848
+ });
849
+ });
850
+ var pull = (name, folder, quiet2 = false) => rsync(`${name}.box:repo/`, join5(folder, "repo") + "/", ["--delete"], quiet2);
851
+ var push = (name, folder, quiet2 = false, extra = []) => rsync(join5(folder, "repo") + "/", `${name}.box:repo/`, extra, quiet2);
852
+ async function openLocal({ name, env, here, branch, fresh = false }) {
853
+ ensureSshConfig();
854
+ const stop = progress([[0, `Opening ${name}`], [1500, "Waking the box up"]]);
855
+ const [got, envs] = await Promise.all([
856
+ api(`/api/boxes/${name}/connect`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(laptopKey().publicKey) }) }),
857
+ api("/api/environments")
858
+ ]).finally(() => stop());
859
+ const environment = envs.find((e) => e.name === env) ?? fail(`No environment named ${env}.`);
860
+ const folder = prepareFolder(name, env, here, got.mcp);
861
+ try {
862
+ writeFileSync4(join5(BOXES, "llms.txt"), await fetch(new URL("/llms.txt", load().server)).then((r) => r.ok ? r.text() : Promise.reject()));
863
+ } catch {
864
+ }
865
+ trustFolder(folder);
866
+ if (!await setUpWorktree({ clone: await ensureClone(environment), folder, name, branch, fresh })) await pull(name, folder);
867
+ if (here) ok(`Your local copy: ${tilde(here.dir)}${here.files.length || here.services.length ? dim(` \xB7 ${[...here.files, ...here.services].join(", ")}`) : ""}`);
868
+ console.log(dim(`\u25CF Keeping ${name} awake and ${tilde(folder)}/repo in sync while Claude is open`));
869
+ const stopWatching = watchRepo(name, folder);
870
+ const heartbeat = () => void api(`/api/boxes/${name}/heartbeat`, { method: "POST", body: JSON.stringify({ typed: true, ...laptopState(folder) }) }).catch(() => void 0);
871
+ const beat = setInterval(heartbeat, 6e4);
872
+ heartbeat();
873
+ const background = backgroundSession(folder);
874
+ const code2 = background && await claude(folder, ["attach", background]) === 0 ? 0 : await claude(folder, ["--dangerously-skip-permissions", ...newestTranscript(folder) ? ["--continue"] : []]);
875
+ clearInterval(beat);
876
+ await stopWatching();
877
+ await push(name, folder);
878
+ ok(`Claude closed \xB7 repo synced \xB7 ${name} sleeps once it's quiet ${dim(`\xB7 come back with: box ${name}`)}`);
879
+ process.exit(code2);
880
+ }
881
+ var claude = (folder, args) => new Promise((done) => spawn3("claude", args, { cwd: folder, stdio: "inherit", env: { ...process.env, ...process.getuid?.() === 0 && { IS_SANDBOX: "1" } } }).on("error", () => fail("Claude Code is not installed on this laptop. npm i -g @anthropic-ai/claude-code, or use box --remote.")).on("close", (c) => done(c ?? 0)));
882
+ function trustFolder(folder) {
883
+ const file2 = join5(homedir4(), ".claude.json");
884
+ try {
885
+ const config = JSON.parse(readFileSync5(file2, "utf8"));
886
+ if (config.projects?.[folder]?.hasTrustDialogAccepted) return;
887
+ config.projects = { ...config.projects, [folder]: { ...config.projects?.[folder], hasTrustDialogAccepted: true } };
888
+ writeFileSync4(file2, JSON.stringify(config, null, 2));
889
+ } catch {
890
+ }
891
+ }
892
+ async function sync(name, direction, folder) {
893
+ const chunks = [];
894
+ for await (const c of process.stdin) chunks.push(c);
895
+ let command = "";
896
+ try {
897
+ command = JSON.parse(Buffer.concat(chunks).toString("utf8")).tool_input?.command ?? "";
898
+ } catch {
899
+ }
900
+ if (!command.includes(`${name}.box`) || !existsSync5(join5(folder, "repo"))) return;
901
+ await (direction === "push" ? push(name, folder) : pull(name, folder));
902
+ }
903
+ async function code(envArg) {
904
+ const boxes = await api("/api/boxes");
905
+ await prune(boxes);
906
+ const here = localBoxes().filter((b) => existsSync5(join5(b.folder, "repo")));
907
+ const envs = [...new Set(here.map((b) => b.env))].sort();
908
+ if (!envs.length) return console.log(dim("No boxes on this laptop yet. Run box first."));
909
+ const env = envArg ?? (envs.length === 1 ? envs[0] : await select2({ message: "Open the boxes of which environment?", choices: envs.map((e) => ({ name: `${e.padEnd(18)}${dim(`${here.filter((b) => b.env === e).length} boxes`)}`, value: e })) }));
910
+ const group = here.filter((b) => b.env === env);
911
+ if (!group.length) fail(`No boxes from ${env} on this laptop.`);
912
+ const awake = new Set(boxes.filter((b) => b.state === "running").map((b) => b.name));
913
+ const environment = (await api("/api/environments")).find((e) => e.name === env) ?? fail(`No environment named ${env}.`);
914
+ const clone = await ensureClone(environment);
915
+ const migrated = [];
916
+ for (const b of group.filter((g) => awake.has(g.name))) {
917
+ if (await setUpWorktree({ clone, folder: b.folder, name: b.name, branch: boxes.find((x) => x.name === b.name)?.branch ?? void 0, fresh: false })) migrated.push(b.name);
918
+ }
919
+ const workspace = join5(BOXES, env, `${env}.code-workspace`);
920
+ writeFileSync4(workspace, JSON.stringify({ folders: group.map((b) => ({ name: basename(b.folder), path: `${basename(b.folder)}/repo` })), settings: { "files.exclude": { "**/node_modules": true } } }, null, 2));
921
+ spawn3("code", [workspace], { stdio: "ignore", detached: true }).on("error", () => miss(`VS Code's code command isn't installed. In VS Code run: Shell Command: Install 'code' command in PATH, or open ${tilde(workspace)}.`)).unref();
922
+ ok(`${group.length} ${env} box${group.length === 1 ? "" : "es"} in one window ${dim(`\xB7 ${tilde(workspace)}`)}`);
923
+ await Promise.all(group.filter((b) => awake.has(b.name) && !migrated.includes(b.name)).map((b) => pull(b.name, b.folder, true)));
924
+ const stops = group.map((b) => watchRepo(b.name, b.folder));
925
+ console.log(dim("Syncing your saves to the boxes. Leave this open; ctrl+c stops syncing."));
926
+ await new Promise((resolve) => process.once("SIGINT", resolve));
927
+ await Promise.all(stops.map((s) => s()));
928
+ ok("Stopped syncing");
929
+ process.exit(0);
930
+ }
931
+ async function rm(name, opts = {}) {
932
+ const local = localBoxes().find((b) => b.name === name);
933
+ if (local && existsSync5(join5(local.folder, "repo"))) await push(name, local.folder, true);
934
+ const stop = progress([[0, `Saving unpushed work to cb/${name}`], [2500, `Deleting ${name}`], [6e3, "Cleaning up its preview URLs"]]);
935
+ await api(`/api/boxes/${name}${opts.force ? "?force=1" : ""}`, { method: "DELETE" }).finally(() => stop());
936
+ if (local) await dropFolder(local);
937
+ ok(`Deleted ${name}${local ? dim(` \xB7 removed ${tilde(local.folder)}`) : ""}`);
938
+ }
939
+
940
+ // src/remote.ts
941
+ import { createHash } from "node:crypto";
942
+ import { styleText as styleText4 } from "node:util";
943
+ import { select as select3 } from "@inquirer/prompts";
944
+ import ssh22 from "ssh2";
945
+
946
+ // src/predict.ts
947
+ import xterm from "@xterm/headless";
948
+ function predictor(out, cols, rows) {
949
+ const screen = new xterm.Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
950
+ let pending = [];
951
+ let visible = false;
952
+ let parsing = 0;
953
+ let boxCursor;
954
+ let quietUntil = 0;
955
+ const inputStart = /* @__PURE__ */ new Map();
956
+ const buf = () => screen.buffer.active;
957
+ const cursor = () => ({ x: buf().cursorX, y: buf().cursorY });
958
+ const chars = (x, y) => buf().getLine(buf().viewportY + y)?.getCell(x)?.getChars() || " ";
959
+ const paint = (x, y) => {
960
+ const c = buf().getLine(buf().viewportY + y)?.getCell(x);
961
+ if (!c) return "";
962
+ const color = (rgb, pal, v, base) => rgb ? `;${base + 8};2;${v >> 16 & 255};${v >> 8 & 255};${v & 255}` : pal ? `;${base + 8};5;${v}` : "";
963
+ const sgr = `0${c.isBold() ? ";1" : ""}${c.isDim() ? ";2" : ""}${c.isItalic() ? ";3" : ""}${c.isUnderline() ? ";4" : ""}${c.isInverse() ? ";7" : ""}${c.isStrikethrough() ? ";9" : ""}` + color(c.isFgRGB(), c.isFgPalette(), c.getFgColor(), 30) + color(c.isBgRGB(), c.isBgPalette(), c.getBgColor(), 40);
964
+ return `\x1B[${y + 1};${x + 1}H\x1B[${sgr}m${c.getChars() || " "}`;
965
+ };
966
+ const at = (x, y) => `\x1B[${y + 1};${x + 1}H`;
967
+ const caret = () => pending.length ? { x: pending.at(-1).x + (pending.at(-1).ch === "\b" ? 0 : 1), y: pending.at(-1).y } : boxCursor ?? cursor();
968
+ function giveUp(quiet2 = 0) {
969
+ if (!pending.length) return;
970
+ const { x, y } = cursor();
971
+ out(`\x1B7${pending.map((g) => paint(g.x, g.y)).join("")}\x1B8${at(x, y)}`);
972
+ pending = [];
973
+ quietUntil = Date.now() + quiet2;
974
+ }
975
+ function settle() {
976
+ if (parsing) return;
977
+ const now = Date.now(), { x, y } = cursor();
978
+ if (pending.some((g) => g.y !== y)) return giveUp(500);
979
+ const passed = (g) => g.ch === "\b" ? x <= g.x : x > g.x;
980
+ if (pending.some((g) => passed(g) && chars(g.x, g.y) !== (g.ch === "\b" ? " " : g.ch))) return giveUp(300);
981
+ pending = pending.filter((g) => {
982
+ if (!passed(g)) return true;
983
+ if (g.ch !== "\b") inputStart.set(g.y, Math.min(inputStart.get(g.y) ?? g.x, g.x));
984
+ return false;
985
+ });
986
+ if (pending.some((g) => now - g.at > 1500)) return giveUp(1e3);
987
+ if (!pending.length) return;
988
+ const c = caret();
989
+ out(`\x1B7\x1B[0m${pending.map((g) => at(g.x, g.y) + (g.ch === "\b" ? " " : g.ch)).join("")}\x1B8${at(c.x, c.y)}`);
990
+ }
991
+ const timer = setInterval(settle, 200);
992
+ timer.unref();
993
+ return {
994
+ fromBox(d) {
995
+ out(d);
996
+ const text = Buffer.from(d).toString("latin1");
997
+ const show = text.lastIndexOf("\x1B[?25h"), hide = text.lastIndexOf("\x1B[?25l");
998
+ if (show !== hide) visible = show > hide;
999
+ const end = /\x1b\[(\d+);(\d+)H(?:\x1b\[\?25[hl])*$/.exec(text);
1000
+ boxCursor = end ? { x: Number(end[2]) - 1, y: Number(end[1]) - 1 } : void 0;
1001
+ parsing++;
1002
+ screen.write(d, () => {
1003
+ parsing--;
1004
+ settle();
1005
+ });
1006
+ },
1007
+ fromKeys(d) {
1008
+ if (!visible || parsing && !boxCursor && !pending.length || Date.now() < quietUntil || pending.length > 40) return;
1009
+ for (const b of d) {
1010
+ const { x, y } = caret();
1011
+ if (b >= 32 && b < 127 && x < cols - 2 && d.length === 1) {
1012
+ const ch = String.fromCharCode(b);
1013
+ pending.push({ x, y, ch, at: Date.now() });
1014
+ out(`\x1B7\x1B[0m${at(x, y)}${ch}\x1B8${at(x + 1, y)}`);
1015
+ } else if ((b === 127 || b === 8) && d.length === 1) {
1016
+ const last = pending.at(-1);
1017
+ if (last && last.ch !== "\b" && last.x === x - 1) {
1018
+ pending.pop();
1019
+ out(`\x1B7${paint(x - 1, y)}\x1B8${at(x - 1, y)}`);
1020
+ } else if (x - 1 >= (inputStart.get(y) ?? Infinity) && (chars(x, y) === " " || pending.some((g) => g.ch === "\b" && g.x === x && g.y === y))) {
1021
+ pending.push({ x: x - 1, y, ch: "\b", at: Date.now() });
1022
+ out(`\x1B7\x1B[0m${at(x - 1, y)} \x1B8${at(x - 1, y)}`);
1023
+ }
1024
+ }
1025
+ }
1026
+ },
1027
+ resize(c, r) {
1028
+ cols = c;
1029
+ giveUp();
1030
+ screen.resize(c, r);
1031
+ }
1032
+ };
1033
+ }
1034
+
1035
+ // src/remote.ts
1036
+ var mcpCount = (s) => {
1037
+ const n = Object.keys(s).length;
1038
+ return `${n} MCP server${n === 1 ? "" : "s"}`;
1039
+ };
1040
+ async function newRemoteBox(env, branch, name, o) {
1041
+ const agent = o.agent;
1042
+ const idle = o.alwaysOn ? "0" : void 0;
1043
+ const { mcpServers, mcpOAuth, skipped } = localMcp();
1044
+ ok(`${mcpCount(mcpServers)} will be copied into the box ${dim(skipped.length ? `(skipping local-only: ${skipped.join(", ")})` : "")}`);
1045
+ const claudeHome = localClaudeHome();
1046
+ const stop = progress([
1047
+ [0, "Finding a machine"],
1048
+ [900, `Unpacking the ${env} snapshot`],
1049
+ [2e3, "Signing Claude Code in"],
1050
+ [2900, `Copying ${mcpCount(mcpServers)}`],
1051
+ [3300, `Copying ${claudeHome.skills} skill${claudeHome.skills === 1 ? "" : "s"} and ${claudeHome.enabled.length} plugin${claudeHome.enabled.length === 1 ? "" : "s"}`],
1052
+ [3700, "Connecting GitHub"],
1053
+ [4600, "Waking Claude up"],
1054
+ [9e3, "Bigger machines take a few extra seconds"]
1055
+ ]);
1056
+ const { tar, ...home2 } = claudeHome;
1057
+ const box = await api("/api/boxes", { method: "POST", body: JSON.stringify({ env, branch, name, size: o.size, idleMinutes: idle === void 0 ? void 0 : Number(idle), mcpServers, mcpOAuth, claudeHome: home2, sshKey: sshKeys(laptopKey().publicKey), agent }) }).finally(() => stop());
1058
+ if (home2.pending) void sendHome(box.name, tar);
1059
+ await attach(box.name, { name: box.name, ssh: box.ssh });
1060
+ }
1061
+ async function sendHome(name, tar) {
1062
+ const buf = await tar;
1063
+ if (!buf || buf.length > 30 << 20) return api(`/api/boxes/${name}/home`, { method: "POST", body: "{}" }).catch(() => void 0);
1064
+ const sha = createHash("sha256").update(buf).digest("hex");
1065
+ const post = (body) => api(`/api/boxes/${name}/home`, { method: "POST", body: JSON.stringify(body) });
1066
+ if ((await post({ sha }).catch(() => ({ need: false }))).need) await post({ sha, tgz: buf.toString("base64") }).catch(() => void 0);
1067
+ }
1068
+ async function dial({ user, hosts }, privateKey, attempts) {
1069
+ const once = () => new Promise((resolve, reject) => {
1070
+ let left = hosts.length, won = false;
1071
+ if (!left) return reject(new Error("The box has no address yet."));
1072
+ for (const [host, port] of hosts) {
1073
+ const c = new ssh22.Client();
1074
+ c.on("ready", () => {
1075
+ if (won) return c.end();
1076
+ won = true;
1077
+ resolve(c);
1078
+ }).on("error", (e) => {
1079
+ if (!won && --left === 0) reject(e);
1080
+ }).connect({ host, port, username: user, privateKey, readyTimeout: 6e3, keepaliveInterval: 15e3, hostVerifier: () => true });
1081
+ }
1082
+ });
1083
+ for (let attempt = 1; ; attempt++) {
1084
+ try {
1085
+ return await once();
1086
+ } catch (err) {
1087
+ if (attempt >= attempts) throw err;
1088
+ await new Promise((r) => setTimeout(r, 1500));
1089
+ }
1090
+ }
1091
+ }
1092
+ async function attach(name, ready) {
1093
+ if (!name) {
1094
+ const boxes = await api("/api/boxes");
1095
+ if (!boxes.length) return console.log(dim("No boxes yet. Run box."));
1096
+ name = await select3({ message: "Attach to", choices: boxes.map((b) => ({ name: `${b.name.padEnd(28)}${label(b)}`, value: b.name })) });
1097
+ }
1098
+ const boxName = name;
1099
+ const key = laptopKey();
1100
+ const keys = [];
1101
+ let session, conn, echo, typed = false, pressed = () => {
1102
+ };
1103
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
1104
+ process.stdin.resume();
1105
+ process.stdin.on("data", (d) => {
1106
+ if (d.includes(29) || !session && d.includes(3)) return leave();
1107
+ typed = true;
1108
+ if (session) {
1109
+ echo?.fromKeys(d);
1110
+ session.write(d);
1111
+ } else {
1112
+ keys.push(d);
1113
+ pressed();
1114
+ }
1115
+ });
1116
+ setInterval(() => {
1117
+ if (session) void api(`/api/boxes/${boxName}/heartbeat`, { method: "POST", body: JSON.stringify({ typed }) }).catch(() => void 0);
1118
+ typed = false;
1119
+ }, 3e4).unref();
1120
+ function leave() {
1121
+ conn?.end();
1122
+ process.stdout.write(TERMINAL_RESET);
1123
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
1124
+ console.log(dim(`
1125
+ Detached. ${boxName} sleeps once Claude is done. box ${boxName} to return.`));
1126
+ process.exit(0);
1127
+ }
1128
+ let ssh = ready?.ssh, attempts = 4, announced = false;
1129
+ for (; ; ) {
1130
+ if (!ssh) {
1131
+ attempts = 4;
1132
+ const box = await findBox(boxName);
1133
+ if (box.state === "gone") fail(`${boxName} is gone. Make a new one with box.`);
1134
+ if (box.state === "paused" || box.state === "pausing") {
1135
+ sleepScreen(box);
1136
+ if (!keys.length) await new Promise((r) => {
1137
+ pressed = r;
1138
+ });
1139
+ wakingScreen(box, keys);
1140
+ pressed = () => wakingScreen(box, keys);
1141
+ }
1142
+ const stop = keys.length ? () => void 0 : progress([[0, `Opening ${boxName}`], [1200, "Waking the box up"], [4e3, "Starting Claude"]]);
1143
+ ssh = (await api(`/api/boxes/${boxName}/attach`, { method: "POST", body: JSON.stringify({ sshKey: sshKeys(key.publicKey) }) }).finally(() => stop())).ssh;
1144
+ }
1145
+ conn = await dial(ssh, key.privateKey, attempts).catch(() => void 0);
1146
+ attempts = 1;
1147
+ if (conn && !announced && !keys.length) console.log(dim(`Attaching to ${boxName}. Detach with ctrl-], come back with box ${boxName}.`));
1148
+ announced = true;
1149
+ if (!conn) {
1150
+ ssh = void 0;
1151
+ for (let i = 0; i < 20 && (await findBox(boxName)).state === "running"; i++) await new Promise((r) => setTimeout(r, 1500));
1152
+ continue;
1153
+ }
1154
+ const { columns: cols = 100, rows = 30 } = process.stdout;
1155
+ echo = process.env.CBOX_PREDICT === "0" ? void 0 : predictor((s) => process.stdout.write(s), cols, rows);
1156
+ const stream = await new Promise((resolve, reject) => conn.exec(
1157
+ "dtach -A ~/.cb/claude.sock -E -r winch bash -l ~/.cb/start.sh",
1158
+ { pty: { term: process.env.TERM || "xterm-256color", cols, rows } },
1159
+ (err, s) => err ? reject(err) : resolve(s)
1160
+ ));
1161
+ let exited = false;
1162
+ stream.on("exit", (code2, signal) => {
1163
+ exited = code2 === 0 && !signal;
1164
+ });
1165
+ const closed = new Promise((r) => stream.on("close", r));
1166
+ let settle;
1167
+ stream.on("data", (d) => {
1168
+ echo ? echo.fromBox(d) : process.stdout.write(d);
1169
+ if (!keys.length) return;
1170
+ clearTimeout(settle);
1171
+ settle = setTimeout(() => {
1172
+ session?.write(Buffer.concat(keys.splice(0)));
1173
+ }, 800);
1174
+ });
1175
+ stream.setWindow(rows, cols - 1, 0, 0);
1176
+ setTimeout(() => stream.setWindow(rows, cols, 0, 0), 100);
1177
+ const resize = () => {
1178
+ echo?.resize(process.stdout.columns, process.stdout.rows);
1179
+ stream.setWindow(process.stdout.rows, process.stdout.columns, 0, 0);
1180
+ };
1181
+ process.stdout.on("resize", resize);
1182
+ session = stream;
1183
+ typed = false;
1184
+ void api(`/api/boxes/${boxName}/heartbeat`, { method: "POST", body: JSON.stringify({ typed: false }) }).catch(() => void 0);
1185
+ await closed;
1186
+ session = void 0;
1187
+ process.stdout.off("resize", resize);
1188
+ if (exited) leave();
1189
+ conn.end();
1190
+ process.stdout.write(TERMINAL_RESET);
1191
+ }
1192
+ }
1193
+ var RATES = { small: 0.018, medium: 0.036, large: 0.072 };
1194
+ function centered(lines) {
1195
+ const { columns = 100, rows = 30 } = process.stdout;
1196
+ const visible = (t) => t.replace(/\x1b\[[0-9;]*m/g, "").length;
1197
+ const top = Math.max(0, Math.floor((rows - lines.length) / 2));
1198
+ process.stdout.write("\x1B[?1049h\x1B[2J\x1B[?25l" + lines.map((l, i) => `\x1B[${top + i + 1};${Math.max(1, Math.floor((columns - visible(l)) / 2) + 1)}H${l}`).join(""));
1199
+ }
1200
+ function sleepScreen(box) {
1201
+ const since = box.asleepSince ? Date.now() - new Date(box.asleepSince).getTime() : 0;
1202
+ const saved = since / 36e5 * (RATES[box.size ?? "small"] ?? RATES.small);
1203
+ centered([
1204
+ styleText4("blue", "\u263E"),
1205
+ "",
1206
+ styleText4("bold", `${box.name} is asleep`),
1207
+ dim(`Last used ${span(Date.now() - new Date(box.lastActiveAt ?? Date.now()).getTime())} ago. Your files and the conversation are saved.`),
1208
+ "",
1209
+ `Press any key to wake it ${dim("\xB7")} ctrl+] to leave`,
1210
+ "",
1211
+ dim(since ? `asleep ${span(since)} \xB7 $${saved.toFixed(2)} not spent` : "asleep")
1212
+ ]);
1213
+ }
1214
+ function wakingScreen(box, keys) {
1215
+ const text = Buffer.concat(keys).toString("utf8").replace(/[\x00-\x1f\x7f]/g, "");
1216
+ centered([
1217
+ styleText4("yellow", "\u2600"),
1218
+ "",
1219
+ styleText4("bold", `Waking ${box.name}`),
1220
+ dim("a few seconds"),
1221
+ "",
1222
+ text ? `${dim("you typed:")} ${text}` : "",
1223
+ dim("what you type now goes to Claude once it is up")
1224
+ ]);
1225
+ }
1226
+ var TERMINAL_RESET = "\x1B[?1000l\x1B[?1002l\x1B[?1003l\x1B[?1005l\x1B[?1006l\x1B[?1015l\x1B[?1004l\x1B[?2004l\x1B[<u\x1B[?1049l\x1B[?25h\x1B[0m";
1227
+
1228
+ // src/update.ts
1229
+ import { execFileSync as execFileSync4, spawn as spawn4, spawnSync as spawnSync2 } from "node:child_process";
1230
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
1231
+ import { basename as basename2, dirname as dirname2, join as join6 } from "node:path";
1232
+ import { fileURLToPath } from "node:url";
1233
+ var PACKAGE = "@abuaboud/box";
1234
+ var LATEST = `https://registry.npmjs.org/${PACKAGE.replace("/", "%2f")}/latest`;
1235
+ var INSTALLED = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf8"));
1236
+ var VERSION = INSTALLED.version;
1237
+ function npmPrefix() {
1238
+ const pkgDir = dirname2(dirname2(fileURLToPath(import.meta.url)));
1239
+ const modules = basename2(dirname2(pkgDir)).startsWith("@") ? dirname2(dirname2(pkgDir)) : dirname2(pkgDir);
1240
+ if (basename2(modules) !== "node_modules") return void 0;
1241
+ return basename2(dirname2(modules)) === "lib" ? dirname2(dirname2(modules)) : dirname2(modules);
1242
+ }
1243
+ var newer = (a, b) => {
1244
+ const x = a.split(".").map(Number), y = b.split(".").map(Number);
1245
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
1246
+ return false;
1247
+ };
1248
+ var due = (latest) => INSTALLED.name !== PACKAGE || newer(latest, VERSION);
1249
+ var NPM = process.platform === "win32" ? "npm.cmd" : "npm";
1250
+ function installSteps(version, prefix) {
1251
+ const at = prefix ? ["--prefix", prefix] : [];
1252
+ const box = `${PACKAGE}@${version}`;
1253
+ return INSTALLED.name === PACKAGE ? [["i", "-g", "--prefer-online", ...at, box]] : [["i", "-g", "--prefer-online", "--force", ...at, box], ["rm", "-g", ...at, INSTALLED.name], ["i", "-g", "--prefer-offline", "--force", ...at, box]];
1254
+ }
1255
+ async function updateFirst() {
1256
+ const prefix = npmPrefix();
1257
+ if (!prefix || process.env.CBOX_NO_UPDATE || process.env.CBOX_UPDATED) return;
1258
+ const latest = await fetch(LATEST, { signal: AbortSignal.timeout(2500) }).then((r) => r.json()).then((j) => j.version, () => void 0);
1259
+ if (!latest || !due(latest)) return;
1260
+ const stop = progress([[0, `Updating box ${VERSION} \u2192 ${latest}`]]);
1261
+ const done = installSteps(latest, prefix).every((args) => spawnSync2(NPM, args, { stdio: "ignore" }).status === 0);
1262
+ stop();
1263
+ if (!done) return miss(`Couldn't update box to ${latest}, so carrying on with ${VERSION}`);
1264
+ ok(`Updated box ${dim(VERSION)} \u2192 ${latest}`);
1265
+ try {
1266
+ writeFileSync5(join6(CONFIG_DIR, "update.json"), JSON.stringify({ version: latest, checkedAt: Date.now() }));
1267
+ } catch {
1268
+ }
1269
+ const again = spawnSync2(process.execPath, process.argv.slice(1), { stdio: "inherit", env: { ...process.env, CBOX_UPDATED: "1" } });
1270
+ process.exit(again.status ?? 0);
1271
+ }
1272
+ function autoUpdate() {
1273
+ const prefix = npmPrefix();
1274
+ if (process.env.CBOX_NO_UPDATE || process.env.CBOX_UPDATED || !prefix) return;
1275
+ const file2 = join6(CONFIG_DIR, "update.json");
1276
+ let state = {};
1277
+ try {
1278
+ state = JSON.parse(readFileSync6(file2, "utf8"));
1279
+ } catch {
1280
+ }
1281
+ if (state.version && state.version !== VERSION) ok(`Updated box ${dim(state.version)} \u2192 ${VERSION}`);
1282
+ const check = INSTALLED.name !== PACKAGE || !state.checkedAt || Date.now() - state.checkedAt > 12 * 36e5;
1283
+ try {
1284
+ mkdirSync5(CONFIG_DIR, { recursive: true });
1285
+ writeFileSync5(file2, JSON.stringify({ version: VERSION, checkedAt: check ? Date.now() : state.checkedAt }));
1286
+ } catch {
1287
+ return;
1288
+ }
1289
+ if (!check) return;
1290
+ const job = `fetch(${JSON.stringify(LATEST)}).then(r => r.json()).then(({ version }) => {
1291
+ const newer = (a, b) => { const x = a.split('.').map(Number), y = b.split('.').map(Number); for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0); return false }
1292
+ if (${JSON.stringify(INSTALLED.name === PACKAGE)} && !newer(version, ${JSON.stringify(VERSION)})) return
1293
+ for (const args of ${JSON.stringify(installSteps("{v}", prefix))}) require('node:child_process').execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args.map((a) => a.replace('{v}', version)), { stdio: 'ignore' })
1294
+ }).catch(() => {})`;
1295
+ spawn4(process.execPath, ["-e", job], { detached: true, stdio: "ignore" }).on("error", () => {
1296
+ }).unref();
1297
+ }
1298
+ async function upgrade() {
1299
+ const latest = await fetch(LATEST).then((r) => r.json()).then((j) => j.version).catch(() => fail("Could not reach the npm registry."));
1300
+ if (!due(latest)) return ok(`box ${VERSION} is the latest`);
1301
+ console.log(dim(`Updating box ${VERSION} \u2192 ${latest}\u2026`));
1302
+ try {
1303
+ for (const args of installSteps(latest, npmPrefix())) execFileSync4(NPM, args, { stdio: "inherit" });
1304
+ } catch {
1305
+ fail(`npm could not update box. Try: npm i -g ${PACKAGE}@latest`);
1306
+ }
1307
+ ok(`Updated box to ${latest}`);
1308
+ }
1309
+
1310
+ // src/index.ts
1311
+ var ANIMALS = ["otter", "heron", "lynx", "moth", "yak", "orca", "wren", "newt", "ibis", "mole", "kiwi", "crab", "swan", "toad", "hare", "seal"];
1312
+ var NAME = /^[a-z0-9][a-z0-9-]{0,30}$/;
1313
+ async function newBox(o = {}) {
1314
+ await updateFirst();
1315
+ if (o.size && !["small", "medium", "large"].includes(o.size)) fail("Pick --size small, medium or large.");
1316
+ if (o.name !== void 0 && !NAME.test(o.name)) fail("Name the box with lowercase letters, digits and dashes, like webhook-retry.");
1317
+ const here = gitRepo();
1318
+ const picked = await chooseEnvironment(o.env, here);
1319
+ const branch = here && picked.repo.toLowerCase() === here.repo.toLowerCase() ? here.branch : void 0;
1320
+ const name = o.name ?? await askName(branch);
1321
+ return o.remote || o.agent === "codex" ? newRemoteBox(picked.name, branch, name, o) : newLocalBox(picked.name, branch, name, o);
1322
+ }
1323
+ async function askName(branch) {
1324
+ const fromBranch = branch?.split("/").at(-1)?.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30);
1325
+ const suggestion = fromBranch || `${ANIMALS[Math.floor(Math.random() * ANIMALS.length)]}-${Math.random().toString(16).slice(2, 5)}`;
1326
+ if (!process.stdin.isTTY) return void 0;
1327
+ const picked = await input2({
1328
+ message: `Name this box ${dim("(optional \xB7 you will see it in its folder, ssh name and the dashboard)")}`,
1329
+ default: suggestion,
1330
+ validate: (v) => !v.trim() || NAME.test(v.trim()) || "Lowercase letters, digits and dashes"
1331
+ });
1332
+ return picked.trim() || suggestion;
1333
+ }
1334
+ async function home(name, o) {
1335
+ const making = o.env || o.remote || o.agent || o.size || o.alwaysOn;
1336
+ if (name && making) fail(`--env, --remote, --agent, --size and --always-on only shape a new box. Open ${name} with box ${name}, or make a new one with box new --name <name> and the options.`);
1337
+ if (name) return openBox(name);
1338
+ const email = load().email;
1339
+ const all = await api("/api/boxes");
1340
+ await prune(all);
1341
+ const mine = all.filter((b) => b.state !== "gone" && (!email || b.owner === email));
1342
+ if (!mine.length || !process.stdin.isTTY || making) return newBox(o);
1343
+ const pick = await select4({ message: "Open a box", choices: [
1344
+ ...mine.map((b) => ({ name: `${b.name.padEnd(28)}${label(b)}${dim(b.agent === "local" ? "" : " \xB7 remote")}`, value: b.name })),
1345
+ { name: styleText5("cyan", "+ new box"), value: "" }
1346
+ ] });
1347
+ return pick ? openBox(pick) : newBox(o);
1348
+ }
1349
+ async function openBox(name) {
1350
+ const box = await findBox(name);
1351
+ if (box.state === "gone") fail(`${name} is gone. Make a new one with box new.`);
1352
+ return box.agent === "local" ? openLocal({ name: box.name, env: box.env ?? "boxes", here: localCopy(), branch: box.branch ?? void 0 }) : attach(box.name);
1353
+ }
1354
+ async function ls() {
1355
+ const email = load().email;
1356
+ const all = await api("/api/boxes");
1357
+ await prune(all);
1358
+ const mine = all.filter((b) => !email || b.owner === email);
1359
+ if (!mine.length) return console.log(dim("No boxes of yours. Run box."));
1360
+ const width = Math.max(...mine.map((b) => b.name.length)) + 2;
1361
+ console.log(dim(`${"BOX".padEnd(width)}${"STATE".padEnd(20)}WHERE`));
1362
+ for (const b of mine) {
1363
+ const l = label(b);
1364
+ console.log(`${b.name.padEnd(width)}${l}${" ".repeat(Math.max(1, 20 - l.replace(/\x1b\[[0-9;]*m/g, "").length))}${dim(b.agent === "local" ? "laptop" : "remote")}`);
1365
+ }
1366
+ }
1367
+ var quiet = ["proxy", "sync"].includes(process.argv[2] ?? "");
1368
+ if (quiet) console.log = console.error;
1369
+ if (!quiet && !process.env.CBOX_UPDATED && !process.argv.slice(2).some((a) => ["-V", "--version", "-h", "--help", "help"].includes(a))) console.log(dim(`box ${VERSION}`));
1370
+ if (!quiet) {
1371
+ moveHome();
1372
+ autoUpdate();
1373
+ ensureSshConfig();
1374
+ }
1375
+ var boxOptions = (c) => c.option("-e, --env <name>", "environment").option("-n, --name <name>", "name the box, like webhook-retry (default: asked, or generated)").option("--remote", "run Claude on the machine instead of this laptop").option("--agent <name>", "claude (default) or codex; codex implies --remote").option("--size <size>", "small, medium or large (default: the environment's)").option("--always-on", "never sleep, for example for a demo");
1376
+ var cli = boxOptions(new Command("box").enablePositionalOptions().description("Dev machines for Claude: Claude on your laptop, the work on a box").version(VERSION, "-v, --version").argument("[box]", "open this box")).action((name, o) => home(name, o));
1377
+ boxOptions(cli.command("new").description("always make a fresh box")).action((o) => newBox(o));
1378
+ cli.command("ls").description("list your boxes").action(ls);
1379
+ cli.command("code [env]").description("open an environment's boxes in one VS Code window, each a git worktree, and sync your saves").action((env) => code(env));
1380
+ cli.command("rm <box>").option("-f, --force", "delete even while someone is attached or Claude is working").description("delete a box, pushing unpushed work to cb/<box>").action(rm);
1381
+ cli.command("login").option("--server <url>", "box server").description("sign in from the browser").action((o) => login(o.server));
1382
+ cli.command("upgrade").description("update box to the latest version now").action(upgrade);
1383
+ cli.command("attach [box]", { hidden: true }).action((name) => name ? openBox(name) : home(void 0, {}));
1384
+ cli.command("proxy <host>", { hidden: true }).action(proxy);
1385
+ cli.command("sync <box> <direction> <folder>", { hidden: true }).action(sync);
1386
+ await cli.parseAsync();