codehost 0.1.1 → 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/TODO.md +93 -0
- package/docs/vscode-cdn-proxy.md +117 -0
- package/package.json +11 -1
- package/public/_redirects +5 -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 +27 -55
- package/src/cli/commands/setup.ts +9 -6
- package/src/cli/commands/stop.ts +2 -2
- package/src/cli/daemonize.ts +9 -4
- package/src/cli/git.ts +53 -0
- package/src/cli/index.ts +4 -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/run-server.ts +78 -0
- package/src/cli/tunnel.ts +30 -4
- package/src/cli/vscode.ts +4 -2
- 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
|
@@ -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 { SignalingClient } from "../../shared/signaling-client";
|
|
7
|
-
import { RtcDaemon } from "../rtc-daemon";
|
|
8
|
-
import { launchVscode } from "../vscode";
|
|
9
|
-
import { Tunnel } from "../tunnel";
|
|
10
7
|
import { launchServeDaemon } from "../daemonize";
|
|
8
|
+
import { announceConnect } from "../open-url";
|
|
9
|
+
import { runServer } from "../run-server";
|
|
10
|
+
import { launchVscode } from "../vscode";
|
|
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,15 +67,11 @@ 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 { ok } = launchServeDaemon({
|
|
73
|
+
const { ok } = await launchServeDaemon({
|
|
74
|
+
command: "serve",
|
|
78
75
|
dir,
|
|
79
76
|
token: argv.token,
|
|
80
77
|
signal: argv.signal,
|
|
@@ -82,55 +79,30 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
82
79
|
port: argv.port,
|
|
83
80
|
host,
|
|
84
81
|
});
|
|
82
|
+
if (ok) announceConnect(argv.token);
|
|
85
83
|
process.exit(ok ? 0 : 1);
|
|
86
84
|
}
|
|
87
85
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const vscode = await launchVscode({ dir, basePath, port: argv.port });
|
|
98
|
-
|
|
99
|
-
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
|
+
};
|
|
100
95
|
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
announceConnect(argv.token);
|
|
97
|
+
await runServer({
|
|
103
98
|
token: argv.token,
|
|
104
|
-
|
|
105
|
-
peerId,
|
|
99
|
+
signal: argv.signal,
|
|
106
100
|
meta,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
rtc = new RtcDaemon({
|
|
113
|
-
sendSignal: (to, data) => client.sendSignal(to, data),
|
|
114
|
-
// Each viewer's data channel is bridged to the local VS Code server.
|
|
115
|
-
onChannel: (viewerId, channel) => {
|
|
116
|
-
console.log(`[codehost] viewer ${viewerId.slice(0, 8)} connected; bridging to VS Code`);
|
|
117
|
-
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 };
|
|
118
105
|
},
|
|
119
106
|
});
|
|
120
|
-
|
|
121
|
-
client.connect();
|
|
122
|
-
|
|
123
|
-
const shutdown = () => {
|
|
124
|
-
console.log("\n[codehost] shutting down");
|
|
125
|
-
rtc.closeAll();
|
|
126
|
-
client.close();
|
|
127
|
-
vscode.stop();
|
|
128
|
-
process.exit(0);
|
|
129
|
-
};
|
|
130
|
-
process.on("SIGINT", shutdown);
|
|
131
|
-
process.on("SIGTERM", shutdown);
|
|
132
|
-
|
|
133
|
-
// Keep the process alive.
|
|
134
|
-
await new Promise<never>(() => {});
|
|
135
107
|
},
|
|
136
108
|
};
|
|
@@ -4,11 +4,11 @@ import type { CommandModule } from "yargs";
|
|
|
4
4
|
import { generateToken, validateToken, TOKEN_REQUIREMENTS } from "../../shared/token";
|
|
5
5
|
import { readConfig, writeConfig } from "../config";
|
|
6
6
|
import { launchServeDaemon } from "../daemonize";
|
|
7
|
+
import { isGitRepo } from "../git";
|
|
7
8
|
import { resolveCodeBinary } from "../vscode-install";
|
|
9
|
+
import { announceConnect } from "../open-url";
|
|
8
10
|
import { DEFAULT_SIGNAL_URL } from "./serve";
|
|
9
11
|
|
|
10
|
-
const PAGE_URL = "https://codehost.dev";
|
|
11
|
-
|
|
12
12
|
interface SetupArgs {
|
|
13
13
|
dir: string;
|
|
14
14
|
token?: string;
|
|
@@ -53,8 +53,10 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
|
53
53
|
const codeBin = await resolveCodeBinary();
|
|
54
54
|
console.log(`[codehost] using VS Code: ${codeBin}`);
|
|
55
55
|
|
|
56
|
-
// 3. Start the WebRTC + VS Code server under oxmgr.
|
|
57
|
-
|
|
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",
|
|
58
60
|
dir,
|
|
59
61
|
token,
|
|
60
62
|
signal: argv.signal,
|
|
@@ -64,10 +66,11 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
|
64
66
|
});
|
|
65
67
|
if (!ok) process.exit(1);
|
|
66
68
|
|
|
67
|
-
// 4. Tell the user how to connect
|
|
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.
|
|
68
71
|
console.log("");
|
|
69
72
|
console.log(`[codehost] ✓ server "${name}" is live, serving ${dir}`);
|
|
70
|
-
|
|
73
|
+
announceConnect(token);
|
|
71
74
|
console.log(`[codehost] manage it with: codehost list · codehost stop ${name}`);
|
|
72
75
|
},
|
|
73
76
|
};
|
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
|
};
|
package/src/cli/daemonize.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { daemonName, startDaemon } from "./oxmgr";
|
|
2
2
|
|
|
3
3
|
export interface ServeDaemonOptions {
|
|
4
|
-
/**
|
|
4
|
+
/** Subcommand to re-launch under oxmgr. */
|
|
5
|
+
command?: "serve" | "dev" | "expose";
|
|
6
|
+
/** Absolute directory to serve (also the oxmgr working dir). */
|
|
5
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;
|
|
6
11
|
/** Room token (already validated). */
|
|
7
12
|
token: string;
|
|
8
13
|
/** Signaling server URL. */
|
|
@@ -25,12 +30,12 @@ export interface ServeDaemonResult {
|
|
|
25
30
|
* Launch a foreground `codehost serve` (without -d) under oxmgr so it survives
|
|
26
31
|
* the shell and restarts on failure. Shared by `serve -d` and `setup`.
|
|
27
32
|
*/
|
|
28
|
-
export function launchServeDaemon(opts: ServeDaemonOptions): ServeDaemonResult {
|
|
33
|
+
export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<ServeDaemonResult> {
|
|
29
34
|
const label = opts.name ?? opts.dir.split("/").pop() ?? opts.host;
|
|
30
35
|
const name = daemonName(label);
|
|
31
36
|
const command = buildForegroundCommand(opts);
|
|
32
37
|
console.log(`[codehost] starting daemon "${name}" via oxmgr`);
|
|
33
|
-
const ok = startDaemon({ name, command, cwd: opts.dir });
|
|
38
|
+
const ok = await startDaemon({ name, command, cwd: opts.dir });
|
|
34
39
|
if (ok) {
|
|
35
40
|
console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
|
|
36
41
|
}
|
|
@@ -43,7 +48,7 @@ export function launchServeDaemon(opts: ServeDaemonOptions): ServeDaemonResult {
|
|
|
43
48
|
* for `bunx codehost` and local `bun src/cli/index.ts`.
|
|
44
49
|
*/
|
|
45
50
|
function buildForegroundCommand(opts: ServeDaemonOptions): string {
|
|
46
|
-
const parts = [process.execPath, process.argv[1], "serve", opts.dir, "-t", opts.token, "--signal", opts.signal];
|
|
51
|
+
const parts = [process.execPath, process.argv[1], opts.command ?? "serve", opts.arg ?? opts.dir, "-t", opts.token, "--signal", opts.signal];
|
|
47
52
|
if (opts.name) parts.push("--name", opts.name);
|
|
48
53
|
if (opts.port) parts.push("--port", String(opts.port));
|
|
49
54
|
return parts.map(quote).join(" ");
|
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
|
@@ -3,6 +3,8 @@ import yargs from "yargs";
|
|
|
3
3
|
import { hideBin } from "yargs/helpers";
|
|
4
4
|
import { setupCommand } from "./commands/setup";
|
|
5
5
|
import { serveCommand } from "./commands/serve";
|
|
6
|
+
import { devCommand } from "./commands/dev";
|
|
7
|
+
import { exposeCommand } from "./commands/expose";
|
|
6
8
|
import { listCommand } from "./commands/list";
|
|
7
9
|
import { stopCommand } from "./commands/stop";
|
|
8
10
|
import { updateCommand } from "./commands/update";
|
|
@@ -12,6 +14,8 @@ yargs(hideBin(process.argv))
|
|
|
12
14
|
.usage("$0 <command> [options]")
|
|
13
15
|
.command(setupCommand)
|
|
14
16
|
.command(serveCommand)
|
|
17
|
+
.command(devCommand)
|
|
18
|
+
.command(exposeCommand)
|
|
15
19
|
.command(listCommand)
|
|
16
20
|
.command(stopCommand)
|
|
17
21
|
.command(updateCommand)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
createWriteStream,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import https from "node:https";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
// Self-heal oxmgr's native binary. Two real-world gaps:
|
|
19
|
+
// 1. Under bunx/bun (and `bun i -g`), install lifecycle scripts are skipped,
|
|
20
|
+
// so oxmgr's postinstall never downloads its prebuilt — the vendored
|
|
21
|
+
// binary is simply absent.
|
|
22
|
+
// 2. On older Linux distros, the downloaded `*-linux-gnu` prebuilt needs a
|
|
23
|
+
// newer glibc than the host has (GLIBC_x.y not found).
|
|
24
|
+
// Linux is healed by swapping in oxmgr's fully static `*-linux-musl` build (no
|
|
25
|
+
// libc dependency, also covers the missing case). Other platforms are healed by
|
|
26
|
+
// running oxmgr's own installer via the current runtime (bun) to fetch the
|
|
27
|
+
// right binary — no Node required. Mirrors the node-datachannel self-heal.
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
let healAttempted = false;
|
|
31
|
+
|
|
32
|
+
interface OxmgrInstall {
|
|
33
|
+
/** oxmgr package root. */
|
|
34
|
+
root: string;
|
|
35
|
+
/** Platform-correct vendored binary path. */
|
|
36
|
+
vendorBin: string;
|
|
37
|
+
version: string;
|
|
38
|
+
/** GitHub "owner/repo" the release assets live under. */
|
|
39
|
+
slug: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Repair oxmgr's binary. Idempotent within a process. */
|
|
43
|
+
export async function healOxmgr(): Promise<boolean> {
|
|
44
|
+
if (healAttempted) return false;
|
|
45
|
+
healAttempted = true;
|
|
46
|
+
|
|
47
|
+
const install = locateOxmgr();
|
|
48
|
+
if (!install) return false;
|
|
49
|
+
|
|
50
|
+
// Linux: portable musl static build covers both the glibc mismatch and a
|
|
51
|
+
// missing binary in one download.
|
|
52
|
+
if (process.platform === "linux") return swapMusl(install);
|
|
53
|
+
|
|
54
|
+
// Windows / macOS: run oxmgr's own installer to fetch the platform binary the
|
|
55
|
+
// skipped postinstall never downloaded.
|
|
56
|
+
return runInstaller(install);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run oxmgr's bundled installer via the current runtime (bun) to fetch its
|
|
60
|
+
* native binary. Cross-platform; no Node needed. */
|
|
61
|
+
function runInstaller(install: OxmgrInstall): boolean {
|
|
62
|
+
const script = join(install.root, "scripts", "install.js");
|
|
63
|
+
if (!existsSync(script)) return false;
|
|
64
|
+
console.log("[codehost] fetching oxmgr's native binary…");
|
|
65
|
+
const r = spawnSync(process.execPath, [script], { cwd: install.root, stdio: "inherit" });
|
|
66
|
+
return r.status === 0 && existsSync(install.vendorBin);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Download + install oxmgr's static musl binary (Linux). */
|
|
70
|
+
async function swapMusl(install: OxmgrInstall): Promise<boolean> {
|
|
71
|
+
const target = process.arch === "x64" ? "x86_64-unknown-linux-musl" : process.arch === "arm64" ? "aarch64-unknown-linux-musl" : null;
|
|
72
|
+
if (!target) return false;
|
|
73
|
+
|
|
74
|
+
const archive = `oxmgr-v${install.version}-${target}.tar.gz`;
|
|
75
|
+
const base =
|
|
76
|
+
process.env.OXMGR_DIST_BASE ||
|
|
77
|
+
`https://github.com/${install.slug}/releases/download/v${install.version}`;
|
|
78
|
+
const url = `${base}/${archive}`;
|
|
79
|
+
|
|
80
|
+
const tmp = mkdtempSync(join(tmpdir(), "oxmgr-heal-"));
|
|
81
|
+
const archivePath = join(tmp, archive);
|
|
82
|
+
console.log("[codehost] fetching oxmgr's portable (musl) binary…");
|
|
83
|
+
try {
|
|
84
|
+
await download(url, archivePath);
|
|
85
|
+
const untar = spawnSync("tar", ["xzf", archivePath, "-C", tmp], { stdio: "ignore" });
|
|
86
|
+
if (untar.status !== 0) throw new Error("could not extract the archive (is `tar` installed?)");
|
|
87
|
+
const binary = findFile(tmp, "oxmgr");
|
|
88
|
+
if (!binary) throw new Error("musl archive did not contain an oxmgr binary");
|
|
89
|
+
// Unlink first so we can replace a binary that's currently executing (ETXTBSY).
|
|
90
|
+
rmSync(install.vendorBin, { force: true });
|
|
91
|
+
copyFileSync(binary, install.vendorBin);
|
|
92
|
+
chmodSync(install.vendorBin, 0o755);
|
|
93
|
+
console.log("[codehost] oxmgr repaired with its static musl build.");
|
|
94
|
+
return true;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[codehost] automatic oxmgr repair failed: ${(err as Error).message}`);
|
|
97
|
+
return false;
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve oxmgr's on-disk install from codehost's own node_modules. */
|
|
104
|
+
function locateOxmgr(): OxmgrInstall | null {
|
|
105
|
+
let pkgPath: string;
|
|
106
|
+
try {
|
|
107
|
+
pkgPath = require.resolve("oxmgr/package.json");
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const root = dirname(pkgPath);
|
|
112
|
+
const vendorBin = join(root, "vendor", process.platform === "win32" ? "oxmgr.exe" : "oxmgr");
|
|
113
|
+
|
|
114
|
+
let pkg: { version?: string; repository?: { url?: string } };
|
|
115
|
+
try {
|
|
116
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (!pkg.version) return null;
|
|
121
|
+
return { root, vendorBin, version: pkg.version, slug: oxmgrSlug(pkg) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** GitHub slug for oxmgr's releases, matching its own installer's resolution. */
|
|
125
|
+
function oxmgrSlug(pkg: { repository?: { url?: string } }): string {
|
|
126
|
+
if (process.env.OXMGR_NPM_REPOSITORY) return process.env.OXMGR_NPM_REPOSITORY;
|
|
127
|
+
const url = pkg.repository?.url ?? "";
|
|
128
|
+
const m = url.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?$/i);
|
|
129
|
+
return m ? m[1] : "Vladimir-Urik/OxMgr";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Download a URL to a file, following GitHub's redirect to release-assets. */
|
|
133
|
+
function download(url: string, dest: string): Promise<void> {
|
|
134
|
+
return new Promise((resolvePromise, reject) => {
|
|
135
|
+
const file = createWriteStream(dest);
|
|
136
|
+
https
|
|
137
|
+
.get(url, (res) => {
|
|
138
|
+
const status = res.statusCode ?? 0;
|
|
139
|
+
if ([301, 302, 307, 308].includes(status)) {
|
|
140
|
+
const location = res.headers.location;
|
|
141
|
+
file.close();
|
|
142
|
+
rmSync(dest, { force: true });
|
|
143
|
+
if (!location) return reject(new Error(`redirect without location for ${url}`));
|
|
144
|
+
return download(location, dest).then(resolvePromise).catch(reject);
|
|
145
|
+
}
|
|
146
|
+
if (status !== 200) {
|
|
147
|
+
file.close();
|
|
148
|
+
rmSync(dest, { force: true });
|
|
149
|
+
return reject(new Error(`download failed (HTTP ${status})`));
|
|
150
|
+
}
|
|
151
|
+
res.pipe(file);
|
|
152
|
+
file.on("finish", () => file.close(() => resolvePromise()));
|
|
153
|
+
})
|
|
154
|
+
.on("error", (err) => {
|
|
155
|
+
file.close();
|
|
156
|
+
rmSync(dest, { force: true });
|
|
157
|
+
reject(err);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Depth-first search for a file named `name` under `dir`. */
|
|
163
|
+
function findFile(dir: string, name: string): string | null {
|
|
164
|
+
for (const entry of readdirSync(dir)) {
|
|
165
|
+
const full = join(dir, entry);
|
|
166
|
+
const st = statSync(full);
|
|
167
|
+
if (st.isDirectory()) {
|
|
168
|
+
const found = findFile(full, name);
|
|
169
|
+
if (found) return found;
|
|
170
|
+
} else if (entry === name) {
|
|
171
|
+
return full;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|