@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.
package/README.md CHANGED
@@ -46,6 +46,7 @@ edit, no `sshd_config` change, and no root.
46
46
  - [Deploying on a VPS](#deploying-on-a-vps)
47
47
  - [What the box needs](#what-the-box-needs)
48
48
  - [Install](#install)
49
+ - [Setup](#setup)
49
50
  - [Behind Tailscale](#behind-tailscale)
50
51
  - [Firewall](#firewall)
51
52
  - [Running it as a service](#running-it-as-a-service)
@@ -55,6 +56,8 @@ edit, no `sshd_config` change, and no root.
55
56
  - [Security](#security)
56
57
  - [CLI reference](#cli-reference)
57
58
  - [Development](#development)
59
+ - [Building from source](#building-from-source)
60
+ - [Releasing](#releasing)
58
61
  - [Project status](#project-status)
59
62
  - [Contributing](#contributing)
60
63
  - [License](#license)
@@ -66,50 +69,31 @@ edit, no `sshd_config` change, and no root.
66
69
  ### Try it locally
67
70
 
68
71
  ```bash
69
- docker compose up --build
72
+ docker run --rm -p 127.0.0.1:8080:80 -e VIBE_OS_NO_TOKEN=1 ghcr.io/tsconfigdotjson/vibe-os
70
73
  open http://localhost:8080
71
74
  ```
72
75
 
73
- A blank Debian box with sshd, dtach and git, which is the same shape as a fresh
74
- VPS. The compose file turns the token off so the URL above just works, and
75
- publishes the port on loopback only, because a vibe-os with no token is a shell
76
- for anyone who can reach it.
77
-
78
- ### Or point an agent at a VPS
79
-
80
- You have an agent. Give it SSH access to a fresh box and this:
81
-
82
- > Set up vibe-os (https://github.com/tsconfigdotjson/vibe-os) on this VPS.
83
- >
84
- > 1. Create a non-root user with sudo if I am logged in as root, and do the rest
85
- > as that user. vibe-os hands out shells as whoever runs it.
86
- > 2. Install the prerequisites: `openssh-server`, `openssh-client`, `dtach`,
87
- > `git`, and `gh`. Install Claude Code with the standalone installer from
88
- > https://claude.ai/install.sh, not npm.
89
- > 3. Install Tailscale, run `tailscale up`, and tell me the tailnet address.
90
- > 4. Build the binary. If you have Bun locally, `bun run compile` and copy
91
- > `dist/bin/vibe-os-linux-x64` to `/usr/local/bin/vibe-os`. Otherwise clone
92
- > the repo on the box, install Bun, and run `bun install && bun run build`.
93
- > 5. Run `vibe-os doctor` and then `sudo vibe-os doctor`. Fix what they report
94
- > before continuing. Do not skip this.
95
- > 6. Install the service: `sudo vibe-os install-service --port 7681 --host
96
- > 127.0.0.1 --token`. Run that with sudo from the user account, not as root.
97
- > 7. Put HTTPS in front: `sudo tailscale serve --bg 7681`.
98
- > 8. Close the box down with ufw: allow in on `tailscale0`, allow `41641/udp`,
99
- > default deny incoming, then enable. Confirm you can still reach the box
100
- > over the tailnet in a second session before enabling it.
101
- > 9. Turn off SSH password authentication by writing
102
- > `PasswordAuthentication no` to `/etc/ssh/sshd_config.d/01-hardening.conf`.
103
- > That file has to sort before `50-cloud-init.conf`, which sets it to yes.
104
- > Run `sshd -t` before reloading.
105
- > 10. Run `sudo vibe-os doctor` one more time and paste the output, along with
106
- > the URL and token from `journalctl -u vibe-os`.
107
- >
108
- > Tell me what you are about to run before you run anything that opens a port
109
- > or changes sshd.
110
-
111
- Read what it proposes before you let it run. Step 8 can lock you out of the box
112
- if the tailnet is not actually working yet.
76
+ A blank Debian box with sshd, dtach and git, the same shape as a fresh VPS. The
77
+ token is off, so the port is on loopback only: a vibe-os with no token is a
78
+ shell for anyone who can reach it. The image has no harness, so profiles open a
79
+ shell; from a clone, `docker compose up --build` adds Claude Code.
80
+
81
+ ### Or set up a VPS
82
+
83
+ On a fresh Debian or Ubuntu box, as any user with sudo (or as root, which
84
+ creates one):
85
+
86
+ ```bash
87
+ curl -fsSL https://raw.githubusercontent.com/tsconfigdotjson/vibe-os/main/install.sh | sh
88
+ vibe-os setup
89
+ ```
90
+
91
+ `setup` runs the same checks as `doctor` and offers to fix each one, printing
92
+ every command before it runs it. See [Setup](#setup) for what it does.
93
+
94
+ For an agent with SSH access to the box, the prompt is one line:
95
+
96
+ > Install vibe-os with `curl -fsSL https://raw.githubusercontent.com/tsconfigdotjson/vibe-os/main/install.sh | sh`, then run `vibe-os setup --yes` and show me its output.
113
97
 
114
98
  ---
115
99
 
@@ -418,11 +402,14 @@ invocation, so without it a profile opens a shell and does nothing else.
418
402
  ### Install
419
403
 
420
404
  ```bash
421
- bun run compile # writes dist/bin/vibe-os-linux-{x64,arm64}
422
- scp dist/bin/vibe-os-linux-x64 you@host:/usr/local/bin/vibe-os
423
- ssh you@host 'chmod +x /usr/local/bin/vibe-os'
405
+ curl -fsSL https://raw.githubusercontent.com/tsconfigdotjson/vibe-os/main/install.sh | sh
424
406
  ```
425
407
 
408
+ It installs the latest [release](https://github.com/tsconfigdotjson/vibe-os/releases)
409
+ for Linux x64, Linux arm64 or Apple Silicon to `/usr/local/bin` (or
410
+ `~/.local/bin` without sudo), after checking its SHA-256. Set `VIBE_OS_VERSION`
411
+ to pin a tag, or `VIBE_OS_INSTALL_DIR` to choose the directory.
412
+
426
413
  The binary carries the whole app, including the 20MB SSH WASM runtime. No Bun,
427
414
  Node or npm on the target.
428
415
 
@@ -446,6 +433,53 @@ sudo vibe-os doctor # adds the sshd and firewall checks
446
433
  `doctor` evaluates the flags it is given and cannot see a systemd unit, so pass
447
434
  the same flags the service uses to get a true answer.
448
435
 
436
+ ### Setup
437
+
438
+ ```bash
439
+ vibe-os setup
440
+ ```
441
+
442
+ Each step checks first and skips itself when there is nothing to do, so running
443
+ it again on a configured box changes nothing. In order:
444
+
445
+ 1. **User.** As root, it offers to create a user with passwordless sudo and
446
+ root's SSH keys, then stops so you can log in as them and run it again.
447
+ 2. **Prerequisites.** `openssh-server`, `openssh-client`, `dtach`, `git`,
448
+ `curl` and optionally `gh`, through apt, dnf, yum, pacman or zypper.
449
+ 3. **sshd**, enabled and started if nothing answers on port 22.
450
+ 4. **Harnesses.** Claude Code, then optionally Hermes and Cursor.
451
+ 5. **Tailscale**, installed and brought up (it prints a login URL).
452
+ 6. **The service**, on `127.0.0.1:7681` with a generated token kept in
453
+ `~/.vibe-os/config.json`. An installed unit is left alone unless you pass a
454
+ different `--port` or `--host`.
455
+ 7. **`tailscale serve --bg 7681`**, for HTTPS on the tailnet.
456
+ 8. **SSH hardening.** Writes `01-hardening.conf` to turn off password logins,
457
+ runs `sshd -t` and reloads. Skipped if your user has no SSH key yet.
458
+ 9. **Firewall**, with ufw: allow the tailnet and `41641/udp`, deny the rest.
459
+ 10. **`doctor`**, run with sudo, then the URL with `?token=`.
460
+
461
+ The firewall step can lock you out, so before enabling ufw it arms a systemd
462
+ timer that disables it again in five minutes. Setup then waits for you to
463
+ confirm from a **new** session over the tailnet:
464
+
465
+ ```bash
466
+ ssh <user>@<machine>.<tailnet>.ts.net vibe-os confirm-firewall
467
+ ```
468
+
469
+ If that never arrives, ufw turns itself off. Your provider's firewall is
470
+ separate; leave `41641/udp` open there.
471
+
472
+ | flag | |
473
+ | --- | --- |
474
+ | `-y`, `--yes` | take the default answer to every question |
475
+ | `--firewall` | with `--yes`, do the firewall step too (it is skipped otherwise) |
476
+ | `--hermes`, `--cursor` | install those harnesses too |
477
+ | `--user <name>` | as root, the user to create |
478
+ | `--port`, `--host` | where the service listens (default `7681`, `127.0.0.1`) |
479
+
480
+ With `--yes`, an agent runs the firewall confirmation from a second SSH
481
+ connection while setup waits in the first.
482
+
449
483
  ### Behind Tailscale
450
484
 
451
485
  The recommended setup. You stop needing to bind port 80, and you stop needing
@@ -681,12 +715,8 @@ accept that `gh` will not work.
681
715
  ### Updating a running box
682
716
 
683
717
  ```bash
684
- bun run build && bun scripts/compile.ts linux-x64
685
- scp dist/bin/vibe-os-linux-x64 you@host:/tmp/vibe-os
686
- ssh you@host 'sudo systemctl stop vibe-os \
687
- && sudo mv /tmp/vibe-os /usr/local/bin/vibe-os \
688
- && sudo chmod +x /usr/local/bin/vibe-os \
689
- && sudo systemctl start vibe-os'
718
+ curl -fsSL https://raw.githubusercontent.com/tsconfigdotjson/vibe-os/main/install.sh | sh
719
+ sudo systemctl restart vibe-os
690
720
  ```
691
721
 
692
722
  Nothing is lost by that restart. Two things it does not pick up:
@@ -758,6 +788,8 @@ in a session could otherwise ask what you last copied.
758
788
  | `vibe-os` / `vibe-os start` | serve the UI and the SSH bridge |
759
789
  | `vibe-os attach [window]` | attach this terminal to a window's session |
760
790
  | `vibe-os doctor` | check this machine is ready, and say what is missing |
791
+ | `vibe-os setup` | fix what `doctor` finds, asking before each change |
792
+ | `vibe-os confirm-firewall` | tell a waiting `setup` the tailnet still gets in |
761
793
  | `sudo vibe-os install-service` | write and enable a systemd unit |
762
794
  | `sudo vibe-os install-browser` | run one Chrome on a virtual display |
763
795
  | `vibe-os connect-hermes` | point Hermes' browser tools at that Chrome |
@@ -810,6 +842,9 @@ in a session could otherwise ask what you last copied.
810
842
  --no-vnc-password serve the display with no authentication
811
843
  ```
812
844
 
845
+ `setup` takes the [flags above](#setup) as well as `--port`, `--host`,
846
+ `--workspace` and `--state-dir`, which it passes to the service.
847
+
813
848
  `connect-hermes` takes one:
814
849
 
815
850
  ```
@@ -854,6 +889,32 @@ step of its own.
854
889
  its SHA-256 verified before extraction. To move it, set `SSHTERM_VERSION`, run
855
890
  `bun run fetch-wasm`, and copy the checksum it prints into `PINNED`.
856
891
 
892
+ ### Building from source
893
+
894
+ ```bash
895
+ bun install && bun run build
896
+ bun scripts/compile.ts linux-x64 # or linux-arm64, darwin-arm64; none for all
897
+ scp dist/bin/vibe-os-linux-x64 you@host:/tmp/vibe-os
898
+ ssh you@host 'sudo install -m 0755 /tmp/vibe-os /usr/local/bin/vibe-os'
899
+ ```
900
+
901
+ ### Releasing
902
+
903
+ Bump `version` in `package.json`, merge, then tag the merge commit:
904
+
905
+ ```bash
906
+ git tag v0.2.0 && git push origin v0.2.0
907
+ ```
908
+
909
+ The tag must match `package.json`. The workflow publishes the binaries with
910
+ `SHA256SUMS` as a GitHub Release, pushes `ghcr.io/tsconfigdotjson/vibe-os`, and
911
+ publishes `@tsconfigdotjson/vibe-os` to npm with provenance, through npm's
912
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers). A hyphenated
913
+ tag such as `v0.2.0-rc.1` is a prerelease: the installer skips it, and npm gets
914
+ it under the `next` dist-tag rather than `latest`.
915
+
916
+ ### Icons
917
+
857
918
  App icons are generated from `public/icon.svg`:
858
919
 
859
920
  ```bash
@@ -867,8 +928,7 @@ rsvg-convert -w 512 -h 512 public/icon-maskable.svg -o public/icon-maskable-512.
867
928
 
868
929
  ## Project status
869
930
 
870
- Usable and in daily use, but young. No release binaries are published yet, so
871
- build one or run the container.
931
+ Usable and in daily use, but young.
872
932
 
873
933
  Everything in [Deploying on a VPS](#deploying-on-a-vps) has been walked end to
874
934
  end on a fresh OVHcloud VPS on Ubuntu 26.04, behind Tailscale, serving HTTPS.
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsconfigdotjson/vibe-os",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A coding desktop in the browser, on a box you own. Git worktrees per piece of work, terminals that survive a reload, and SSH that runs in the tab.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@ import a18 from "../dist/web/wasm_exec.js" with { type: 'file' };
26
26
  import a19 from "../dist/web/wasm_exec.js.br" with { type: 'file' };
27
27
  import a20 from "../dist/web/wasm_exec.js.gz" with { type: 'file' };
28
28
 
29
- export const BUILD_ID = "754753f62b6b";
29
+ export const BUILD_ID = "4784e0916592";
30
30
 
31
31
  export const EMBEDDED: Record<string, string> = {
32
32
  "/apple-touch-icon.png": a0,
package/server/cli.ts CHANGED
@@ -43,6 +43,7 @@ import { startServer } from "./index.ts";
43
43
  import { color, describeError, log } from "./log.ts";
44
44
  import { reapLeaks } from "./reaper.ts";
45
45
  import { ENTRY, FETCH_WASM, IS_COMPILED } from "./runtime.ts";
46
+ import { confirmFirewall, runSetup } from "./setup.ts";
46
47
 
47
48
  const run = promisify(execFile);
48
49
  const BUN = process.execPath;
@@ -56,6 +57,7 @@ const HELP = `
56
57
  vibe-os [start] serve the UI and the SSH bridge
57
58
  vibe-os attach [window] attach a real terminal to a window's session
58
59
  vibe-os doctor check this machine is ready
60
+ vibe-os setup fix what doctor finds, asking first
59
61
  vibe-os install-service write and enable a systemd unit (needs root)
60
62
  vibe-os install-browser run one Chrome on a virtual display (needs root)
61
63
  vibe-os connect-hermes point Hermes' browser tools at that Chrome
@@ -113,6 +115,14 @@ const HELP = `
113
115
 
114
116
  ${color.bold("doctor")}
115
117
  --reap kill processes that outlived their workspace or session
118
+
119
+ ${color.bold("setup")}
120
+ -y, --yes take the default answer to every question
121
+ --firewall close the box to all but the tailnet with ufw; needed
122
+ with --yes, which otherwise leaves the firewall alone
123
+ --hermes install Hermes too (asked otherwise, default no)
124
+ --cursor install Cursor too (asked otherwise, default no)
125
+ --user <name> as root: the user to create and run vibe-os as
116
126
  `;
117
127
 
118
128
  function version(): string {
@@ -810,6 +820,10 @@ export async function main(argv: string[]): Promise<number> {
810
820
  if (values.reap) await reapLeaks(config);
811
821
  return printChecks(await runDoctor(config));
812
822
  }
823
+ case "setup":
824
+ return runSetup(config, values);
825
+ case "confirm-firewall":
826
+ return confirmFirewall(config);
813
827
  case "install-service":
814
828
  return installService(config, argv);
815
829
  case "install-browser":
package/server/config.ts CHANGED
@@ -93,6 +93,11 @@ export const OPTION_SPEC = {
93
93
  // doctor only, same reasoning as the install-browser block below: parseArgs
94
94
  // is strict, so a flag it has never heard of is an error.
95
95
  reap: { type: "boolean" as const },
96
+ // setup only, for the same reason.
97
+ yes: { type: "boolean" as const, short: "y" },
98
+ firewall: { type: "boolean" as const },
99
+ hermes: { type: "boolean" as const },
100
+ cursor: { type: "boolean" as const },
96
101
  // install-browser only. Kept here because parseArgs is strict, and a flag it
97
102
  // has never heard of is an error rather than something a subcommand can read.
98
103
  geometry: { type: "string" as const },
@@ -151,6 +156,24 @@ export function parseCliArgs(argv: string[]): {
151
156
  return { values: values as RawOptions, positionals };
152
157
  }
153
158
 
159
+ /**
160
+ * The user this process runs as.
161
+ *
162
+ * Bun's `os.userInfo().username` comes from `$USER`, and reads "unknown" when
163
+ * that is unset, which `docker exec`, cron and some `su` invocations all do.
164
+ * `id` asks the passwd database for the real uid instead.
165
+ */
166
+ async function currentUser(): Promise<string> {
167
+ const name = os.userInfo().username;
168
+ if (name && name !== "unknown") return name;
169
+ try {
170
+ const { stdout } = await run("id", ["-un"], { timeout: 5_000 });
171
+ return stdout.trim() || name;
172
+ } catch {
173
+ return name;
174
+ }
175
+ }
176
+
154
177
  async function hasDtach(): Promise<boolean> {
155
178
  try {
156
179
  // dtach exits non-zero with no mode, so its usage text is the liveness
@@ -334,7 +357,7 @@ export async function resolveConfig(values: RawOptions): Promise<Config> {
334
357
  process.env.VIBE_OS_SSH_ADVERTISE ??
335
358
  undefined,
336
359
  user: userName(
337
- values.user ?? process.env.VIBE_OS_USER ?? os.userInfo().username,
360
+ values.user ?? process.env.VIBE_OS_USER ?? (await currentUser()),
338
361
  "user",
339
362
  ),
340
363
  stateDir,
package/server/doctor.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from "./browser.ts";
27
27
  import type { Config } from "./config.ts";
28
28
  import {
29
+ CLAUDE_INSTALL,
29
30
  CURSOR_INSTALL,
30
31
  discoverCursor,
31
32
  discoverHermes,
@@ -161,7 +162,7 @@ const fatal = (label: string, detail: string, fix?: string): Check => ({
161
162
  fatal: true,
162
163
  });
163
164
 
164
- function probeTcp(
165
+ export function probeTcp(
165
166
  host: string,
166
167
  port: number,
167
168
  timeout = 2000,
@@ -473,6 +474,25 @@ export async function homeFor(user: string): Promise<string | null> {
473
474
  }
474
475
  }
475
476
 
477
+ /**
478
+ * `sshd -T` output as a map.
479
+ *
480
+ * sshd -T lowercases keywords and prints repeated ones on their own lines;
481
+ * joining preserves multi-value keywords like allowusers.
482
+ */
483
+ export function parseSshdConfig(stdout: string): Map<string, string> {
484
+ const map = new Map<string, string>();
485
+ for (const line of stdout.split("\n")) {
486
+ const trimmed = line.trim();
487
+ if (!trimmed) continue;
488
+ const space = trimmed.indexOf(" ");
489
+ const key = space === -1 ? trimmed : trimmed.slice(0, space);
490
+ const value = space === -1 ? "" : trimmed.slice(space + 1);
491
+ map.set(key, map.has(key) ? `${map.get(key)} ${value}` : value);
492
+ }
493
+ return map;
494
+ }
495
+
476
496
  /**
477
497
  * sshd's effective configuration, evaluated for the user who will log in.
478
498
  *
@@ -491,18 +511,7 @@ async function sshdEffectiveConfig(
491
511
  ["-T", "-C", `user=${user},host=localhost,addr=127.0.0.1`],
492
512
  { timeout: 10_000, maxBuffer: 4 * 1024 * 1024 },
493
513
  );
494
- const map = new Map<string, string>();
495
- for (const line of stdout.split("\n")) {
496
- const trimmed = line.trim();
497
- if (!trimmed) continue;
498
- const space = trimmed.indexOf(" ");
499
- // sshd -T lowercases keywords and prints repeated ones on their own
500
- // lines; joining preserves multi-value keywords like allowusers.
501
- const key = space === -1 ? trimmed : trimmed.slice(0, space);
502
- const value = space === -1 ? "" : trimmed.slice(space + 1);
503
- map.set(key, map.has(key) ? `${map.get(key)} ${value}` : value);
504
- }
505
- return map;
514
+ return parseSshdConfig(stdout);
506
515
  } catch {
507
516
  // not this path, or not root — try the next
508
517
  }
@@ -641,7 +650,7 @@ export async function runDoctor(config: Config): Promise<Check[]> {
641
650
  : bad(
642
651
  "claude",
643
652
  "not on PATH — profiles using the Claude harness fall back to a shell",
644
- "curl -fsSL https://claude.ai/install.sh | bash",
653
+ CLAUDE_INSTALL,
645
654
  ),
646
655
  );
647
656
 
@@ -671,8 +680,10 @@ export async function runDoctor(config: Config): Promise<Check[]> {
671
680
  );
672
681
  } else {
673
682
  const mine = os.homedir();
683
+ // Root is checking on someone's behalf (`sudo vibe-os doctor`, or the end
684
+ // of setup), not running the server, so where its own home is says nothing.
674
685
  checks.push(
675
- home === mine
686
+ home === mine || process.getuid?.() === 0
676
687
  ? ok("login user", `${user} (${home})`)
677
688
  : bad(
678
689
  "login user",
package/server/harness.ts CHANGED
@@ -110,6 +110,7 @@ const UVX_PATHS = [`${home}/.local/bin/uvx`, "/usr/local/bin/uvx"];
110
110
  * one when there is nothing to configure. Both used to be about to grow their
111
111
  * own copy of a URL that has to be right.
112
112
  */
113
+ export const CLAUDE_INSTALL = "curl -fsSL https://claude.ai/install.sh | bash";
113
114
  export const HERMES_INSTALL =
114
115
  "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash";
115
116
  export const UV_INSTALL = "curl -LsSf https://astral.sh/uv/install.sh | sh";