codehost 0.16.0 → 0.18.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,116 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { toPosixPath } from "../../shared/repo";
5
+ import type { AgentInfo } from "../../shared/signaling";
6
+ import type { DaemonPlugin } from "./types";
7
+
8
+ // agent-yes plugin: advertises the machine's live agent CLI sessions (read
9
+ // straight from agent-yes's registry, so it works even when `ay serve` is
10
+ // down) and proxies /__codehost/agent-yes/* to the local `ay serve` API with
11
+ // its bearer token injected. Granting room members agent control is not new
12
+ // trust: the room token already grants code execution (the editor is a
13
+ // terminal) — same boundary as provisioning (see shared/provision.ts).
14
+
15
+ const AY_DIR = join(homedir(), ".agent-yes");
16
+ const AY_PORT = Number(process.env.CODEHOST_AY_PORT) || 7432;
17
+ /** Cap the advertised list (meta rides the signaling room broadcast). */
18
+ const MAX_AGENTS = 50;
19
+
20
+ interface AyRecord {
21
+ pid: number;
22
+ cli?: string;
23
+ prompt?: string | null;
24
+ cwd?: string;
25
+ status?: "active" | "idle" | "exited";
26
+ started_at?: number;
27
+ }
28
+
29
+ /** agent-yes's global registry: JSONL, last line per pid wins. */
30
+ export function readAgents(dir: string = AY_DIR): AgentInfo[] {
31
+ let raw: string;
32
+ try {
33
+ raw = readFileSync(join(dir, "pids.jsonl"), "utf8");
34
+ } catch {
35
+ return [];
36
+ }
37
+ const byPid = new Map<number, AyRecord>();
38
+ for (const line of raw.split("\n")) {
39
+ if (!line.trim()) continue;
40
+ try {
41
+ const rec = JSON.parse(line) as AyRecord;
42
+ if (typeof rec.pid === "number") byPid.set(rec.pid, { ...byPid.get(rec.pid), ...rec });
43
+ } catch {
44
+ // skip malformed lines
45
+ }
46
+ }
47
+ const out: AgentInfo[] = [];
48
+ for (const rec of [...byPid.values()].sort((a, b) => (b.started_at ?? 0) - (a.started_at ?? 0))) {
49
+ if (out.length >= MAX_AGENTS) break;
50
+ if (rec.status === "exited" || !alive(rec.pid)) continue;
51
+ out.push({
52
+ pid: rec.pid,
53
+ tool: rec.cli || "agent",
54
+ ...(rec.prompt ? { title: rec.prompt.slice(0, 120) } : {}),
55
+ cwd: toPosixPath(rec.cwd ?? ""),
56
+ state: rec.status === "active" ? "active" : "idle",
57
+ ...(rec.started_at ? { startedAt: rec.started_at } : {}),
58
+ });
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function alive(pid: number): boolean {
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function serveToken(dir: string): string | null {
73
+ try {
74
+ const t = readFileSync(join(dir, ".serve-token"), "utf8").trim();
75
+ return t || null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** The plugin, or null when this machine has never run agent-yes. */
82
+ export function agentYesPlugin(dir: string = AY_DIR): DaemonPlugin | null {
83
+ if (!existsSync(dir)) return null;
84
+ return {
85
+ name: "agent-yes",
86
+ meta: () => {
87
+ const agents = readAgents(dir);
88
+ return agents.length > 0 ? { agents } : {};
89
+ },
90
+ route: async (path, req) => {
91
+ const token = serveToken(dir);
92
+ if (!token) {
93
+ return new Response(JSON.stringify({ error: "ay serve has no token (is agent-yes installed?)" }), {
94
+ status: 503,
95
+ headers: { "content-type": "application/json" },
96
+ });
97
+ }
98
+ const headers = new Headers(req.headers);
99
+ headers.set("authorization", `Bearer ${token}`);
100
+ headers.set("host", `127.0.0.1:${AY_PORT}`);
101
+ try {
102
+ return await fetch(`http://127.0.0.1:${AY_PORT}${path}`, {
103
+ method: req.method,
104
+ headers,
105
+ body: req.body as BodyInit | undefined,
106
+ redirect: "manual",
107
+ });
108
+ } catch {
109
+ return new Response(JSON.stringify({ error: "ay serve is not running (start it with `ay serve install`)" }), {
110
+ status: 502,
111
+ headers: { "content-type": "application/json" },
112
+ });
113
+ }
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,39 @@
1
+ import type { PeerMeta } from "../../shared/signaling";
2
+
3
+ // Daemon plugin surface: a plugin contributes (a) fields merged into the
4
+ // advertised PeerMeta on every refresh, and (b) an HTTP route mounted under the
5
+ // tunnel at /__codehost/<name>/*. codehost stays the transport + registry;
6
+ // plugins (agent-yes first) own their domain.
7
+
8
+ export interface DaemonPlugin {
9
+ /** Identifier; also the tunnel mount: requests to /__codehost/<name>/* land
10
+ * in `route`. */
11
+ name: string;
12
+ /** Resource contribution, merged into PeerMeta by the daemon's meta builder
13
+ * (startup + every refresh). Keep it small — it rides room broadcasts. */
14
+ meta?: () => Partial<PeerMeta>;
15
+ /** Serve a tunneled request. `path` is the remainder after the mount, always
16
+ * starting with "/" and keeping the query string. */
17
+ route?: (path: string, req: { method: string; headers: Headers; body?: Uint8Array }) => Promise<Response>;
18
+ }
19
+
20
+ /** Route a local tunnel request to the owning plugin, or null to fall through. */
21
+ export function routePlugins(
22
+ plugins: DaemonPlugin[],
23
+ req: { method: string; path: string; headers: Headers; body?: Uint8Array },
24
+ ): Promise<Response> | null {
25
+ for (const p of plugins) {
26
+ if (!p.route) continue;
27
+ const mount = `/__codehost/${p.name}`;
28
+ if (req.path === mount || req.path.startsWith(`${mount}/`) || req.path.startsWith(`${mount}?`)) {
29
+ const rest = req.path.slice(mount.length) || "/";
30
+ return p.route(rest.startsWith("/") ? rest : `/${rest}`, req);
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** Merge every plugin's meta contribution into a base meta. */
37
+ export function withPluginMeta(base: PeerMeta, plugins: DaemonPlugin[]): PeerMeta {
38
+ return Object.assign({}, base, ...plugins.map((p) => p.meta?.() ?? {}));
39
+ }
@@ -18,9 +18,12 @@ export interface ProvisionDeps {
18
18
  homeDir: string;
19
19
  /** Git host advertised by this daemon (default github.com). */
20
20
  host: string;
21
+ /** Called after a setup script finishes (any exit code) — lets the daemon
22
+ * re-enumerate + re-advertise its workspaces. */
23
+ onProvisioned?: () => void;
21
24
  }
22
25
 
23
- interface CodehostConfig {
26
+ export interface CodehostConfig {
24
27
  workspace?: string; // layout template, e.g. "ws/{owner}/{repo}/tree/{branch}"
25
28
  allowlist?: string[];
26
29
  }
@@ -30,7 +33,7 @@ export function isProvisionPath(path: string): boolean {
30
33
  return path.split("?")[0] === PROVISION_PATH;
31
34
  }
32
35
 
33
- function readConfig(homeDir: string): CodehostConfig {
36
+ export function readCodehostConfig(homeDir: string): CodehostConfig {
34
37
  try {
35
38
  const raw = readFileSync(join(homeDir, ".codehost", "config.yaml"), "utf8");
36
39
  const c = (parseYaml(raw) ?? {}) as Record<string, unknown>;
@@ -74,7 +77,7 @@ export async function handleProvision(rawPath: string, deps: ProvisionDeps): Pro
74
77
  if (!v.ok) return json(400, { error: v.reason });
75
78
 
76
79
  const host = (url.searchParams.get("host") ?? deps.host).toLowerCase();
77
- const cfg = readConfig(deps.homeDir);
80
+ const cfg = readCodehostConfig(deps.homeDir);
78
81
  const key = repoKey({ host, owner: v.target.owner, name: v.target.repo });
79
82
  if (!repoAllowed(key, cfg.allowlist)) return json(403, { error: `repo not allowlisted: ${key}` });
80
83
 
@@ -152,6 +155,11 @@ function freshBody(
152
155
  resolveDone(code);
153
156
  say(`\n::codehost:exit=${code}\n`);
154
157
  controller.close();
158
+ try {
159
+ deps.onProvisioned?.();
160
+ } catch {
161
+ // advertising is best-effort; never fail the provision response
162
+ }
155
163
  }
156
164
  },
157
165
  });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ clearDaemonPresence,
7
+ liveDaemon,
8
+ readRegisteredWorkspaces,
9
+ registerWorkspace,
10
+ writeDaemonPresence,
11
+ } from "./registry";
12
+
13
+ const tmp = () => mkdtempSync(join(tmpdir(), "codehost-registry-"));
14
+
15
+ describe("daemon presence", () => {
16
+ test("round-trips while the pid is alive; cleared file -> null", () => {
17
+ const file = join(tmp(), "daemon.json");
18
+ const p = { pid: process.pid, root: "/Users/x", token: "Str0ng-Token-99", startedAt: 1 };
19
+ writeDaemonPresence(p, file);
20
+ expect(liveDaemon(file)).toEqual(p);
21
+ clearDaemonPresence(file);
22
+ expect(liveDaemon(file)).toBeNull();
23
+ });
24
+
25
+ test("a dead pid (stale kill -9 leftover) reads as no daemon", () => {
26
+ const file = join(tmp(), "daemon.json");
27
+ writeDaemonPresence({ pid: 99999999, root: "/x", token: "t0k-En-Stronk", startedAt: 1 }, file);
28
+ expect(liveDaemon(file)).toBeNull();
29
+ });
30
+
31
+ test("garbage file reads as no daemon", () => {
32
+ const file = join(tmp(), "daemon.json");
33
+ writeFileSync(file, "not json");
34
+ expect(liveDaemon(file)).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe("workspace registry", () => {
39
+ test("register is idempotent; missing dirs are filtered on read", () => {
40
+ const dir = tmp();
41
+ const file = join(dir, "workspaces.json");
42
+ const ws = join(dir, "proj");
43
+ mkdirSync(ws);
44
+ registerWorkspace(ws, file);
45
+ registerWorkspace(ws, file);
46
+ registerWorkspace(join(dir, "gone"), file); // never created on disk
47
+ const got = readRegisteredWorkspaces(file);
48
+ expect(got).toHaveLength(1);
49
+ expect(got[0].path).toBe(ws);
50
+ });
51
+ });
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ // One-daemon-per-host coordination (Phase 5 of the resource-model redesign).
6
+ // A single VS Code serve-web opens ANY local path via ?folder=, so one root
7
+ // daemon can carry every workspace on the machine: a later `codehost dev`
8
+ // REGISTERS its directory here and exits instead of spawning a second peer +
9
+ // VS Code. The daemon advertises registered dirs in meta.workspaces and
10
+ // re-reads this registry on fs.watch + the slow refresh tick.
11
+
12
+ export const CODEHOST_DIR = join(homedir(), ".codehost");
13
+ const DAEMON_FILE = join(CODEHOST_DIR, "daemon.json");
14
+ const WORKSPACES_FILE = join(CODEHOST_DIR, "workspaces.json");
15
+
16
+ /** Written by a foreground root daemon while it runs; staleness is detected by
17
+ * pid liveness, so a kill -9 leftover never blocks anything. */
18
+ export interface DaemonPresence {
19
+ pid: number;
20
+ /** Real OS path of the served root. */
21
+ root: string;
22
+ /** Room token the daemon registered with — later `dev` runs print a link
23
+ * into THIS room, since that's where the workspace will appear. */
24
+ token: string;
25
+ startedAt: number;
26
+ }
27
+
28
+ export interface RegisteredWorkspace {
29
+ /** Real OS path. */
30
+ path: string;
31
+ addedAt: number;
32
+ }
33
+
34
+ export function writeDaemonPresence(p: DaemonPresence, file: string = DAEMON_FILE): void {
35
+ mkdirSync(dirname(file), { recursive: true });
36
+ writeFileSync(file, JSON.stringify(p, null, 2));
37
+ }
38
+
39
+ export function clearDaemonPresence(file: string = DAEMON_FILE): void {
40
+ try {
41
+ rmSync(file);
42
+ } catch {
43
+ // already gone
44
+ }
45
+ }
46
+
47
+ /** The live host daemon, or null (no file, unparsable, or its pid is dead). */
48
+ export function liveDaemon(file: string = DAEMON_FILE): DaemonPresence | null {
49
+ try {
50
+ const p = JSON.parse(readFileSync(file, "utf8")) as DaemonPresence;
51
+ if (typeof p.pid !== "number" || !p.root || !p.token) return null;
52
+ process.kill(p.pid, 0);
53
+ return p;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** Add a directory to the host's workspace registry (idempotent). */
60
+ export function registerWorkspace(path: string, file: string = WORKSPACES_FILE): void {
61
+ const dir = resolve(path);
62
+ const all = readRegistry(file);
63
+ if (all.some((w) => w.path === dir)) return;
64
+ all.push({ path: dir, addedAt: Date.now() });
65
+ mkdirSync(dirname(file), { recursive: true });
66
+ writeFileSync(file, JSON.stringify(all, null, 2));
67
+ }
68
+
69
+ /** Registered workspaces whose directories still exist. */
70
+ export function readRegisteredWorkspaces(file: string = WORKSPACES_FILE): RegisteredWorkspace[] {
71
+ return readRegistry(file).filter((w) => {
72
+ try {
73
+ return statSync(w.path).isDirectory();
74
+ } catch {
75
+ return false;
76
+ }
77
+ });
78
+ }
79
+
80
+ /** The registry path — daemons fs.watch its directory for instant re-advertise. */
81
+ export function workspacesFile(): string {
82
+ return WORKSPACES_FILE;
83
+ }
84
+
85
+ function readRegistry(file: string): RegisteredWorkspace[] {
86
+ try {
87
+ if (!existsSync(file)) return [];
88
+ const arr = JSON.parse(readFileSync(file, "utf8"));
89
+ return Array.isArray(arr) ? arr.filter((w) => w && typeof w.path === "string") : [];
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
@@ -1,8 +1,11 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, dirname } from "node:path";
1
3
  import { type PeerMeta, newPeerId } from "../shared/signaling";
2
4
  import { SignalingClient } from "../shared/signaling-client";
3
5
  import { RtcDaemon } from "./rtc-daemon";
4
- import { Tunnel } from "./tunnel";
5
- import { handleProvision, type ProvisionDeps } from "./provision-server";
6
+ import { type LocalRequest, Tunnel } from "./tunnel";
7
+ import { handleProvision, isProvisionPath, type ProvisionDeps } from "./provision-server";
8
+ import { type DaemonPlugin, routePlugins } from "./plugins/types";
6
9
 
7
10
  export interface LaunchResult {
8
11
  /** Local port to tunnel to. */
@@ -28,8 +31,22 @@ export interface RunServerOptions {
28
31
  /** Enables `/__codehost/provision` on the tunnel (serve only — runs the home's
29
32
  * setup.sh). Omitted by `expose`, which has no home/workspace. */
30
33
  provision?: ProvisionDeps;
34
+ /** Recompute the advertised meta (e.g. re-enumerate workspaces). Polled on a
35
+ * slow interval and right after each provision; pushed to the room only when
36
+ * it actually changed. */
37
+ refreshMeta?: () => PeerMeta;
38
+ /** Daemon plugins: tunneled routes under /__codehost/<name>/ (their meta
39
+ * contributions are the caller's job, inside `meta`/`refreshMeta`). */
40
+ plugins?: DaemonPlugin[];
41
+ /** Files whose change should trigger an immediate `refreshMeta` (e.g. the
42
+ * host workspace registry). Watched via their parent directory so the file
43
+ * may not exist yet. */
44
+ watchFiles?: string[];
31
45
  }
32
46
 
47
+ /** How often a daemon re-enumerates its workspaces (manual clones show up). */
48
+ const META_REFRESH_MS = 60_000;
49
+
33
50
  /**
34
51
  * Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
35
52
  * signaling room with the given meta and bridge each viewer's data channel to a
@@ -64,20 +81,54 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
64
81
  onSignal: (from, data) => rtc.handleSignal(from, data),
65
82
  });
66
83
 
84
+ // Re-advertise when the workspace set changes (provision, manual clone).
85
+ let lastMeta = JSON.stringify(opts.meta);
86
+ const refreshMeta = () => {
87
+ if (!opts.refreshMeta) return;
88
+ const meta = opts.refreshMeta();
89
+ const s = JSON.stringify(meta);
90
+ if (s === lastMeta) return;
91
+ lastMeta = s;
92
+ client.updateMeta(meta);
93
+ };
94
+ const provision: ProvisionDeps | undefined = opts.provision
95
+ ? { ...opts.provision, onProvisioned: refreshMeta }
96
+ : undefined;
97
+ const plugins = opts.plugins ?? [];
98
+ const onLocal =
99
+ provision || plugins.length > 0
100
+ ? (req: LocalRequest) => {
101
+ if (provision && isProvisionPath(req.path)) return handleProvision(req.path, provision);
102
+ return routePlugins(plugins, req);
103
+ }
104
+ : undefined;
105
+
67
106
  rtc = new RtcDaemon({
68
107
  sendSignal: (to, data) => client.sendSignal(to, data),
69
108
  onChannel: (viewerId, channel) => {
70
109
  console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to :${target.port}`);
71
- new Tunnel(
72
- channel,
73
- target.port,
74
- target.stripBasePath,
75
- opts.provision ? (rawPath) => handleProvision(rawPath, opts.provision!) : undefined,
76
- );
110
+ new Tunnel(channel, target.port, target.stripBasePath, onLocal);
77
111
  },
78
112
  });
79
113
 
80
114
  client.connect();
115
+ if (opts.refreshMeta) {
116
+ setInterval(refreshMeta, META_REFRESH_MS);
117
+ // Instant re-advertise when a watched file changes (debounced — editors
118
+ // and fs.watch both fire in bursts).
119
+ let pending: ReturnType<typeof setTimeout> | null = null;
120
+ for (const file of opts.watchFiles ?? []) {
121
+ try {
122
+ watch(dirname(file), (_event, filename) => {
123
+ if (filename && filename !== basename(file)) return;
124
+ if (pending) clearTimeout(pending);
125
+ pending = setTimeout(refreshMeta, 300);
126
+ });
127
+ } catch {
128
+ // missing dir / unsupported platform — the interval still covers it
129
+ }
130
+ }
131
+ }
81
132
 
82
133
  const shutdown = () => {
83
134
  console.log("\n[codehost] shutting down");
package/src/cli/tunnel.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { DataChannel } from "node-datachannel";
2
- import { isProvisionPath } from "./provision-server";
3
2
  import {
4
3
  type HttpReqHead,
5
4
  Op,
@@ -9,7 +8,6 @@ import {
9
8
  encodeFrame,
10
9
  encodeJson,
11
10
  payloadJson,
12
- payloadText,
13
11
  wsMessageFrames,
14
12
  } from "../shared/protocol";
15
13
 
@@ -34,6 +32,19 @@ interface HttpStream {
34
32
  body: Uint8Array[];
35
33
  }
36
34
 
35
+ /** A tunneled request offered to the daemon's local routes before proxying. */
36
+ export interface LocalRequest {
37
+ method: string;
38
+ /** Raw tunneled path incl. query (no /vs/<peerId> stripping applied). */
39
+ path: string;
40
+ headers: Headers;
41
+ body?: Uint8Array;
42
+ }
43
+
44
+ /** Serve `/__codehost/*` requests in-daemon (provisioning, plugins). Return
45
+ * null/undefined to fall through to the local-server proxy. */
46
+ export type LocalHandler = (req: LocalRequest) => Promise<Response> | null | undefined;
47
+
37
48
  /**
38
49
  * Bridges one WebRTC data channel to a local `code serve-web` instance.
39
50
  * Multiplexes concurrent HTTP requests and WebSocket connections by streamId.
@@ -55,9 +66,9 @@ export class Tunnel {
55
66
  * doesn't know it, so we strip `/vs/<peerId>` before proxying.
56
67
  */
57
68
  private stripPrefix?: string,
58
- /** Handles `/__codehost/*` requests locally (provisioning) instead of
59
- * forwarding to the local server. Wired only for `serve` (not `expose`). */
60
- private onProvision?: (rawPath: string) => Promise<Response>,
69
+ /** Serves `/__codehost/*` requests locally (provisioning, plugins) instead
70
+ * of forwarding to the local server. Wired only for `serve` (not `expose`). */
71
+ private onLocal?: LocalHandler,
61
72
  ) {
62
73
  this.origin = `http://127.0.0.1:${vscodePort}`;
63
74
  this.wsOrigin = `ws://127.0.0.1:${vscodePort}`;
@@ -138,15 +149,15 @@ export class Tunnel {
138
149
  const body = hasBody ? concat(stream.body) : undefined;
139
150
 
140
151
  try {
141
- const res =
142
- this.onProvision && isProvisionPath(path)
143
- ? await this.onProvision(path)
144
- : await fetch(this.origin + this.localPath(path), {
145
- method,
146
- headers: reqHeaders,
147
- body: body as BodyInit | undefined,
148
- redirect: "manual",
149
- });
152
+ const local = this.onLocal?.({ method, path, headers: reqHeaders, body });
153
+ const res = local
154
+ ? await local
155
+ : await fetch(this.origin + this.localPath(path), {
156
+ method,
157
+ headers: reqHeaders,
158
+ body: body as BodyInit | undefined,
159
+ redirect: "manual",
160
+ });
150
161
 
151
162
  const resHeaders: Record<string, string> = {};
152
163
  res.headers.forEach((v, k) => {
@@ -49,10 +49,17 @@ export async function resolveCodeBinary(opts: { force?: boolean } = {}): Promise
49
49
  return override;
50
50
  }
51
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.
52
+ // Prefer a user-owned `code` on PATH — but only if it actually runs AND can
53
+ // `serve-web`. The desktop app's bin/code wrapper passes --version yet treats
54
+ // `serve-web` as a file path ("Ignoring option 'host': not supported") and
55
+ // exits 0 without ever listening — so probe the subcommand, not just the
56
+ // binary (observed on macOS VS Code 1.123: serve started, then "VS Code
57
+ // server exited (code 0) before becoming ready").
54
58
  const system = Bun.which("code");
55
- if (system && runsOk(system)) return system;
59
+ if (system && runsOk(system)) {
60
+ if (supportsServeWeb(system)) return system;
61
+ console.warn(`[codehost] system code (${system}) can't serve-web — using a managed VS Code CLI instead`);
62
+ }
56
63
 
57
64
  return ensureManagedBinary(opts.force ?? false);
58
65
  }
@@ -119,6 +126,16 @@ function runsOk(bin: string): boolean {
119
126
  return r.status === 0;
120
127
  }
121
128
 
129
+ /** True if `<bin> serve-web --help` is a real subcommand: clean exit, no
130
+ * desktop-wrapper "Ignoring option" complaints, and help text that actually
131
+ * mentions serve-web. (Run with --help so nothing starts listening.) */
132
+ function supportsServeWeb(bin: string): boolean {
133
+ const r = spawnSync(bin, ["serve-web", "--help"], { encoding: "utf8", timeout: 10_000 });
134
+ if (r.status !== 0) return false;
135
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
136
+ return !/ignoring option/i.test(out) && /serve-web/i.test(out);
137
+ }
138
+
122
139
  function isMusl(): boolean {
123
140
  if (existsSync("/etc/alpine-release")) return true;
124
141
  // `ldd --version` mentions "musl" on musl systems; ignore failures.
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DEFAULT_LAYOUT } from "../shared/repo";
6
+ import { enumerateWorkspaces } from "./workspaces";
7
+
8
+ function makeRoot(): string {
9
+ return mkdtempSync(join(tmpdir(), "codehost-ws-"));
10
+ }
11
+
12
+ /** Create a checkout dir; .git is a dir by default, a file when `worktree`. */
13
+ function checkout(root: string, rel: string, worktree = false): void {
14
+ const dir = join(root, rel);
15
+ mkdirSync(dir, { recursive: true });
16
+ if (worktree) writeFileSync(join(dir, ".git"), "gitdir: elsewhere");
17
+ else mkdirSync(join(dir, ".git"));
18
+ }
19
+
20
+ describe("enumerateWorkspaces", () => {
21
+ test("walks the default layout and reports repo identity + branch", () => {
22
+ const root = makeRoot();
23
+ checkout(root, "snomiao/codehost/tree/main");
24
+ checkout(root, "symval/symval/tree/dev", true); // worktree-style .git file
25
+ mkdirSync(join(root, "snomiao/empty/tree/main"), { recursive: true }); // no .git
26
+
27
+ const found = enumerateWorkspaces(root, DEFAULT_LAYOUT);
28
+ expect(found).toHaveLength(2);
29
+ expect(found).toContainEqual({
30
+ path: join(root, "snomiao/codehost/tree/main"),
31
+ repo: "github.com/snomiao/codehost",
32
+ branch: "main",
33
+ });
34
+ expect(found).toContainEqual({
35
+ path: join(root, "symval/symval/tree/dev"),
36
+ repo: "github.com/symval/symval",
37
+ branch: "dev",
38
+ });
39
+ });
40
+
41
+ test("literal segments must exist; placeholders match one level", () => {
42
+ const root = makeRoot();
43
+ checkout(root, "ws/snomiao/codehost"); // layout ws/{owner}/{repo}
44
+ checkout(root, "other/snomiao/codehost"); // doesn't match the literal "ws"
45
+
46
+ const found = enumerateWorkspaces(root, "ws/{owner}/{repo}");
47
+ expect(found).toHaveLength(1);
48
+ expect(found[0].repo).toBe("github.com/snomiao/codehost");
49
+ expect(found[0].branch).toBeUndefined();
50
+ });
51
+
52
+ test("skips dot-directories and tolerates a missing root", () => {
53
+ const root = makeRoot();
54
+ checkout(root, ".hidden/codehost/tree/main");
55
+ expect(enumerateWorkspaces(root, DEFAULT_LAYOUT)).toHaveLength(0);
56
+ expect(enumerateWorkspaces(join(root, "nope"), DEFAULT_LAYOUT)).toHaveLength(0);
57
+ });
58
+
59
+ test("uses the given git host in repo identity", () => {
60
+ const root = makeRoot();
61
+ checkout(root, "group/proj/tree/main");
62
+ const found = enumerateWorkspaces(root, DEFAULT_LAYOUT, "gitlab.com");
63
+ expect(found[0].repo).toBe("gitlab.com/group/proj");
64
+ });
65
+ });