codehost 0.1.0 → 0.3.1
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/.github/workflows/deploy.yml +30 -0
- package/.github/workflows/release.yaml +39 -0
- package/.releaserc.json +17 -0
- package/CHANGELOG.md +29 -0
- package/README.md +36 -5
- package/TODO.md +93 -0
- package/docs/vscode-cdn-proxy.md +117 -0
- package/package.json +11 -1
- package/public/_redirects +5 -0
- package/public/install.ps1 +43 -0
- package/public/install.sh +55 -0
- package/src/cli/commands/dev.ts +107 -0
- package/src/cli/commands/expose.ts +93 -0
- package/src/cli/commands/list.ts +2 -2
- package/src/cli/commands/serve.ts +35 -80
- package/src/cli/commands/setup.ts +99 -0
- package/src/cli/commands/stop.ts +2 -2
- package/src/cli/commands/update.ts +19 -0
- package/src/cli/config.ts +27 -0
- package/src/cli/daemonize.ts +59 -0
- package/src/cli/git.ts +53 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/open-url.ts +47 -0
- package/src/cli/oxmgr-heal.ts +175 -0
- package/src/cli/oxmgr.ts +104 -36
- package/src/cli/rtc-daemon.ts +57 -1
- package/src/cli/run-server.ts +78 -0
- package/src/cli/tunnel.ts +30 -4
- package/src/cli/vscode-install.ts +222 -0
- package/src/cli/vscode.ts +8 -4
- package/src/shared/repo.ts +104 -0
- package/src/shared/signaling.ts +18 -2
- package/src/web/config.ts +44 -7
- package/src/web/discovery.tsx +131 -6
- package/src/web/history.ts +58 -0
- package/src/web/sw.ts +36 -1
- package/worker/index.ts +49 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import type { CommandModule } from "yargs";
|
|
3
|
+
import type { PeerMeta } from "../../shared/signaling";
|
|
4
|
+
import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
|
|
5
|
+
import { launchServeDaemon } from "../daemonize";
|
|
6
|
+
import { runServer } from "../run-server";
|
|
7
|
+
import { DEFAULT_SIGNAL_URL } from "./serve";
|
|
8
|
+
|
|
9
|
+
interface ExposeArgs {
|
|
10
|
+
port: number;
|
|
11
|
+
token: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
signal: string;
|
|
14
|
+
daemon: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const exposeCommand: CommandModule<{}, ExposeArgs> = {
|
|
18
|
+
command: "expose <port>",
|
|
19
|
+
describe:
|
|
20
|
+
"Tunnel an existing local HTTP/WS server (any port) over WebRTC — reachable at codehost.dev/vs/<peerId>/",
|
|
21
|
+
builder: (y) =>
|
|
22
|
+
y
|
|
23
|
+
.positional("port", {
|
|
24
|
+
describe: "Local port to expose (e.g. 7432)",
|
|
25
|
+
type: "number",
|
|
26
|
+
demandOption: true,
|
|
27
|
+
})
|
|
28
|
+
.option("token", {
|
|
29
|
+
alias: "t",
|
|
30
|
+
describe: "Room token shared with the codehost.dev page",
|
|
31
|
+
type: "string",
|
|
32
|
+
demandOption: true,
|
|
33
|
+
})
|
|
34
|
+
.option("name", {
|
|
35
|
+
describe: "Display name for this server (defaults to localhost:<port>)",
|
|
36
|
+
type: "string",
|
|
37
|
+
})
|
|
38
|
+
.option("signal", {
|
|
39
|
+
describe: "Signaling server URL",
|
|
40
|
+
type: "string",
|
|
41
|
+
default: DEFAULT_SIGNAL_URL,
|
|
42
|
+
})
|
|
43
|
+
.option("daemon", {
|
|
44
|
+
alias: "d",
|
|
45
|
+
describe: "Run in the background under oxmgr (auto-starts on login)",
|
|
46
|
+
type: "boolean",
|
|
47
|
+
default: false,
|
|
48
|
+
}) as any,
|
|
49
|
+
handler: async (argv) => {
|
|
50
|
+
argv.token = argv.token.trim();
|
|
51
|
+
const check = validateToken(argv.token);
|
|
52
|
+
if (!check.ok) {
|
|
53
|
+
console.error(`[codehost] ${check.reason}`);
|
|
54
|
+
console.error(`[codehost] room token requires: ${TOKEN_REQUIREMENTS}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
if (!Number.isInteger(argv.port) || argv.port <= 0 || argv.port > 65535) {
|
|
58
|
+
console.error(`[codehost] invalid port: ${argv.port}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const host = hostname();
|
|
63
|
+
|
|
64
|
+
if (argv.daemon) {
|
|
65
|
+
const { ok } = await launchServeDaemon({
|
|
66
|
+
command: "expose",
|
|
67
|
+
dir: process.cwd(),
|
|
68
|
+
arg: String(argv.port),
|
|
69
|
+
token: argv.token,
|
|
70
|
+
signal: argv.signal,
|
|
71
|
+
name: argv.name,
|
|
72
|
+
host,
|
|
73
|
+
});
|
|
74
|
+
process.exit(ok ? 0 : 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const meta: PeerMeta = {
|
|
78
|
+
name: argv.name ?? `localhost:${argv.port}`,
|
|
79
|
+
cwd: `localhost:${argv.port}`,
|
|
80
|
+
host,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// No VS Code: tunnel directly to the given port, stripping the /vs/<peerId>
|
|
84
|
+
// prefix the server doesn't know about.
|
|
85
|
+
await runServer({
|
|
86
|
+
token: argv.token,
|
|
87
|
+
signal: argv.signal,
|
|
88
|
+
meta,
|
|
89
|
+
label: `exposing localhost:${argv.port}`,
|
|
90
|
+
launch: async (basePath) => ({ port: argv.port, stripBasePath: basePath }),
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
};
|
package/src/cli/commands/list.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import type { CommandModule } from "yargs";
|
|
4
|
-
import {
|
|
4
|
+
import type { PeerMeta } from "../../shared/signaling";
|
|
5
|
+
import { DEFAULT_LAYOUT } from "../../shared/repo";
|
|
5
6
|
import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import { launchServeDaemon } from "../daemonize";
|
|
8
|
+
import { announceConnect } from "../open-url";
|
|
9
|
+
import { runServer } from "../run-server";
|
|
8
10
|
import { launchVscode } from "../vscode";
|
|
9
|
-
import { Tunnel } from "../tunnel";
|
|
10
|
-
import { daemonName, startDaemon } from "../oxmgr";
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_SIGNAL_URL = "wss://signal.codehost.dev";
|
|
13
13
|
|
|
@@ -22,7 +22,8 @@ interface ServeArgs {
|
|
|
22
22
|
|
|
23
23
|
export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
24
24
|
command: "serve [dir]",
|
|
25
|
-
describe:
|
|
25
|
+
describe:
|
|
26
|
+
"Serve a workspace root over WebRTC; repos under it open via codehost.dev/gh/<owner>/<repo>",
|
|
26
27
|
builder: (y) =>
|
|
27
28
|
y
|
|
28
29
|
.positional("dir", {
|
|
@@ -47,7 +48,7 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
47
48
|
})
|
|
48
49
|
.option("daemon", {
|
|
49
50
|
alias: "d",
|
|
50
|
-
describe: "Run in the background under oxmgr",
|
|
51
|
+
describe: "Run in the background under oxmgr (auto-starts on login)",
|
|
51
52
|
type: "boolean",
|
|
52
53
|
default: false,
|
|
53
54
|
})
|
|
@@ -66,88 +67,42 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
66
67
|
|
|
67
68
|
const dir = resolve(process.cwd(), argv.dir);
|
|
68
69
|
const host = hostname();
|
|
69
|
-
const meta: PeerMeta = {
|
|
70
|
-
name: argv.name ?? host,
|
|
71
|
-
cwd: dir,
|
|
72
|
-
host,
|
|
73
|
-
};
|
|
74
70
|
|
|
75
71
|
// `-d`: re-launch this same `serve` (without -d) under oxmgr, then exit.
|
|
76
72
|
if (argv.daemon) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
73
|
+
const { ok } = await launchServeDaemon({
|
|
74
|
+
command: "serve",
|
|
75
|
+
dir,
|
|
76
|
+
token: argv.token,
|
|
77
|
+
signal: argv.signal,
|
|
78
|
+
name: argv.name,
|
|
79
|
+
port: argv.port,
|
|
80
|
+
host,
|
|
81
|
+
});
|
|
82
|
+
if (ok) announceConnect(argv.token);
|
|
83
|
+
process.exit(ok ? 0 : 1);
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const vscode = await launchVscode({ dir, basePath, port: argv.port });
|
|
99
|
-
|
|
100
|
-
let rtc: RtcDaemon;
|
|
86
|
+
// 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
|
+
const meta: PeerMeta = {
|
|
89
|
+
name: argv.name ?? host,
|
|
90
|
+
cwd: dir,
|
|
91
|
+
host,
|
|
92
|
+
kind: "root",
|
|
93
|
+
layout: DEFAULT_LAYOUT,
|
|
94
|
+
};
|
|
101
95
|
|
|
102
|
-
|
|
103
|
-
|
|
96
|
+
announceConnect(argv.token);
|
|
97
|
+
await runServer({
|
|
104
98
|
token: argv.token,
|
|
105
|
-
|
|
106
|
-
peerId,
|
|
99
|
+
signal: argv.signal,
|
|
107
100
|
meta,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
rtc = new RtcDaemon({
|
|
114
|
-
sendSignal: (to, data) => client.sendSignal(to, data),
|
|
115
|
-
// Each viewer's data channel is bridged to the local VS Code server.
|
|
116
|
-
onChannel: (viewerId, channel) => {
|
|
117
|
-
console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to VS Code`);
|
|
118
|
-
new Tunnel(channel, vscode.port);
|
|
101
|
+
label: `serving workspace root ${dir}`,
|
|
102
|
+
launch: async (basePath) => {
|
|
103
|
+
const v = await launchVscode({ dir, basePath, port: argv.port });
|
|
104
|
+
return { port: v.port, stop: v.stop };
|
|
119
105
|
},
|
|
120
106
|
});
|
|
121
|
-
|
|
122
|
-
client.connect();
|
|
123
|
-
|
|
124
|
-
const shutdown = () => {
|
|
125
|
-
console.log("\n[codehost] shutting down");
|
|
126
|
-
rtc.closeAll();
|
|
127
|
-
client.close();
|
|
128
|
-
vscode.stop();
|
|
129
|
-
process.exit(0);
|
|
130
|
-
};
|
|
131
|
-
process.on("SIGINT", shutdown);
|
|
132
|
-
process.on("SIGTERM", shutdown);
|
|
133
|
-
|
|
134
|
-
// Keep the process alive.
|
|
135
|
-
await new Promise<never>(() => {});
|
|
136
107
|
},
|
|
137
108
|
};
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Reconstruct the exact foreground `serve` invocation (without -d) for oxmgr to
|
|
141
|
-
* run. Uses the same runtime + entry script that launched us, so it works both
|
|
142
|
-
* for `bunx codehost` and local `bun src/cli/index.ts`.
|
|
143
|
-
*/
|
|
144
|
-
function buildForegroundCommand(dir: string, argv: ServeArgs): string {
|
|
145
|
-
const parts = [process.execPath, process.argv[1], "serve", dir, "-t", argv.token, "--signal", argv.signal];
|
|
146
|
-
if (argv.name) parts.push("--name", argv.name);
|
|
147
|
-
if (argv.port) parts.push("--port", String(argv.port));
|
|
148
|
-
return parts.map(quote).join(" ");
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function quote(s: string): string {
|
|
152
|
-
return /[^A-Za-z0-9_\/:=.-]/.test(s) ? `'${s.replace(/'/g, `'\\''`)}'` : s;
|
|
153
|
-
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { CommandModule } from "yargs";
|
|
4
|
+
import { generateToken, validateToken, TOKEN_REQUIREMENTS } from "../../shared/token";
|
|
5
|
+
import { readConfig, writeConfig } from "../config";
|
|
6
|
+
import { launchServeDaemon } from "../daemonize";
|
|
7
|
+
import { isGitRepo } from "../git";
|
|
8
|
+
import { resolveCodeBinary } from "../vscode-install";
|
|
9
|
+
import { announceConnect } from "../open-url";
|
|
10
|
+
import { DEFAULT_SIGNAL_URL } from "./serve";
|
|
11
|
+
|
|
12
|
+
interface SetupArgs {
|
|
13
|
+
dir: string;
|
|
14
|
+
token?: string;
|
|
15
|
+
newToken: boolean;
|
|
16
|
+
name?: string;
|
|
17
|
+
signal: string;
|
|
18
|
+
port?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
22
|
+
command: "setup [dir]",
|
|
23
|
+
describe: "One-shot: pick a token, ensure VS Code, and start a daemonized server",
|
|
24
|
+
builder: (y) =>
|
|
25
|
+
y
|
|
26
|
+
.positional("dir", { describe: "Directory to serve (defaults to cwd)", type: "string", default: "." })
|
|
27
|
+
.option("token", {
|
|
28
|
+
alias: "t",
|
|
29
|
+
describe: "Room token (generated + saved if omitted)",
|
|
30
|
+
type: "string",
|
|
31
|
+
})
|
|
32
|
+
.option("new-token", {
|
|
33
|
+
describe: "Generate a fresh token even if one is saved",
|
|
34
|
+
type: "boolean",
|
|
35
|
+
default: false,
|
|
36
|
+
})
|
|
37
|
+
.option("name", { describe: "Display name for this server (defaults to hostname)", type: "string" })
|
|
38
|
+
.option("signal", { describe: "Signaling server URL", type: "string", default: DEFAULT_SIGNAL_URL })
|
|
39
|
+
.option("port", { describe: "Fixed port for the local VS Code server", type: "number" }) as any,
|
|
40
|
+
handler: async (argv) => {
|
|
41
|
+
const dir = resolve(process.cwd(), argv.dir);
|
|
42
|
+
const host = hostname();
|
|
43
|
+
|
|
44
|
+
// 1. Resolve the room token: validate an explicit one, otherwise reuse the
|
|
45
|
+
// saved token (stable room URL) or mint and persist a strong new one.
|
|
46
|
+
const token = resolveToken(argv);
|
|
47
|
+
console.log(`[codehost] room token: ${token}`);
|
|
48
|
+
|
|
49
|
+
// 2. Make sure a working VS Code is available, installing/upgrading the
|
|
50
|
+
// managed CLI if the system `code` is missing or broken. Doing it here
|
|
51
|
+
// surfaces download progress before we hand off to the daemon.
|
|
52
|
+
console.log("[codehost] checking VS Code…");
|
|
53
|
+
const codeBin = await resolveCodeBinary();
|
|
54
|
+
console.log(`[codehost] using VS Code: ${codeBin}`);
|
|
55
|
+
|
|
56
|
+
// 3. Start the WebRTC + VS Code server under oxmgr. A git repo is a single
|
|
57
|
+
// workspace (`dev`); anything else is treated as a root (`serve`).
|
|
58
|
+
const { ok, name } = await launchServeDaemon({
|
|
59
|
+
command: isGitRepo(dir) ? "dev" : "serve",
|
|
60
|
+
dir,
|
|
61
|
+
token,
|
|
62
|
+
signal: argv.signal,
|
|
63
|
+
name: argv.name,
|
|
64
|
+
port: argv.port,
|
|
65
|
+
host,
|
|
66
|
+
});
|
|
67
|
+
if (!ok) process.exit(1);
|
|
68
|
+
|
|
69
|
+
// 4. Tell the user how to connect, and open the browser straight at the
|
|
70
|
+
// token-carrying URL so VS Code loads without typing the token in.
|
|
71
|
+
console.log("");
|
|
72
|
+
console.log(`[codehost] ✓ server "${name}" is live, serving ${dir}`);
|
|
73
|
+
announceConnect(token);
|
|
74
|
+
console.log(`[codehost] manage it with: codehost list · codehost stop ${name}`);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function resolveToken(argv: SetupArgs): string {
|
|
79
|
+
if (argv.token) {
|
|
80
|
+
const t = argv.token.trim();
|
|
81
|
+
const check = validateToken(t);
|
|
82
|
+
if (!check.ok) {
|
|
83
|
+
console.error(`[codehost] ${check.reason}`);
|
|
84
|
+
console.error(`[codehost] room token requires: ${TOKEN_REQUIREMENTS}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
// Persist an explicit token too, so later `setup` runs reuse the same room.
|
|
88
|
+
writeConfig({ ...readConfig(), token: t });
|
|
89
|
+
return t;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const config = readConfig();
|
|
93
|
+
if (config.token && !argv.newToken) return config.token;
|
|
94
|
+
|
|
95
|
+
const token = generateToken();
|
|
96
|
+
writeConfig({ ...config, token });
|
|
97
|
+
console.log("[codehost] generated a new room token (saved to ~/.codehost/config.json)");
|
|
98
|
+
return token;
|
|
99
|
+
}
|
package/src/cli/commands/stop.ts
CHANGED
|
@@ -14,7 +14,7 @@ export const stopCommand: CommandModule<{}, StopArgs> = {
|
|
|
14
14
|
type: "string",
|
|
15
15
|
demandOption: true,
|
|
16
16
|
}) as any,
|
|
17
|
-
handler: (argv) => {
|
|
18
|
-
process.exit(stopDaemon(argv.name));
|
|
17
|
+
handler: async (argv) => {
|
|
18
|
+
process.exit(await stopDaemon(argv.name));
|
|
19
19
|
},
|
|
20
20
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CommandModule } from "yargs";
|
|
2
|
+
import { resolveCodeBinary } from "../vscode-install";
|
|
3
|
+
|
|
4
|
+
export const updateCommand: CommandModule = {
|
|
5
|
+
command: "update",
|
|
6
|
+
describe: "Download the latest VS Code CLI now (ignores the daily check throttle)",
|
|
7
|
+
handler: async () => {
|
|
8
|
+
if (process.env.CODEHOST_CODE_BIN) {
|
|
9
|
+
console.log(`[codehost] CODEHOST_CODE_BIN is set; nothing to update (${process.env.CODEHOST_CODE_BIN})`);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (Bun.which("code")) {
|
|
13
|
+
console.log("[codehost] using system `code` on PATH; update it with your package manager.");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const bin = await resolveCodeBinary({ force: true });
|
|
17
|
+
console.log(`[codehost] up to date: ${bin}`);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Persistent CLI config under ~/.codehost (same root as the managed VS Code
|
|
6
|
+
// cache in vscode-install.ts). Currently just the reusable room token so
|
|
7
|
+
// `codehost setup` lands on a stable room URL across runs.
|
|
8
|
+
|
|
9
|
+
const CONFIG_FILE = join(homedir(), ".codehost", "config.json");
|
|
10
|
+
|
|
11
|
+
export interface CliConfig {
|
|
12
|
+
/** Room token reused by `setup` until --new-token or an explicit -t. */
|
|
13
|
+
token?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readConfig(): CliConfig {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as CliConfig;
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeConfig(config: CliConfig): void {
|
|
25
|
+
mkdirSync(dirname(CONFIG_FILE), { recursive: true });
|
|
26
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
27
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { daemonName, startDaemon } from "./oxmgr";
|
|
2
|
+
|
|
3
|
+
export interface ServeDaemonOptions {
|
|
4
|
+
/** Subcommand to re-launch under oxmgr. */
|
|
5
|
+
command?: "serve" | "dev" | "expose";
|
|
6
|
+
/** Absolute directory to serve (also the oxmgr working dir). */
|
|
7
|
+
dir: string;
|
|
8
|
+
/** Positional argument for the re-launched command (defaults to `dir`); for
|
|
9
|
+
* `expose` this is the port, while `dir` stays a real cwd for oxmgr. */
|
|
10
|
+
arg?: string;
|
|
11
|
+
/** Room token (already validated). */
|
|
12
|
+
token: string;
|
|
13
|
+
/** Signaling server URL. */
|
|
14
|
+
signal: string;
|
|
15
|
+
/** Display name; also seeds the oxmgr process label. */
|
|
16
|
+
name?: string;
|
|
17
|
+
/** Fixed local VS Code port. */
|
|
18
|
+
port?: number;
|
|
19
|
+
/** Hostname, used as a label fallback. */
|
|
20
|
+
host: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ServeDaemonResult {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
/** oxmgr process name this server was registered under. */
|
|
26
|
+
name: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Launch a foreground `codehost serve` (without -d) under oxmgr so it survives
|
|
31
|
+
* the shell and restarts on failure. Shared by `serve -d` and `setup`.
|
|
32
|
+
*/
|
|
33
|
+
export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<ServeDaemonResult> {
|
|
34
|
+
const label = opts.name ?? opts.dir.split("/").pop() ?? opts.host;
|
|
35
|
+
const name = daemonName(label);
|
|
36
|
+
const command = buildForegroundCommand(opts);
|
|
37
|
+
console.log(`[codehost] starting daemon "${name}" via oxmgr`);
|
|
38
|
+
const ok = await startDaemon({ name, command, cwd: opts.dir });
|
|
39
|
+
if (ok) {
|
|
40
|
+
console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
|
|
41
|
+
}
|
|
42
|
+
return { ok, name };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reconstruct the exact foreground `serve` invocation (without -d) for oxmgr to
|
|
47
|
+
* run. Uses the same runtime + entry script that launched us, so it works both
|
|
48
|
+
* for `bunx codehost` and local `bun src/cli/index.ts`.
|
|
49
|
+
*/
|
|
50
|
+
function buildForegroundCommand(opts: ServeDaemonOptions): string {
|
|
51
|
+
const parts = [process.execPath, process.argv[1], opts.command ?? "serve", opts.arg ?? opts.dir, "-t", opts.token, "--signal", opts.signal];
|
|
52
|
+
if (opts.name) parts.push("--name", opts.name);
|
|
53
|
+
if (opts.port) parts.push("--port", String(opts.port));
|
|
54
|
+
return parts.map(quote).join(" ");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function quote(s: string): string {
|
|
58
|
+
return /[^A-Za-z0-9_\/:=.-]/.test(s) ? `'${s.replace(/'/g, `'\\''`)}'` : s;
|
|
59
|
+
}
|
package/src/cli/git.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Derive a normalized repo identity ("gh/<owner>/<repo>") + branch from a git
|
|
4
|
+
// working tree, so a `codehost dev` daemon can be addressed by GitHub-shaped
|
|
5
|
+
// deep links. Best-effort: returns undefined fields off git / off GitHub.
|
|
6
|
+
|
|
7
|
+
export interface RepoIdentity {
|
|
8
|
+
/** Normalized identity, e.g. "gh/snomiao/codehost". */
|
|
9
|
+
repo?: string;
|
|
10
|
+
/** Current branch, e.g. "main". */
|
|
11
|
+
branch?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** True if `dir` is inside a git working tree (has a resolvable .git). */
|
|
15
|
+
export function isGitRepo(dir: string): boolean {
|
|
16
|
+
const r = git(dir, ["rev-parse", "--is-inside-work-tree"]);
|
|
17
|
+
return r.trim() === "true";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function repoIdentity(dir: string): RepoIdentity {
|
|
21
|
+
if (!isGitRepo(dir)) return {};
|
|
22
|
+
const remote =
|
|
23
|
+
git(dir, ["remote", "get-url", "origin"]) || git(dir, ["config", "--get", "remote.origin.url"]);
|
|
24
|
+
const branch =
|
|
25
|
+
git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]) || git(dir, ["symbolic-ref", "--short", "HEAD"]);
|
|
26
|
+
return {
|
|
27
|
+
repo: parseGitHubRemote(remote),
|
|
28
|
+
branch: branch && branch !== "HEAD" ? branch : undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a GitHub remote URL into "gh/<owner>/<repo>". Handles:
|
|
34
|
+
* https://github.com/owner/repo(.git)
|
|
35
|
+
* git@github.com:owner/repo(.git)
|
|
36
|
+
* ssh://git@github.com/owner/repo(.git)
|
|
37
|
+
* Returns undefined for non-GitHub or unparseable remotes.
|
|
38
|
+
*/
|
|
39
|
+
export function parseGitHubRemote(url: string): string | undefined {
|
|
40
|
+
const u = url.trim();
|
|
41
|
+
if (!u) return undefined;
|
|
42
|
+
const m = u.match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?\/?$/i);
|
|
43
|
+
if (!m) return undefined;
|
|
44
|
+
const owner = m[1];
|
|
45
|
+
const repo = m[2];
|
|
46
|
+
if (!owner || !repo) return undefined;
|
|
47
|
+
return `gh/${owner}/${repo}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function git(dir: string, args: string[]): string {
|
|
51
|
+
const r = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
52
|
+
return r.status === 0 ? (r.stdout ?? "").trim() : "";
|
|
53
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import yargs from "yargs";
|
|
3
3
|
import { hideBin } from "yargs/helpers";
|
|
4
|
+
import { setupCommand } from "./commands/setup";
|
|
4
5
|
import { serveCommand } from "./commands/serve";
|
|
6
|
+
import { devCommand } from "./commands/dev";
|
|
7
|
+
import { exposeCommand } from "./commands/expose";
|
|
5
8
|
import { listCommand } from "./commands/list";
|
|
6
9
|
import { stopCommand } from "./commands/stop";
|
|
10
|
+
import { updateCommand } from "./commands/update";
|
|
7
11
|
|
|
8
12
|
yargs(hideBin(process.argv))
|
|
9
13
|
.scriptName("codehost")
|
|
10
14
|
.usage("$0 <command> [options]")
|
|
15
|
+
.command(setupCommand)
|
|
11
16
|
.command(serveCommand)
|
|
17
|
+
.command(devCommand)
|
|
18
|
+
.command(exposeCommand)
|
|
12
19
|
.command(listCommand)
|
|
13
20
|
.command(stopCommand)
|
|
21
|
+
.command(updateCommand)
|
|
14
22
|
.demandCommand(1, "Specify a command, e.g. `codehost serve`")
|
|
15
23
|
.strict()
|
|
16
24
|
.help()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/** Public codehost page that brokers the WebRTC handshake. */
|
|
4
|
+
export const PAGE_URL = "https://codehost.dev";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build the auto-connect URL for a room token. The token rides in the URL
|
|
8
|
+
* *fragment* (`#t=<token>`) on purpose: the page is a static asset, so the
|
|
9
|
+
* fragment never leaves the browser — it isn't sent to Cloudflare, nor written
|
|
10
|
+
* to access logs or `Referer` headers. The page reads it, fills the token, and
|
|
11
|
+
* auto-connects when a single server is live.
|
|
12
|
+
*/
|
|
13
|
+
export function connectUrl(token: string, page: string = PAGE_URL): string {
|
|
14
|
+
return `${page}/#t=${encodeURIComponent(token)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Open `url` in the default browser. Best-effort, cross-platform, detached. */
|
|
18
|
+
export function openBrowser(url: string): void {
|
|
19
|
+
try {
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
// `start` is a cmd builtin; the empty "" is the (ignored) window title so
|
|
22
|
+
// a quoted URL isn't mistaken for one.
|
|
23
|
+
spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true, windowsHide: true }).unref();
|
|
24
|
+
} else if (process.platform === "darwin") {
|
|
25
|
+
spawn("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
26
|
+
} else {
|
|
27
|
+
spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// best-effort; the URL is always printed too.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Print the connect URL and, in an interactive terminal, open it in the
|
|
36
|
+
* browser. The TTY guard keeps the oxmgr-spawned daemon (which re-runs
|
|
37
|
+
* `serve`/`dev` in the foreground with no TTY) from popping a tab on every
|
|
38
|
+
* restart — it just logs the URL.
|
|
39
|
+
*/
|
|
40
|
+
export function announceConnect(token: string, page: string = PAGE_URL): void {
|
|
41
|
+
const url = connectUrl(token, page);
|
|
42
|
+
console.log(`[codehost] connect: ${url}`);
|
|
43
|
+
if (process.stdout.isTTY) {
|
|
44
|
+
console.log("[codehost] opening your browser…");
|
|
45
|
+
openBrowser(url);
|
|
46
|
+
}
|
|
47
|
+
}
|