codehost 0.1.0 → 0.3.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.
@@ -0,0 +1,93 @@
1
+ import { hostname } from "node:os";
2
+ import type { CommandModule } from "yargs";
3
+ import type { PeerMeta } from "../../shared/signaling";
4
+ import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
5
+ import { launchServeDaemon } from "../daemonize";
6
+ import { runServer } from "../run-server";
7
+ import { DEFAULT_SIGNAL_URL } from "./serve";
8
+
9
+ interface ExposeArgs {
10
+ port: number;
11
+ token: string;
12
+ name?: string;
13
+ signal: string;
14
+ daemon: boolean;
15
+ }
16
+
17
+ export const exposeCommand: CommandModule<{}, ExposeArgs> = {
18
+ command: "expose <port>",
19
+ describe:
20
+ "Tunnel an existing local HTTP/WS server (any port) over WebRTC — reachable at codehost.dev/vs/<peerId>/",
21
+ builder: (y) =>
22
+ y
23
+ .positional("port", {
24
+ describe: "Local port to expose (e.g. 7432)",
25
+ type: "number",
26
+ demandOption: true,
27
+ })
28
+ .option("token", {
29
+ alias: "t",
30
+ describe: "Room token shared with the codehost.dev page",
31
+ type: "string",
32
+ demandOption: true,
33
+ })
34
+ .option("name", {
35
+ describe: "Display name for this server (defaults to localhost:<port>)",
36
+ type: "string",
37
+ })
38
+ .option("signal", {
39
+ describe: "Signaling server URL",
40
+ type: "string",
41
+ default: DEFAULT_SIGNAL_URL,
42
+ })
43
+ .option("daemon", {
44
+ alias: "d",
45
+ describe: "Run in the background under oxmgr (auto-starts on login)",
46
+ type: "boolean",
47
+ default: false,
48
+ }) as any,
49
+ handler: async (argv) => {
50
+ argv.token = argv.token.trim();
51
+ const check = validateToken(argv.token);
52
+ if (!check.ok) {
53
+ console.error(`[codehost] ${check.reason}`);
54
+ console.error(`[codehost] room token requires: ${TOKEN_REQUIREMENTS}`);
55
+ process.exit(1);
56
+ }
57
+ if (!Number.isInteger(argv.port) || argv.port <= 0 || argv.port > 65535) {
58
+ console.error(`[codehost] invalid port: ${argv.port}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ const host = hostname();
63
+
64
+ if (argv.daemon) {
65
+ const { ok } = await launchServeDaemon({
66
+ command: "expose",
67
+ dir: process.cwd(),
68
+ arg: String(argv.port),
69
+ token: argv.token,
70
+ signal: argv.signal,
71
+ name: argv.name,
72
+ host,
73
+ });
74
+ process.exit(ok ? 0 : 1);
75
+ }
76
+
77
+ const meta: PeerMeta = {
78
+ name: argv.name ?? `localhost:${argv.port}`,
79
+ cwd: `localhost:${argv.port}`,
80
+ host,
81
+ };
82
+
83
+ // No VS Code: tunnel directly to the given port, stripping the /vs/<peerId>
84
+ // prefix the server doesn't know about.
85
+ await runServer({
86
+ token: argv.token,
87
+ signal: argv.signal,
88
+ meta,
89
+ label: `exposing localhost:${argv.port}`,
90
+ launch: async (basePath) => ({ port: argv.port, stripBasePath: basePath }),
91
+ });
92
+ },
93
+ };
@@ -5,7 +5,7 @@ export const listCommand: CommandModule = {
5
5
  command: "list",
6
6
  aliases: ["ls"],
7
7
  describe: "List codehost servers running under oxmgr",
8
- handler: () => {
9
- process.exit(listDaemons());
8
+ handler: async () => {
9
+ process.exit(await listDaemons());
10
10
  },
11
11
  };
@@ -1,13 +1,13 @@
1
1
  import { hostname } from "node:os";
2
2
  import { resolve } from "node:path";
3
3
  import type { CommandModule } from "yargs";
4
- import { type PeerMeta, newPeerId } from "../../shared/signaling";
4
+ import type { PeerMeta } from "../../shared/signaling";
5
+ import { DEFAULT_LAYOUT } from "../../shared/repo";
5
6
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
6
- import { SignalingClient } from "../../shared/signaling-client";
7
- import { RtcDaemon } from "../rtc-daemon";
7
+ import { launchServeDaemon } from "../daemonize";
8
+ import { announceConnect } from "../open-url";
9
+ import { runServer } from "../run-server";
8
10
  import { launchVscode } from "../vscode";
9
- import { Tunnel } from "../tunnel";
10
- import { daemonName, startDaemon } from "../oxmgr";
11
11
 
12
12
  export const DEFAULT_SIGNAL_URL = "wss://signal.codehost.dev";
13
13
 
@@ -22,7 +22,8 @@ interface ServeArgs {
22
22
 
23
23
  export const serveCommand: CommandModule<{}, ServeArgs> = {
24
24
  command: "serve [dir]",
25
- describe: "Serve VS Code from a directory and peer it to codehost.dev over WebRTC",
25
+ describe:
26
+ "Serve a workspace root over WebRTC; repos under it open via codehost.dev/gh/<owner>/<repo>",
26
27
  builder: (y) =>
27
28
  y
28
29
  .positional("dir", {
@@ -47,7 +48,7 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
47
48
  })
48
49
  .option("daemon", {
49
50
  alias: "d",
50
- describe: "Run in the background under oxmgr",
51
+ describe: "Run in the background under oxmgr (auto-starts on login)",
51
52
  type: "boolean",
52
53
  default: false,
53
54
  })
@@ -66,88 +67,42 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
66
67
 
67
68
  const dir = resolve(process.cwd(), argv.dir);
68
69
  const host = hostname();
69
- const meta: PeerMeta = {
70
- name: argv.name ?? host,
71
- cwd: dir,
72
- host,
73
- };
74
70
 
75
71
  // `-d`: re-launch this same `serve` (without -d) under oxmgr, then exit.
76
72
  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);
73
+ const { ok } = await launchServeDaemon({
74
+ command: "serve",
75
+ dir,
76
+ token: argv.token,
77
+ signal: argv.signal,
78
+ name: argv.name,
79
+ port: argv.port,
80
+ host,
81
+ });
82
+ if (ok) announceConnect(argv.token);
83
+ process.exit(ok ? 0 : 1);
87
84
  }
88
85
 
89
- // peerId is fixed up front so VS Code can be mounted under /vs/<peerId>,
90
- // which the browser Service Worker uses to route requests to this daemon.
91
- const peerId = newPeerId();
92
- const basePath = `/vs/${peerId}`;
93
-
94
- console.log(`[codehost] serving ${dir}`);
95
- console.log(`[codehost] room token: ${argv.token}`);
96
- console.log(`[codehost] signaling: ${argv.signal}`);
97
-
98
- const vscode = await launchVscode({ dir, basePath, port: argv.port });
99
-
100
- let rtc: RtcDaemon;
86
+ // A workspace root: repos under it open by GitHub-shaped deep link, mapped
87
+ // onto subfolders via VS Code's ?folder= using this layout.
88
+ const meta: PeerMeta = {
89
+ name: argv.name ?? host,
90
+ cwd: dir,
91
+ host,
92
+ kind: "root",
93
+ layout: DEFAULT_LAYOUT,
94
+ };
101
95
 
102
- const client = new SignalingClient({
103
- url: argv.signal,
96
+ announceConnect(argv.token);
97
+ await runServer({
104
98
  token: argv.token,
105
- role: "server",
106
- peerId,
99
+ signal: argv.signal,
107
100
  meta,
108
- onOpen: () => console.log(`[codehost] registered as "${meta.name}" (${peerId.slice(0, 8)})`),
109
- onClose: () => console.log("[codehost] disconnected from signaling, reconnecting…"),
110
- onSignal: (from, data) => rtc.handleSignal(from, data),
111
- });
112
-
113
- rtc = new RtcDaemon({
114
- sendSignal: (to, data) => client.sendSignal(to, data),
115
- // Each viewer's data channel is bridged to the local VS Code server.
116
- onChannel: (viewerId, channel) => {
117
- console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to VS Code`);
118
- new Tunnel(channel, vscode.port);
101
+ label: `serving workspace root ${dir}`,
102
+ launch: async (basePath) => {
103
+ const v = await launchVscode({ dir, basePath, port: argv.port });
104
+ return { port: v.port, stop: v.stop };
119
105
  },
120
106
  });
121
-
122
- client.connect();
123
-
124
- const shutdown = () => {
125
- console.log("\n[codehost] shutting down");
126
- rtc.closeAll();
127
- client.close();
128
- vscode.stop();
129
- process.exit(0);
130
- };
131
- process.on("SIGINT", shutdown);
132
- process.on("SIGTERM", shutdown);
133
-
134
- // Keep the process alive.
135
- await new Promise<never>(() => {});
136
107
  },
