codehost 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,15 +20,32 @@ directly; a tiny Cloudflare Worker only brokers the handshake.
20
20
 
21
21
  ## Quickstart
22
22
 
23
- On the machine you want to edit (needs the `code` CLI and [Bun](https://bun.sh)):
23
+ On the machine you want to edit, run the one-line installer it installs
24
+ [Bun](https://bun.sh) if needed, the `codehost` CLI, and then `codehost setup`
25
+ (picks a token, installs VS Code, and starts a server):
24
26
 
25
27
  ```bash
26
- bunx codehost serve -d -t <token>
28
+ # macOS / Linux
29
+ curl -fsSL https://codehost.dev/install.sh | sh
30
+
31
+ # Windows (PowerShell)
32
+ powershell -c "irm codehost.dev/install.ps1 | iex"
27
33
  ```
28
34
 
29
- Then open **https://codehost.dev**, enter the same `<token>`, and your server
30
- appears in the list. Click **Connect** — VS Code loads in the page, served
31
- entirely over the peer-to-peer data channel.
35
+ Already have Bun? `bun add -g codehost && codehost setup` does the same. Or, for
36
+ a specific directory/token in one shot:
37
+
38
+ ```bash
39
+ bunx codehost setup ~/my-project -t <token>
40
+ ```
41
+
42
+ Then open **https://codehost.dev**, enter the token printed by `setup`, and your
43
+ server appears in the list. Click **Connect** — VS Code loads in the page,
44
+ served entirely over the peer-to-peer data channel.
45
+
46
+ > Use `setup` (not `bunx codehost serve`) for the first run: `setup` installs
47
+ > everything and self-heals the native WebRTC module that a bare `bunx` can't
48
+ > fetch on its own.
32
49
 
33
50
  The `<token>` is a shared secret: anyone with it can see and connect to the
34
51
  servers in that room, so treat it like a password. It must be **at least 12
@@ -39,11 +56,25 @@ weaker tokens (e.g. `Str0ng-Token-99`).
39
56
  ## CLI
40
57
 
41
58
  ```bash
59
+ codehost setup [dir] [-t <token>] # token + VS Code + daemon, one shot
42
60
  codehost serve [dir] -t <token> [options] # serve a directory (default: cwd)
43
61
  codehost list # list daemonized servers (oxmgr)
44
62
  codehost stop <name> # stop a daemonized server
63
+ codehost update # fetch the latest VS Code CLI now
45
64
  ```
46
65
 
66
+ **`codehost setup`** is the easy path: it reuses (or generates and saves to
67
+ `~/.codehost/config.json`) a strong room token, ensures a working VS Code,
68
+ starts the server under oxmgr, and prints the token + URL. Pass `-t` to set a
69
+ token explicitly, or `--new-token` to rotate the saved one.
70
+
71
+ **VS Code is auto-installed.** On first `serve`, codehost uses a `code` already
72
+ on your `PATH` if present; otherwise it downloads Microsoft's standalone VS Code
73
+ CLI for your OS/arch (verifying its sha256), caches it under `~/.codehost/vscode/`,
74
+ and re-checks the stable channel at most once per day. Force a refresh with
75
+ `codehost update`, or point at a specific binary with the `CODEHOST_CODE_BIN`
76
+ environment variable.
77
+
47
78
  `serve` options:
48
79
 
49
80
  | flag | default | meaning |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "codehost": "./src/cli/index.ts"
@@ -0,0 +1,43 @@
1
+ # codehost installer — https://codehost.dev
2
+ #
3
+ # powershell -c "irm codehost.dev/install.ps1 | iex"
4
+ #
5
+ # Ensures Bun is installed, installs the `codehost` CLI globally (which fetches
6
+ # the native WebRTC binary via Bun's lifecycle scripts), then runs `codehost
7
+ # setup` to pick a token, install VS Code, and start a server daemon.
8
+ #
9
+ # Env override: $env:CODEHOST_NO_SETUP = "1" -> install only, skip setup.
10
+
11
+ $ErrorActionPreference = "Stop"
12
+
13
+ function Info($m) { Write-Host "[codehost] $m" -ForegroundColor Cyan }
14
+ function Fail($m) { Write-Host "[codehost] $m" -ForegroundColor Red; exit 1 }
15
+
16
+ # Bun installs its global bin under %USERPROFILE%\.bun\bin by default.
17
+ $bunBin = Join-Path $env:USERPROFILE ".bun\bin"
18
+ if (Test-Path $bunBin) { $env:Path = "$bunBin;$env:Path" }
19
+
20
+ if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
21
+ Info "Bun not found - installing from bun.sh..."
22
+ Invoke-RestMethod https://bun.sh/install.ps1 | Invoke-Expression
23
+ if (Test-Path $bunBin) { $env:Path = "$bunBin;$env:Path" }
24
+ }
25
+
26
+ if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
27
+ Fail "Bun install did not land on PATH. Open a new terminal and re-run."
28
+ }
29
+
30
+ Info "installing the codehost CLI (bun add -g codehost)..."
31
+ bun add -g codehost
32
+
33
+ if (-not (Get-Command codehost -ErrorAction SilentlyContinue)) {
34
+ Fail "codehost installed but not on PATH. Add $bunBin to PATH and run: codehost setup"
35
+ }
36
+
37
+ if ($env:CODEHOST_NO_SETUP -eq "1") {
38
+ Info "installed. Run ``codehost setup`` in the directory you want to serve."
39
+ exit 0
40
+ }
41
+
42
+ Info "running ``codehost setup``..."
43
+ codehost setup
@@ -0,0 +1,55 @@
1
+ #!/bin/sh
2
+ # codehost installer — https://codehost.dev
3
+ #
4
+ # curl -fsSL https://codehost.dev/install.sh | sh
5
+ #
6
+ # Ensures Bun is installed, installs the `codehost` CLI globally (which fetches
7
+ # the native WebRTC binary via Bun's lifecycle scripts), then runs `codehost
8
+ # setup` to pick a token, install VS Code, and start a server daemon.
9
+ #
10
+ # Env overrides:
11
+ # CODEHOST_NO_SETUP=1 install only; don't run `codehost setup`
12
+ set -eu
13
+
14
+ info() { printf '\033[1;36m[codehost]\033[0m %s\n' "$1"; }
15
+ err() { printf '\033[1;31m[codehost]\033[0m %s\n' "$1" >&2; }
16
+
17
+ # Bun installs its global bin here by default; make sure it's on PATH for both
18
+ # the bun we may install and the `codehost` we're about to install.
19
+ BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
20
+ export BUN_INSTALL
21
+ export PATH="$BUN_INSTALL/bin:$PATH"
22
+
23
+ if ! command -v bun >/dev/null 2>&1; then
24
+ info "Bun not found — installing from bun.sh…"
25
+ if command -v curl >/dev/null 2>&1; then
26
+ curl -fsSL https://bun.sh/install | bash
27
+ elif command -v wget >/dev/null 2>&1; then
28
+ wget -qO- https://bun.sh/install | bash
29
+ else
30
+ err "need curl or wget to install Bun. Install one and re-run."
31
+ exit 1
32
+ fi
33
+ export PATH="$BUN_INSTALL/bin:$PATH"
34
+ fi
35
+
36
+ if ! command -v bun >/dev/null 2>&1; then
37
+ err "Bun install did not land on PATH. Open a new shell or add $BUN_INSTALL/bin to PATH, then re-run."
38
+ exit 1
39
+ fi
40
+
41
+ info "installing the codehost CLI (bun add -g codehost)…"
42
+ bun add -g codehost
43
+
44
+ if ! command -v codehost >/dev/null 2>&1; then
45
+ err "codehost installed but not on PATH. Add $BUN_INSTALL/bin to your PATH and run: codehost setup"
46
+ exit 1
47
+ fi
48
+
49
+ if [ "${CODEHOST_NO_SETUP:-}" = "1" ]; then
50
+ info "installed. Run \`codehost setup\` in the directory you want to serve."
51
+ exit 0
52
+ fi
53
+
54
+ info "running \`codehost setup\`…"
55
+ exec codehost setup
@@ -7,7 +7,7 @@ import { SignalingClient } from "../../shared/signaling-client";
7
7
  import { RtcDaemon } from "../rtc-daemon";
8
8
  import { launchVscode } from "../vscode";
9
9
  import { Tunnel } from "../tunnel";
10
- import { daemonName, startDaemon } from "../oxmgr";
10
+ import { launchServeDaemon } from "../daemonize";
11
11
 
12
12
  export const DEFAULT_SIGNAL_URL = "wss://signal.codehost.dev";
13
13
 
@@ -74,16 +74,15 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
74
74
 
75
75
  // `-d`: re-launch this same `serve` (without -d) under oxmgr, then exit.
76
76
  if (argv.daemon) {
77
- const label = argv.name ?? dir.split("/").pop() ?? host;
78
- const name = daemonName(label);
79
- const command = buildForegroundCommand(dir, argv);
80
- console.log(`[codehost] starting daemon "${name}" via oxmgr`);
81
- const ok = startDaemon({ name, command, cwd: dir });
82
- if (ok) {
83
- console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
84
- process.exit(0);
85
- }
86
- process.exit(1);
77
+ const { ok } = launchServeDaemon({
78
+ dir,
79
+ token: argv.token,
80
+ signal: argv.signal,
81
+ name: argv.name,
82
+ port: argv.port,
83
+ host,
84
+ });
85
+ process.exit(ok ? 0 : 1);
87
86
  }
88
87
 
89
88
  // peerId is fixed up front so VS Code can be mounted under /vs/<peerId>,
@@ -135,19 +134,3 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
135
134
  await new Promise<never>(() => {});
136
135
  },
