codehost 0.20.5 → 0.22.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 CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.22.0](https://github.com/snomiao/codehost/compare/v0.21.0...v0.22.0) (2026-06-14)
2
+
3
+
4
+ ### Features
5
+
6
+ * **web:** "Set up a machine" card on the home page ([c2cec58](https://github.com/snomiao/codehost/commit/c2cec580695cf759a986b18fc8fedd1494fc542f))
7
+
8
+ # [0.21.0](https://github.com/snomiao/codehost/compare/v0.20.5...v0.21.0) (2026-06-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * room roster, host approval/kick, `codehost open`; rename viewer→client ([f3a2a82](https://github.com/snomiao/codehost/commit/f3a2a8265aa8b1adf95ad764fb6d30d9561c6e3c))
14
+
1
15
  ## [0.20.5](https://github.com/snomiao/codehost/compare/v0.20.4...v0.20.5) (2026-06-12)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.20.5",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,212 @@
1
+ import { createInterface, type Interface } from "node:readline";
2
+
3
+ /**
4
+ * Host-side admission policy for clients (connecting browsers — each gets a full
5
+ * VS Code session, so this is a real gate, not just UX).
6
+ * - "auto": admit everyone with the token (default; the token is the gate).
7
+ * - "confirm": hold each new client until the host approves it at the terminal,
8
+ * unless its label matches a pre-approved `--allow` pattern.
9
+ */
10
+ export type ApprovePolicy = "auto" | "confirm";
11
+
12
+ export interface ApproverOptions {
13
+ policy: ApprovePolicy;
14
+ /** Case-insensitive label substrings auto-approved even under "confirm". */
15
+ allow: string[];
16
+ /** Tear down a live client's connection (kick). */
17
+ kick: (clientId: string) => void;
18
+ /** Emit a "pending approval" hint to a client that's now waiting on a human. */
19
+ notifyPending: (clientId: string) => void;
20
+ /** Whether stdin is an interactive terminal (defaults to process.stdin.isTTY). */
21
+ isTTY?: boolean;
22
+ /** Injected input/output for tests; default to the real process streams. */
23
+ input?: NodeJS.ReadableStream;
24
+ output?: NodeJS.WritableStream;
25
+ log?: (msg: string) => void;
26
+ }
27
+
28
+ interface Pending {
29
+ clientId: string;
30
+ label: string;
31
+ resolve: (ok: boolean) => void;
32
+ }
33
+
34
+ /**
35
+ * Decides whether to admit clients and drives an interactive terminal console
36
+ * for approve / deny / kick / list. Self-contained (no WebRTC deps) so the
37
+ * daemon stays thin and this stays unit-testable.
38
+ */
39
+ export class Approver {
40
+ private opts: ApproverOptions;
41
+ private isTTY: boolean;
42
+ private out: NodeJS.WritableStream;
43
+ private log: (msg: string) => void;
44
+
45
+ /** Labels approved with "a(lways)" this session — auto-admit on sight. */
46
+ private sessionAllow = new Set<string>();
47
+ /** Live clients (admitted + connected), for `list` / `kick`. */
48
+ private active = new Map<string, string>();
49
+ /** Approvals waiting on the host; the head is the one being asked. */
50
+ private queue: Pending[] = [];
51
+ private asking = false;
52
+ private rl: Interface | null = null;
53
+
54
+ constructor(opts: ApproverOptions) {
55
+ this.opts = opts;
56
+ this.isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY);
57
+ this.out = opts.output ?? process.stdout;
58
+ this.log = opts.log ?? ((m) => console.log(m));
59
+ }
60
+
61
+ /** Announce the active policy once at startup. */
62
+ banner(): void {
63
+ if (this.opts.policy === "auto") return;
64
+ if (this.isTTY) {
65
+ this.log("[codehost] approval: confirm — new clients wait for your OK. Commands: list · kick <n>");
66
+ } else {
67
+ this.log(
68
+ "[codehost] approval: confirm, but no terminal is attached — clients can't be approved " +
69
+ "interactively and will be denied. Use --approve auto or --allow <label> for a daemon.",
70
+ );
71
+ }
72
+ if (this.opts.allow.length) this.log(`[codehost] auto-approving labels matching: ${this.opts.allow.join(", ")}`);
73
+ }
74
+
75
+ /** Resolve true to admit `clientId`, false to deny. Called once per offer. */
76
+ admit(clientId: string, label: string): Promise<boolean> {
77
+ if (this.opts.policy === "auto" || this.preApproved(label)) return Promise.resolve(true);
78
+
79
+ // Confirm mode but no terminal to ask at: deny safely.
80
+ if (!this.isTTY) {
81
+ this.log(`[codehost] denied "${label}" (${short(clientId)}): confirm mode needs a terminal`);
82
+ return Promise.resolve(false);
83
+ }
84
+
85
+ this.opts.notifyPending(clientId);
86
+ return new Promise<boolean>((resolve) => {
87
+ this.queue.push({ clientId, label, resolve });
88
+ this.pumpQueue();
89
+ });
90
+ }
91
+
92
+ /** Mark a client live once its tunnel is bridged (for the console roster). */
93
+ onConnected(clientId: string, label: string): void {
94
+ this.active.set(clientId, label);
95
+ }
96
+
97
+ /** Drop a client from the roster and resolve any in-flight approval as denied. */
98
+ onDisconnected(clientId: string): void {
99
+ this.active.delete(clientId);
100
+ const idx = this.queue.findIndex((p) => p.clientId === clientId);
101
+ if (idx >= 0) {
102
+ const [p] = this.queue.splice(idx, 1);
103
+ p.resolve(false);
104
+ }
105
+ }
106
+
107
+ private preApproved(label: string): boolean {
108
+ if (this.sessionAllow.has(label)) return true;
109
+ const l = label.toLowerCase();
110
+ return this.opts.allow.some((p) => p && l.includes(p.toLowerCase()));
111
+ }
112
+
113
+ // ---- interactive console ----
114
+
115
+ /** Start the readline console (no-op unless confirm + TTY). */
116
+ start(): void {
117
+ if (this.opts.policy !== "confirm" || !this.isTTY || this.rl) return;
118
+ this.rl = createInterface({ input: this.opts.input ?? process.stdin, output: this.out });
119
+ this.rl.on("line", (line) => this.onLine(line.trim()));
120
+ }
121
+
122
+ stop(): void {
123
+ this.rl?.close();
124
+ this.rl = null;
125
+ }
126
+
127
+ private pumpQueue(): void {
128
+ if (this.asking || this.queue.length === 0) return;
129
+ this.asking = true;
130
+ const { label, clientId } = this.queue[0];
131
+ this.out.write(`\n[codehost] "${label}" (${short(clientId)}) wants to connect. Approve? [y/N/a=always] `);
132
+ }
133
+
134
+ private onLine(line: string): void {
135
+ if (this.asking) {
136
+ this.answer(line);
137
+ return;
138
+ }
139
+ this.command(line);
140
+ }
141
+
142
+ private answer(line: string): void {
143
+ const pending = this.queue.shift();
144
+ this.asking = false;
145
+ if (!pending) return;
146
+ const a = line.toLowerCase();
147
+ const always = a === "a" || a === "always";
148
+ const yes = always || a === "y" || a === "yes";
149
+ if (always) this.sessionAllow.add(pending.label);
150
+ this.log(`[codehost] ${yes ? "approved" : "denied"} "${pending.label}" (${short(pending.clientId)})`);
151
+ pending.resolve(yes);
152
+ this.pumpQueue();
153
+ }
154
+
155
+ private command(line: string): void {
156
+ if (!line) return;
157
+ const [cmd, ...rest] = line.split(/\s+/);
158
+ const arg = rest.join(" ");
159
+ switch (cmd) {
160
+ case "l":
161
+ case "ls":
162
+ case "list":
163
+ this.printList();
164
+ break;
165
+ case "k":
166
+ case "kick":
167
+ this.doKick(arg);
168
+ break;
169
+ case "h":
170
+ case "help":
171
+ case "?":
172
+ this.log("[codehost] commands: list · kick <n|id> · help");
173
+ break;
174
+ default:
175
+ this.log(`[codehost] unknown command "${cmd}" — try: list · kick <n|id>`);
176
+ }
177
+ }
178
+
179
+ private printList(): void {
180
+ const entries = [...this.active.entries()];
181
+ if (entries.length === 0) {
182
+ this.log("[codehost] no clients connected");
183
+ return;
184
+ }
185
+ this.log("[codehost] connected clients:");
186
+ entries.forEach(([id, label], i) => this.log(` ${i + 1}. ${label} (${short(id)})`));
187
+ }
188
+
189
+ private doKick(arg: string): void {
190
+ const id = this.resolveClient(arg);
191
+ if (!id) {
192
+ this.log(`[codehost] no connected client matches "${arg}" — see: list`);
193
+ return;
194
+ }
195
+ const label = this.active.get(id) ?? "client";
196
+ this.log(`[codehost] kicking "${label}" (${short(id)})`);
197
+ this.opts.kick(id);
198
+ }
199
+
200
+ /** Resolve a `kick` argument: 1-based index from `list`, or a peerId prefix. */
201
+ private resolveClient(arg: string): string | null {
202
+ if (!arg) return null;
203
+ const entries = [...this.active.keys()];
204
+ const n = Number(arg);
205
+ if (Number.isInteger(n) && n >= 1 && n <= entries.length) return entries[n - 1];
206
+ return entries.find((id) => id.startsWith(arg) || short(id) === arg) ?? null;
207
+ }
208
+ }
209
+
210
+ function short(id: string): string {
211
+ return id.slice(0, 8);
212
+ }
@@ -2,6 +2,7 @@ import { hostname } from "node:os";
2
2
  import { resolve } from "node:path";
3
3
  import type { CommandModule } from "yargs";
4
4
  import type { PeerMeta } from "../../shared/signaling";
5
+ import type { ApprovePolicy } from "../approver";
5
6
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
6
7
  import { ensureHostId } from "../config";
7
8
  import { launchServeDaemon } from "../daemonize";
@@ -21,6 +22,8 @@ interface DevArgs {
21
22
  daemon: boolean;
22
23
  standalone: boolean;
23
24
  port?: number;
25
+ approve: string;
26
+ allow: string[];
24
27
  }
25
28
 
26
29
  export const devCommand: CommandModule<{}, DevArgs> = {
@@ -63,6 +66,18 @@ export const devCommand: CommandModule<{}, DevArgs> = {
63
66
  .option("port", {
64
67
  describe: "Fixed port for the local VS Code server (default: ephemeral)",
65
68
  type: "number",
69
+ })
70
+ .option("approve", {
71
+ describe: "Client admission: 'auto' (anyone with the token) or 'confirm' (approve each at the terminal)",
72
+ type: "string",
73
+ choices: ["auto", "confirm"],
74
+ default: "auto",
75
+ })
76
+ .option("allow", {
77
+ describe: "Under --approve confirm, auto-approve clients whose label matches (repeatable)",
78
+ type: "string",
79
+ array: true,
80
+ default: [],
66
81
  }) as any,
67
82
  handler: async (argv) => {
68
83
  argv.token = argv.token.trim();
@@ -107,6 +122,8 @@ export const devCommand: CommandModule<{}, DevArgs> = {
107
122
  name: argv.name,
108
123
  port: argv.port,
109
124
  host,
125
+ approve: argv.approve,
126
+ allow: argv.allow,
110
127
  });
111
128
  if (ok) announceConnect(argv.token);
112
129
  process.exit(ok ? 0 : 1);
@@ -132,6 +149,8 @@ export const devCommand: CommandModule<{}, DevArgs> = {
132
149
  signal: argv.signal,
133
150
  meta,
134
151
  label: `serving ${dir}`,
152
+ approve: argv.approve as ApprovePolicy,
153
+ allow: argv.allow,
135
154
  launch: async (basePath) => {
136
155
  const v = await launchVscode({ dir, basePath, port: argv.port });
137
156
  return { port: v.port, stop: v.stop };
@@ -0,0 +1,95 @@
1
+ import { resolve } from "node:path";
2
+ import type { CommandModule } from "yargs";
3
+ import { readConfig } from "../config";
4
+ import { repoIdentity } from "../git";
5
+ import { GITHUB_HOST, gitUrlToPath, shareableDeepLink } from "../../shared/repo";
6
+ import { PAGE_URL, openBrowser } from "../open-url";
7
+
8
+ interface OpenArgs {
9
+ target?: string;
10
+ token?: string;
11
+ anon: boolean;
12
+ page: string;
13
+ print: boolean;
14
+ }
15
+
16
+ export const openCommand: CommandModule<{}, OpenArgs> = {
17
+ command: "open [target]",
18
+ describe: "Open a codehost deep link in the browser (defaults to the repo in the current directory)",
19
+ builder: (y) =>
20
+ y
21
+ .positional("target", {
22
+ describe:
23
+ "What to open: a repo (owner/repo, a git URL, or gh/git/host/dev path) or a full URL. Omit to use the git repo in the current directory.",
24
+ type: "string",
25
+ })
26
+ .option("token", {
27
+ alias: "t",
28
+ describe: "Token to embed for auto-connect (defaults to the saved room token)",
29
+ type: "string",
30
+ })
31
+ .option("anon", {
32
+ describe: "Open without embedding a token",
33
+ type: "boolean",
34
+ default: false,
35
+ })
36
+ .option("page", { describe: "Page URL to open against", type: "string", default: PAGE_URL })
37
+ .option("print", {
38
+ describe: "Print the URL instead of opening a browser",
39
+ type: "boolean",
40
+ default: false,
41
+ }) as any,
42
+ handler: async (argv) => {
43
+ const url = buildUrl(argv);
44
+ console.log(`[codehost] ${url}`);
45
+ if (argv.print) return;
46
+ openBrowser(url);
47
+ },
48
+ };
49
+
50
+ /**
51
+ * Resolve the URL to open. A full non-git URL is used verbatim; otherwise we
52
+ * build a codehost deep-link path (reusing the same helpers the page resolves
53
+ * with) and, unless `--anon`, append the room token in the `#t=<token>` fragment
54
+ * so the page auto-connects (the fragment stays in the browser). With no target
55
+ * we derive the path from the git repo in the current directory.
56
+ */
57
+ function buildUrl(argv: OpenArgs): string {
58
+ const target = argv.target?.trim();
59
+
60
+ // A full non-repo URL is opened as-is. (A repo URL like
61
+ // https://github.com/o/r is turned into a deep link below.)
62
+ if (target && /^https?:\/\//i.test(target) && !gitUrlToPath(target)) return target;
63
+
64
+ const path = target ? pathFromTarget(target) : pathFromCwd();
65
+ const base = `${argv.page.replace(/\/+$/, "")}${path}`;
66
+
67
+ const token = argv.anon ? undefined : (argv.token?.trim() || readConfig().token);
68
+ return token ? `${base}#t=${encodeURIComponent(token)}` : base;
69
+ }
70
+
71
+ /** Map a positional target to a deep-link path (always starts with `/`). */
72
+ function pathFromTarget(target: string): string {
73
+ const clean = target.replace(/^\/+/, "");
74
+ // Already a known deep-link shape — pass through.
75
+ if (/^(gh|git|host|dev)\//.test(clean)) return `/${clean}`;
76
+ // A git URL / scp / host/owner/repo (dotted host), incl. /tree/<branch>.
77
+ const fromUrl = gitUrlToPath(target);
78
+ if (fromUrl) return fromUrl;
79
+ // Bare `owner/repo[/tree/branch]` -> GitHub deep link.
80
+ const m = clean.match(/^([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/);
81
+ if (m) return shareableDeepLink({ repo: `${GITHUB_HOST}/${m[1]}/${m[2]}`, branch: m[3] }) ?? "/";
82
+ // Anything else (e.g. a folder path) -> legacy host-agnostic folder mount.
83
+ return `/dev/${clean}`;
84
+ }
85
+
86
+ /** Derive a deep-link path from the git repo in the current directory. */
87
+ function pathFromCwd(): string {
88
+ const { repo, branch } = repoIdentity(resolve(process.cwd()));
89
+ const path = repo ? shareableDeepLink({ repo, branch }) : null;
90
+ if (!path) {
91
+ console.error("[codehost] no git repo found here; opening the codehost home page");
92
+ return "/";
93
+ }
94
+ return path;
95
+ }
@@ -3,6 +3,7 @@ import { homedir, hostname } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import type { CommandModule } from "yargs";
5
5
  import type { PeerMeta } from "../../shared/signaling";
6
+ import type { ApprovePolicy } from "../approver";
6
7
  import { DEFAULT_LAYOUT, GITHUB_HOST, toPosixPath } from "../../shared/repo";
7
8
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
8
9
  import { defaultRoot, ensureHostId } from "../config";
@@ -66,6 +67,8 @@ interface ServeArgs {
66
67
  signal: string;
67
68
  daemon: boolean;
68
69
  port?: number;
70
+ approve: string;
71
+ allow: string[];
69
72
  }
70
73
 
71
74
  export const serveCommand: CommandModule<{}, ServeArgs> = {
@@ -102,6 +105,18 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
102
105
  .option("port", {
103
106
  describe: "Fixed port for the local VS Code server (default: ephemeral)",
104
107
  type: "number",
108
+ })
109
+ .option("approve", {
110
+ describe: "Client admission: 'auto' (anyone with the token) or 'confirm' (approve each at the terminal)",
111
+ type: "string",
112
+ choices: ["auto", "confirm"],
113
+ default: "auto",
114
+ })
115
+ .option("allow", {
116
+ describe: "Under --approve confirm, auto-approve clients whose label matches (repeatable)",
117
+ type: "string",
118
+ array: true,
119
+ default: [],
105
120
  }) as any,
106
121
  handler: async (argv) => {
107
122
  argv.token = argv.token.trim();
@@ -135,6 +150,8 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
135
150
  name: argv.name,
136
151
  port: argv.port,
137
152
  host,
153
+ approve: argv.approve,
154
+ allow: argv.allow,
138
155
  });
139
156
  if (ok) announceConnect(argv.token);
140
157
  process.exit(ok ? 0 : 1);
@@ -208,6 +225,8 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
208
225
  metaRefreshMs: 3_000,
209
226
  watchFiles: [workspacesFile()],
210
227
  plugins,
228
+ approve: argv.approve as ApprovePolicy,
229
+ allow: argv.allow,
211
230
  label: `serving workspace root ${dir}`,
212
231
  provision: { homeDir: dir, host: GITHUB_HOST },
213
232
  launch: async (basePath) => {
@@ -19,6 +19,8 @@ interface SetupArgs {
19
19
  name?: string;
20
20
  signal: string;
21
21
  port?: number;
22
+ approve: string;
23
+ allow: string[];
22
24
  }
23
25
 
24
26
  export const setupCommand: CommandModule<{}, SetupArgs> = {
@@ -42,7 +44,19 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
42
44
  })
43
45
  .option("name", { describe: "Display name for this server (defaults to hostname)", type: "string" })
44
46
  .option("signal", { describe: "Signaling server URL", type: "string", default: DEFAULT_SIGNAL_URL })
45
- .option("port", { describe: "Fixed port for the local VS Code server", type: "number" }) as any,
47
+ .option("port", { describe: "Fixed port for the local VS Code server", type: "number" })
48
+ .option("approve", {
49
+ describe: "Client admission: 'auto' (anyone with the token) or 'confirm' (approve via --allow; the daemon has no terminal)",
50
+ type: "string",
51
+ choices: ["auto", "confirm"],
52
+ default: "auto",
53
+ })
54
+ .option("allow", {
55
+ describe: "Under --approve confirm, auto-approve clients whose label matches (repeatable)",
56
+ type: "string",
57
+ array: true,
58
+ default: [],
59
+ }) as any,
46
60
  handler: async (argv) => {
47
61
  // Explicit dir > a git cwd (serve THIS repo) > remembered root > ~/ws —
48
62
  // so a bare `codehost setup` (e.g. from the installer) lands on a sane
@@ -99,6 +113,8 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
99
113
  name: argv.name,
100
114
  port: argv.port,
101
115
  host,
116
+ approve: argv.approve,
117
+ allow: argv.allow,
102
118
  });
103
119
  if (!ok) process.exit(1);
104
120
 
@@ -20,6 +20,10 @@ export interface ServeDaemonOptions {
20
20
  port?: number;
21
21
  /** Hostname, used as a label fallback. */
22
22
  host: string;
23
+ /** Client admission policy ("auto" | "confirm"); propagated to the daemon. */
24
+ approve?: string;
25
+ /** Pre-approved client label patterns, propagated to the daemon. */
26
+ allow?: string[];
23
27
  }
24
28
 
25
29
  export interface ServeDaemonResult {
@@ -79,6 +83,10 @@ function buildForegroundArgv(opts: ServeDaemonOptions): string[] {
79
83
  // never collapse into register-with-the-host-daemon-and-exit (oxmgr would
80
84
  // see an instant exit and thrash).
81
85
  if ((opts.command ?? "serve") === "dev") parts.push("--standalone");
86
+ // A backgrounded daemon has no terminal, so "confirm" can only admit clients
87
+ // that match an --allow pattern; we still propagate it so that works.
88
+ if (opts.approve && opts.approve !== "auto") parts.push("--approve", opts.approve);
89
+ for (const pat of opts.allow ?? []) parts.push("--allow", pat);
82
90
  return parts;
83
91
  }
84
92
 
package/src/cli/index.ts CHANGED
@@ -6,6 +6,7 @@ import { initCommand } from "./commands/init";
6
6
  import { serveCommand } from "./commands/serve";
7
7
  import { devCommand } from "./commands/dev";
8
8
  import { exposeCommand } from "./commands/expose";
9
+ import { openCommand } from "./commands/open";
9
10
  import { listCommand } from "./commands/list";
10
11
  import { stopCommand } from "./commands/stop";
11
12
  import { updateCommand } from "./commands/update";
@@ -19,6 +20,7 @@ yargs(hideBin(process.argv))
19
20
  .command(serveCommand)
20
21
  .command(devCommand)
21
22
  .command(exposeCommand)
23
+ .command(openCommand)
22
24
  .command(listCommand)
23
25
  .command(stopCommand)
24
26
  .command(updateCommand)