codehost 0.16.0 → 0.17.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,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
+ });
@@ -0,0 +1,80 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { GITHUB_HOST, toPosixPath } from "../shared/repo";
4
+ import type { WorkspaceInfo } from "../shared/signaling";
5
+
6
+ // Enumerate the checkouts that exist under a root daemon's home by walking the
7
+ // workspace layout template (e.g. "{owner}/{repo}/tree/{branch}") one segment
8
+ // at a time: a literal segment must exist, a placeholder segment matches one
9
+ // directory level. A leaf only counts as a workspace when it holds a `.git`
10
+ // (directory, or file for a worktree). Feeds PeerMeta.workspaces.
11
+
12
+ /** Cap the advertised list (meta rides the signaling room broadcast). */
13
+ const MAX_WORKSPACES = 200;
14
+ /** Cap the intermediate walk so a huge home dir can't blow up enumeration. */
15
+ const MAX_FRONTIER = 1000;
16
+
17
+ export function enumerateWorkspaces(rootDir: string, layout: string, host = GITHUB_HOST): WorkspaceInfo[] {
18
+ let frontier = [{ dir: rootDir, vars: {} as Record<string, string> }];
19
+ for (const seg of layout.split("/").filter(Boolean)) {
20
+ const names = [...seg.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
21
+ const next: typeof frontier = [];
22
+ for (const f of frontier) {
23
+ if (next.length >= MAX_FRONTIER) break;
24
+ if (names.length === 0) {
25
+ const dir = join(f.dir, seg);
26
+ if (isDir(dir)) next.push({ dir, vars: f.vars });
27
+ continue;
28
+ }
29
+ const re = segmentPattern(seg);
30
+ for (const name of listDirs(f.dir)) {
31
+ if (next.length >= MAX_FRONTIER) break;
32
+ if (name.startsWith(".")) continue;
33
+ const m = re.exec(name);
34
+ if (!m) continue;
35
+ const vars = { ...f.vars };
36
+ names.forEach((n, i) => (vars[n] = m[i + 1]));
37
+ next.push({ dir: join(f.dir, name), vars });
38
+ }
39
+ }
40
+ frontier = next;
41
+ if (frontier.length === 0) return [];
42
+ }
43
+
44
+ const out: WorkspaceInfo[] = [];
45
+ for (const f of frontier) {
46
+ if (out.length >= MAX_WORKSPACES) break;
47
+ if (!existsSync(join(f.dir, ".git"))) continue;
48
+ const { owner, repo, branch } = f.vars;
49
+ out.push({
50
+ path: toPosixPath(f.dir),
51
+ ...(owner && repo ? { repo: `${host}/${owner}/${repo}` } : {}),
52
+ ...(branch ? { branch } : {}),
53
+ });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** A layout segment as a matcher: literals exact, `{name}`s capture. */
59
+ function segmentPattern(seg: string): RegExp {
60
+ const escaped = seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ return new RegExp(`^${escaped.replace(/\\\{\w+\\\}/g, "([^/]+)")}$`);
62
+ }
63
+
64
+ function isDir(p: string): boolean {
65
+ try {
66
+ return statSync(p).isDirectory();
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function listDirs(p: string): string[] {
73
+ try {
74
+ return readdirSync(p, { withFileTypes: true })
75
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
76
+ .map((e) => e.name);
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { gitUrlToPath, parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, resolveRepoTarget, shareableDeepLink, toPosixPath } from "./repo";
3
- import type { PeerInfo } from "./signaling";
3
+ import type { PeerInfo, WorkspaceInfo } from "./signaling";
4
4
  import { parseGitRemote } from "../cli/git";
5
5
 
6
6
  describe("toPosixPath", () => {
@@ -217,6 +217,115 @@ describe("resolveRepoTarget root selection", () => {
217
217
  });
218
218
  });
219
219
 
220
+ describe("resolveDevTarget advertised workspaces", () => {
221
+ const root: PeerInfo = {
222
+ peerId: "root1",
223
+ role: "server",
224
+ meta: {
225
+ name: "mac",
226
+ host: "mbp",
227
+ cwd: "/Users/sno",
228
+ kind: "root",
229
+ workspaces: [{ path: "/Users/sno/scratch/tool" }],
230
+ },
231
+ };
232
+
233
+ test("a /host/<host>/<path> link matches a registered workspace on a root daemon", () => {
234
+ const res = resolveDevTarget([root], { host: "mbp", path: "/Users/sno/scratch/tool" });
235
+ expect(res?.peerId).toBe("root1");
236
+ expect(res?.folder).toBe("/Users/sno/scratch/tool");
237
+ expect(res?.exact).toBe(true);
238
+ });
239
+
240
+ test("host scoping still applies to workspace matches", () => {
241
+ expect(resolveDevTarget([root], { host: "other", path: "/Users/sno/scratch/tool" })).toBeNull();
242
+ });
243
+
244
+ test("an exact cwd mount still wins (no folder synthesized)", () => {
245
+ const res = resolveDevTarget([root], { host: "mbp", path: "/Users/sno" });
246
+ expect(res).toEqual({ peerId: "root1" });
247
+ });
248
+ });
249
+
250
+ describe("resolveRepoTarget enumerated workspaces (exact)", () => {
251
+ const target = { host: "github.com", owner: "snomiao", name: "codehost" };
252
+ const root = (peerId: string, cwd: string, workspaces?: WorkspaceInfo[]): PeerInfo => ({
253
+ peerId,
254
+ role: "server",
255
+ meta: { name: peerId, host: "Mac", cwd, kind: "root", workspaces },
256
+ });
257
+
258
+ test("an enumerated checkout beats a deeper root's synthesized fallback", () => {
259
+ const servers = [
260
+ root("deep", "/Users/sno/ws"),
261
+ root("listed", "/Users/sno", [
262
+ { path: "/Users/sno/snomiao/codehost/tree/main", repo: "github.com/snomiao/codehost", branch: "main" },
263
+ ]),
264
+ ];
265
+ const res = resolveRepoTarget(servers, target);
266
+ expect(res?.peerId).toBe("listed");
267
+ expect(res?.folder).toBe("/Users/sno/snomiao/codehost/tree/main");
268
+ expect(res?.exact).toBe(true);
269
+ });
270
+
271
+ test("branch mismatch falls back to the synthesized path", () => {
272
+ const servers = [
273
+ root("listed", "/Users/sno/ws", [
274
+ { path: "/Users/sno/ws/snomiao/codehost/tree/main", repo: "github.com/snomiao/codehost", branch: "main" },
275
+ ]),
276
+ ];
277
+ const res = resolveRepoTarget(servers, { ...target, branch: "dev" });
278
+ expect(res?.exact).toBeUndefined();
279
+ expect(res?.folder).toBe("/Users/sno/ws/snomiao/codehost/tree/dev");
280
+ });
281
+
282
+ test("pickRoomMatch ranks an exact enumerated match like a repo daemon", () => {
283
+ const fallback = { token: "room-a", resolution: { peerId: "x", folder: "/a/b" } };
284
+ const exact = { token: "room-b", resolution: { peerId: "y", folder: "/c/d", exact: true } };
285
+ expect(pickRoomMatch([fallback, exact])?.token).toBe("room-b");
286
+ });
287
+ });
288
+
289
+ describe("resolveRepoTarget machine preference (history)", () => {
290
+ const target = { host: "github.com", owner: "snomiao", name: "codehost" };
291
+ const repoDaemon = (peerId: string, hostId?: string, host = "Mac"): PeerInfo => ({
292
+ peerId,
293
+ role: "server",
294
+ meta: { name: peerId, host, hostId, cwd: "/x", repo: "github.com/snomiao/codehost" },
295
+ });
296
+ const root = (peerId: string, cwd: string, hostId?: string, host = "Mac"): PeerInfo => ({
297
+ peerId,
298
+ role: "server",
299
+ meta: { name: peerId, host, hostId, cwd, kind: "root" },
300
+ });
301
+
302
+ test("among equal repo daemons, prefers the history hostId", () => {
303
+ const servers = [repoDaemon("a", "id-a"), repoDaemon("b", "id-b")];
304
+ expect(resolveRepoTarget(servers, target, { hostId: "id-b" })?.peerId).toBe("b");
305
+ expect(resolveRepoTarget(servers, target, { hostId: "id-a" })?.peerId).toBe("a");
306
+ });
307
+
308
+ test("hostname fallback matches entries/daemons without a hostId", () => {
309
+ const servers = [repoDaemon("a", undefined, "mbp"), repoDaemon("b", undefined, "win")];
310
+ expect(resolveRepoTarget(servers, target, { host: "win" })?.peerId).toBe("b");
311
+ });
312
+
313
+ test("preferred root beats a deeper root on another machine", () => {
314
+ const servers = [root("deep", "/Users/sno/ws/x", "id-other"), root("mine", "/Users/sno", "id-mine")];
315
+ expect(resolveRepoTarget(servers, target, { hostId: "id-mine" })?.peerId).toBe("mine");
316
+ });
317
+
318
+ test("no preference keeps the existing order (deepest root, first repo daemon)", () => {
319
+ const servers = [root("shallow", "/Users/sno", "id-1"), root("deep", "/Users/sno/ws", "id-2")];
320
+ expect(resolveRepoTarget(servers, target)?.peerId).toBe("deep");
321
+ });
322
+
323
+ test("a stale preference (machine offline) falls back gracefully", () => {
324
+ const servers = [repoDaemon("a", "id-a")];
325
+ expect(resolveRepoTarget(servers, target, { hostId: "gone" })?.peerId).toBe("a");
326
+ });
327
+ });
328
+
220
329
  describe("gitUrlToPath", () => {
221
330
  test("github URLs -> /gh, preserving branch (incl. slashes)", () => {
222
331
  expect(gitUrlToPath("https://github.com/snomiao/codehost")).toBe("/gh/snomiao/codehost");