137
136
  };
138
-
139
- /**
140
- * Reconstruct the exact foreground `serve` invocation (without -d) for oxmgr to
141
- * run. Uses the same runtime + entry script that launched us, so it works both
142
- * for `bunx codehost` and local `bun src/cli/index.ts`.
143
- */
144
- function buildForegroundCommand(dir: string, argv: ServeArgs): string {
145
- const parts = [process.execPath, process.argv[1], "serve", dir, "-t", argv.token, "--signal", argv.signal];
146
- if (argv.name) parts.push("--name", argv.name);
147
- if (argv.port) parts.push("--port", String(argv.port));
148
- return parts.map(quote).join(" ");
149
- }
150
-
151
- function quote(s: string): string {
152
- return /[^A-Za-z0-9_\/:=.-]/.test(s) ? `'${s.replace(/'/g, `'\\''`)}'` : s;
153
- }
@@ -0,0 +1,96 @@
1
+ import { hostname } from "node:os";
2
+ import { resolve } from "node:path";
3
+ import type { CommandModule } from "yargs";
4
+ import { generateToken, validateToken, TOKEN_REQUIREMENTS } from "../../shared/token";
5
+ import { readConfig, writeConfig } from "../config";
6
+ import { launchServeDaemon } from "../daemonize";
7
+ import { resolveCodeBinary } from "../vscode-install";
8
+ import { DEFAULT_SIGNAL_URL } from "./serve";
9
+
10
+ const PAGE_URL = "https://codehost.dev";
11
+
12
+ interface SetupArgs {
13
+ dir: string;
14
+ token?: string;
15
+ newToken: boolean;
16
+ name?: string;
17
+ signal: string;
18
+ port?: number;
19
+ }
20
+
21
+ export const setupCommand: CommandModule<{}, SetupArgs> = {
22
+ command: "setup [dir]",
23
+ describe: "One-shot: pick a token, ensure VS Code, and start a daemonized server",
24
+ builder: (y) =>
25
+ y
26
+ .positional("dir", { describe: "Directory to serve (defaults to cwd)", type: "string", default: "." })
27
+ .option("token", {
28
+ alias: "t",
29
+ describe: "Room token (generated + saved if omitted)",
30
+ type: "string",
31
+ })
32
+ .option("new-token", {
33
+ describe: "Generate a fresh token even if one is saved",
34
+ type: "boolean",
35
+ default: false,
36
+ })
37
+ .option("name", { describe: "Display name for this server (defaults to hostname)", type: "string" })
38
+ .option("signal", { describe: "Signaling server URL", type: "string", default: DEFAULT_SIGNAL_URL })
39
+ .option("port", { describe: "Fixed port for the local VS Code server", type: "number" }) as any,
40
+ handler: async (argv) => {
41
+ const dir = resolve(process.cwd(), argv.dir);
42
+ const host = hostname();
43
+
44
+ // 1. Resolve the room token: validate an explicit one, otherwise reuse the
45
+ // saved token (stable room URL) or mint and persist a strong new one.
46
+ const token = resolveToken(argv);
47
+ console.log(`[codehost] room token: ${token}`);
48
+
49
+ // 2. Make sure a working VS Code is available, installing/upgrading the
50
+ // managed CLI if the system `code` is missing or broken. Doing it here
51
+ // surfaces download progress before we hand off to the daemon.
52
+ console.log("[codehost] checking VS Code…");
53
+ const codeBin = await resolveCodeBinary();
54
+ console.log(`[codehost] using VS Code: ${codeBin}`);
55
+
56
+ // 3. Start the WebRTC + VS Code server under oxmgr.
57
+ const { ok, name } = launchServeDaemon({
58
+ dir,
59
+ token,
60
+ signal: argv.signal,
61
+ name: argv.name,
62
+ port: argv.port,
63
+ host,
64
+ });
65
+ if (!ok) process.exit(1);
66
+
67
+ // 4. Tell the user how to connect.
68
+ console.log("");
69
+ console.log(`[codehost] ✓ server "${name}" is live, serving ${dir}`);
70
+ console.log(`[codehost] open ${PAGE_URL} and enter your token to connect.`);
71
+ console.log(`[codehost] manage it with: codehost list · codehost stop ${name}`);
72
+ },
73
+ };
74
+
75
+ function resolveToken(argv: SetupArgs): string {
76
+ if (argv.token) {
77
+ const t = argv.token.trim();
78
+ const check = validateToken(t);
79
+ if (!check.ok) {
80
+ console.error(`[codehost] ${check.reason}`);
81
+ console.error(`[codehost] room token requires: ${TOKEN_REQUIREMENTS}`);
82
+ process.exit(1);
83
+ }
84
+ // Persist an explicit token too, so later `setup` runs reuse the same room.
85
+ writeConfig({ ...readConfig(), token: t });
86
+ return t;
87
+ }
88
+
89
+ const config = readConfig();
90
+ if (config.token && !argv.newToken) return config.token;
91
+
92
+ const token = generateToken();
93
+ writeConfig({ ...config, token });
94
+ console.log("[codehost] generated a new room token (saved to ~/.codehost/config.json)");
95
+ return token;
96
+ }
@@ -0,0 +1,19 @@
1
+ import type { CommandModule } from "yargs";
2
+ import { resolveCodeBinary } from "../vscode-install";
3
+
4
+ export const updateCommand: CommandModule = {
5
+ command: "update",
6
+ describe: "Download the latest VS Code CLI now (ignores the daily check throttle)",
7
+ handler: async () => {
8
+ if (process.env.CODEHOST_CODE_BIN) {
9
+ console.log(`[codehost] CODEHOST_CODE_BIN is set; nothing to update (${process.env.CODEHOST_CODE_BIN})`);
10
+ return;
11
+ }
12
+ if (Bun.which("code")) {
13
+ console.log("[codehost] using system `code` on PATH; update it with your package manager.");
14
+ return;
15
+ }
16
+ const bin = await resolveCodeBinary({ force: true });
17
+ console.log(`[codehost] up to date: ${bin}`);
18
+ },
19
+ };
@@ -0,0 +1,27 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ // Persistent CLI config under ~/.codehost (same root as the managed VS Code
6
+ // cache in vscode-install.ts). Currently just the reusable room token so
7
+ // `codehost setup` lands on a stable room URL across runs.
8
+
9
+ const CONFIG_FILE = join(homedir(), ".codehost", "config.json");
10
+
11
+ export interface CliConfig {
12
+ /** Room token reused by `setup` until --new-token or an explicit -t. */
13
+ token?: string;
14
+ }
15
+
16
+ export function readConfig(): CliConfig {
17
+ try {
18
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as CliConfig;
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ export function writeConfig(config: CliConfig): void {
25
+ mkdirSync(dirname(CONFIG_FILE), { recursive: true });
26
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
27
+ }
@@ -0,0 +1,54 @@
1
+ import { daemonName, startDaemon } from "./oxmgr";
2
+
3
+ export interface ServeDaemonOptions {
4
+ /** Absolute directory to serve. */
5
+ dir: string;
6
+ /** Room token (already validated). */
7
+ token: string;
8
+ /** Signaling server URL. */
9
+ signal: string;
10
+ /** Display name; also seeds the oxmgr process label. */
11
+ name?: string;
12
+ /** Fixed local VS Code port. */
13
+ port?: number;
14
+ /** Hostname, used as a label fallback. */
15
+ host: string;
16
+ }
17
+
18
+ export interface ServeDaemonResult {
19
+ ok: boolean;
20
+ /** oxmgr process name this server was registered under. */
21
+ name: string;
22
+ }
23
+
24
+ /**
25
+ * Launch a foreground `codehost serve` (without -d) under oxmgr so it survives
26
+ * the shell and restarts on failure. Shared by `serve -d` and `setup`.
27
+ */
28
+ export function launchServeDaemon(opts: ServeDaemonOptions): ServeDaemonResult {
29
+ const label = opts.name ?? opts.dir.split("/").pop() ?? opts.host;
30
+ const name = daemonName(label);
31
+ const command = buildForegroundCommand(opts);
32
+ console.log(`[codehost] starting daemon "${name}" via oxmgr`);
33
+ const ok = startDaemon({ name, command, cwd: opts.dir });
34
+ if (ok) {
35
+ console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
36
+ }
37
+ return { ok, name };
38
+ }
39
+
40
+ /**
41
+ * Reconstruct the exact foreground `serve` invocation (without -d) for oxmgr to
42
+ * run. Uses the same runtime + entry script that launched us, so it works both
43
+ * for `bunx codehost` and local `bun src/cli/index.ts`.
44
+ */
45
+ function buildForegroundCommand(opts: ServeDaemonOptions): string {
46
+ const parts = [process.execPath, process.argv[1], "serve", opts.dir, "-t", opts.token, "--signal", opts.signal];
47
+ if (opts.name) parts.push("--name", opts.name);
48
+ if (opts.port) parts.push("--port", String(opts.port));
49
+ return parts.map(quote).join(" ");
50
+ }
51
+
52
+ function quote(s: string): string {
53
+ return /[^A-Za-z0-9_\/:=.-]/.test(s) ? `'${s.replace(/'/g, `'\\''`)}'` : s;
54
+ }
package/src/cli/index.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env bun
2
2
  import yargs from "yargs";