137
108
  };
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,99 @@
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 { isGitRepo } from "../git";
8
+ import { resolveCodeBinary } from "../vscode-install";
9
+ import { announceConnect } from "../open-url";
10
+ import { DEFAULT_SIGNAL_URL } from "./serve";
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. A git repo is a single
57
+ // workspace (`dev`); anything else is treated as a root (`serve`).
58
+ const { ok, name } = await launchServeDaemon({
59
+ command: isGitRepo(dir) ? "dev" : "serve",
60
+ dir,
61
+ token,
62
+ signal: argv.signal,
63
+ name: argv.name,
64
+ port: argv.port,
65
+ host,
66
+ });
67
+ if (!ok) process.exit(1);
68
+
69
+ // 4. Tell the user how to connect, and open the browser straight at the
70
+ // token-carrying URL so VS Code loads without typing the token in.
71
+ console.log("");
72
+ console.log(`[codehost] ✓ server "${name}" is live, serving ${dir}`);
73
+ announceConnect(token);
74
+ console.log(`[codehost] manage it with: codehost list · codehost stop ${name}`);
75
+ },
76
+ };
77
+
78
+ function resolveToken(argv: SetupArgs): string {
79
+ if (argv.token) {
80
+ const t = argv.token.trim();
81
+ const check = validateToken(t);
82
+ if (!check.ok) {
83
+ console.error(`[codehost] ${check.reason}`);
84
+ console.error(`[codehost] room token requires: ${TOKEN_REQUIREMENTS}`);
85
+ process.exit(1);
86
+ }
87
+ // Persist an explicit token too, so later `setup` runs reuse the same room.
88
+ writeConfig({ ...readConfig(), token: t });
89
+ return t;
90
+ }
91
+
92
+ const config = readConfig();
93
+ if (config.token && !argv.newToken) return config.token;
94
+
95
+ const token = generateToken();
96
+ writeConfig({ ...config, token });
97
+ console.log("[codehost] generated a new room token (saved to ~/.codehost/config.json)");
98
+ return token;
99
+ }
@@ -14,7 +14,7 @@ export const stopCommand: CommandModule<{}, StopArgs> = {
14
14
  type: "string",
15
15
  demandOption: true,
16
16
  }) as any,
