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.
- package/CHANGELOG.md +25 -0
- package/package.json +2 -1
- package/src/cli/commands/dev.ts +32 -1
- package/src/cli/commands/expose.ts +2 -0
- package/src/cli/commands/serve.ts +104 -15
- package/src/cli/commands/setup.ts +43 -7
- package/src/cli/config.test.ts +31 -0
- package/src/cli/config.ts +29 -7
- package/src/cli/daemonize.ts +4 -0
- package/src/cli/init.test.ts +4 -2
- package/src/cli/init.ts +9 -3
- package/src/cli/plugins/agent-yes.test.ts +71 -0
- package/src/cli/plugins/agent-yes.ts +116 -0
- package/src/cli/plugins/types.ts +39 -0
- package/src/cli/provision-server.ts +11 -3
- package/src/cli/registry.test.ts +51 -0
- package/src/cli/registry.ts +93 -0
- package/src/cli/run-server.ts +59 -8
- package/src/cli/tunnel.ts +25 -14
- package/src/cli/vscode-install.ts +20 -3
- package/src/cli/workspaces.test.ts +65 -0
- package/src/cli/workspaces.ts +80 -0
- package/src/shared/repo.test.ts +110 -1
- package/src/shared/repo.ts +67 -19
- package/src/shared/signaling-client.ts +10 -0
- package/src/shared/signaling.ts +55 -1
- package/src/web/discovery.tsx +134 -38
- package/src/web/history.ts +4 -1
- package/src/web/room-client.ts +114 -0
- package/worker/room.ts +9 -0
|
@@ -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
|
|
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 =
|
|
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
|
+
}
|
package/src/cli/run-server.ts
CHANGED
|
@@ -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
|
-
/**
|
|
59
|
-
* forwarding to the local server. Wired only for `serve` (not `expose`). */
|
|
60
|
-
private
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
53
|
-
//
|
|
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))
|
|
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
|
+
});
|