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 +14 -0
- package/package.json +1 -1
- package/src/cli/approver.ts +212 -0
- package/src/cli/commands/dev.ts +19 -0
- package/src/cli/commands/open.ts +95 -0
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/commands/setup.ts +17 -1
- package/src/cli/daemonize.ts +8 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/rtc-daemon.ts +70 -30
- package/src/cli/run-server.ts +49 -4
- package/src/shared/repo.ts +3 -3
- package/src/shared/rtc.ts +13 -1
- package/src/shared/signaling-client.ts +2 -2
- package/src/shared/signaling.ts +39 -7
- package/src/web/discovery.tsx +204 -20
- package/src/web/room-client.ts +2 -2
- package/worker/room.ts +7 -3
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
|
@@ -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
|
+
}
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -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" })
|
|
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
|
|
package/src/cli/daemonize.ts
CHANGED
|
@@ -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)
|