3
3
  import { hideBin } from "yargs/helpers";
4
+ import { setupCommand } from "./commands/setup";
4
5
  import { serveCommand } from "./commands/serve";
5
6
  import { listCommand } from "./commands/list";
6
7
  import { stopCommand } from "./commands/stop";
8
+ import { updateCommand } from "./commands/update";
7
9
 
8
10
  yargs(hideBin(process.argv))
9
11
  .scriptName("codehost")
10
12
  .usage("$0 <command> [options]")
13
+ .command(setupCommand)
11
14
  .command(serveCommand)
12
15
  .command(listCommand)
13
16
  .command(stopCommand)
17
+ .command(updateCommand)
14
18
  .demandCommand(1, "Specify a command, e.g. `codehost serve`")
15
19
  .strict()
16
20
  .help()
@@ -1,3 +1,5 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { dirname } from "node:path";
1
3
  import { createRequire } from "node:module";
2
4
  import type {
3
5
  DataChannel,
@@ -10,7 +12,61 @@ import { ICE_SERVERS, type RtcSignal } from "../shared/rtc";
10
12
  // resolves correctly from the project's node_modules. So load it via require;
11
13
  // the `import type` above is erased at build time and triggers no runtime load.
12
14
  const require = createRequire(import.meta.url);
13
- const ndc = require("node-datachannel") as typeof import("node-datachannel");
15
+ const ndc = loadNodeDataChannel();
16
+
17
+ /**
18
+ * Load the native node-datachannel addon, self-healing the common failure where
19
+ * its prebuilt `.node` was never fetched. That happens under `bunx codehost`:
20
+ * bunx skips install lifecycle scripts (and `trustedDependencies` only applies
21
+ * to `bun install`), so node-datachannel's `install` step — which downloads the
22
+ * binary via `prebuild-install` — never runs. On the first load failure we run
23
+ * that prebuild-install ourselves, then retry. A normal `bun add -g` install
24
+ * already has the binary, so this is a no-op there.
25
+ */
26
+ function loadNodeDataChannel(): typeof import("node-datachannel") {
27
+ try {
28
+ return require("node-datachannel") as typeof import("node-datachannel");
29
+ } catch (firstErr) {
30
+ if (!fetchNodeDataChannelBinary()) throw nativeLoadError(firstErr);
31
+ try {
32
+ return require("node-datachannel") as typeof import("node-datachannel");
33
+ } catch (secondErr) {
34
+ throw nativeLoadError(secondErr);
35
+ }
36
+ }
37
+ }
38
+
39
+ /** Run node-datachannel's bundled `prebuild-install` to fetch the prebuilt
40
+ * binary. Returns true if it exited cleanly. */
41
+ function fetchNodeDataChannelBinary(): boolean {
42
+ let pkgDir: string;
43
+ let prebuildBin: string;
44
+ try {
45
+ pkgDir = dirname(require.resolve("node-datachannel/package.json"));
46
+ // prebuild-install is a dependency of node-datachannel; resolve its CLI
47
+ // entry from the package's own module scope.
48
+ prebuildBin = require.resolve("prebuild-install/bin.js", { paths: [pkgDir] });
49
+ } catch {
50
+ return false;
51
+ }
52
+ console.log("[codehost] fetching node-datachannel native binary (prebuild-install)…");
53
+ const r = spawnSync(process.execPath, [prebuildBin, "-r", "napi"], {
54
+ cwd: pkgDir,
55
+ stdio: "inherit",
56
+ });
57
+ return r.status === 0;
58
+ }
59
+
60
+ function nativeLoadError(cause: unknown): Error {
61
+ return new Error(
62
+ "Failed to load the native WebRTC module (node-datachannel). Its prebuilt " +
63
+ "binary could not be fetched automatically. Install codehost globally so " +
64
+ "install scripts run — `bun add -g codehost` (or `npm i -g codehost`) — " +
65
+ "and ensure network access. If your platform has no prebuilt, a C++ " +
66
+ "toolchain + cmake is needed to build from source. " +
67
+ `(cause: ${(cause as Error)?.message ?? cause})`,
68
+ );
69
+ }
14
70
 
15
71
  export interface RtcDaemonOptions {
16
72
  /** Relay a signal to a viewer peer via the signaling channel. */
@@ -0,0 +1,222 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { readdir } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ // Resolves a runnable VS Code CLI binary for `code serve-web`. Strategy
8
+ // ("managed, prefer system"): honor an explicit override, then a system `code`
9
+ // on PATH, otherwise download + cache Microsoft's standalone ~31MB "VS Code
10
+ // CLI" binary for this platform. The standalone CLI fully supports serve-web
11
+ // with the same flags as a full install. Update cadence is "check but cache":
12
+ // the managed binary's version is re-checked against the stable channel at most
13
+ // once per ~24h; otherwise the cached binary is used with no network call.
14
+
15
+ const CACHE_ROOT = join(homedir(), ".codehost", "vscode");
16
+ const STATE_FILE = join(CACHE_ROOT, "state.json");
17
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
+ const UPDATE_API = "https://update.code.visualstudio.com/api/update";
19
+
20
+ interface Manifest {
21
+ url: string;
22
+ version: string;
23
+ productVersion: string;
24
+ sha256hash: string;
25
+ }
26
+
27
+ interface State {
28
+ /** Commit-style version id from the manifest. */
29
+ version: string;
30
+ /** Human version, e.g. "1.123.0". */
31
+ productVersion: string;
32
+ /** Absolute path to the extracted `code`/`code.exe`. */
33
+ binPath: string;
34
+ /** Wall-clock ms of the last manifest check. */
35
+ checkedAt: number;
36
+ }
37
+
38
+ /**
39
+ * Return a path (or bare `"code"`) that can be spawned to run `serve-web`.
40
+ * Downloads and caches the standalone CLI on first use when no system VS Code
41
+ * is available. `opts.force` skips the 24h throttle (used by `codehost update`).
42
+ */
43
+ export async function resolveCodeBinary(opts: { force?: boolean } = {}): Promise<string> {
44
+ const override = process.env.CODEHOST_CODE_BIN;
45
+ if (override) {
46
+ if (!existsSync(override)) {
47
+ throw new Error(`CODEHOST_CODE_BIN points to a missing file: ${override}`);
48
+ }
49
+ return override;
50
+ }
51
+
52
+ // Prefer a user-owned `code` on PATH — but only if it actually runs. A broken
53
+ // or stub `code` should fall through to a managed install/upgrade.
54
+ const system = Bun.which("code");
55
+ if (system && runsOk(system)) return system;
56
+
57
+ return ensureManagedBinary(opts.force ?? false);
58
+ }
59
+
60
+ async function ensureManagedBinary(force: boolean): Promise<string> {
61
+ const key = platformKey();
62
+ const state = readState();
63
+
64
+ const fresh = state && existsSync(state.binPath) && Date.now() - state.checkedAt < CHECK_INTERVAL_MS;
65
+ if (fresh && !force) return state!.binPath;
66
+
67
+ let manifest: Manifest;
68
+ try {
69
+ manifest = await fetchManifest(key);
70
+ } catch (err) {
71
+ // Offline: fall back to whatever we have cached, otherwise fail loudly.
72
+ if (state && existsSync(state.binPath)) {
73
+ console.warn(`[codehost] could not check for VS Code updates (${(err as Error).message}); using cached ${state.productVersion}`);
74
+ return state.binPath;
75
+ }
76
+ throw new Error(
77
+ `Unable to download VS Code and none is cached: ${(err as Error).message}. ` +
78
+ `Install VS Code's \`code\` CLI manually, or set CODEHOST_CODE_BIN to an existing binary.`,
79
+ );
80
+ }
81
+
82
+ // Already on the latest stable: just refresh the throttle timestamp.
83
+ if (state && state.version === manifest.version && existsSync(state.binPath)) {
84
+ writeState({ ...state, checkedAt: Date.now() });
85
+ return state.binPath;
86
+ }
87
+
88
+ console.log(`[codehost] installing VS Code ${manifest.productVersion} (${key})…`);
89
+ const binPath = await downloadAndExtract(manifest, key);
90
+ writeState({
91
+ version: manifest.version,
92
+ productVersion: manifest.productVersion,
93
+ binPath,
94
+ checkedAt: Date.now(),
95
+ });
96
+ console.log(`[codehost] VS Code ${manifest.productVersion} ready at ${binPath}`);
97
+ return binPath;
98
+ }
99
+
100
+ /** Map this host to a `cli-<os>-<arch>` key understood by the update API. */
101
+ function platformKey(): string {
102
+ const arch = process.arch; // "x64" | "arm64" | "arm" | ...
103
+ if (process.platform === "darwin") {
104
+ return arch === "arm64" ? "cli-darwin-arm64" : "cli-darwin-x64";
105
+ }
106
+ if (process.platform === "win32") {
107
+ return arch === "arm64" ? "cli-win32-arm64" : "cli-win32-x64";
108
+ }
109
+ // Linux: distinguish musl (Alpine) from glibc, and map arch.
110
+ const os = isMusl() ? "alpine" : "linux";
111
+ if (arch === "arm64") return `cli-${os}-arm64`;
112
+ if (arch === "arm") return `cli-${os}-armhf`;
113
+ return `cli-${os}-x64`;
114
+ }
115
+
116
+ /** True if `<bin> --version` exits cleanly — i.e. the binary actually works. */
117
+ function runsOk(bin: string): boolean {
118
+ const r = spawnSync(bin, ["--version"], { stdio: "ignore", timeout: 10_000 });
119
+ return r.status === 0;
120
+ }
121
+
122
+ function isMusl(): boolean {
123
+ if (existsSync("/etc/alpine-release")) return true;
124
+ // `ldd --version` mentions "musl" on musl systems; ignore failures.
125
+ const r = spawnSync("ldd", ["--version"], { encoding: "utf8" });
126
+ return /musl/i.test(`${r.stdout ?? ""}${r.stderr ?? ""}`);
127
+ }
128
+
129
+ async function fetchManifest(key: string): Promise<Manifest> {
130
+ const res = await fetch(`${UPDATE_API}/${key}/stable/latest`);
131
+ if (!res.ok) throw new Error(`manifest HTTP ${res.status} for ${key}`);
132
+ const m = (await res.json()) as Partial<Manifest>;
133
+ if (!m.url || !m.version || !m.sha256hash) {
134
+ throw new Error(`malformed manifest for ${key}`);
135
+ }
136
+ return {
137
+ url: m.url,
138
+ version: m.version,
139
+ productVersion: m.productVersion ?? m.version,
140
+ sha256hash: m.sha256hash,
141
+ };
142
+ }
143
+
144
+ async function downloadAndExtract(manifest: Manifest, key: string): Promise<string> {
145
+ mkdirSync(CACHE_ROOT, { recursive: true });
146
+ const isZip = manifest.url.endsWith(".zip");
147
+ const tmpArchive = join(tmpdir(), `codehost-vscode-${manifest.version}${isZip ? ".zip" : ".tar.gz"}`);
148
+
149
+ const res = await fetch(manifest.url);
150
+ if (!res.ok) throw new Error(`download HTTP ${res.status}`);
151
+ await Bun.write(tmpArchive, res);
152
+
153
+ await verifySha256(tmpArchive, manifest.sha256hash);
154
+
155
+ // Extract into a fresh temp dir, then move the single binary into the cache.
156
+ const extractDir = join(tmpdir(), `codehost-vscode-x-${manifest.version}`);
157
+ rmSync(extractDir, { recursive: true, force: true });
158
+ mkdirSync(extractDir, { recursive: true });
159
+ // bsdtar (macOS / Windows 10+) extracts .zip via `tar -xf`; GNU tar handles
160
+ // the Linux .tar.gz with `-xzf`.
161
+ const tarArgs = isZip ? ["-xf", tmpArchive] : ["-xzf", tmpArchive];
162
+ const tar = spawnSync("tar", [...tarArgs, "-C", extractDir], { encoding: "utf8" });
163
+ if (tar.status !== 0) {
164
+ throw new Error(`failed to extract VS Code archive: ${tar.stderr ?? tar.error?.message ?? "tar error"}`);
165
+ }
166
+
167
+ const exe = process.platform === "win32" ? "code.exe" : "code";
168
+ const found = await findFile(extractDir, exe);
169
+ if (!found) throw new Error(`extracted archive did not contain ${exe}`);
170
+
171
+ const destDir = join(CACHE_ROOT, manifest.version);
172
+ rmSync(destDir, { recursive: true, force: true });
173
+ mkdirSync(destDir, { recursive: true });
174
+ const destBin = join(destDir, exe);
175
+ renameSync(found, destBin);
176
+ if (process.platform !== "win32") {
177
+ const { chmodSync } = await import("node:fs");
178
+ chmodSync(destBin, 0o755);
179
+ }
180
+
181
+ rmSync(tmpArchive, { force: true });
182
+ rmSync(extractDir, { recursive: true, force: true });
183
+ return destBin;
184
+ }
185
+
186
+ async function verifySha256(path: string, expected: string): Promise<void> {
187
+ const hasher = new Bun.CryptoHasher("sha256");
188
+ hasher.update(await Bun.file(path).arrayBuffer());
189
+ const actual = hasher.digest("hex");
190
+ if (actual.toLowerCase() !== expected.toLowerCase()) {
191
+ rmSync(path, { force: true });
192
+ throw new Error(`sha256 mismatch (expected ${expected}, got ${actual})`);
193
+ }
194
+ }
195
+
196
+ /** Shallow-first search for a file named `name` under `dir`. */
197
+ async function findFile(dir: string, name: string): Promise<string | null> {
198
+ const entries = await readdir(dir, { withFileTypes: true });
199
+ for (const e of entries) {
200
+ if (e.isFile() && e.name === name) return join(dir, e.name);
201
+ }
202
+ for (const e of entries) {
203
+ if (e.isDirectory()) {
204
+ const hit = await findFile(join(dir, e.name), name);
205
+ if (hit) return hit;
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
211
+ function readState(): State | null {
212
+ try {
213
+ return JSON.parse(readFileSync(STATE_FILE, "utf8")) as State;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ function writeState(state: State): void {
220
+ mkdirSync(CACHE_ROOT, { recursive: true });
221
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
222
+ }
package/src/cli/vscode.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
+ import { resolveCodeBinary } from "./vscode-install";
2
3
 
3
4
  export interface VscodeServer {
4
5
  port: number;
@@ -37,8 +38,9 @@ export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
37
38
  "--accept-server-license-terms",
38
39
  ];
39
40
 
40
- console.log(`[codehost] launching: code ${args.join(" ")}`);
41
- const proc = spawn(["code", ...args], {
41
+ const codeBin = await resolveCodeBinary();
42
+ console.log(`[codehost] launching: ${codeBin} ${args.join(" ")}`);
43
+ const proc = spawn([codeBin, ...args], {
42
44
  cwd: opts.dir,
43
45
  stdout: "inherit",
44
46
  stderr: "inherit",