@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +928 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +843 -0
- package/server/config.ts +362 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +966 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +654 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
package/server/cli.ts
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import pkg from "../package.json" with { type: "json" };
|
|
9
|
+
import {
|
|
10
|
+
type AttachTarget,
|
|
11
|
+
commandFor,
|
|
12
|
+
listTargets,
|
|
13
|
+
liveSessions,
|
|
14
|
+
resolveTarget,
|
|
15
|
+
} from "./attach.ts";
|
|
16
|
+
import {
|
|
17
|
+
archSupported,
|
|
18
|
+
BROWSER_COMMANDS,
|
|
19
|
+
browserServices,
|
|
20
|
+
browserUnits,
|
|
21
|
+
CHROME_UNIT_PATH,
|
|
22
|
+
DEFAULT_CDP_PORT,
|
|
23
|
+
defaultBrowserOptions,
|
|
24
|
+
hermesBrowserSettings,
|
|
25
|
+
portsFromUnits,
|
|
26
|
+
type Unit,
|
|
27
|
+
VNC_PASSWORD_LENGTH,
|
|
28
|
+
vncPasswordFileFromUnit,
|
|
29
|
+
vncPasswordPath,
|
|
30
|
+
XVNC_UNIT_PATH,
|
|
31
|
+
} from "./browser.ts";
|
|
32
|
+
import {
|
|
33
|
+
type Config,
|
|
34
|
+
parseCliArgs,
|
|
35
|
+
type RawOptions,
|
|
36
|
+
resolveConfig,
|
|
37
|
+
savePersisted,
|
|
38
|
+
} from "./config.ts";
|
|
39
|
+
import { openDb } from "./db.ts";
|
|
40
|
+
import { type Check, homeFor, onPath, runDoctor } from "./doctor.ts";
|
|
41
|
+
import { HERMES_INSTALL } from "./harness.ts";
|
|
42
|
+
import { startServer } from "./index.ts";
|
|
43
|
+
import { color, describeError, log } from "./log.ts";
|
|
44
|
+
import { reapLeaks } from "./reaper.ts";
|
|
45
|
+
import { ENTRY, FETCH_WASM, IS_COMPILED } from "./runtime.ts";
|
|
46
|
+
|
|
47
|
+
const run = promisify(execFile);
|
|
48
|
+
const BUN = process.execPath;
|
|
49
|
+
/** Plenty for the fetcher's few lines of progress output. */
|
|
50
|
+
const FETCH_OUTPUT_LIMIT = 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
const HELP = `
|
|
53
|
+
${color.bold("vibe-os")} — a terminal multiplexer in the browser
|
|
54
|
+
|
|
55
|
+
${color.bold("Usage")}
|
|
56
|
+
vibe-os [start] serve the UI and the SSH bridge
|
|
57
|
+
vibe-os attach [window] attach a real terminal to a window's session
|
|
58
|
+
vibe-os doctor check this machine is ready
|
|
59
|
+
vibe-os install-service write and enable a systemd unit (needs root)
|
|
60
|
+
vibe-os install-browser run one Chrome on a virtual display (needs root)
|
|
61
|
+
vibe-os connect-hermes point Hermes' browser tools at that Chrome
|
|
62
|
+
vibe-os fetch-wasm (re)download the SSH WASM runtime
|
|
63
|
+
|
|
64
|
+
${color.bold("Options")}
|
|
65
|
+
--port <n> HTTP port (default 80)
|
|
66
|
+
--host <addr> bind address (default 0.0.0.0)
|
|
67
|
+
--domain <fqdn> provision a Let's Encrypt certificate and serve HTTPS
|
|
68
|
+
--email <addr> contact address for Let's Encrypt
|
|
69
|
+
--acme-staging use the Let's Encrypt staging environment
|
|
70
|
+
--tls-port <n> HTTPS port (default 443)
|
|
71
|
+
|
|
72
|
+
--token [value] the token to require (default: the last one used, or
|
|
73
|
+
a new one, remembered in the state dir)
|
|
74
|
+
--no-token disable the gate (prints a warning)
|
|
75
|
+
--allowed-host <name>
|
|
76
|
+
with no token, a name this server answers to beyond
|
|
77
|
+
its hostname, --domain and its Tailscale name.
|
|
78
|
+
Repeatable
|
|
79
|
+
|
|
80
|
+
--ssh-host <addr> SSH target for the bridge (default 127.0.0.1)
|
|
81
|
+
--ssh-port <n> SSH target port (default 22)
|
|
82
|
+
--ssh-advertise <host[:port]>
|
|
83
|
+
host to print in attach commands, when it is not the
|
|
84
|
+
one the browser reached the desktop on
|
|
85
|
+
--user <name> unix user to log in as (default: current user)
|
|
86
|
+
--no-sessions plain login shells instead of persistent dtach sessions
|
|
87
|
+
--cert-ttl <secs> certificate lifetime (default 43200)
|
|
88
|
+
|
|
89
|
+
--workspace <dir> root for projects and worktrees (default ~/workspace)
|
|
90
|
+
--state-dir <dir> CA and TLS material (default ~/.vibe-os)
|
|
91
|
+
--web-root <dir> built web assets
|
|
92
|
+
|
|
93
|
+
-h, --help show this
|
|
94
|
+
-v, --version print the version
|
|
95
|
+
|
|
96
|
+
${color.bold("install-browser")}
|
|
97
|
+
--geometry <WxH> virtual screen size (default 1600x900)
|
|
98
|
+
--display <n> X display number (default 99)
|
|
99
|
+
--vnc-port <n> VNC port, bound to loopback (default 5900)
|
|
100
|
+
--cdp-port <n> Chrome debug port, bound to loopback (default 9222)
|
|
101
|
+
--restart-at <expr> nightly restart, a systemd OnCalendar expression
|
|
102
|
+
(default '*-*-* 02:00:00', the box's own timezone)
|
|
103
|
+
--no-restart do not install the nightly restart timer
|
|
104
|
+
--vnc-password [value]
|
|
105
|
+
require a VNC password; generates and prints one if
|
|
106
|
+
omitted. macOS Screen Sharing will not connect without
|
|
107
|
+
this. An existing one is kept unless --no-vnc-password
|
|
108
|
+
--no-vnc-password serve the display with no authentication
|
|
109
|
+
|
|
110
|
+
${color.bold("connect-hermes")}
|
|
111
|
+
--cdp-port <n> the debug port to point Hermes at, when there is no
|
|
112
|
+
installed browser unit to read it from
|
|
113
|
+
|
|
114
|
+
${color.bold("doctor")}
|
|
115
|
+
--reap kill processes that outlived their workspace or session
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
function version(): string {
|
|
119
|
+
return pkg.version;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Prints the check list.
|
|
124
|
+
*
|
|
125
|
+
* Failures carry the command that fixes them, indented underneath, because the
|
|
126
|
+
* person reading this is on a fresh box and should not have to go and look it
|
|
127
|
+
* up somewhere else.
|
|
128
|
+
*/
|
|
129
|
+
function printChecks(checks: Check[]): number {
|
|
130
|
+
console.log("");
|
|
131
|
+
let failed = 0;
|
|
132
|
+
for (const check of checks) {
|
|
133
|
+
const mark = check.ok
|
|
134
|
+
? color.green("\u2713")
|
|
135
|
+
: check.fatal
|
|
136
|
+
? color.red("\u2717")
|
|
137
|
+
: color.yellow("!");
|
|
138
|
+
if (!check.ok && check.fatal) failed += 1;
|
|
139
|
+
console.log(
|
|
140
|
+
` ${mark} ${color.bold(check.label.padEnd(14))} ${color.dim(check.detail)}`,
|
|
141
|
+
);
|
|
142
|
+
if (!check.ok && check.fix)
|
|
143
|
+
console.log(` ${" ".repeat(14)} ${color.cyan(check.fix)}`);
|
|
144
|
+
}
|
|
145
|
+
console.log("");
|
|
146
|
+
if (failed > 0) {
|
|
147
|
+
console.log(
|
|
148
|
+
` ${color.red(`${failed} blocking problem${failed === 1 ? "" : "s"}.`)} vibe-os will not work until these are fixed.`,
|
|
149
|
+
);
|
|
150
|
+
console.log("");
|
|
151
|
+
}
|
|
152
|
+
return failed > 0 ? 1 : 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const UNIT_PATH = "/etc/systemd/system/vibe-os.service";
|
|
156
|
+
|
|
157
|
+
async function installService(config: Config, argv: string[]): Promise<number> {
|
|
158
|
+
if (process.getuid?.() !== 0) {
|
|
159
|
+
log.error(
|
|
160
|
+
"install-service must run as root (try: sudo vibe-os install-service …)",
|
|
161
|
+
);
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Forward the flags used here to the unit so the service behaves identically.
|
|
166
|
+
const forwarded = argv.filter((a) => a !== "install-service");
|
|
167
|
+
|
|
168
|
+
const user = process.env.SUDO_USER ?? config.user;
|
|
169
|
+
if (!user || user === "unknown" || user === "root") {
|
|
170
|
+
log.error(
|
|
171
|
+
`refusing to install a unit that runs as ${user || "an unknown user"} — ` +
|
|
172
|
+
"windows would get a root shell, and the CA line would land in root's home.",
|
|
173
|
+
);
|
|
174
|
+
log.error(
|
|
175
|
+
"run this with sudo from your own account, or pass --user <name>.",
|
|
176
|
+
);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Asked for, not built from a template: a home directory is whatever the
|
|
181
|
+
// passwd entry says, and /home/<user> is only usually right.
|
|
182
|
+
const home = await homeFor(user);
|
|
183
|
+
if (!home) {
|
|
184
|
+
log.error(`could not resolve a home directory for ${user}`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A compiled binary is its own entry point.
|
|
190
|
+
*
|
|
191
|
+
* `process.execPath` plus the source path is right from a checkout and wrong
|
|
192
|
+
* everywhere else: inside a standalone executable the source lives at a
|
|
193
|
+
* virtual `/$bunfs/` path that exists only while the process is running. The
|
|
194
|
+
* generated unit would pass that path as the subcommand and fail on every
|
|
195
|
+
* boot — on precisely the install the VPS instructions recommend.
|
|
196
|
+
*/
|
|
197
|
+
const command = IS_COMPILED ? process.execPath : `${BUN} ${ENTRY}`;
|
|
198
|
+
|
|
199
|
+
const unit = `[Unit]
|
|
200
|
+
Description=vibe-os — terminal multiplexer in the browser
|
|
201
|
+
After=network-online.target sshd.service
|
|
202
|
+
Wants=network-online.target
|
|
203
|
+
|
|
204
|
+
[Service]
|
|
205
|
+
Type=simple
|
|
206
|
+
User=${user}
|
|
207
|
+
Environment=HOME=${home}
|
|
208
|
+
# systemd hands a service a minimal PATH, which omits the ~/.local/bin that the
|
|
209
|
+
# Claude installer — and most "curl | sh" installers — write to. A login shell
|
|
210
|
+
# gets it from .profile, so without this the harness a window launches resolves
|
|
211
|
+
# and the server's own lookup of the same binary does not.
|
|
212
|
+
Environment=PATH=${home}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
213
|
+
Environment=NODE_ENV=production
|
|
214
|
+
WorkingDirectory=${home}
|
|
215
|
+
ExecStart=${command} start${forwarded.length ? ` ${forwarded.join(" ")}` : ""}
|
|
216
|
+
Restart=on-failure
|
|
217
|
+
RestartSec=2
|
|
218
|
+
# Lets an unprivileged user bind port 80 without setcap on the node binary.
|
|
219
|
+
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
220
|
+
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
|
221
|
+
NoNewPrivileges=false
|
|
222
|
+
|
|
223
|
+
[Install]
|
|
224
|
+
WantedBy=multi-user.target
|
|
225
|
+
`;
|
|
226
|
+
|
|
227
|
+
await writeFile(UNIT_PATH, unit, { mode: 0o644 });
|
|
228
|
+
log.ok(`wrote ${UNIT_PATH}`);
|
|
229
|
+
|
|
230
|
+
await run("systemctl", ["daemon-reload"]);
|
|
231
|
+
await run("systemctl", ["enable", "--now", "vibe-os"]);
|
|
232
|
+
log.ok("enabled and started vibe-os.service");
|
|
233
|
+
console.log("");
|
|
234
|
+
console.log(` logs: ${color.cyan("journalctl -u vibe-os -f")}`);
|
|
235
|
+
console.log(` restart: ${color.cyan("systemctl restart vibe-os")}`);
|
|
236
|
+
console.log("");
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Installs the always-on browser.
|
|
242
|
+
*
|
|
243
|
+
* Separate from `install-service` on purpose. vibe-os runs perfectly well with
|
|
244
|
+
* no browser on the box, the packages this needs are large, and Chrome for
|
|
245
|
+
* Linux is x86_64 only — so this is opted into rather than arriving with
|
|
246
|
+
* everything else.
|
|
247
|
+
*/
|
|
248
|
+
async function installBrowser(
|
|
249
|
+
config: Config,
|
|
250
|
+
values: RawOptions,
|
|
251
|
+
): Promise<number> {
|
|
252
|
+
if (process.getuid?.() !== 0) {
|
|
253
|
+
log.error(
|
|
254
|
+
"install-browser must run as root (try: sudo vibe-os install-browser …)",
|
|
255
|
+
);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!archSupported()) {
|
|
260
|
+
log.error(
|
|
261
|
+
`Google publishes no Chrome for Linux on ${os.arch()} — only x86_64.`,
|
|
262
|
+
);
|
|
263
|
+
log.error(
|
|
264
|
+
"Chromium is not a substitute here: the Claude extension requires Chrome itself.",
|
|
265
|
+
);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const user = process.env.SUDO_USER ?? config.user;
|
|
270
|
+
if (!user || user === "unknown" || user === "root") {
|
|
271
|
+
log.error(
|
|
272
|
+
`refusing to run a browser as ${user || "an unknown user"} — Chrome will ` +
|
|
273
|
+
"not start as root without --no-sandbox, and turning the sandbox off on " +
|
|
274
|
+
"a box that holds your credentials is not a trade worth making.",
|
|
275
|
+
);
|
|
276
|
+
log.error("run this with sudo from your own account.");
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const home = await homeFor(user);
|
|
281
|
+
if (!home) {
|
|
282
|
+
log.error(`could not resolve a home directory for ${user}`);
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Every missing command at once. Finding out about them one install at a time
|
|
287
|
+
// is three round trips to a box you are probably ssh'd into.
|
|
288
|
+
const missing: string[] = [];
|
|
289
|
+
for (const entry of BROWSER_COMMANDS) {
|
|
290
|
+
if (!(await onPath(entry.command)))
|
|
291
|
+
missing.push(`${entry.command} (${entry.package}) — ${entry.why}`);
|
|
292
|
+
}
|
|
293
|
+
if (missing.length > 0) {
|
|
294
|
+
log.error("missing commands:");
|
|
295
|
+
for (const line of missing) console.log(` ${line}`);
|
|
296
|
+
console.log("");
|
|
297
|
+
console.log(
|
|
298
|
+
` ${color.cyan("sudo apt install -y tigervnc-standalone-server openbox x11-utils")}`,
|
|
299
|
+
);
|
|
300
|
+
console.log(
|
|
301
|
+
` ${color.dim("Chrome is not in the distro repos; see the README for the Google apt repo.")}`,
|
|
302
|
+
);
|
|
303
|
+
console.log("");
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const opts = defaultBrowserOptions(user, home);
|
|
308
|
+
if (values.geometry) opts.geometry = String(values.geometry);
|
|
309
|
+
if (values.display) opts.display = numeric(values.display, "display");
|
|
310
|
+
if (values["vnc-port"])
|
|
311
|
+
opts.vncPort = numeric(values["vnc-port"], "vnc-port");
|
|
312
|
+
if (values["cdp-port"])
|
|
313
|
+
opts.cdpPort = numeric(values["cdp-port"], "cdp-port");
|
|
314
|
+
if (values["restart-at"]) opts.restartAt = String(values["restart-at"]);
|
|
315
|
+
if (values["no-restart"]) opts.restartAt = null;
|
|
316
|
+
|
|
317
|
+
// VNC authentication, resolved before anything is written.
|
|
318
|
+
//
|
|
319
|
+
// The default is "whatever this box already had". install-browser rewrites
|
|
320
|
+
// every unit, so a reinstall is exactly when a password someone set by hand
|
|
321
|
+
// would disappear without a word.
|
|
322
|
+
const existing = vncPasswordFileFromUnit(
|
|
323
|
+
await readFile(XVNC_UNIT_PATH, "utf8").catch(() => null),
|
|
324
|
+
);
|
|
325
|
+
let generated: string | null = null;
|
|
326
|
+
|
|
327
|
+
if (values["no-vnc-password"]) {
|
|
328
|
+
opts.vncPasswordFile = null;
|
|
329
|
+
} else if (values["vnc-password"] !== undefined) {
|
|
330
|
+
const supplied = String(values["vnc-password"]);
|
|
331
|
+
// A bare --vnc-password means "make one up", and the caller has to be told
|
|
332
|
+
// what it was, so remember it for the summary rather than only writing it.
|
|
333
|
+
if (!supplied) generated = randomVncPassword();
|
|
334
|
+
const password = supplied || (generated as string);
|
|
335
|
+
const file = vncPasswordPath(home);
|
|
336
|
+
try {
|
|
337
|
+
await writeVncPassword(file, password, user);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
log.error(`could not write ${file}: ${describeError(err)}`);
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
342
|
+
opts.vncPasswordFile = file;
|
|
343
|
+
if (supplied.length > VNC_PASSWORD_LENGTH)
|
|
344
|
+
log.warn(
|
|
345
|
+
`VNC authentication truncates at ${VNC_PASSWORD_LENGTH} characters, so only the first ${VNC_PASSWORD_LENGTH} count`,
|
|
346
|
+
);
|
|
347
|
+
} else if (existing) {
|
|
348
|
+
opts.vncPasswordFile = existing;
|
|
349
|
+
log.info(`keeping the VNC password already set in ${existing}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let units: Unit[];
|
|
353
|
+
try {
|
|
354
|
+
units = browserUnits(opts);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
log.error(describeError(err));
|
|
357
|
+
return 1;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// systemd is the authority on its own calendar syntax, and a timer that never
|
|
361
|
+
// fires is silent. Ask it before writing the unit rather than after.
|
|
362
|
+
if (opts.restartAt) {
|
|
363
|
+
try {
|
|
364
|
+
await run("systemd-analyze", ["calendar", opts.restartAt]);
|
|
365
|
+
} catch {
|
|
366
|
+
log.error(
|
|
367
|
+
`systemd cannot parse --restart-at ${JSON.stringify(opts.restartAt)}`,
|
|
368
|
+
);
|
|
369
|
+
log.error(
|
|
370
|
+
"try: --restart-at '*-*-* 02:00:00 America/New_York' (include the timezone)",
|
|
371
|
+
);
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
await mkdir(opts.profileDir, { recursive: true });
|
|
377
|
+
await run("chown", ["-R", `${user}:`, opts.profileDir]).catch(() => {});
|
|
378
|
+
|
|
379
|
+
for (const unit of units) {
|
|
380
|
+
await writeFile(`/etc/systemd/system/${unit.name}`, unit.contents, {
|
|
381
|
+
mode: 0o644,
|
|
382
|
+
});
|
|
383
|
+
log.ok(`wrote /etc/systemd/system/${unit.name}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await run("systemctl", ["daemon-reload"]);
|
|
387
|
+
await run("systemctl", ["enable", "--now", ...browserServices(opts)]);
|
|
388
|
+
log.ok("enabled and started the browser");
|
|
389
|
+
|
|
390
|
+
console.log("");
|
|
391
|
+
console.log(
|
|
392
|
+
` view it: ${color.cyan(`ssh -L ${opts.vncPort}:127.0.0.1:${opts.vncPort} ${user}@<this-host>`)}`,
|
|
393
|
+
);
|
|
394
|
+
console.log(
|
|
395
|
+
` ${color.dim(`then point any VNC viewer at 127.0.0.1:${opts.vncPort}`)}`,
|
|
396
|
+
);
|
|
397
|
+
console.log(` logs: ${color.cyan("journalctl -u vibe-os-chrome -f")}`);
|
|
398
|
+
console.log(` restart: ${color.cyan("systemctl restart vibe-os-chrome")}`);
|
|
399
|
+
if (opts.restartAt) console.log(` nightly: ${color.dim(opts.restartAt)}`);
|
|
400
|
+
console.log("");
|
|
401
|
+
if (generated) {
|
|
402
|
+
console.log(` ${color.bold("VNC password:")} ${color.cyan(generated)}`);
|
|
403
|
+
console.log(
|
|
404
|
+
` ${color.dim("shown once, and stored obfuscated in " + opts.vncPasswordFile)}`,
|
|
405
|
+
);
|
|
406
|
+
} else if (opts.vncPasswordFile) {
|
|
407
|
+
console.log(
|
|
408
|
+
` ${color.dim(`VNC password from ${opts.vncPasswordFile}, username blank`)}`,
|
|
409
|
+
);
|
|
410
|
+
} else {
|
|
411
|
+
console.log(
|
|
412
|
+
` ${color.dim("No VNC password. macOS Screen Sharing needs one: rerun with --vnc-password")}`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
console.log("");
|
|
416
|
+
console.log(
|
|
417
|
+
` ${color.dim("Sign in to the extension once, through the viewer. The profile keeps it.")}`,
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// Last, and quietly. Hermes usually arrives after the browser, so not finding
|
|
421
|
+
// it is the common case rather than a failure worth interrupting the summary.
|
|
422
|
+
if (await connectHermes(user, home, opts.cdpPort, true)) {
|
|
423
|
+
console.log("");
|
|
424
|
+
console.log(
|
|
425
|
+
` ${color.dim(`Hermes browser tools point at 127.0.0.1:${opts.cdpPort}.`)}`,
|
|
426
|
+
);
|
|
427
|
+
} else {
|
|
428
|
+
console.log("");
|
|
429
|
+
console.log(
|
|
430
|
+
` ${color.dim("For Hermes browser tools, install it and run: vibe-os connect-hermes")}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
console.log("");
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Points Hermes' browser tools at this box's Chrome.
|
|
439
|
+
*
|
|
440
|
+
* Every setting goes through `hermes config set` rather than into the YAML
|
|
441
|
+
* directly. Hermes owns that file, knows which of its keys are secrets and
|
|
442
|
+
* belong in `.env` instead, and migrates its own schema between versions —
|
|
443
|
+
* three things a hand-written merge here would have to keep guessing at.
|
|
444
|
+
*
|
|
445
|
+
* ── Why this is a box-wide setting and not a profile one ─────────────────────
|
|
446
|
+
* The CDP target lives at `browser.cdp_url` in `~/.hermes/config.yaml` and has
|
|
447
|
+
* no command-line equivalent, so there is nothing a profile could carry. Doing
|
|
448
|
+
* it per launch would mean two Python startups before every window and a global
|
|
449
|
+
* file rewritten by whichever window opened last.
|
|
450
|
+
*
|
|
451
|
+
* Returns false when Hermes is not installed, which is not an error: plenty of
|
|
452
|
+
* boxes run the browser for the Claude extension and nothing else.
|
|
453
|
+
*/
|
|
454
|
+
async function connectHermes(
|
|
455
|
+
user: string,
|
|
456
|
+
home: string,
|
|
457
|
+
cdpPort: number,
|
|
458
|
+
/** True when this process is root and has to drop to the login user. */
|
|
459
|
+
asUser: boolean,
|
|
460
|
+
): Promise<boolean> {
|
|
461
|
+
/*
|
|
462
|
+
* A login shell, deliberately.
|
|
463
|
+
*
|
|
464
|
+
* Hermes installs to `/usr/local/bin` when it can write there and
|
|
465
|
+
* `~/.local/bin` when it cannot, and root's PATH reaches neither reliably
|
|
466
|
+
* under systemd. `runuser -l` runs `.profile`, which is what put the second
|
|
467
|
+
* one on PATH in the first place. runuser is util-linux, the same package
|
|
468
|
+
* `flock` comes from, so it is present wherever the session lock already is.
|
|
469
|
+
*/
|
|
470
|
+
const asLoginUser = (script: string): [string, string[]] =>
|
|
471
|
+
asUser
|
|
472
|
+
? ["runuser", ["-u", user, "--", "/bin/sh", "-lc", script]]
|
|
473
|
+
: ["/bin/sh", ["-lc", script]];
|
|
474
|
+
|
|
475
|
+
const [probe, probeArgs] = asLoginUser("command -v hermes");
|
|
476
|
+
const installed = await run(probe, probeArgs, {
|
|
477
|
+
timeout: 10_000,
|
|
478
|
+
env: { ...process.env, HOME: home },
|
|
479
|
+
})
|
|
480
|
+
.then(({ stdout }) => Boolean(stdout.trim()))
|
|
481
|
+
.catch(() => false);
|
|
482
|
+
if (!installed) return false;
|
|
483
|
+
|
|
484
|
+
for (const { key, value } of hermesBrowserSettings(cdpPort)) {
|
|
485
|
+
// Quoted as a single argument: a value never contains a space today, and
|
|
486
|
+
// the day one does is not the day to find out this was a bare expansion.
|
|
487
|
+
const [cmd, args] = asLoginUser(
|
|
488
|
+
`hermes config set ${JSON.stringify(key)} ${JSON.stringify(value)}`,
|
|
489
|
+
);
|
|
490
|
+
try {
|
|
491
|
+
await run(cmd, args, {
|
|
492
|
+
timeout: 60_000,
|
|
493
|
+
env: { ...process.env, HOME: home },
|
|
494
|
+
});
|
|
495
|
+
log.ok(`hermes ${key} = ${value}`);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
log.error(`could not set hermes ${key}: ${describeError(err)}`);
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* `vibe-os connect-hermes` — the same thing, for a Hermes installed later.
|
|
506
|
+
*
|
|
507
|
+
* `install-browser` does this as its last step, but it is the browser's
|
|
508
|
+
* installer and Hermes usually arrives after it. No root: it writes one file in
|
|
509
|
+
* your own home directory, through a command you own.
|
|
510
|
+
*/
|
|
511
|
+
async function connectHermesCommand(
|
|
512
|
+
config: Config,
|
|
513
|
+
values: RawOptions,
|
|
514
|
+
): Promise<number> {
|
|
515
|
+
const user = process.env.SUDO_USER ?? config.user;
|
|
516
|
+
const home = (await homeFor(user)) ?? os.homedir();
|
|
517
|
+
|
|
518
|
+
// What the unit actually opened, not what the default is. install-browser may
|
|
519
|
+
// have been given --cdp-port long ago, and writing the default into Hermes
|
|
520
|
+
// would point it at a port nothing is listening on.
|
|
521
|
+
const unit = await readFile(CHROME_UNIT_PATH, "utf8").catch(() => null);
|
|
522
|
+
const cdpPort = values["cdp-port"]
|
|
523
|
+
? numeric(values["cdp-port"], "cdp-port")
|
|
524
|
+
: unit
|
|
525
|
+
? portsFromUnits({ chrome: unit }).cdpPort
|
|
526
|
+
: DEFAULT_CDP_PORT;
|
|
527
|
+
|
|
528
|
+
if (!unit && !values["cdp-port"]) {
|
|
529
|
+
log.warn(
|
|
530
|
+
`no browser installed here, so this assumes the default port ${cdpPort}`,
|
|
531
|
+
);
|
|
532
|
+
log.warn("run sudo vibe-os install-browser first, or pass --cdp-port");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const connected = await connectHermes(
|
|
536
|
+
user,
|
|
537
|
+
home,
|
|
538
|
+
cdpPort,
|
|
539
|
+
process.getuid?.() === 0,
|
|
540
|
+
);
|
|
541
|
+
if (!connected) {
|
|
542
|
+
log.error(`hermes is not installed for ${user}`);
|
|
543
|
+
console.log("");
|
|
544
|
+
console.log(` ${color.cyan(HERMES_INSTALL)}`);
|
|
545
|
+
console.log("");
|
|
546
|
+
return 1;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
console.log("");
|
|
550
|
+
console.log(
|
|
551
|
+
` Hermes browser tools will drive the box's Chrome on 127.0.0.1:${cdpPort}.`,
|
|
552
|
+
);
|
|
553
|
+
console.log(
|
|
554
|
+
` ${color.dim("Watch it work: ssh -L 5900:127.0.0.1:5900, then any VNC viewer.")}`,
|
|
555
|
+
);
|
|
556
|
+
console.log("");
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A flag that must be a positive integer, rejected loudly when it is not. */
|
|
561
|
+
function numeric(value: string | boolean, label: string): number {
|
|
562
|
+
const n = Number(String(value));
|
|
563
|
+
if (!Number.isInteger(n) || n < 0)
|
|
564
|
+
throw new Error(`invalid --${label}: ${String(value)}`);
|
|
565
|
+
return n;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Eight characters, because VNC authentication silently ignores the rest.
|
|
570
|
+
*
|
|
571
|
+
* Alphanumeric only: this gets typed into a viewer's password box by hand, and
|
|
572
|
+
* a character that needs a modifier on somebody's keyboard layout is a support
|
|
573
|
+
* question rather than security.
|
|
574
|
+
*/
|
|
575
|
+
function randomVncPassword(): string {
|
|
576
|
+
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
|
|
577
|
+
const bytes = randomBytes(VNC_PASSWORD_LENGTH * 2);
|
|
578
|
+
let out = "";
|
|
579
|
+
for (const byte of bytes) {
|
|
580
|
+
if (out.length === VNC_PASSWORD_LENGTH) break;
|
|
581
|
+
// Reject the tail of the byte range rather than modulo it, so every
|
|
582
|
+
// character stays equally likely.
|
|
583
|
+
if (byte >= 256 - (256 % alphabet.length)) continue;
|
|
584
|
+
out += alphabet[byte % alphabet.length];
|
|
585
|
+
}
|
|
586
|
+
return out.padEnd(VNC_PASSWORD_LENGTH, "x");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Writes TigerVNC's obfuscated password file.
|
|
591
|
+
*
|
|
592
|
+
* The plaintext goes in on stdin rather than as an argument: an argument would
|
|
593
|
+
* be visible in `ps` for as long as the call takes, on the one command whose
|
|
594
|
+
* entire purpose is to keep a secret.
|
|
595
|
+
*/
|
|
596
|
+
async function writeVncPassword(
|
|
597
|
+
file: string,
|
|
598
|
+
password: string,
|
|
599
|
+
user: string,
|
|
600
|
+
): Promise<void> {
|
|
601
|
+
const result = spawnSync("vncpasswd", ["-f"], { input: password });
|
|
602
|
+
if (result.error) throw result.error;
|
|
603
|
+
if (result.status !== 0)
|
|
604
|
+
throw new Error(
|
|
605
|
+
`vncpasswd exited ${result.status}: ${String(result.stderr).trim()}`,
|
|
606
|
+
);
|
|
607
|
+
const obfuscated = result.stdout;
|
|
608
|
+
if (!obfuscated || obfuscated.length === 0)
|
|
609
|
+
throw new Error("vncpasswd produced an empty password file");
|
|
610
|
+
|
|
611
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
612
|
+
await writeFile(file, obfuscated, { mode: 0o600 });
|
|
613
|
+
// Written by root, read by the X server running as the login user.
|
|
614
|
+
await run("chown", [`${user}:`, file]).catch(() => {});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Lets someone pick a window when they did not name one.
|
|
619
|
+
*
|
|
620
|
+
* The reason this exists: on your own laptop you do not have a window id or a
|
|
621
|
+
* ref, you have "the thing I was doing yesterday". `ssh -t box vibe-os attach`
|
|
622
|
+
* with nothing after it is the command worth remembering, and this is what
|
|
623
|
+
* makes it answerable.
|
|
624
|
+
*
|
|
625
|
+
* Returns undefined when the person backs out, which must not be confused with
|
|
626
|
+
* a failure — quitting the picker is a perfectly good outcome.
|
|
627
|
+
*/
|
|
628
|
+
async function pickTarget(
|
|
629
|
+
config: Config,
|
|
630
|
+
targets: AttachTarget[],
|
|
631
|
+
): Promise<AttachTarget | undefined> {
|
|
632
|
+
const live = await liveSessions(config);
|
|
633
|
+
console.log("");
|
|
634
|
+
console.log(
|
|
635
|
+
` ${color.bold(color.cyan("vibe-os"))} ${color.dim(`· ${targets.length} window${targets.length === 1 ? "" : "s"}`)}`,
|
|
636
|
+
);
|
|
637
|
+
console.log("");
|
|
638
|
+
targets.forEach((t, i) => {
|
|
639
|
+
const n = color.bold(String(i + 1).padStart(3));
|
|
640
|
+
const state = live.has(t.session) ? color.green("live") : color.dim("idle");
|
|
641
|
+
const role = t.role ?? color.dim("terminal");
|
|
642
|
+
console.log(
|
|
643
|
+
` ${n} ${state} ${t.ref.padEnd(28)} ${role.padEnd(22)} ${color.dim(`${t.project}/${t.workspace}`)}`,
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
console.log("");
|
|
647
|
+
|
|
648
|
+
// Closed before anything is spawned: dtach needs the terminal in raw mode and
|
|
649
|
+
// readline holds it in canonical mode until it lets go.
|
|
650
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
651
|
+
const answer = await rl.question(
|
|
652
|
+
` attach [1-${targets.length}, q to quit]: `,
|
|
653
|
+
);
|
|
654
|
+
rl.close();
|
|
655
|
+
|
|
656
|
+
const choice = Number(answer.trim());
|
|
657
|
+
if (!Number.isInteger(choice) || choice < 1 || choice > targets.length)
|
|
658
|
+
return undefined;
|
|
659
|
+
return targets[choice - 1];
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Attaches this terminal to a window's session.
|
|
664
|
+
*
|
|
665
|
+
* The command comes from session.ts, which is the same place the certificate
|
|
666
|
+
* signer gets it — so arriving over ssh puts you in the same session, in the
|
|
667
|
+
* same worktree, running the same harness as the browser would. Composing a
|
|
668
|
+
* dtach invocation here instead would be one line shorter and would drift away
|
|
669
|
+
* from the browser's the first time either changed.
|
|
670
|
+
*/
|
|
671
|
+
async function attach(
|
|
672
|
+
config: Config,
|
|
673
|
+
ref: string | undefined,
|
|
674
|
+
): Promise<number> {
|
|
675
|
+
if (!config.sessions) {
|
|
676
|
+
log.error(
|
|
677
|
+
"attach needs dtach — this server runs plain login shells (--no-sessions)",
|
|
678
|
+
);
|
|
679
|
+
return 1;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const db = openDb(config.stateDir);
|
|
683
|
+
const targets = listTargets(db);
|
|
684
|
+
if (targets.length === 0) {
|
|
685
|
+
log.error(`no windows in ${config.stateDir}`);
|
|
686
|
+
// Almost always the cause: sshd logged you in as someone else, so the
|
|
687
|
+
// state directory resolved to a different home and vibe-os made an empty
|
|
688
|
+
// database there rather than reading the one with the windows in it.
|
|
689
|
+
log.error(
|
|
690
|
+
`open the desktop and make one, or check you are logged in as the user vibe-os runs as`,
|
|
691
|
+
);
|
|
692
|
+
return 1;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const target = ref
|
|
696
|
+
? resolveTarget(db, ref)
|
|
697
|
+
: await pickTarget(config, targets);
|
|
698
|
+
if (ref && !target) {
|
|
699
|
+
log.error(`no window called ${ref}`);
|
|
700
|
+
console.log("");
|
|
701
|
+
for (const t of targets) console.log(` ${t.ref}`);
|
|
702
|
+
console.log("");
|
|
703
|
+
return 1;
|
|
704
|
+
}
|
|
705
|
+
if (!target) return 0;
|
|
706
|
+
|
|
707
|
+
const command = commandFor(db, config, target);
|
|
708
|
+
if (!command) {
|
|
709
|
+
log.error(`could not build a command for ${target.ref}`);
|
|
710
|
+
return 1;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/*
|
|
714
|
+
* Run through a shell, because that is what the string is written for.
|
|
715
|
+
*
|
|
716
|
+
* It is the same string sshd hands to `$SHELL -c` as a forced command, `\;`
|
|
717
|
+
* separators and two layers of quoting included. Handing it to a shell here
|
|
718
|
+
* is what makes the two paths identical rather than merely similar.
|
|
719
|
+
*
|
|
720
|
+
* An async spawn, awaited, where this used to be spawnSync — and on Linux
|
|
721
|
+
* that difference is a CPU core. Inside Bun's spawnSync wait the main
|
|
722
|
+
* thread sat in a zero-timeout poll, ~35k epoll_pwait2 a second returning
|
|
723
|
+
* immediately, for the life of the child; once the terminal was gone
|
|
724
|
+
* nothing ended it, and one attach burned a core for eight days (#39).
|
|
725
|
+
* Awaiting an ordinary spawn leaves the event loop in its normal blocking
|
|
726
|
+
* wait. There is still no exec() to replace the process with in a Bun
|
|
727
|
+
* binary, so this stays resident for the session: idle now, not spinning.
|
|
728
|
+
*/
|
|
729
|
+
const child = spawn("/bin/sh", ["-c", command], { stdio: "inherit" });
|
|
730
|
+
|
|
731
|
+
/*
|
|
732
|
+
* A client must not survive its terminal (#40, rule 2).
|
|
733
|
+
*
|
|
734
|
+
* When the terminal dies the kernel sends SIGHUP to the foreground process
|
|
735
|
+
* group, which is both of us. Forward it, give dtach a moment to detach
|
|
736
|
+
* cleanly, then leave regardless — whatever the state of the wait above,
|
|
737
|
+
* this caps a lost terminal at seconds rather than days. SIGTERM gets the
|
|
738
|
+
* same treatment so `kill <pid>` takes the pair down, not half of it.
|
|
739
|
+
*/
|
|
740
|
+
for (const [signal, code] of [
|
|
741
|
+
["SIGHUP", 129],
|
|
742
|
+
["SIGTERM", 143],
|
|
743
|
+
] as const) {
|
|
744
|
+
process.on(signal, () => {
|
|
745
|
+
child.kill(signal);
|
|
746
|
+
setTimeout(() => process.exit(code), 2_000).unref();
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return await new Promise<number>((resolve) => {
|
|
751
|
+
child.once("error", (err) => {
|
|
752
|
+
log.error(`could not start the session: ${err.message}`);
|
|
753
|
+
resolve(1);
|
|
754
|
+
});
|
|
755
|
+
child.once("exit", (code) => resolve(code ?? 0));
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export async function main(argv: string[]): Promise<number> {
|
|
760
|
+
let parsed: ReturnType<typeof parseCliArgs>;
|
|
761
|
+
try {
|
|
762
|
+
parsed = parseCliArgs(argv);
|
|
763
|
+
} catch (err) {
|
|
764
|
+
log.error(describeError(err));
|
|
765
|
+
console.log(HELP);
|
|
766
|
+
return 1;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const { values, positionals } = parsed;
|
|
770
|
+
const command = positionals[0] ?? "start";
|
|
771
|
+
|
|
772
|
+
if (values.help) {
|
|
773
|
+
console.log(HELP);
|
|
774
|
+
return 0;
|
|
775
|
+
}
|
|
776
|
+
if (values.version) {
|
|
777
|
+
console.log(version());
|
|
778
|
+
return 0;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Inside the same guard as arg parsing: `--port abc` and `--bogus` are the
|
|
782
|
+
// same class of mistake, and only one of them used to get a readable answer.
|
|
783
|
+
// `resolveConfig` throws for any unparsable numeric flag, and main.ts has no
|
|
784
|
+
// catch, so the other one printed a raw unhandled-rejection stack trace.
|
|
785
|
+
let config: Awaited<ReturnType<typeof resolveConfig>>;
|
|
786
|
+
try {
|
|
787
|
+
config = await resolveConfig(values);
|
|
788
|
+
} catch (err) {
|
|
789
|
+
log.error(describeError(err));
|
|
790
|
+
console.log(HELP);
|
|
791
|
+
return 1;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
switch (command) {
|
|
795
|
+
case "start": {
|
|
796
|
+
// A generated token is only useful if it survives a restart.
|
|
797
|
+
if (config.token)
|
|
798
|
+
await savePersisted(config.stateDir, { token: config.token });
|
|
799
|
+
// Sweep up what the last life left behind: processes in deleted
|
|
800
|
+
// worktrees, sessions whose socket is gone. Teardown is supposed to make
|
|
801
|
+
// these impossible, and a crash mid-teardown makes them real anyway.
|
|
802
|
+
await reapLeaks(config).catch(() => {});
|
|
803
|
+
await startServer(config);
|
|
804
|
+
return -1; // keep running
|
|
805
|
+
}
|
|
806
|
+
case "attach":
|
|
807
|
+
return attach(config, positionals[1]);
|
|
808
|
+
case "doctor": {
|
|
809
|
+
// Reap first, so the check list below reports the box as it now is.
|
|
810
|
+
if (values.reap) await reapLeaks(config);
|
|
811
|
+
return printChecks(await runDoctor(config));
|
|
812
|
+
}
|
|
813
|
+
case "install-service":
|
|
814
|
+
return installService(config, argv);
|
|
815
|
+
case "install-browser":
|
|
816
|
+
return installBrowser(config, values);
|
|
817
|
+
case "connect-hermes":
|
|
818
|
+
return connectHermesCommand(config, values);
|
|
819
|
+
case "fetch-wasm": {
|
|
820
|
+
// A standalone binary carries the wasm inside it: there is no script on
|
|
821
|
+
// disk to run (FETCH_WASM points into the virtual /$bunfs root) and
|
|
822
|
+
// process.execPath is this binary rather than bun, so running it would
|
|
823
|
+
// re-invoke vibe-os with a path as its subcommand, hit the default arm,
|
|
824
|
+
// and surface as an unhandled rejection.
|
|
825
|
+
if (IS_COMPILED) {
|
|
826
|
+
log.info("this build has the SSH runtime embedded — nothing to fetch");
|
|
827
|
+
return 0;
|
|
828
|
+
}
|
|
829
|
+
const { stdout } = await run(process.execPath, [FETCH_WASM], {
|
|
830
|
+
maxBuffer: FETCH_OUTPUT_LIMIT,
|
|
831
|
+
});
|
|
832
|
+
process.stdout.write(stdout);
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
835
|
+
case "help":
|
|
836
|
+
console.log(HELP);
|
|
837
|
+
return 0;
|
|
838
|
+
default:
|
|
839
|
+
log.error(`unknown command: ${command}`);
|
|
840
|
+
console.log(HELP);
|
|
841
|
+
return 1;
|
|
842
|
+
}
|
|
843
|
+
}
|