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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
# [0.18.0](https://github.com/snomiao/codehost/compare/v0.17.0...v0.18.0) (2026-06-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **cli:** ~/ws as the default workspace root + loud y/N confirm for serving $HOME ([aee5a20](https://github.com/snomiao/codehost/commit/aee5a207a930058472f5b2042a56904051e079a7))
|
|
7
|
+
* **provision:** batteries-included roots — setup auto-scaffolds .codehost/, scaffold detaches fresh clones ([625d958](https://github.com/snomiao/codehost/commit/625d9587d97b59da0bb595aa456a56d3bf920ac4))
|
|
8
|
+
|
|
9
|
+
# [0.17.0](https://github.com/snomiao/codehost/compare/v0.16.0...v0.17.0) (2026-06-11)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* **vscode:** reject the desktop bin/code wrapper — probe serve-web support, not just --version ([e5b133d](https://github.com/snomiao/codehost/commit/e5b133d0ff38f4e1052f87c22279c7850eb98968))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **history:** durable history via hostId + machine-preferring deep-link resolution ([1935579](https://github.com/snomiao/codehost/commit/1935579a40fb7c42f46b8f1596497dac812cdc2b))
|
|
20
|
+
* **host:** one daemon per host — `dev` registers with a live root daemon instead of spawning a second peer ([43051de](https://github.com/snomiao/codehost/commit/43051deaae33f647b26bdcf0facb0bac9507471f))
|
|
21
|
+
* **identity:** stable per-machine hostId, advertised in PeerMeta; web groups workspaces by host ([43a48a2](https://github.com/snomiao/codehost/commit/43a48a2630534c36c399f0cce294a2e96235b5fb))
|
|
22
|
+
* **lib:** embeddable room-client bundle for external consoles (agent-yes.com) ([fd0afd1](https://github.com/snomiao/codehost/commit/fd0afd19234816127b640c397eeac5cc7968f484))
|
|
23
|
+
* **plugins:** daemon plugin layer + agent-yes plugin (agents[] in meta, ay API proxy over the tunnel) ([60f77b2](https://github.com/snomiao/codehost/commit/60f77b2dbbe3b649bbc8a34982b028d5a432ddb7))
|
|
24
|
+
* **tree:** root daemons advertise enumerated workspaces; live meta updates; exact deep-link matching ([81493be](https://github.com/snomiao/codehost/commit/81493be6694d85110a0e9f1244bd6604875c02a2))
|
|
25
|
+
|
|
1
26
|
# [0.16.0](https://github.com/snomiao/codehost/compare/v0.15.0...v0.16.0) (2026-06-10)
|
|
2
27
|
|
|
3
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codehost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"dev:signal": "cd worker && wrangler dev",
|
|
19
19
|
"cli": "bun src/cli/index.ts",
|
|
20
20
|
"build": "vite build && vite build --config vite.sw.config.ts",
|
|
21
|
+
"build:lib": "bun build src/web/room-client.ts --target browser --format esm --minify --outfile dist/room-client.js",
|
|
21
22
|
"deploy:signal": "cd worker && wrangler deploy",
|
|
22
23
|
"deploy:pages": "vite build && wrangler pages deploy dist/public --project-name codehost",
|
|
23
24
|
"start": "bun src/server.ts",
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -3,12 +3,14 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import type { CommandModule } from "yargs";
|
|
4
4
|
import type { PeerMeta } from "../../shared/signaling";
|
|
5
5
|
import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
|
|
6
|
+
import { ensureHostId } from "../config";
|
|
6
7
|
import { launchServeDaemon } from "../daemonize";
|
|
7
8
|
import { announceConnect } from "../open-url";
|
|
8
9
|
import { runServer } from "../run-server";
|
|
9
10
|
import { launchVscode } from "../vscode";
|
|
10
11
|
import { repoIdentity } from "../git";
|
|
11
|
-
import {
|
|
12
|
+
import { liveDaemon, registerWorkspace } from "../registry";
|
|
13
|
+
import { shareableDeepLink, toPosixPath } from "../../shared/repo";
|
|
12
14
|
import { DEFAULT_SIGNAL_URL } from "./serve";
|
|
13
15
|
|
|
14
16
|
interface DevArgs {
|
|
@@ -17,6 +19,7 @@ interface DevArgs {
|
|
|
17
19
|
name?: string;
|
|
18
20
|
signal: string;
|
|
19
21
|
daemon: boolean;
|
|
22
|
+
standalone: boolean;
|
|
20
23
|
port?: number;
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -52,6 +55,11 @@ export const devCommand: CommandModule<{}, DevArgs> = {
|
|
|
52
55
|
type: "boolean",
|
|
53
56
|
default: false,
|
|
54
57
|
})
|
|
58
|
+
.option("standalone", {
|
|
59
|
+
describe: "Always spawn an own peer + VS Code, even when a host daemon already runs",
|
|
60
|
+
type: "boolean",
|
|
61
|
+
default: false,
|
|
62
|
+
})
|
|
55
63
|
.option("port", {
|
|
56
64
|
describe: "Fixed port for the local VS Code server (default: ephemeral)",
|
|
57
65
|
type: "number",
|
|
@@ -68,6 +76,28 @@ export const devCommand: CommandModule<{}, DevArgs> = {
|
|
|
68
76
|
const dir = resolve(process.cwd(), argv.dir);
|
|
69
77
|
const host = hostname();
|
|
70
78
|
|
|
79
|
+
// One daemon per host: a single VS Code serve-web opens any local path via
|
|
80
|
+
// ?folder=, so when a root daemon already runs here, just REGISTER this
|
|
81
|
+
// directory with it (it re-advertises within moments) instead of spawning a
|
|
82
|
+
// second peer + VS Code. --standalone restores the old behavior.
|
|
83
|
+
const daemon = argv.standalone ? null : liveDaemon();
|
|
84
|
+
if (daemon) {
|
|
85
|
+
registerWorkspace(dir);
|
|
86
|
+
const id = repoIdentity(dir);
|
|
87
|
+
const path =
|
|
88
|
+
(id.repo
|
|
89
|
+
? shareableDeepLink({ repo: id.repo, branch: id.branch })
|
|
90
|
+
: shareableDeepLink({ folder: toPosixPath(dir), host })) ?? "/";
|
|
91
|
+
console.log(`[codehost] host daemon already serving "${daemon.root}" (pid ${daemon.pid})`);
|
|
92
|
+
console.log(`[codehost] registered ${dir} with it — no second daemon needed`);
|
|
93
|
+
console.log(`[codehost] open: https://codehost.dev${path}#t=${encodeURIComponent(daemon.token)}`);
|
|
94
|
+
if (daemon.token !== argv.token) {
|
|
95
|
+
console.log("[codehost] note: the host daemon's room differs from your -t; the link above uses the daemon's room");
|
|
96
|
+
}
|
|
97
|
+
console.log("[codehost] (use --standalone to force an own daemon instead)");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
if (argv.daemon) {
|
|
72
102
|
const { ok } = await launchServeDaemon({
|
|
73
103
|
command: "dev",
|
|
@@ -90,6 +120,7 @@ export const devCommand: CommandModule<{}, DevArgs> = {
|
|
|
90
120
|
// the real OS path for the local VS Code working dir.
|
|
91
121
|
cwd: toPosixPath(dir),
|
|
92
122
|
host,
|
|
123
|
+
hostId: ensureHostId(),
|
|
93
124
|
kind: "repo",
|
|
94
125
|
repo: id.repo,
|
|
95
126
|
branch: id.branch,
|
|
@@ -2,6 +2,7 @@ import { hostname } from "node:os";
|
|
|
2
2
|
import type { CommandModule } from "yargs";
|
|
3
3
|
import type { PeerMeta } from "../../shared/signaling";
|
|
4
4
|
import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
|
|
5
|
+
import { ensureHostId } from "../config";
|
|
5
6
|
import { launchServeDaemon } from "../daemonize";
|
|
6
7
|
import { runServer } from "../run-server";
|
|
7
8
|
import { DEFAULT_SIGNAL_URL } from "./serve";
|
|
@@ -78,6 +79,7 @@ export const exposeCommand: CommandModule<{}, ExposeArgs> = {
|
|
|
78
79
|
name: argv.name ?? `localhost:${argv.port}`,
|
|
79
80
|
cwd: `localhost:${argv.port}`,
|
|
80
81
|
host,
|
|
82
|
+
hostId: ensureHostId(),
|
|
81
83
|
};
|
|
82
84
|
|
|
83
85
|
// No VS Code: tunnel directly to the given port, stripping the /vs/<peerId>
|
|
@@ -1,18 +1,66 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir, hostname } from "node:os";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
import type { CommandModule } from "yargs";
|
|
4
5
|
import type { PeerMeta } from "../../shared/signaling";
|
|
5
6
|
import { DEFAULT_LAYOUT, GITHUB_HOST, toPosixPath } from "../../shared/repo";
|
|
6
7
|
import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
|
|
8
|
+
import { defaultRoot, ensureHostId } from "../config";
|
|
7
9
|
import { launchServeDaemon } from "../daemonize";
|
|
8
10
|
import { announceConnect } from "../open-url";
|
|
11
|
+
import { agentYesPlugin } from "../plugins/agent-yes";
|
|
12
|
+
import { withPluginMeta } from "../plugins/types";
|
|
13
|
+
import { readCodehostConfig } from "../provision-server";
|
|
14
|
+
import {
|
|
15
|
+
clearDaemonPresence,
|
|
16
|
+
readRegisteredWorkspaces,
|
|
17
|
+
workspacesFile,
|
|
18
|
+
writeDaemonPresence,
|
|
19
|
+
} from "../registry";
|
|
20
|
+
import { repoIdentity } from "../git";
|
|
9
21
|
import { runServer } from "../run-server";
|
|
10
22
|
import { launchVscode } from "../vscode";
|
|
23
|
+
import { enumerateWorkspaces } from "../workspaces";
|
|
11
24
|
|
|
12
25
|
export const DEFAULT_SIGNAL_URL = "wss://signal.codehost.dev";
|
|
13
26
|
|
|
27
|
+
/** Warn + interactively confirm a risky root (default No). Non-TTY contexts
|
|
28
|
+
* (the oxmgr-daemonized child, CI) can't answer — there the human already
|
|
29
|
+
* confirmed at launch time, so warn loudly and proceed. */
|
|
30
|
+
export async function confirmRiskyRoot(dir: string): Promise<boolean> {
|
|
31
|
+
const warning = rootWarning(dir);
|
|
32
|
+
if (!warning) return true;
|
|
33
|
+
console.warn(`[codehost] warning: ${warning}`);
|
|
34
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return true;
|
|
35
|
+
process.stdout.write("[codehost] serve it anyway? [y/N] ");
|
|
36
|
+
const line = await new Promise<string>((res) => {
|
|
37
|
+
process.stdin.resume();
|
|
38
|
+
process.stdin.once("data", (d) => {
|
|
39
|
+
process.stdin.pause();
|
|
40
|
+
res(String(d));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
return /^y(es)?$/i.test(line.trim());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Warning for serving a risky workspace root, or null if fine. $HOME (and the
|
|
47
|
+
* filesystem root) expose everything you own over the room, collide the
|
|
48
|
+
* provisioning `.codehost/` with the machine-level `~/.codehost`, and make
|
|
49
|
+
* workspace enumeration walk your whole home. Allowed if you insist — but
|
|
50
|
+
* say so loudly and point at a dedicated dir like ~/ws. */
|
|
51
|
+
export function rootWarning(dir: string): string | null {
|
|
52
|
+
const norm = resolve(dir);
|
|
53
|
+
if (norm === resolve(homedir())) {
|
|
54
|
+
return "serving your HOME directory as the workspace root — everything in it is reachable through this room. A dedicated dir is safer: codehost serve ~/ws";
|
|
55
|
+
}
|
|
56
|
+
if (norm === resolve("/")) {
|
|
57
|
+
return "serving the filesystem root — the entire machine is reachable through this room. A dedicated dir is safer, e.g. ~/ws";
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
14
62
|
interface ServeArgs {
|
|
15
|
-
dir
|
|
63
|
+
dir?: string;
|
|
16
64
|
token: string;
|
|
17
65
|
name?: string;
|
|
18
66
|
signal: string;
|
|
@@ -27,9 +75,8 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
27
75
|
builder: (y) =>
|
|
28
76
|
y
|
|
29
77
|
.positional("dir", {
|
|
30
|
-
describe: "
|
|
78
|
+
describe: "Workspace root to serve (default: the remembered root, else ~/ws)",
|
|
31
79
|
type: "string",
|
|
32
|
-
default: ".",
|
|
33
80
|
})
|
|
34
81
|
.option("token", {
|
|
35
82
|
alias: "t",
|
|
@@ -65,7 +112,17 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
65
112
|
process.exit(1);
|
|
66
113
|
}
|
|
67
114
|
|
|
68
|
-
|
|
115
|
+
// Explicit dir > remembered root (config.json) > ~/ws. Bare `codehost
|
|
116
|
+
// serve` should land somewhere sane, never accidentally on $HOME/cwd.
|
|
117
|
+
const dir = argv.dir ? resolve(process.cwd(), argv.dir) : defaultRoot();
|
|
118
|
+
if (!argv.dir) {
|
|
119
|
+
mkdirSync(dir, { recursive: true });
|
|
120
|
+
console.log(`[codehost] no dir given — serving workspace root ${dir}`);
|
|
121
|
+
}
|
|
122
|
+
if (!(await confirmRiskyRoot(dir))) {
|
|
123
|
+
console.error("[codehost] aborted");
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
69
126
|
const host = hostname();
|
|
70
127
|
|
|
71
128
|
// `-d`: re-launch this same `serve` (without -d) under oxmgr, then exit.
|
|
@@ -84,22 +141,54 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
84
141
|
}
|
|
85
142
|
|
|
86
143
|
// 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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
144
|
+
// onto subfolders via VS Code's ?folder= using this layout. The layout is
|
|
145
|
+
// the same template provisioning uses (.codehost/config.yaml `workspace`),
|
|
146
|
+
// so the advertised list and the provisioned paths agree.
|
|
147
|
+
const layout = readCodehostConfig(dir).workspace || DEFAULT_LAYOUT;
|
|
148
|
+
const plugins = [agentYesPlugin()].filter((p) => p != null);
|
|
149
|
+
const buildMeta = (): PeerMeta => {
|
|
150
|
+
// Layout-enumerated checkouts plus directories other `codehost dev` runs
|
|
151
|
+
// registered with this host daemon (git-identified best-effort).
|
|
152
|
+
const workspaces = enumerateWorkspaces(dir, layout);
|
|
153
|
+
for (const w of readRegisteredWorkspaces()) {
|
|
154
|
+
const path = toPosixPath(w.path);
|
|
155
|
+
if (workspaces.some((x) => x.path === path)) continue;
|
|
156
|
+
const id = repoIdentity(w.path);
|
|
157
|
+
workspaces.push({
|
|
158
|
+
path,
|
|
159
|
+
...(id.repo ? { repo: id.repo } : {}),
|
|
160
|
+
...(id.branch ? { branch: id.branch } : {}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return withPluginMeta(
|
|
164
|
+
{
|
|
165
|
+
name: argv.name ?? host,
|
|
166
|
+
// VS Code-web ?folder= form for the browser (C:\ws -> /C:/ws); the
|
|
167
|
+
// real OS path `dir` is still what we spawn VS Code in.
|
|
168
|
+
cwd: toPosixPath(dir),
|
|
169
|
+
host,
|
|
170
|
+
hostId: ensureHostId(),
|
|
171
|
+
kind: "root",
|
|
172
|
+
layout,
|
|
173
|
+
workspaces,
|
|
174
|
+
},
|
|
175
|
+
plugins,
|
|
176
|
+
);
|
|
96
177
|
};
|
|
97
178
|
|
|
179
|
+
// Mark this process as the host daemon, so later `codehost dev` runs
|
|
180
|
+
// register their directory with it instead of spawning a second peer.
|
|
181
|
+
writeDaemonPresence({ pid: process.pid, root: dir, token: argv.token, startedAt: Date.now() });
|
|
182
|
+
process.on("exit", () => clearDaemonPresence());
|
|
183
|
+
|
|
98
184
|
announceConnect(argv.token);
|
|
99
185
|
await runServer({
|
|
100
186
|
token: argv.token,
|
|
101
187
|
signal: argv.signal,
|
|
102
|
-
meta,
|
|
188
|
+
meta: buildMeta(),
|
|
189
|
+
refreshMeta: buildMeta,
|
|
190
|
+
watchFiles: [workspacesFile()],
|
|
191
|
+
plugins,
|
|
103
192
|
label: `serving workspace root ${dir}`,
|
|
104
193
|
provision: { homeDir: dir, host: GITHUB_HOST },
|
|
105
194
|
launch: async (basePath) => {
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
1
2
|
import { hostname } from "node:os";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
import type { CommandModule } from "yargs";
|
|
4
5
|
import { generateToken, validateToken, TOKEN_REQUIREMENTS } from "../../shared/token";
|
|
5
|
-
import { readConfig, writeConfig } from "../config";
|
|
6
|
+
import { defaultRoot, readConfig, writeConfig } from "../config";
|
|
6
7
|
import { launchServeDaemon } from "../daemonize";
|
|
7
8
|
import { isGitRepo } from "../git";
|
|
9
|
+
import { scaffoldCodehost } from "../init";
|
|
10
|
+
import { confirmRiskyRoot } from "./serve";
|
|
8
11
|
import { resolveCodeBinary } from "../vscode-install";
|
|
9
12
|
import { announceConnect } from "../open-url";
|
|
10
13
|
import { DEFAULT_SIGNAL_URL } from "./serve";
|
|
11
14
|
|
|
12
15
|
interface SetupArgs {
|
|
13
|
-
dir
|
|
16
|
+
dir?: string;
|
|
14
17
|
token?: string;
|
|
15
18
|
newToken: boolean;
|
|
16
19
|
name?: string;
|
|
@@ -23,7 +26,10 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
|
23
26
|
describe: "One-shot: pick a token, ensure VS Code, and start a daemonized server",
|
|
24
27
|
builder: (y) =>
|
|
25
28
|
y
|
|
26
|
-
.positional("dir", {
|
|
29
|
+
.positional("dir", {
|
|
30
|
+
describe: "Directory to serve (default: a git cwd serves itself, else the remembered root / ~/ws)",
|
|
31
|
+
type: "string",
|
|
32
|
+
})
|
|
27
33
|
.option("token", {
|
|
28
34
|
alias: "t",
|
|
29
35
|
describe: "Room token (generated + saved if omitted)",
|
|
@@ -38,7 +44,23 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
|
38
44
|
.option("signal", { describe: "Signaling server URL", type: "string", default: DEFAULT_SIGNAL_URL })
|
|
39
45
|
.option("port", { describe: "Fixed port for the local VS Code server", type: "number" }) as any,
|
|
40
46
|
handler: async (argv) => {
|
|
41
|
-
|
|
47
|
+
// Explicit dir > a git cwd (serve THIS repo) > remembered root > ~/ws —
|
|
48
|
+
// so a bare `codehost setup` (e.g. from the installer) lands on a sane
|
|
49
|
+
// workspace root instead of whatever directory it happened to run in.
|
|
50
|
+
let dir: string;
|
|
51
|
+
if (argv.dir) {
|
|
52
|
+
dir = resolve(process.cwd(), argv.dir);
|
|
53
|
+
} else if (isGitRepo(process.cwd())) {
|
|
54
|
+
dir = process.cwd();
|
|
55
|
+
} else {
|
|
56
|
+
dir = defaultRoot();
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
console.log(`[codehost] no dir given — using workspace root ${dir}`);
|
|
59
|
+
}
|
|
60
|
+
if (!(await confirmRiskyRoot(dir))) {
|
|
61
|
+
console.error("[codehost] aborted");
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
42
64
|
const host = hostname();
|
|
43
65
|
|
|
44
66
|
// 1. Resolve the room token: validate an explicit one, otherwise reuse the
|
|
@@ -53,10 +75,24 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
|
53
75
|
const codeBin = await resolveCodeBinary();
|
|
54
76
|
console.log(`[codehost] using VS Code: ${codeBin}`);
|
|
55
77
|
|
|
56
|
-
// 3.
|
|
78
|
+
// 3. Batteries included: a workspace root gets its `.codehost/` scaffold
|
|
79
|
+
// (config.yaml + clone/worktree setup hook) so /gh/<owner>/<repo> links
|
|
80
|
+
// provision on demand out of the box. Existing files are never touched;
|
|
81
|
+
// no new trust — the room token already grants code execution.
|
|
82
|
+
const root = !isGitRepo(dir);
|
|
83
|
+
if (root) {
|
|
84
|
+
const written = scaffoldCodehost(dir);
|
|
85
|
+
if (written.length > 0) {
|
|
86
|
+
console.log(`[codehost] scaffolded ${dir}/.codehost (config.yaml + setup hook — edit freely)`);
|
|
87
|
+
}
|
|
88
|
+
// Remember an explicitly chosen root so future bare runs reuse it.
|
|
89
|
+
if (argv.dir) writeConfig({ ...readConfig(), root: dir });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 4. Start the WebRTC + VS Code server under oxmgr. A git repo is a single
|
|
57
93
|
// workspace (`dev`); anything else is treated as a root (`serve`).
|
|
58
94
|
const { ok, name } = await launchServeDaemon({
|
|
59
|
-
command:
|
|
95
|
+
command: root ? "serve" : "dev",
|
|
60
96
|
dir,
|
|
61
97
|
token,
|
|
62
98
|
signal: argv.signal,
|
|
@@ -66,7 +102,7 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
|
66
102
|
});
|
|
67
103
|
if (!ok) process.exit(1);
|
|
68
104
|
|
|
69
|
-
//
|
|
105
|
+
// 5. Tell the user how to connect, and open the browser straight at the
|
|
70
106
|
// token-carrying URL so VS Code loads without typing the token in.
|
|
71
107
|
console.log("");
|
|
72
108
|
console.log(`[codehost] ✓ server "${name}" is live, serving ${dir}`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ensureHostId, readConfig, writeConfig } from "./config";
|
|
6
|
+
|
|
7
|
+
const tmpConfig = () => join(mkdtempSync(join(tmpdir(), "codehost-config-")), "config.json");
|
|
8
|
+
|
|
9
|
+
describe("ensureHostId", () => {
|
|
10
|
+
test("mints a UUID once and returns the same id on later calls", () => {
|
|
11
|
+
const file = tmpConfig();
|
|
12
|
+
const first = ensureHostId(file);
|
|
13
|
+
expect(first).toMatch(/^[0-9a-f-]{36}$/);
|
|
14
|
+
expect(ensureHostId(file)).toBe(first);
|
|
15
|
+
expect(readConfig(file).hostId).toBe(first);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("preserves other config fields when minting", () => {
|
|
19
|
+
const file = tmpConfig();
|
|
20
|
+
writeConfig({ token: "Str0ng-Token-99" }, file);
|
|
21
|
+
const id = ensureHostId(file);
|
|
22
|
+
const config = JSON.parse(readFileSync(file, "utf8"));
|
|
23
|
+
expect(config).toEqual({ token: "Str0ng-Token-99", hostId: id });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns an existing hostId without rewriting", () => {
|
|
27
|
+
const file = tmpConfig();
|
|
28
|
+
writeConfig({ hostId: "pre-existing-id" }, file);
|
|
29
|
+
expect(ensureHostId(file)).toBe("pre-existing-id");
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/cli/config.ts
CHANGED
|
@@ -3,25 +3,47 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
|
|
5
5
|
// Persistent CLI config under ~/.codehost (same root as the managed VS Code
|
|
6
|
-
// cache in vscode-install.ts)
|
|
7
|
-
//
|
|
6
|
+
// cache in vscode-install.ts): the reusable room token and this machine's
|
|
7
|
+
// stable hostId.
|
|
8
8
|
|
|
9
9
|
const CONFIG_FILE = join(homedir(), ".codehost", "config.json");
|
|
10
10
|
|
|
11
11
|
export interface CliConfig {
|
|
12
12
|
/** Room token reused by `setup` until --new-token or an explicit -t. */
|
|
13
13
|
token?: string;
|
|
14
|
+
/** Stable machine identity (UUID), minted once on first use. Every daemon on
|
|
15
|
+
* this machine advertises it, so the web UI can group peers by host and
|
|
16
|
+
* history entries survive daemon restarts (peerIds are per-process). */
|
|
17
|
+
hostId?: string;
|
|
18
|
+
/** Workspace root remembered from an explicit `codehost setup <dir>` —
|
|
19
|
+
* reused when serve/setup run with no dir. Absent -> ~/ws. */
|
|
20
|
+
root?: string;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
|
-
export function readConfig(): CliConfig {
|
|
23
|
+
export function readConfig(file: string = CONFIG_FILE): CliConfig {
|
|
17
24
|
try {
|
|
18
|
-
return JSON.parse(readFileSync(
|
|
25
|
+
return JSON.parse(readFileSync(file, "utf8")) as CliConfig;
|
|
19
26
|
} catch {
|
|
20
27
|
return {};
|
|
21
28
|
}
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
export function writeConfig(config: CliConfig): void {
|
|
25
|
-
mkdirSync(dirname(
|
|
26
|
-
writeFileSync(
|
|
31
|
+
export function writeConfig(config: CliConfig, file: string = CONFIG_FILE): void {
|
|
32
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
33
|
+
writeFileSync(file, JSON.stringify(config, null, 2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The workspace root to use when no dir was given: the remembered root from
|
|
37
|
+
* config, else ~/ws (created on demand by the caller). Never $HOME itself. */
|
|
38
|
+
export function defaultRoot(file: string = CONFIG_FILE): string {
|
|
39
|
+
return readConfig(file).root || join(homedir(), "ws");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** This machine's persistent hostId, minting + saving it on first call. */
|
|
43
|
+
export function ensureHostId(file: string = CONFIG_FILE): string {
|
|
44
|
+
const config = readConfig(file);
|
|
45
|
+
if (config.hostId) return config.hostId;
|
|
46
|
+
const hostId = crypto.randomUUID();
|
|
47
|
+
writeConfig({ ...config, hostId }, file);
|
|
48
|
+
return hostId;
|
|
27
49
|
}
|
package/src/cli/daemonize.ts
CHANGED
|
@@ -75,6 +75,10 @@ function buildForegroundArgv(opts: ServeDaemonOptions): string[] {
|
|
|
75
75
|
const parts = [process.execPath, process.argv[1], opts.command ?? "serve", opts.arg ?? opts.dir, "-t", opts.token, "--signal", opts.signal];
|
|
76
76
|
if (opts.name) parts.push("--name", opts.name);
|
|
77
77
|
if (opts.port) parts.push("--port", String(opts.port));
|
|
78
|
+
// An oxmgr-managed `dev` must stay a real daemon across restarts/reboots —
|
|
79
|
+
// never collapse into register-with-the-host-daemon-and-exit (oxmgr would
|
|
80
|
+
// see an instant exit and thrash).
|
|
81
|
+
if ((opts.command ?? "serve") === "dev") parts.push("--standalone");
|
|
78
82
|
return parts;
|
|
79
83
|
}
|
|
80
84
|
|
package/src/cli/init.test.ts
CHANGED
|
@@ -23,11 +23,11 @@ describe("scaffoldCodehost", () => {
|
|
|
23
23
|
expect(existsSync(join(home, ".codehost", "setup.ps1"))).toBe(true);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("config.yaml is valid YAML with a workspace template", () => {
|
|
26
|
+
test("config.yaml is valid YAML with a prefix-free workspace template", () => {
|
|
27
27
|
const home = mkHome();
|
|
28
28
|
scaffoldCodehost(home);
|
|
29
29
|
const cfg = parseYaml(readFileSync(join(home, ".codehost", "config.yaml"), "utf8"));
|
|
30
|
-
expect(cfg.workspace).toBe("
|
|
30
|
+
expect(cfg.workspace).toBe("{owner}/{repo}/tree/{branch}");
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
test("setup.sh keeps shell vars literal (no JS interpolation leaked)", () => {
|
|
@@ -37,6 +37,8 @@ describe("scaffoldCodehost", () => {
|
|
|
37
37
|
expect(sh).toContain("$CODEHOST_WS");
|
|
38
38
|
expect(sh).toContain("${ws%/tree/$CODEHOST_BRANCH}");
|
|
39
39
|
expect(sh).toContain("git -C \"$repo\" worktree add");
|
|
40
|
+
// Fresh clones detach so the default branch is worktree-able too.
|
|
41
|
+
expect(sh).toContain('git -C "$repo" switch --detach');
|
|
40
42
|
});
|
|
41
43
|
|
|
42
44
|
test("idempotent: a second run writes nothing; --force overwrites", () => {
|
package/src/cli/init.ts
CHANGED
|
@@ -7,9 +7,9 @@ import { join } from "node:path";
|
|
|
7
7
|
|
|
8
8
|
const CONFIG_YAML = `# codehost workspace config — see docs/provisioning.md
|
|
9
9
|
|
|
10
|
-
# Where /gh/<owner>/<repo>/tree/<branch> lands, relative to this
|
|
11
|
-
#
|
|
12
|
-
workspace: "
|
|
10
|
+
# Where /gh/<owner>/<repo>/tree/<branch> lands, relative to this served root
|
|
11
|
+
# (serve a dedicated workspace dir like ~/ws — never \$HOME itself).
|
|
12
|
+
workspace: "{owner}/{repo}/tree/{branch}"
|
|
13
13
|
|
|
14
14
|
# Optional: only these repos may auto-provision (a trailing /* is an owner
|
|
15
15
|
# wildcard). Empty/absent = allow all. The room token already grants access, so
|
|
@@ -41,6 +41,10 @@ if [ ! -e "$repo/.git" ]; then
|
|
|
41
41
|
echo "[setup] cloning $url"
|
|
42
42
|
mkdir -p "$(dirname "$repo")"
|
|
43
43
|
git clone "$url" "$repo"
|
|
44
|
+
# Detach the fresh primary clone so EVERY branch (incl. the default one it
|
|
45
|
+
# just checked out) can be worktree-added under tree/<branch>. Skipped for
|
|
46
|
+
# pre-existing clones — they may be someone's live working copy.
|
|
47
|
+
git -C "$repo" switch --detach
|
|
44
48
|
fi
|
|
45
49
|
|
|
46
50
|
echo "[setup] adding worktree $ws @ $CODEHOST_BRANCH"
|
|
@@ -67,6 +71,8 @@ if (-not (Test-Path (Join-Path $repo ".git"))) {
|
|
|
67
71
|
Write-Host "[setup] cloning $url"
|
|
68
72
|
New-Item -ItemType Directory -Force -Path (Split-Path $repo) | Out-Null
|
|
69
73
|
git clone $url $repo
|
|
74
|
+
# Detach so every branch (incl. the default) can be worktree-added.
|
|
75
|
+
git -C $repo switch --detach
|
|
70
76
|
}
|
|
71
77
|
Write-Host "[setup] adding worktree $ws @ $($env:CODEHOST_BRANCH)"
|
|
72
78
|
New-Item -ItemType Directory -Force -Path (Split-Path $ws) | Out-Null
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { agentYesPlugin, readAgents } from "./agent-yes";
|
|
6
|
+
import { routePlugins, withPluginMeta } from "./types";
|
|
7
|
+
|
|
8
|
+
function makeAyDir(lines: object[]): string {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "codehost-ay-"));
|
|
10
|
+
writeFileSync(join(dir, "pids.jsonl"), lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("readAgents", () => {
|
|
15
|
+
test("last line per pid wins; exited and dead pids are dropped", () => {
|
|
16
|
+
const dir = makeAyDir([
|
|
17
|
+
{ pid: process.pid, cli: "claude", prompt: "fix the bug", cwd: "/tmp", status: "idle", started_at: 5 },
|
|
18
|
+
{ pid: 99999999, cli: "gemini", cwd: "/tmp", status: "active" }, // dead pid
|
|
19
|
+
{ pid: process.pid, cli: "claude", status: "exited", exit_code: 0 }, // last line per pid wins
|
|
20
|
+
]);
|
|
21
|
+
// The final line marks our (live) pid exited, and the other pid is dead.
|
|
22
|
+
expect(readAgents(dir)).toHaveLength(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("maps a live record to AgentInfo", () => {
|
|
26
|
+
const dir = makeAyDir([
|
|
27
|
+
{ pid: process.pid, cli: "claude", prompt: "do things", cwd: "/tmp/x", status: "active", started_at: 7 },
|
|
28
|
+
]);
|
|
29
|
+
expect(readAgents(dir)).toEqual([
|
|
30
|
+
{ pid: process.pid, tool: "claude", title: "do things", cwd: "/tmp/x", state: "active", startedAt: 7 },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("missing registry -> empty", () => {
|
|
35
|
+
expect(readAgents(mkdtempSync(join(tmpdir(), "codehost-ay-empty-")))).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("plugin routing + meta", () => {
|
|
40
|
+
test("routePlugins dispatches under /__codehost/<name>/ with the sub-path", async () => {
|
|
41
|
+
const seen: string[] = [];
|
|
42
|
+
const plugin = {
|
|
43
|
+
name: "agent-yes",
|
|
44
|
+
route: async (path: string) => {
|
|
45
|
+
seen.push(path);
|
|
46
|
+
return new Response("ok");
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const res = routePlugins([plugin], {
|
|
50
|
+
method: "GET",
|
|
51
|
+
path: "/__codehost/agent-yes/api/ls?active=1",
|
|
52
|
+
headers: new Headers(),
|
|
53
|
+
});
|
|
54
|
+
expect(res).not.toBeNull();
|
|
55
|
+
await res;
|
|
56
|
+
expect(seen).toEqual(["/api/ls?active=1"]);
|
|
57
|
+
expect(
|
|
58
|
+
routePlugins([plugin], { method: "GET", path: "/__codehost/other/x", headers: new Headers() }),
|
|
59
|
+
).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("withPluginMeta merges contributions over the base", () => {
|
|
63
|
+
const base = { name: "x", cwd: "/", host: "mac" };
|
|
64
|
+
const merged = withPluginMeta(base, [{ name: "agent-yes", meta: () => ({ agents: [] }) }]);
|
|
65
|
+
expect(merged).toEqual({ name: "x", cwd: "/", host: "mac", agents: [] });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("agentYesPlugin returns null when the dir doesn't exist", () => {
|
|
69
|
+
expect(agentYesPlugin("/nonexistent/definitely-not-here")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|