17
- handler: (argv) => {
18
- process.exit(stopDaemon(argv.name));
17
+ handler: async (argv) => {
18
+ process.exit(await stopDaemon(argv.name));
19
19
  },
20
20
  };
@@ -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,59 @@
1
+ import { daemonName, startDaemon } from "./oxmgr";
2
+
3
+ export interface ServeDaemonOptions {
4
+ /** Subcommand to re-launch under oxmgr. */
5
+ command?: "serve" | "dev" | "expose";
6
+ /** Absolute directory to serve (also the oxmgr working dir). */
7
+ dir: string;
8
+ /** Positional argument for the re-launched command (defaults to `dir`); for
9
+ * `expose` this is the port, while `dir` stays a real cwd for oxmgr. */
10
+ arg?: string;
11
+ /** Room token (already validated). */
12
+ token: string;
13
+ /** Signaling server URL. */
14
+ signal: string;
15
+ /** Display name; also seeds the oxmgr process label. */
16
+ name?: string;
17
+ /** Fixed local VS Code port. */
18
+ port?: number;
19
+ /** Hostname, used as a label fallback. */
20
+ host: string;
21
+ }
22
+
23
+ export interface ServeDaemonResult {
24
+ ok: boolean;
25
+ /** oxmgr process name this server was registered under. */
26
+ name: string;
27
+ }
28
+
29
+ /**
30
+ * Launch a foreground `codehost serve` (without -d) under oxmgr so it survives
31
+ * the shell and restarts on failure. Shared by `serve -d` and `setup`.
32
+ */
33
+ export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<ServeDaemonResult> {
34
+ const label = opts.name ?? opts.dir.split("/").pop() ?? opts.host;
35
+ const name = daemonName(label);
36
+ const command = buildForegroundCommand(opts);
37
+ console.log(`[codehost] starting daemon "${name}" via oxmgr`);
38
+ const ok = await startDaemon({ name, command, cwd: opts.dir });
39
+ if (ok) {
40
+ console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
41
+ }
42
+ return { ok, name };
43
+ }
44
+
45
+ /**
46
+ * Reconstruct the exact foreground `serve` invocation (without -d) for oxmgr to
47
+ * run. Uses the same runtime + entry script that launched us, so it works both
48
+ * for `bunx codehost` and local `bun src/cli/index.ts`.
49
+ */
50
+ function buildForegroundCommand(opts: ServeDaemonOptions): string {
51
+ const parts = [process.execPath, process.argv[1], opts.command ?? "serve", opts.arg ?? opts.dir, "-t", opts.token, "--signal", opts.signal];
52
+ if (opts.name) parts.push("--name", opts.name);
53
+ if (opts.port) parts.push("--port", String(opts.port));
54
+ return parts.map(quote).join(" ");
55
+ }
56
+
57
+ function quote(s: string): string {
58
+ return /[^A-Za-z0-9_\/:=.-]/.test(s) ? `'${s.replace(/'/g, `'\\''`)}'` : s;
59
+ }
package/src/cli/git.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ // Derive a normalized repo identity ("gh/<owner>/<repo>") + branch from a git
4
+ // working tree, so a `codehost dev` daemon can be addressed by GitHub-shaped
5
+ // deep links. Best-effort: returns undefined fields off git / off GitHub.
6
+
7
+ export interface RepoIdentity {
8
+ /** Normalized identity, e.g. "gh/snomiao/codehost". */
9
+ repo?: string;
10
+ /** Current branch, e.g. "main". */
11
+ branch?: string;
12
+ }
13
+
14
+ /** True if `dir` is inside a git working tree (has a resolvable .git). */
15
+ export function isGitRepo(dir: string): boolean {
16
+ const r = git(dir, ["rev-parse", "--is-inside-work-tree"]);
17
+ return r.trim() === "true";
18
+ }
19
+
20
+ export function repoIdentity(dir: string): RepoIdentity {
21
+ if (!isGitRepo(dir)) return {};
22
+ const remote =
23
+ git(dir, ["remote", "get-url", "origin"]) || git(dir, ["config", "--get", "remote.origin.url"]);
24
+ const branch =
25
+ git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]) || git(dir, ["symbolic-ref", "--short", "HEAD"]);
26
+ return {
27
+ repo: parseGitHubRemote(remote),
28
+ branch: branch && branch !== "HEAD" ? branch : undefined,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Parse a GitHub remote URL into "gh/<owner>/<repo>". Handles:
34
+ * https://github.com/owner/repo(.git)
35
+ * git@github.com:owner/repo(.git)
36
+ * ssh://git@github.com/owner/repo(.git)
37
+ * Returns undefined for non-GitHub or unparseable remotes.
38
+ */
39
+ export function parseGitHubRemote(url: string): string | undefined {
40
+ const u = url.trim();
41
+ if (!u) return undefined;
42
+ const m = u.match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?\/?$/i);
43
+ if (!m) return undefined;
44
+ const owner = m[1];
45
+ const repo = m[2];
46
+ if (!owner || !repo) return undefined;
47
+ return `gh/${owner}/${repo}`;
48
+ }
49
+
50
+ function git(dir: string, args: string[]): string {
51
+ const r = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8" });
52
+ return r.status === 0 ? (r.stdout ?? "").trim() : "";
53
+ }
package/src/cli/index.ts CHANGED
@@ -1,16 +1,24 @@
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";
6
+ import { devCommand } from "./commands/dev";
7
+ import { exposeCommand } from "./commands/expose";
5
8
  import { listCommand } from "./commands/list";
6
9
  import { stopCommand } from "./commands/stop";
10
+ import { updateCommand } from "./commands/update";
7
11
 
8
12
  yargs(hideBin(process.argv))
9
13
  .scriptName("codehost")
10
14
  .usage("$0 <command> [options]")
15
+ .command(setupCommand)
11
16
  .command(serveCommand)
17
+ .command(devCommand)
18
+ .command(exposeCommand)
12
19
  .command(listCommand)
13
20
  .command(stopCommand)
21
+ .command(updateCommand)
14
22
  .demandCommand(1, "Specify a command, e.g. `codehost serve`")
15
23
  .strict()
16
24
  .help()
@@ -0,0 +1,47 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /** Public codehost page that brokers the WebRTC handshake. */
4
+ export const PAGE_URL = "https://codehost.dev";
5
+
6
+ /**
7
+ * Build the auto-connect URL for a room token. The token rides in the URL
8
+ * *fragment* (`#t=<token>`) on purpose: the page is a static asset, so the
9
+ * fragment never leaves the browser — it isn't sent to Cloudflare, nor written
10
+ * to access logs or `Referer` headers. The page reads it, fills the token, and
11
+ * auto-connects when a single server is live.
12
+ */
13
+ export function connectUrl(token: string, page: string = PAGE_URL): string {
14
+ return `${page}/#t=${encodeURIComponent(token)}`;
15
+ }
16
+
17
+ /** Open `url` in the default browser. Best-effort, cross-platform, detached. */
18
+ export function openBrowser(url: string): void {
19
+ try {
20
+ if (process.platform === "win32") {
21
+ // `start` is a cmd builtin; the empty "" is the (ignored) window title so
22
+ // a quoted URL isn't mistaken for one.
23
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true, windowsHide: true }).unref();
24
+ } else if (process.platform === "darwin") {
25
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
26
+ } else {
27
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
28
+ }
29
+ } catch {
30
+ // best-effort; the URL is always printed too.
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Print the connect URL and, in an interactive terminal, open it in the
36
+ * browser. The TTY guard keeps the oxmgr-spawned daemon (which re-runs
37
+ * `serve`/`dev` in the foreground with no TTY) from popping a tab on every
38
+ * restart — it just logs the URL.
39
+ */
40
+ export function announceConnect(token: string, page: string = PAGE_URL): void {
41
+ const url = connectUrl(token, page);
42
+ console.log(`[codehost] connect: ${url}`);
43
+ if (process.stdout.isTTY) {
44
+ console.log("[codehost] opening your browser…");
45
+ openBrowser(url);
46
+ }
47
+ }