@tsconfigdotjson/vibe-os 0.1.0 → 0.2.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.
@@ -0,0 +1,1151 @@
1
+ // `vibe-os setup`: doctor's checks, with an offer to fix each one.
2
+ //
3
+ // The rule every step follows is the one the README's agent prompt used to ask
4
+ // an agent for: say what is about to run, then run it. Each step checks first
5
+ // and stays quiet when there is nothing to do, so running setup again on a box
6
+ // it already set up is a cheap way to see that nothing drifted.
7
+ //
8
+ // It runs as the login user and reaches for sudo per command, rather than
9
+ // running as root throughout. Harness installers write to the home directory of
10
+ // whoever runs them, the token belongs in that user's state dir, and
11
+ // install-service reads SUDO_USER to decide who the unit runs as. All three are
12
+ // right by construction this way and would each need correcting from root.
13
+
14
+ import { spawn } from "node:child_process";
15
+ import {
16
+ access,
17
+ mkdir,
18
+ mkdtemp,
19
+ readFile,
20
+ rm,
21
+ writeFile,
22
+ } from "node:fs/promises";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { createInterface } from "node:readline/promises";
26
+
27
+ import { type Config, type RawOptions, savePersisted } from "./config.ts";
28
+ import {
29
+ authorizedKeysPaths,
30
+ homeFor,
31
+ onPath,
32
+ parseSshdConfig,
33
+ parseUfw,
34
+ probeTcp,
35
+ } from "./doctor.ts";
36
+ import {
37
+ CLAUDE_INSTALL,
38
+ CURSOR_INSTALL,
39
+ HERMES_INSTALL,
40
+ UV_INSTALL,
41
+ } from "./harness.ts";
42
+ import { color, describeError } from "./log.ts";
43
+ import { ENTRY, IS_COMPILED, invocation } from "./runtime.ts";
44
+
45
+ export const TAILSCALE_INSTALL =
46
+ "curl -fsSL https://tailscale.com/install.sh | sh";
47
+
48
+ /** The port setup serves on when not told otherwise, behind `tailscale serve`. */
49
+ export const SETUP_PORT = 7681;
50
+
51
+ const UNIT_PATH = "/etc/systemd/system/vibe-os.service";
52
+ const HARDENING_PATH = "/etc/ssh/sshd_config.d/01-hardening.conf";
53
+ const HARDENING =
54
+ "PasswordAuthentication no\nKbdInteractiveAuthentication no\n";
55
+
56
+ /** The transient timer that turns ufw back off unless someone confirms. */
57
+ const REVERT_UNIT = "vibe-os-firewall-revert";
58
+ const REVERT_AFTER_SECONDS = 300;
59
+ /** Written by `vibe-os confirm-firewall`, from a session over the tailnet. */
60
+ const CONFIRM_FILE = "firewall-confirmed";
61
+
62
+ // ── the pure parts ─────────────────────────────────────────────────────────
63
+
64
+ export type Manager = "apt" | "dnf" | "yum" | "pacman" | "zypper";
65
+
66
+ /**
67
+ * What a missing command is called in each distro's repositories.
68
+ *
69
+ * Only the names that differ are the reason this is a table: openssh is one
70
+ * package on Arch and two everywhere else, and the client half is
71
+ * `openssh-clients` on the RPM side.
72
+ */
73
+ const PACKAGES: Record<string, Record<Manager, string>> = {
74
+ sshd: {
75
+ apt: "openssh-server",
76
+ dnf: "openssh-server",
77
+ yum: "openssh-server",
78
+ pacman: "openssh",
79
+ zypper: "openssh-server",
80
+ },
81
+ "ssh-keygen": {
82
+ apt: "openssh-client",
83
+ dnf: "openssh-clients",
84
+ yum: "openssh-clients",
85
+ pacman: "openssh",
86
+ zypper: "openssh-clients",
87
+ },
88
+ "ssh-keyscan": {
89
+ apt: "openssh-client",
90
+ dnf: "openssh-clients",
91
+ yum: "openssh-clients",
92
+ pacman: "openssh",
93
+ zypper: "openssh-clients",
94
+ },
95
+ gh: {
96
+ apt: "gh",
97
+ dnf: "gh",
98
+ yum: "gh",
99
+ pacman: "github-cli",
100
+ zypper: "gh",
101
+ },
102
+ };
103
+
104
+ /** Package names for a list of commands, deduplicated, in the order given. */
105
+ export function packagesFor(commands: string[], manager: Manager): string[] {
106
+ const names = commands.map((c) => PACKAGES[c]?.[manager] ?? c);
107
+ return [...new Set(names)];
108
+ }
109
+
110
+ /** The commands that install packages, non-interactively. */
111
+ export function installCommands(manager: Manager, pkgs: string[]): string[][] {
112
+ switch (manager) {
113
+ case "apt":
114
+ // DEBIAN_FRONTEND keeps a package's debconf questions from stopping an
115
+ // unattended run; `env` carries it through sudo, which drops the rest of
116
+ // the environment.
117
+ return [
118
+ ["apt-get", "update"],
119
+ [
120
+ "env",
121
+ "DEBIAN_FRONTEND=noninteractive",
122
+ "apt-get",
123
+ "install",
124
+ "-y",
125
+ ...pkgs,
126
+ ],
127
+ ];
128
+ case "dnf":
129
+ case "yum":
130
+ return [[manager, "install", "-y", ...pkgs]];
131
+ case "pacman":
132
+ return [["pacman", "-S", "--needed", "--noconfirm", ...pkgs]];
133
+ case "zypper":
134
+ return [["zypper", "--non-interactive", "install", ...pkgs]];
135
+ }
136
+ }
137
+
138
+ /** A command as a person would type it, for showing before it runs. */
139
+ export function display(argv: string[]): string {
140
+ return argv
141
+ .map((a) =>
142
+ a === "" || /[^\w@%+=:,./-]/.test(a)
143
+ ? `'${a.replaceAll("'", `'\\''`)}'`
144
+ : a,
145
+ )
146
+ .join(" ");
147
+ }
148
+
149
+ export interface UnitFlags {
150
+ port: number | null;
151
+ host: string | null;
152
+ /** undefined: no token flag, so the service uses the remembered one. */
153
+ token: string | null | undefined;
154
+ }
155
+
156
+ /**
157
+ * The flags an installed vibe-os unit starts with.
158
+ *
159
+ * The service is the source of truth for where vibe-os is listening and which
160
+ * token it wants, and setup reads it rather than assuming its own defaults
161
+ * were the ones used: a unit installed by hand with `--port 8080` is served on
162
+ * 8080, and the URL printed at the end has to say so.
163
+ */
164
+ export function unitFlags(unit: string): UnitFlags {
165
+ const line = unit.split("\n").find((l) => l.startsWith("ExecStart="));
166
+ const words = line?.slice("ExecStart=".length).trim().split(/\s+/) ?? [];
167
+ const flags: UnitFlags = { port: null, host: null, token: undefined };
168
+
169
+ const value = (i: number, name: string): string | undefined => {
170
+ const word = words[i] as string;
171
+ if (word.startsWith(`${name}=`)) return word.slice(name.length + 1);
172
+ const next = words[i + 1];
173
+ return next === undefined || next.startsWith("-") ? "" : next;
174
+ };
175
+
176
+ words.forEach((word, i) => {
177
+ if (word === "--port" || word.startsWith("--port=")) {
178
+ const n = Number(value(i, "--port"));
179
+ if (Number.isInteger(n) && n > 0) flags.port = n;
180
+ } else if (word === "--host" || word.startsWith("--host=")) {
181
+ flags.host = value(i, "--host") || null;
182
+ } else if (word === "--no-token") {
183
+ flags.token = null;
184
+ } else if (word === "--token" || word.startsWith("--token=")) {
185
+ // A bare `--token` means "the remembered one", same as no flag at all.
186
+ flags.token = value(i, "--token") || undefined;
187
+ }
188
+ });
189
+ return flags;
190
+ }
191
+
192
+ /** Whether sshd still accepts a password, by any of its names for one. */
193
+ export function passwordLoginOn(sshd: Map<string, string>): boolean {
194
+ return (
195
+ sshd.get("passwordauthentication") !== "no" ||
196
+ // Absent on sshd older than 8.7, which calls it challengeresponse; absent
197
+ // is not the same as on.
198
+ (sshd.get("kbdinteractiveauthentication") ?? "no") !== "no"
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Whether an authorized_keys file lets somebody in with a key.
204
+ *
205
+ * The question before turning password logins off is whether that leaves any
206
+ * way in at all. The `cert-authority` line vibe-os writes does not count: it
207
+ * trusts certificates this box signs, which a person on a laptop does not have.
208
+ */
209
+ export function hasLoginKey(authorizedKeys: string): boolean {
210
+ return authorizedKeys.split("\n").some((raw) => {
211
+ const line = raw.trim();
212
+ return (
213
+ line.length > 0 &&
214
+ !line.startsWith("#") &&
215
+ !/\bcert-authority\b/.test(line)
216
+ );
217
+ });
218
+ }
219
+
220
+ export interface TailscaleState {
221
+ running: boolean;
222
+ /** The MagicDNS name, without the trailing dot. */
223
+ dnsName: string | null;
224
+ ip: string | null;
225
+ }
226
+
227
+ export function parseTailscaleStatus(json: string): TailscaleState {
228
+ try {
229
+ const status = JSON.parse(json) as {
230
+ BackendState?: string;
231
+ Self?: { DNSName?: string; TailscaleIPs?: string[] };
232
+ };
233
+ return {
234
+ running: status.BackendState === "Running",
235
+ dnsName: status.Self?.DNSName?.replace(/\.$/, "") || null,
236
+ ip: status.Self?.TailscaleIPs?.find((a) => a.includes(".")) ?? null,
237
+ };
238
+ } catch {
239
+ return { running: false, dnsName: null, ip: null };
240
+ }
241
+ }
242
+
243
+ /** Whether `tailscale serve status` already proxies to this port. */
244
+ export function serveCovers(status: string, port: number): boolean {
245
+ return new RegExp(`(127\\.0\\.0\\.1|localhost):${port}\\b`).test(status);
246
+ }
247
+
248
+ // ── running things ─────────────────────────────────────────────────────────
249
+
250
+ interface Options {
251
+ yes: boolean;
252
+ firewall: boolean;
253
+ hermes: boolean;
254
+ cursor: boolean;
255
+ }
256
+
257
+ /** Closed over by every step: how to ask, and how to run with privileges. */
258
+ class Session {
259
+ /** Things to do by hand afterwards, printed together at the end. */
260
+ readonly next: string[] = [];
261
+ private readonly sudo: string[];
262
+
263
+ constructor(
264
+ readonly opts: Options,
265
+ asRoot: boolean,
266
+ ) {
267
+ this.sudo = asRoot ? [] : ["sudo"];
268
+ }
269
+
270
+ heading(text: string): void {
271
+ console.log("");
272
+ console.log(` ${color.bold(text)}`);
273
+ }
274
+
275
+ note(text: string): void {
276
+ console.log(` ${color.dim(text)}`);
277
+ }
278
+
279
+ done(text: string): void {
280
+ console.log(` ${color.green("✓")} ${text}`);
281
+ }
282
+
283
+ warn(text: string): void {
284
+ console.log(` ${color.yellow("!")} ${text}`);
285
+ }
286
+
287
+ /**
288
+ * Asks a yes/no question, or answers it with the default under `--yes`.
289
+ *
290
+ * The commands are printed before the question rather than after the answer,
291
+ * because "install these packages?" is not a question anyone can answer
292
+ * without seeing which packages and how.
293
+ *
294
+ * A fresh readline per question, closed before anything runs: a child given
295
+ * this terminal (sudo asking for a password, `tailscale up` printing its
296
+ * login URL) needs stdin to itself, and readline holds it while open.
297
+ */
298
+ async confirm(
299
+ question: string,
300
+ def: boolean,
301
+ commands: string[][] = [],
302
+ ): Promise<boolean> {
303
+ for (const argv of commands)
304
+ console.log(` ${color.cyan(display(argv))}`);
305
+ if (this.opts.yes) return def;
306
+ const rl = createInterface({
307
+ input: process.stdin,
308
+ output: process.stdout,
309
+ });
310
+ try {
311
+ const answer = (
312
+ await rl.question(` ${question} ${def ? "[Y/n]" : "[y/N]"} `)
313
+ )
314
+ .trim()
315
+ .toLowerCase();
316
+ return answer === "" ? def : answer === "y" || answer === "yes";
317
+ } finally {
318
+ rl.close();
319
+ }
320
+ }
321
+
322
+ /** A free-text answer, for the one question that is not yes or no. */
323
+ async ask(question: string, def: string): Promise<string> {
324
+ const rl = createInterface({
325
+ input: process.stdin,
326
+ output: process.stdout,
327
+ });
328
+ try {
329
+ const answer = (await rl.question(` ${question} [${def}] `)).trim();
330
+ return answer || def;
331
+ } finally {
332
+ rl.close();
333
+ }
334
+ }
335
+
336
+ /** Runs a command on this terminal, printing it first. */
337
+ run(argv: string[], privileged = false): Promise<boolean> {
338
+ const full = privileged ? [...this.sudo, ...argv] : argv;
339
+ console.log(` ${color.dim("$")} ${display(full)}`);
340
+ return new Promise((resolve) => {
341
+ const child = spawn(full[0] as string, full.slice(1), {
342
+ stdio: "inherit",
343
+ });
344
+ child.once("error", (err) => {
345
+ this.warn(`could not run ${full[0]}: ${err.message}`);
346
+ resolve(false);
347
+ });
348
+ child.once("exit", (code) => resolve(code === 0));
349
+ });
350
+ }
351
+
352
+ /** Runs each in turn, stopping at the first that fails. */
353
+ async runAll(commands: string[][], privileged = false): Promise<boolean> {
354
+ for (const argv of commands)
355
+ if (!(await this.run(argv, privileged))) return false;
356
+ return true;
357
+ }
358
+
359
+ /** A command's stdout, quietly, or null if it failed. */
360
+ capture(argv: string[], privileged = false): Promise<string | null> {
361
+ // -n: a read never prompts. The sudo step at the top has already cached
362
+ // credentials, and a read that stops to ask for a password mid-step would
363
+ // be a prompt with nothing printed above it to say why.
364
+ const full =
365
+ privileged && this.sudo.length > 0 ? ["sudo", "-n", ...argv] : argv;
366
+ return new Promise((resolve) => {
367
+ let out = "";
368
+ const child = spawn(full[0] as string, full.slice(1), {
369
+ stdio: ["ignore", "pipe", "ignore"],
370
+ });
371
+ child.stdout.on("data", (chunk) => {
372
+ out += chunk;
373
+ });
374
+ child.once("error", () => resolve(null));
375
+ child.once("exit", (code) => resolve(code === 0 ? out : null));
376
+ });
377
+ }
378
+
379
+ /** Writes a root-owned file by way of a temporary one the user owns. */
380
+ async install(
381
+ file: string,
382
+ contents: string,
383
+ mode: string,
384
+ ): Promise<boolean> {
385
+ const dir = await mkdtemp(path.join(os.tmpdir(), "vibe-os-setup-"));
386
+ const tmp = path.join(dir, path.basename(file));
387
+ try {
388
+ await writeFile(tmp, contents);
389
+ return await this.run(["install", "-m", mode, tmp, file], true);
390
+ } finally {
391
+ await rm(dir, { recursive: true, force: true });
392
+ }
393
+ }
394
+ }
395
+
396
+ /** How to run this same vibe-os again, from a checkout or a binary. */
397
+ const SELF = IS_COMPILED ? [process.execPath] : [process.execPath, ENTRY];
398
+
399
+ const exists = (p: string) =>
400
+ access(p).then(
401
+ () => true,
402
+ () => false,
403
+ );
404
+
405
+ /**
406
+ * A command that may live in sbin.
407
+ *
408
+ * Debian leaves /usr/sbin off an ordinary user's PATH, so `sshd` and `ufw` are
409
+ * both "not found" to a non-root lookup on a box that has them.
410
+ */
411
+ async function findSbin(command: string): Promise<string | null> {
412
+ for (const dir of ["/usr/sbin", "/usr/local/sbin", "/sbin", "/usr/bin"]) {
413
+ const candidate = path.join(dir, command);
414
+ if (await exists(candidate)) return candidate;
415
+ }
416
+ return (await onPath(command)) ? command : null;
417
+ }
418
+
419
+ async function detectManager(): Promise<Manager | null> {
420
+ for (const [command, manager] of [
421
+ ["apt-get", "apt"],
422
+ ["dnf", "dnf"],
423
+ ["yum", "yum"],
424
+ ["pacman", "pacman"],
425
+ ["zypper", "zypper"],
426
+ ] as const) {
427
+ if (await onPath(command)) return manager;
428
+ }
429
+ return null;
430
+ }
431
+
432
+ /** Debian calls the unit `ssh`; everyone else calls it `sshd`. */
433
+ async function sshUnit(s: Session): Promise<string> {
434
+ return (await s.capture(["systemctl", "cat", "ssh.service"])) !== null
435
+ ? "ssh"
436
+ : "sshd";
437
+ }
438
+
439
+ // ── the steps ──────────────────────────────────────────────────────────────
440
+
441
+ /**
442
+ * Root: refuse, or make the user to run as.
443
+ *
444
+ * vibe-os hands out shells as whoever runs it, so the user it runs as matters
445
+ * more than anything else setup decides. Setup stops after creating one rather
446
+ * than carrying on as them: every later step should run as that user, from a
447
+ * login as that user, and the honest way to prove the login works is to use it.
448
+ */
449
+ async function fromRoot(s: Session, values: RawOptions): Promise<number> {
450
+ s.heading("user");
451
+ s.warn("running as root, and vibe-os would hand out root shells");
452
+
453
+ let name = typeof values.user === "string" ? values.user : "";
454
+ if (!name) {
455
+ if (s.opts.yes) {
456
+ s.note("pass --user <name> to create one, or log in as another user");
457
+ return 1;
458
+ }
459
+ name = await s.ask("Name for a new user to run vibe-os as?", "vibe");
460
+ if (!/^[a-z_][a-z0-9_-]*$/.test(name)) {
461
+ s.warn(`not a usable name: ${name}`);
462
+ return 1;
463
+ }
464
+ }
465
+
466
+ if (await homeFor(name)) {
467
+ s.done(`${name} already exists`);
468
+ } else {
469
+ const group = (await s.capture(["getent", "group", "sudo"]))
470
+ ? "sudo"
471
+ : "wheel";
472
+ const sudoers = `/etc/sudoers.d/90-vibe-os-${name}`;
473
+ const commands = [
474
+ [
475
+ "useradd",
476
+ "--create-home",
477
+ "--shell",
478
+ "/bin/bash",
479
+ "--groups",
480
+ group,
481
+ name,
482
+ ],
483
+ ];
484
+ s.note(
485
+ `${name} gets no password, so sudo for it needs none either (${sudoers}),`,
486
+ );
487
+ s.note("and your SSH keys are copied from root so you can log in as it.");
488
+ if (!(await s.confirm(`Create ${name}?`, true, commands))) return 1;
489
+ if (!(await s.runAll(commands))) return 1;
490
+
491
+ const rule = `${name} ALL=(ALL) NOPASSWD:ALL\n`;
492
+ const dir = await mkdtemp(path.join(os.tmpdir(), "vibe-os-setup-"));
493
+ try {
494
+ const tmp = path.join(dir, "sudoers");
495
+ await writeFile(tmp, rule, { mode: 0o440 });
496
+ // visudo -c before it lands: a sudoers file sudo cannot parse breaks sudo
497
+ // for everyone, on a box that may have no root password.
498
+ if (
499
+ !(await s.run(["visudo", "-cf", tmp])) ||
500
+ !(await s.run(["install", "-m", "0440", tmp, sudoers]))
501
+ )
502
+ return 1;
503
+ } finally {
504
+ await rm(dir, { recursive: true, force: true });
505
+ }
506
+
507
+ const home = (await homeFor(name)) ?? `/home/${name}`;
508
+ if (await exists("/root/.ssh/authorized_keys")) {
509
+ await s.runAll([
510
+ ["install", "-d", "-m", "0700", "-o", name, "-g", name, `${home}/.ssh`],
511
+ [
512
+ "install",
513
+ "-m",
514
+ "0600",
515
+ "-o",
516
+ name,
517
+ "-g",
518
+ name,
519
+ "/root/.ssh/authorized_keys",
520
+ `${home}/.ssh/authorized_keys`,
521
+ ],
522
+ ]);
523
+ } else {
524
+ s.warn(
525
+ `root has no authorized_keys to copy, so add a key for ${name} yourself`,
526
+ );
527
+ }
528
+ }
529
+
530
+ // A binary under /root is unreadable to the user it is about to be run as.
531
+ if (IS_COMPILED && process.execPath.startsWith("/root/"))
532
+ s.warn(
533
+ `this binary is in ${path.dirname(process.execPath)}, which ${name} cannot read: reinstall it as ${name}`,
534
+ );
535
+
536
+ console.log("");
537
+ console.log(
538
+ ` Log in as ${color.bold(name)} and run setup again from there:`,
539
+ );
540
+ console.log("");
541
+ console.log(` ${color.cyan(`ssh ${name}@${os.hostname()}`)}`);
542
+ console.log(` ${color.cyan(`${invocation()} setup`)}`);
543
+ console.log("");
544
+ return 0;
545
+ }
546
+
547
+ async function sudoStep(s: Session): Promise<boolean> {
548
+ if ((await s.capture(["sudo", "-n", "true"])) !== null) return true;
549
+ if (!process.stdin.isTTY) {
550
+ s.warn("sudo needs a password, and there is no terminal to type it into");
551
+ s.note(
552
+ "run setup from an interactive session (ssh -t), or give this user passwordless sudo",
553
+ );
554
+ return false;
555
+ }
556
+ s.heading("sudo");
557
+ s.note("most of what follows needs root, one command at a time");
558
+ return s.run(["sudo", "-v"]);
559
+ }
560
+
561
+ async function packagesStep(
562
+ s: Session,
563
+ manager: Manager | null,
564
+ ): Promise<void> {
565
+ const missing: string[] = [];
566
+ if (!(await findSbin("sshd"))) missing.push("sshd");
567
+ for (const command of ["ssh-keygen", "ssh-keyscan", "dtach", "git", "curl"])
568
+ if (!(await onPath(command))) missing.push(command);
569
+ const wantGh = !(await onPath("gh"));
570
+
571
+ if (missing.length === 0 && !wantGh) {
572
+ s.done("prerequisites installed");
573
+ return;
574
+ }
575
+
576
+ s.heading("prerequisites");
577
+ if (!manager) {
578
+ s.warn(
579
+ `missing ${[...missing, ...(wantGh ? ["gh"] : [])].join(", ")}, and no package manager this knows`,
580
+ );
581
+ return;
582
+ }
583
+ if (missing.length > 0) s.note(`missing: ${missing.join(", ")}`);
584
+ if (
585
+ wantGh &&
586
+ (await s.confirm(
587
+ "Install gh as well, for PRs and issues? (optional)",
588
+ true,
589
+ ))
590
+ )
591
+ missing.push("gh");
592
+ if (missing.length === 0) return;
593
+
594
+ const commands = installCommands(manager, packagesFor(missing, manager));
595
+ if (!(await s.confirm("Install them?", true, commands))) return;
596
+ if (await s.runAll(commands, true)) s.done("installed");
597
+ }
598
+
599
+ async function sshdStep(s: Session, config: Config): Promise<void> {
600
+ if (await probeTcp(config.sshHost, config.sshPort)) {
601
+ s.done(`sshd answering on ${config.sshHost}:${config.sshPort}`);
602
+ return;
603
+ }
604
+ const unit = await sshUnit(s);
605
+ s.heading("sshd");
606
+ s.note("every window logs in through it");
607
+ const command = ["systemctl", "enable", "--now", unit];
608
+ if (await s.confirm("Start sshd, and on every boot?", true, [command]))
609
+ if (await s.run(command, true)) s.done("sshd running");
610
+ }
611
+
612
+ async function harnessStep(s: Session): Promise<void> {
613
+ const harnesses = [
614
+ {
615
+ name: "Claude Code",
616
+ command: "claude",
617
+ scripts: [CLAUDE_INSTALL],
618
+ def: true,
619
+ after: "run claude once to sign in",
620
+ },
621
+ {
622
+ name: "Hermes",
623
+ command: "hermes",
624
+ scripts: [HERMES_INSTALL, UV_INSTALL],
625
+ def: s.opts.hermes,
626
+ after: "run hermes setup to pick a provider",
627
+ },
628
+ {
629
+ name: "Cursor",
630
+ command: "cursor-agent",
631
+ scripts: [CURSOR_INSTALL],
632
+ def: s.opts.cursor,
633
+ after: "run cursor-agent login",
634
+ },
635
+ ];
636
+
637
+ const missing = [];
638
+ for (const h of harnesses) {
639
+ if (await onPath(h.command)) s.done(`${h.name} installed`);
640
+ else missing.push(h);
641
+ }
642
+ if (missing.length === 0) return;
643
+
644
+ s.heading("harnesses");
645
+ s.note("the agents a profile can launch; each installs to ~/.local/bin");
646
+ for (const h of missing) {
647
+ const commands = h.scripts.map((script) => ["sh", "-c", script]);
648
+ const optional = h.command === "claude" ? "" : " (optional)";
649
+ if (!(await s.confirm(`Install ${h.name}?${optional}`, h.def, commands)))
650
+ continue;
651
+ if (await s.runAll(commands)) {
652
+ s.done(`${h.name} installed`);
653
+ s.next.push(`${h.name}: ${h.after}`);
654
+ }
655
+ }
656
+ }
657
+
658
+ async function tailscaleStep(s: Session): Promise<TailscaleState> {
659
+ const down: TailscaleState = { running: false, dnsName: null, ip: null };
660
+ if (!(await onPath("tailscale"))) {
661
+ s.heading("tailscale");
662
+ s.note("serves vibe-os over HTTPS on your tailnet, and nowhere else");
663
+ const command = ["sh", "-c", TAILSCALE_INSTALL];
664
+ if (!(await s.confirm("Install Tailscale?", true, [command]))) return down;
665
+ if (!(await s.run(command))) return down;
666
+ }
667
+
668
+ const read = async () =>
669
+ parseTailscaleStatus(
670
+ (await s.capture(["tailscale", "status", "--json"])) ?? "",
671
+ );
672
+ let state = await read();
673
+ if (!state.running) {
674
+ s.heading("tailscale");
675
+ s.note("this prints a URL to open, and waits until you have signed in");
676
+ const command = ["tailscale", "up"];
677
+ if (!(await s.confirm("Bring Tailscale up?", true, [command]))) return down;
678
+ await s.run(command, true);
679
+ state = await read();
680
+ }
681
+ if (state.running)
682
+ s.done(`Tailscale up as ${state.dnsName ?? state.ip ?? "this machine"}`);
683
+ else
684
+ s.warn("Tailscale is not up, so the steps that need a tailnet are skipped");
685
+ return state;
686
+ }
687
+
688
+ interface Service {
689
+ port: number;
690
+ host: string;
691
+ token: string | null;
692
+ installed: boolean;
693
+ }
694
+
695
+ async function serviceStep(
696
+ s: Session,
697
+ config: Config,
698
+ values: RawOptions,
699
+ ): Promise<Service> {
700
+ const unit = await readFile(UNIT_PATH, "utf8").catch(() => null);
701
+ const current = unit ? unitFlags(unit) : null;
702
+ // An installed unit with no --port or --host has vibe-os's own defaults, not
703
+ // setup's.
704
+ const port = values.port
705
+ ? config.port
706
+ : current
707
+ ? (current.port ?? 80)
708
+ : SETUP_PORT;
709
+ const host = values.host
710
+ ? config.host
711
+ : current
712
+ ? (current.host ?? "0.0.0.0")
713
+ : "127.0.0.1";
714
+
715
+ // An existing unit is kept unless asked for something different, which is
716
+ // what makes a second run of setup safe on a box set up by hand.
717
+ const differs =
718
+ current !== null &&
719
+ ((values.port !== undefined && current.port !== port) ||
720
+ (values.host !== undefined && current.host !== host));
721
+ if (current && !differs) {
722
+ s.done(`service installed, on ${host}:${port}`);
723
+ return {
724
+ port,
725
+ host,
726
+ token: current.token === undefined ? config.token : current.token,
727
+ installed: true,
728
+ };
729
+ }
730
+
731
+ // Forwarded so the service sees the same paths setup was given.
732
+ const forwarded: string[] = ["--port", String(port), "--host", host];
733
+ for (const key of ["workspace", "state-dir", "theme-color"] as const) {
734
+ const value = values[key];
735
+ if (typeof value === "string") forwarded.push(`--${key}`, value);
736
+ }
737
+ const command = [...SELF, "install-service", ...forwarded];
738
+
739
+ s.heading("service");
740
+ s.note(
741
+ current
742
+ ? `replaces the installed unit, which listens on ${current.host ?? "0.0.0.0"}:${current.port ?? 80}`
743
+ : "runs vibe-os on boot, as this user",
744
+ );
745
+ s.note(
746
+ `the token is kept in ${path.join(config.stateDir, "config.json")}, not in the unit`,
747
+ );
748
+ if (!(await s.confirm("Install the service?", true, [["sudo", ...command]])))
749
+ return { port, host, token: config.token, installed: false };
750
+
751
+ // Remembered first, so the service's first start finds it. A token on the
752
+ // ExecStart line would be readable by every user on the box: units are 0644.
753
+ if (config.token)
754
+ await savePersisted(config.stateDir, { token: config.token });
755
+ const installed = await s.run(command, true);
756
+ return { port, host, token: config.token, installed };
757
+ }
758
+
759
+ async function serveStep(
760
+ s: Session,
761
+ tailnet: TailscaleState,
762
+ port: number,
763
+ ): Promise<boolean> {
764
+ if (!tailnet.running) return false;
765
+ const status =
766
+ (await s.capture(["tailscale", "serve", "status"], true)) ?? "";
767
+ if (serveCovers(status, port)) {
768
+ s.done(`tailscale serve proxies to port ${port}`);
769
+ return true;
770
+ }
771
+ s.heading("https");
772
+ s.note(
773
+ `https://${tailnet.dnsName ?? "<machine>.<tailnet>.ts.net"}, with a real certificate`,
774
+ );
775
+ const command = ["tailscale", "serve", "--bg", String(port)];
776
+ if (!(await s.confirm("Serve vibe-os on the tailnet?", true, [command])))
777
+ return false;
778
+ return s.run(command, true);
779
+ }
780
+
781
+ async function hardeningStep(s: Session, config: Config): Promise<void> {
782
+ const sshd = await findSbin("sshd");
783
+ if (!sshd) return;
784
+ const spec = [
785
+ "-T",
786
+ "-C",
787
+ `user=${config.user},host=localhost,addr=127.0.0.1`,
788
+ ];
789
+ const read = async () => {
790
+ const out = await s.capture([sshd, ...spec], true);
791
+ return out === null ? null : parseSshdConfig(out);
792
+ };
793
+
794
+ const before = await read();
795
+ if (!before) {
796
+ s.warn(
797
+ "could not read sshd's effective config, so password login was left alone",
798
+ );
799
+ return;
800
+ }
801
+ if (!passwordLoginOn(before)) {
802
+ s.done("sshd refuses passwords");
803
+ return;
804
+ }
805
+
806
+ s.heading("ssh hardening");
807
+ s.note("sshd accepts passwords, which every bot on the internet is guessing");
808
+
809
+ // Turning passwords off with no key in place is the lockout this step most
810
+ // needs to avoid.
811
+ const home = (await homeFor(config.user)) ?? os.homedir();
812
+ const files = authorizedKeysPaths(
813
+ before.get("authorizedkeysfile") ?? ".ssh/authorized_keys",
814
+ home,
815
+ config.user,
816
+ );
817
+ let keyed = false;
818
+ for (const file of files)
819
+ if (hasLoginKey(await readFile(file, "utf8").catch(() => ""))) keyed = true;
820
+ if (!keyed) {
821
+ s.warn(
822
+ `${config.user} has no SSH key in ${files[0]}, so passwords stay on`,
823
+ );
824
+ s.note("add your public key there, log in with it, then run setup again");
825
+ return;
826
+ }
827
+
828
+ const existing = await readFile(HARDENING_PATH, "utf8").catch(() => null);
829
+ const contents = existing
830
+ ? `${existing.replace(/\n?$/, "\n")}${HARDENING}`
831
+ : HARDENING;
832
+ const unit = await sshUnit(s);
833
+ const reload = ["systemctl", "try-reload-or-restart", unit];
834
+ s.note(`adds to ${HARDENING_PATH}:`);
835
+ for (const line of HARDENING.trim().split("\n"))
836
+ console.log(` ${color.cyan(line)}`);
837
+ s.note("then checks the result and reloads:");
838
+ if (
839
+ !(await s.confirm("Turn off password logins?", true, [
840
+ [sshd, "-t"],
841
+ reload,
842
+ ]))
843
+ )
844
+ return;
845
+
846
+ if (!(await s.install(HARDENING_PATH, contents, "0644"))) return;
847
+ if (!(await s.run([sshd, "-t"], true))) {
848
+ // Never reload a config sshd has just said it cannot parse.
849
+ s.warn("sshd rejected it, so the previous file is back");
850
+ if (existing !== null) await s.install(HARDENING_PATH, existing, "0644");
851
+ else await s.run(["rm", "-f", HARDENING_PATH], true);
852
+ return;
853
+ }
854
+ await s.run(reload, true);
855
+
856
+ // sshd takes the first value it sees, and a file that sorts earlier, or a
857
+ // sshd_config with no Include, wins over this one. Only sshd can say.
858
+ const after = await read();
859
+ if (after && !passwordLoginOn(after)) s.done("sshd refuses passwords");
860
+ else
861
+ s.warn(
862
+ `sshd still accepts passwords: something read before ${HARDENING_PATH} turns them on`,
863
+ );
864
+ s.next.push(
865
+ "Check a fresh key-based ssh login works before closing this session",
866
+ );
867
+ }
868
+
869
+ /**
870
+ * Closes the box to everything but the tailnet.
871
+ *
872
+ * The one step that can cost the operator the machine, so it is arranged to
873
+ * fail open. A transient systemd timer that turns ufw off is armed before ufw
874
+ * is turned on, and the only thing that cancels it is a file written from a new
875
+ * session that arrived over the tailnet. Nothing typed into this terminal
876
+ * counts: this session is already connected, and ufw lets established
877
+ * connections live, so it cannot prove a new one would get in.
878
+ */
879
+ async function firewallStep(
880
+ s: Session,
881
+ config: Config,
882
+ tailnet: TailscaleState,
883
+ manager: Manager | null,
884
+ ): Promise<void> {
885
+ if (!tailnet.running) {
886
+ s.warn(
887
+ "firewall skipped: it allows only the tailnet, and Tailscale is not up",
888
+ );
889
+ return;
890
+ }
891
+
892
+ const ufw = await findSbin("ufw");
893
+ const status = ufw
894
+ ? await s.capture(["ufw", "status", "verbose"], true)
895
+ : null;
896
+ if (status) {
897
+ const fw = parseUfw(status);
898
+ if (
899
+ fw.active &&
900
+ fw.defaultDeny &&
901
+ fw.tailnetAllowed &&
902
+ fw.directUdpAllowed
903
+ ) {
904
+ s.done("ufw allows the tailnet and denies the rest");
905
+ return;
906
+ }
907
+ }
908
+
909
+ const rules = [
910
+ ["ufw", "allow", "in", "on", "tailscale0"],
911
+ ["ufw", "allow", "41641/udp"],
912
+ ["ufw", "default", "deny", "incoming"],
913
+ ["ufw", "default", "allow", "outgoing"],
914
+ ];
915
+ const minutes = REVERT_AFTER_SECONDS / 60;
916
+ const arm = (ufwPath: string) => [
917
+ "systemd-run",
918
+ `--unit=${REVERT_UNIT}`,
919
+ `--on-active=${REVERT_AFTER_SECONDS}`,
920
+ // Timers coalesce within a minute by default, which made "five minutes"
921
+ // anything up to six.
922
+ "--timer-property=AccuracySec=1s",
923
+ ufwPath,
924
+ "disable",
925
+ ];
926
+
927
+ s.heading("firewall");
928
+ s.warn("this can lock you out of the box");
929
+ s.note(
930
+ `ufw is turned back off after ${minutes} minutes unless a new ssh session`,
931
+ );
932
+ s.note(
933
+ "over the tailnet confirms it got in. Know where your provider's console is.",
934
+ );
935
+ if (
936
+ !(await s.confirm(
937
+ "Close the box to everything but the tailnet?",
938
+ s.opts.firewall,
939
+ [arm(ufw ?? "/usr/sbin/ufw"), ...rules, ["ufw", "--force", "enable"]],
940
+ ))
941
+ ) {
942
+ if (s.opts.yes)
943
+ s.note("left alone: the firewall needs --firewall as well as --yes");
944
+ return;
945
+ }
946
+
947
+ if (!ufw) {
948
+ if (!manager) {
949
+ s.warn(
950
+ "ufw is not installed, and there is no package manager this knows",
951
+ );
952
+ return;
953
+ }
954
+ if (!(await s.runAll(installCommands(manager, ["ufw"]), true))) return;
955
+ }
956
+ // systemd-run wants a path, and the package may have only just put it there.
957
+ const ufwPath = (await findSbin("ufw")) ?? "/usr/sbin/ufw";
958
+
959
+ const marker = path.join(config.stateDir, CONFIRM_FILE);
960
+ await rm(marker, { force: true });
961
+ // A leftover timer from an earlier run would make systemd-run refuse the name.
962
+ await s.capture(["systemctl", "stop", `${REVERT_UNIT}.timer`], true);
963
+ await s.capture(
964
+ ["systemctl", "reset-failed", `${REVERT_UNIT}.service`],
965
+ true,
966
+ );
967
+ if (!(await s.run(arm(ufwPath), true))) {
968
+ s.warn("could not arm the revert timer, so ufw was not turned on");
969
+ return;
970
+ }
971
+
972
+ const cancel = () =>
973
+ s.capture(["systemctl", "stop", `${REVERT_UNIT}.timer`], true);
974
+ if (!(await s.runAll([...rules, ["ufw", "--force", "enable"]], true))) {
975
+ await s.run(["ufw", "disable"], true);
976
+ await cancel();
977
+ s.warn("a rule failed, so ufw is off again");
978
+ return;
979
+ }
980
+
981
+ const where = tailnet.dnsName ?? tailnet.ip ?? "<tailnet address>";
982
+ console.log("");
983
+ console.log(" From a new terminal, over the tailnet:");
984
+ console.log("");
985
+ console.log(
986
+ ` ${color.cyan(`ssh ${config.user}@${where} ${invocation()} confirm-firewall`)}`,
987
+ );
988
+ console.log("");
989
+ s.note(
990
+ `waiting up to ${minutes} minutes; ufw turns itself off if this never arrives`,
991
+ );
992
+
993
+ const confirmed = await waitFor(marker, async () => {
994
+ const out = await s.capture([
995
+ "systemctl",
996
+ "is-active",
997
+ `${REVERT_UNIT}.timer`,
998
+ ]);
999
+ return out !== null;
1000
+ });
1001
+ await rm(marker, { force: true });
1002
+
1003
+ if (confirmed) {
1004
+ await cancel();
1005
+ s.done("confirmed over the tailnet; ufw stays on");
1006
+ s.next.push(
1007
+ "Your provider's firewall is separate: leave 41641/udp open there for a direct Tailscale path",
1008
+ );
1009
+ } else {
1010
+ // The timer has fired by now, or is about to; make sure rather than trust it.
1011
+ await s.run(["ufw", "disable"], true);
1012
+ await cancel();
1013
+ s.warn(
1014
+ "no confirmation arrived, so ufw is off again; run setup to try again",
1015
+ );
1016
+ }
1017
+ }
1018
+
1019
+ /** Polls for the marker while the timer is still armed. */
1020
+ async function waitFor(
1021
+ marker: string,
1022
+ armed: () => Promise<boolean>,
1023
+ ): Promise<boolean> {
1024
+ const deadline = Date.now() + REVERT_AFTER_SECONDS * 1000;
1025
+ while (Date.now() < deadline) {
1026
+ if (await exists(marker)) return true;
1027
+ if (!(await armed())) return false;
1028
+ await Bun.sleep(1_000);
1029
+ }
1030
+ return exists(marker);
1031
+ }
1032
+
1033
+ /**
1034
+ * The end of setup is doctor's report, run with root so the sshd and firewall
1035
+ * checks are real, and with the flags the service has so the port is the one
1036
+ * that matters.
1037
+ */
1038
+ async function doctorStep(
1039
+ s: Session,
1040
+ config: Config,
1041
+ port: number,
1042
+ host: string,
1043
+ ): Promise<void> {
1044
+ s.heading("doctor");
1045
+ // sudo resets PATH to secure_path, which has no ~/.local/bin, and doctor
1046
+ // would report every harness setup just installed as missing.
1047
+ await s.run(
1048
+ [
1049
+ "env",
1050
+ `PATH=${process.env.PATH ?? ""}`,
1051
+ ...SELF,
1052
+ "doctor",
1053
+ "--user",
1054
+ config.user,
1055
+ "--state-dir",
1056
+ config.stateDir,
1057
+ "--port",
1058
+ String(port),
1059
+ "--host",
1060
+ host,
1061
+ ],
1062
+ true,
1063
+ );
1064
+ }
1065
+
1066
+ export async function runSetup(
1067
+ config: Config,
1068
+ values: RawOptions,
1069
+ ): Promise<number> {
1070
+ const opts: Options = {
1071
+ yes: values.yes === true,
1072
+ firewall: values.firewall === true,
1073
+ hermes: values.hermes === true,
1074
+ cursor: values.cursor === true,
1075
+ };
1076
+
1077
+ if (process.platform !== "linux") {
1078
+ console.error(" setup configures a Linux server; run doctor instead");
1079
+ return 1;
1080
+ }
1081
+ if (!opts.yes && !process.stdin.isTTY) {
1082
+ console.error(
1083
+ " setup asks before each change, and there is no terminal to ask on: pass --yes",
1084
+ );
1085
+ return 1;
1086
+ }
1087
+
1088
+ const asRoot = process.getuid?.() === 0;
1089
+ const s = new Session(opts, asRoot);
1090
+ if (asRoot) return fromRoot(s, values);
1091
+
1092
+ // Installers write to ~/.local/bin, and every later lookup in this process
1093
+ // should find what they wrote without a new shell.
1094
+ const local = path.join(os.homedir(), ".local", "bin");
1095
+ if (!(process.env.PATH ?? "").split(":").includes(local))
1096
+ process.env.PATH = `${local}:${process.env.PATH ?? ""}`;
1097
+
1098
+ try {
1099
+ if (!(await sudoStep(s))) return 1;
1100
+ const manager = await detectManager();
1101
+ await packagesStep(s, manager);
1102
+ await sshdStep(s, config);
1103
+ await harnessStep(s);
1104
+ const tailnet = await tailscaleStep(s);
1105
+ const service = await serviceStep(s, config, values);
1106
+ const served = await serveStep(s, tailnet, service.port);
1107
+ await hardeningStep(s, config);
1108
+ await firewallStep(s, config, tailnet, manager);
1109
+
1110
+ await doctorStep(s, config, service.port, service.host);
1111
+
1112
+ const query = service.token ? `/?token=${service.token}` : "/";
1113
+ console.log("");
1114
+ if (service.installed && served && tailnet.dnsName) {
1115
+ console.log(
1116
+ ` ${color.bold("Open:")} ${color.cyan(`https://${tailnet.dnsName}${query}`)}`,
1117
+ );
1118
+ } else if (service.installed) {
1119
+ console.log(
1120
+ ` ${color.bold("Open:")} ${color.cyan(`http://127.0.0.1:${service.port}${query}`)}`,
1121
+ );
1122
+ s.note(
1123
+ `through a tunnel: ssh -L ${service.port}:127.0.0.1:${service.port} ${config.user}@${os.hostname()}`,
1124
+ );
1125
+ }
1126
+ if (s.next.length > 0) {
1127
+ console.log("");
1128
+ console.log(` ${color.bold("Next")}`);
1129
+ for (const line of s.next) console.log(` ${line}`);
1130
+ }
1131
+ console.log("");
1132
+ return 0;
1133
+ } catch (err) {
1134
+ console.error(` setup stopped: ${describeError(err)}`);
1135
+ return 1;
1136
+ }
1137
+ }
1138
+
1139
+ /**
1140
+ * `vibe-os confirm-firewall`: proof that a new session got in.
1141
+ *
1142
+ * No root, and nothing but a file in the state dir, because the only thing it
1143
+ * has to establish is that it ran at all. Setup is watching for the file from
1144
+ * the other session and does the privileged part itself.
1145
+ */
1146
+ export async function confirmFirewall(config: Config): Promise<number> {
1147
+ await mkdir(config.stateDir, { recursive: true, mode: 0o700 });
1148
+ await writeFile(path.join(config.stateDir, CONFIRM_FILE), `${Date.now()}\n`);
1149
+ console.log(" confirmed; setup will keep the firewall on");
1150
+ return 0;
1151
+ }