codehost 0.5.0 → 0.6.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 +15 -0
- package/package.json +1 -1
- package/src/cli/commands/dev.ts +2 -2
- package/src/cli/commands/list.ts +18 -3
- package/src/cli/commands/serve.ts +1 -1
- package/src/cli/commands/stop.ts +9 -1
- package/src/cli/commands/supervise.ts +24 -0
- package/src/cli/daemonize.ts +29 -11
- package/src/cli/fallback-daemon.ts +139 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/vscode.ts +26 -4
- package/src/shared/repo.test.ts +36 -17
- package/src/shared/repo.ts +11 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
# [0.6.0](https://github.com/snomiao/codehost/compare/v0.5.1...v0.6.0) (2026-06-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* detached fallback daemon when oxmgr is unavailable ([3aeddad](https://github.com/snomiao/codehost/commit/3aeddad595a2fc65f8a65050a5cc6171f41bca8f))
|
|
7
|
+
|
|
8
|
+
## [0.5.1](https://github.com/snomiao/codehost/compare/v0.5.0...v0.5.1) (2026-06-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* don't time out (and oxmgr-restart-loop) on first-run VS Code server download ([5ad1b06](https://github.com/snomiao/codehost/commit/5ad1b068668afda4d19f403286240827a0a4b9c4))
|
|
14
|
+
* Windows ?folder= path must be /C:/ws (file-URI form), not git-bash /c/ws ([f6d2485](https://github.com/snomiao/codehost/commit/f6d24858c943dd2b7b5f1d5a96f71468e9552140))
|
|
15
|
+
|
|
1
16
|
# [0.5.0](https://github.com/snomiao/codehost/compare/v0.4.0...v0.5.0) (2026-06-08)
|
|
2
17
|
|
|
3
18
|
|
package/package.json
CHANGED
package/src/cli/commands/dev.ts
CHANGED
|
@@ -86,8 +86,8 @@ export const devCommand: CommandModule<{}, DevArgs> = {
|
|
|
86
86
|
const id = repoIdentity(dir);
|
|
87
87
|
const meta: PeerMeta = {
|
|
88
88
|
name: argv.name ?? host,
|
|
89
|
-
//
|
|
90
|
-
// OS path for the local VS Code working dir.
|
|
89
|
+
// VS Code-web ?folder= form for the browser (C:\ws -> /C:/ws); `dir` stays
|
|
90
|
+
// the real OS path for the local VS Code working dir.
|
|
91
91
|
cwd: toPosixPath(dir),
|
|
92
92
|
host,
|
|
93
93
|
kind: "repo",
|
package/src/cli/commands/list.ts
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import type { CommandModule } from "yargs";
|
|
2
|
-
import { listDaemons } from "../oxmgr";
|
|
2
|
+
import { hasOxmgr, listDaemons } from "../oxmgr";
|
|
3
|
+
import { listFallbackDaemons } from "../fallback-daemon";
|
|
3
4
|
|
|
4
5
|
export const listCommand: CommandModule = {
|
|
5
6
|
command: "list",
|
|
6
7
|
aliases: ["ls"],
|
|
7
|
-
describe: "List codehost servers
|
|
8
|
+
describe: "List codehost servers (oxmgr-managed and detached)",
|
|
8
9
|
handler: async () => {
|
|
9
|
-
|
|
10
|
+
const detached = listFallbackDaemons();
|
|
11
|
+
if (detached.length) {
|
|
12
|
+
console.log("Detached daemons (no oxmgr):");
|
|
13
|
+
for (const d of detached) {
|
|
14
|
+
console.log(` ${d.name} pid ${d.pid} ${d.cwd} (log: ${d.log})`);
|
|
15
|
+
}
|
|
16
|
+
console.log("");
|
|
17
|
+
}
|
|
18
|
+
// Only hit oxmgr if it's actually runnable — `hasOxmgr` doesn't self-heal,
|
|
19
|
+
// so a broken install won't re-download its binary on every `list`.
|
|
20
|
+
if (await hasOxmgr()) {
|
|
21
|
+
process.exit(await listDaemons());
|
|
22
|
+
}
|
|
23
|
+
if (!detached.length) console.log("No codehost daemons running.");
|
|
24
|
+
process.exit(0);
|
|
10
25
|
},
|
|
11
26
|
};
|
|
@@ -87,7 +87,7 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
87
87
|
// onto subfolders via VS Code's ?folder= using this layout.
|
|
88
88
|
const meta: PeerMeta = {
|
|
89
89
|
name: argv.name ?? host,
|
|
90
|
-
//
|
|
90
|
+
// VS Code-web ?folder= form for the browser (C:\ws -> /C:/ws); the real
|
|
91
91
|
// OS path `dir` is still what we spawn VS Code in.
|
|
92
92
|
cwd: toPosixPath(dir),
|
|
93
93
|
host,
|
package/src/cli/commands/stop.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CommandModule } from "yargs";
|
|
2
|
-
import { stopDaemon } from "../oxmgr";
|
|
2
|
+
import { daemonName, stopDaemon } from "../oxmgr";
|
|
3
|
+
import { stopFallbackDaemon } from "../fallback-daemon";
|
|
3
4
|
|
|
4
5
|
interface StopArgs {
|
|
5
6
|
name: string;
|
|
@@ -15,6 +16,13 @@ export const stopCommand: CommandModule<{}, StopArgs> = {
|
|
|
15
16
|
demandOption: true,
|
|
16
17
|
}) as any,
|
|
17
18
|
handler: async (argv) => {
|
|
19
|
+
// Detached daemons are tracked locally; check those first so we don't poke
|
|
20
|
+
// (and possibly re-download) oxmgr when it isn't even managing this one.
|
|
21
|
+
const full = argv.name.startsWith("codehost-") ? argv.name : daemonName(argv.name);
|
|
22
|
+
if (stopFallbackDaemon(full) || stopFallbackDaemon(argv.name)) {
|
|
23
|
+
console.log(`[codehost] stopped ${full}`);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
18
26
|
process.exit(await stopDaemon(argv.name));
|
|
19
27
|
},
|
|
20
28
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CommandModule } from "yargs";
|
|
2
|
+
import { runSupervisor } from "../fallback-daemon";
|
|
3
|
+
|
|
4
|
+
interface SuperviseArgs {
|
|
5
|
+
name: string;
|
|
6
|
+
argv: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Hidden internal command: the supervisor process behind a detached fallback
|
|
10
|
+
// daemon (see fallback-daemon.ts). Not meant to be run by hand — `serve -d` /
|
|
11
|
+
// `setup` spawn it when oxmgr isn't available.
|
|
12
|
+
export const superviseCommand: CommandModule<{}, SuperviseArgs> = {
|
|
13
|
+
command: "__supervise",
|
|
14
|
+
describe: false, // hidden from help
|
|
15
|
+
builder: (y) =>
|
|
16
|
+
y
|
|
17
|
+
.option("name", { type: "string", demandOption: true })
|
|
18
|
+
.option("argv", { type: "string", demandOption: true, describe: "JSON-encoded serve argv" }) as any,
|
|
19
|
+
handler: async (a) => {
|
|
20
|
+
const argv = JSON.parse(a.argv) as string[];
|
|
21
|
+
const code = await runSupervisor(a.name, argv);
|
|
22
|
+
process.exit(code);
|
|
23
|
+
},
|
|
24
|
+
};
|
package/src/cli/daemonize.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { daemonName, startDaemon } from "./oxmgr";
|
|
2
2
|
import { selfUpdate } from "./self-update";
|
|
3
|
+
import { startFallbackDaemon } from "./fallback-daemon";
|
|
3
4
|
|
|
4
5
|
export interface ServeDaemonOptions {
|
|
5
6
|
/** Subcommand to re-launch under oxmgr. */
|
|
@@ -28,8 +29,11 @@ export interface ServeDaemonResult {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
|
-
* Launch a foreground `codehost serve` (without -d)
|
|
32
|
-
*
|
|
32
|
+
* Launch a foreground `codehost serve` (without -d) so it survives the shell and
|
|
33
|
+
* restarts on failure. Prefers oxmgr (which also adds login auto-start); when
|
|
34
|
+
* oxmgr can't run here (e.g. broken native binary on Windows) it falls back to a
|
|
35
|
+
* detached, self-restarting child instead of failing. `CODEHOST_NO_OXMGR=1`
|
|
36
|
+
* forces the fallback. Shared by `serve -d` and `setup`.
|
|
33
37
|
*/
|
|
34
38
|
export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<ServeDaemonResult> {
|
|
35
39
|
// Upgrade the global install (if that's how we're running) before spawning, so
|
|
@@ -39,25 +43,39 @@ export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<Serve
|
|
|
39
43
|
|
|
40
44
|
const label = opts.name ?? opts.dir.split("/").pop() ?? opts.host;
|
|
41
45
|
const name = daemonName(label);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
const argv = buildForegroundArgv(opts);
|
|
47
|
+
|
|
48
|
+
if (process.env.CODEHOST_NO_OXMGR !== "1") {
|
|
49
|
+
console.log(`[codehost] starting daemon "${name}" via oxmgr`);
|
|
50
|
+
// startDaemon attempts to self-heal oxmgr once; false means it's unusable here.
|
|
51
|
+
const ok = await startDaemon({ name, command: argv.map(quote).join(" "), cwd: opts.dir });
|
|
52
|
+
if (ok) {
|
|
53
|
+
console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
|
|
54
|
+
return { ok: true, name };
|
|
55
|
+
}
|
|
56
|
+
console.warn("[codehost] oxmgr unavailable — falling back to a detached daemon (no login auto-start).");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ok = startFallbackDaemon({ name, argv, cwd: opts.dir });
|
|
45
60
|
if (ok) {
|
|
46
|
-
console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
|
|
61
|
+
console.log(`[codehost] detached daemon "${name}" started. View: codehost list · Stop: codehost stop ${name}`);
|
|
62
|
+
} else {
|
|
63
|
+
console.error("[codehost] failed to start a detached daemon.");
|
|
47
64
|
}
|
|
48
65
|
return { ok, name };
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
/**
|
|
52
|
-
* Reconstruct the exact foreground `serve` invocation (without -d)
|
|
53
|
-
*
|
|
54
|
-
* for `bunx codehost` and local `bun src/cli/index.ts`.
|
|
69
|
+
* Reconstruct the exact foreground `serve` invocation (without -d) as an argv
|
|
70
|
+
* array. Uses the same runtime + entry script that launched us, so it works both
|
|
71
|
+
* for `bunx codehost` and local `bun src/cli/index.ts`. oxmgr takes a shell
|
|
72
|
+
* string (we quote+join); the fallback spawns the argv directly.
|
|
55
73
|
*/
|
|
56
|
-
function
|
|
74
|
+
function buildForegroundArgv(opts: ServeDaemonOptions): string[] {
|
|
57
75
|
const parts = [process.execPath, process.argv[1], opts.command ?? "serve", opts.arg ?? opts.dir, "-t", opts.token, "--signal", opts.signal];
|
|
58
76
|
if (opts.name) parts.push("--name", opts.name);
|
|
59
77
|
if (opts.port) parts.push("--port", String(opts.port));
|
|
60
|
-
return parts
|
|
78
|
+
return parts;
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
function quote(s: string): string {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// A minimal, non-oxmgr daemon manager: a detached, self-restarting child that
|
|
7
|
+
// survives the shell. Used as a fallback when oxmgr's native binary can't run
|
|
8
|
+
// here (e.g. broken on a Windows box) so `-d`/`setup` still leave a running
|
|
9
|
+
// server instead of failing or re-download-looping. Tracked in a JSON registry
|
|
10
|
+
// so `codehost list`/`stop` can see and manage these alongside oxmgr daemons.
|
|
11
|
+
//
|
|
12
|
+
// Tradeoff vs oxmgr: no login auto-start (that's oxmgr's per-OS service bit). It
|
|
13
|
+
// does survive the launching shell and restarts the server on crash.
|
|
14
|
+
|
|
15
|
+
const ROOT = join(homedir(), ".codehost");
|
|
16
|
+
const REGISTRY = join(ROOT, "daemons.json");
|
|
17
|
+
const LOG_DIR = join(ROOT, "logs");
|
|
18
|
+
|
|
19
|
+
export interface FallbackDaemon {
|
|
20
|
+
name: string;
|
|
21
|
+
/** Supervisor process pid. */
|
|
22
|
+
pid: number;
|
|
23
|
+
cwd: string;
|
|
24
|
+
/** The foreground serve argv the supervisor (re)spawns. */
|
|
25
|
+
argv: string[];
|
|
26
|
+
log: string;
|
|
27
|
+
startedAt: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readRegistry(): FallbackDaemon[] {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(REGISTRY, "utf8")) as FallbackDaemon[];
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeRegistry(list: FallbackDaemon[]): void {
|
|
39
|
+
mkdirSync(dirname(REGISTRY), { recursive: true });
|
|
40
|
+
writeFileSync(REGISTRY, JSON.stringify(list, null, 2));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True if a pid is currently alive (signal 0 probe). */
|
|
44
|
+
export function isAlive(pid: number): boolean {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start (replacing any same-named instance) a detached, self-restarting daemon
|
|
55
|
+
* that runs `argv` from `cwd`, with output appended to a per-daemon log. Returns
|
|
56
|
+
* false if the supervisor couldn't be spawned.
|
|
57
|
+
*/
|
|
58
|
+
export function startFallbackDaemon(opts: { name: string; argv: string[]; cwd: string }): boolean {
|
|
59
|
+
stopFallbackDaemon(opts.name); // replace any previous instance with this name
|
|
60
|
+
|
|
61
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
62
|
+
const log = join(LOG_DIR, `${opts.name}.log`);
|
|
63
|
+
const fd = openSync(log, "a");
|
|
64
|
+
const proc = spawn(
|
|
65
|
+
[process.execPath, process.argv[1], "__supervise", "--name", opts.name, "--argv", JSON.stringify(opts.argv)],
|
|
66
|
+
{ cwd: opts.cwd, stdin: "ignore", stdout: fd, stderr: fd },
|
|
67
|
+
);
|
|
68
|
+
// Detach so the launching process (setup / serve -d) can exit while this keeps
|
|
69
|
+
// running as an orphan.
|
|
70
|
+
proc.unref();
|
|
71
|
+
if (!proc.pid) return false;
|
|
72
|
+
|
|
73
|
+
const list = readRegistry().filter((d) => d.name !== opts.name);
|
|
74
|
+
list.push({ name: opts.name, pid: proc.pid, cwd: opts.cwd, argv: opts.argv, log, startedAt: Date.now() });
|
|
75
|
+
writeRegistry(list);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Live detached daemons (dead registry entries are pruned as a side effect). */
|
|
80
|
+
export function listFallbackDaemons(): FallbackDaemon[] {
|
|
81
|
+
const list = readRegistry();
|
|
82
|
+
const alive = list.filter((d) => isAlive(d.pid));
|
|
83
|
+
if (alive.length !== list.length) writeRegistry(alive);
|
|
84
|
+
return alive;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Stop and deregister a detached daemon by name. Returns false if not found. */
|
|
88
|
+
export function stopFallbackDaemon(name: string): boolean {
|
|
89
|
+
const list = readRegistry();
|
|
90
|
+
const hit = list.find((d) => d.name === name);
|
|
91
|
+
if (!hit) return false;
|
|
92
|
+
try {
|
|
93
|
+
process.kill(hit.pid); // SIGTERM -> supervisor kills its child, then exits
|
|
94
|
+
} catch {
|
|
95
|
+
// already gone
|
|
96
|
+
}
|
|
97
|
+
writeRegistry(list.filter((d) => d.name !== name));
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Supervisor body (run via the hidden `__supervise` command). Runs the serve
|
|
103
|
+
* argv, restarting it on a non-zero exit with capped exponential backoff; stops
|
|
104
|
+
* when the child exits cleanly or when this process receives SIGTERM/SIGINT
|
|
105
|
+
* (killing the child first). Output goes to the inherited log fd.
|
|
106
|
+
*/
|
|
107
|
+
export async function runSupervisor(name: string, argv: string[]): Promise<number> {
|
|
108
|
+
let child: Subprocess | null = null;
|
|
109
|
+
let stopping = false;
|
|
110
|
+
const onSignal = () => {
|
|
111
|
+
stopping = true;
|
|
112
|
+
try {
|
|
113
|
+
child?.kill();
|
|
114
|
+
} catch {
|
|
115
|
+
// ignore
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
process.on("SIGTERM", onSignal);
|
|
119
|
+
process.on("SIGINT", onSignal);
|
|
120
|
+
|
|
121
|
+
let attempt = 0;
|
|
122
|
+
while (!stopping) {
|
|
123
|
+
console.log(`[codehost:${name}] starting: ${argv.join(" ")}`);
|
|
124
|
+
child = spawn(argv, { cwd: process.cwd(), stdin: "ignore", stdout: "inherit", stderr: "inherit" });
|
|
125
|
+
const code = await child.exited;
|
|
126
|
+
if (stopping) break;
|
|
127
|
+
if (code === 0) {
|
|
128
|
+
console.log(`[codehost:${name}] server exited cleanly; supervisor stopping.`);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
attempt++;
|
|
132
|
+
const waitMs = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5));
|
|
133
|
+
console.error(
|
|
134
|
+
`[codehost:${name}] server exited with code ${code}; restarting in ${Math.round(waitMs / 1000)}s (attempt ${attempt}).`,
|
|
135
|
+
);
|
|
136
|
+
await Bun.sleep(waitMs);
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { exposeCommand } from "./commands/expose";
|
|
|
8
8
|
import { listCommand } from "./commands/list";
|
|
9
9
|
import { stopCommand } from "./commands/stop";
|
|
10
10
|
import { updateCommand } from "./commands/update";
|
|
11
|
+
import { superviseCommand } from "./commands/supervise";
|
|
11
12
|
|
|
12
13
|
yargs(hideBin(process.argv))
|
|
13
14
|
.scriptName("codehost")
|
|
@@ -19,6 +20,7 @@ yargs(hideBin(process.argv))
|
|
|
19
20
|
.command(listCommand)
|
|
20
21
|
.command(stopCommand)
|
|
21
22
|
.command(updateCommand)
|
|
23
|
+
.command(superviseCommand)
|
|
22
24
|
.demandCommand(1, "Specify a command, e.g. `codehost serve`")
|
|
23
25
|
.strict()
|
|
24
26
|
.help()
|
package/src/cli/vscode.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
2
|
import { resolveCodeBinary } from "./vscode-install";
|
|
3
3
|
|
|
4
|
+
// How long to wait for `code serve-web` to answer. The default is generous
|
|
5
|
+
// because the FIRST run downloads the server component, which can take minutes
|
|
6
|
+
// on a slow link or a fresh Windows box — and under the oxmgr daemon
|
|
7
|
+
// (`--restart on-failure`) a too-short timeout makes us exit mid-download, get
|
|
8
|
+
// restarted, and re-download forever. Override with CODEHOST_VSCODE_READY_TIMEOUT_MS.
|
|
9
|
+
const READY_TIMEOUT_MS = Number(process.env.CODEHOST_VSCODE_READY_TIMEOUT_MS) || 10 * 60_000;
|
|
10
|
+
|
|
4
11
|
export interface VscodeServer {
|
|
5
12
|
port: number;
|
|
6
13
|
basePath: string;
|
|
@@ -49,7 +56,7 @@ export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
|
|
|
49
56
|
});
|
|
50
57
|
|
|
51
58
|
const base = `http://127.0.0.1:${port}${opts.basePath}/`;
|
|
52
|
-
await waitForHttp(base,
|
|
59
|
+
await waitForHttp(base, READY_TIMEOUT_MS, proc);
|
|
53
60
|
console.log(`[codehost] VS Code ready at ${base}`);
|
|
54
61
|
|
|
55
62
|
const stop = () => {
|
|
@@ -62,18 +69,33 @@ export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
|
|
|
62
69
|
return { port, basePath: opts.basePath, proc, stop };
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
async function waitForHttp(url: string, timeoutMs: number): Promise<void> {
|
|
66
|
-
const
|
|
72
|
+
async function waitForHttp(url: string, timeoutMs: number, proc?: Subprocess): Promise<void> {
|
|
73
|
+
const started = Date.now();
|
|
74
|
+
const deadline = started + timeoutMs;
|
|
75
|
+
let nextHeartbeat = started + 15_000;
|
|
67
76
|
while (Date.now() < deadline) {
|
|
77
|
+
// If the server process died (bad flag, crash), fail now instead of waiting
|
|
78
|
+
// out the whole timeout — and surface that it exited rather than hung.
|
|
79
|
+
if (proc && proc.exitCode !== null) {
|
|
80
|
+
throw new Error(`VS Code server exited (code ${proc.exitCode}) before becoming ready at ${url}`);
|
|
81
|
+
}
|
|
68
82
|
try {
|
|
69
83
|
const res = await fetch(url, { redirect: "manual" });
|
|
70
84
|
if (res.status > 0) return;
|
|
71
85
|
} catch {
|
|
72
86
|
// not up yet
|
|
73
87
|
}
|
|
88
|
+
if (Date.now() >= nextHeartbeat) {
|
|
89
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
90
|
+
console.log(`[codehost] waiting for VS Code server to start… (${secs}s; first run downloads the server component)`);
|
|
91
|
+
nextHeartbeat += 15_000;
|
|
92
|
+
}
|
|
74
93
|
await Bun.sleep(300);
|
|
75
94
|
}
|
|
76
|
-
throw new Error(
|
|
95
|
+
throw new Error(
|
|
96
|
+
`VS Code did not become ready at ${url} within ${timeoutMs}ms ` +
|
|
97
|
+
`(first-run server download can be slow — raise CODEHOST_VSCODE_READY_TIMEOUT_MS)`,
|
|
98
|
+
);
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
async function freePort(): Promise<number> {
|
package/src/shared/repo.test.ts
CHANGED
|
@@ -3,23 +3,25 @@ import { parseDeepLink, pickRoomMatch, repoKey, shareableDeepLink, toPosixPath }
|
|
|
3
3
|
import { parseGitRemote } from "../cli/git";
|
|
4
4
|
|
|
5
5
|
describe("toPosixPath", () => {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
// VS Code web's ?folder= on Windows wants the file-URI authority form
|
|
7
|
+
// (/C:/ws), NOT git-bash /c/ws — the latter reports "workspace does not exist".
|
|
8
|
+
test("Windows drive path -> /<Drive>:/... (file-URI form)", () => {
|
|
9
|
+
expect(toPosixPath("C:\\ws")).toBe("/C:/ws");
|
|
10
|
+
expect(toPosixPath("C:\\Users\\taku")).toBe("/C:/Users/taku");
|
|
9
11
|
});
|
|
10
12
|
|
|
11
|
-
test("
|
|
12
|
-
expect(toPosixPath("D:\\foo")).toBe("/
|
|
13
|
-
expect(toPosixPath("c:\\ws")).toBe("/c
|
|
13
|
+
test("preserves drive-letter case (drive is case-insensitive on Windows)", () => {
|
|
14
|
+
expect(toPosixPath("D:\\foo")).toBe("/D:/foo");
|
|
15
|
+
expect(toPosixPath("c:\\ws")).toBe("/c:/ws");
|
|
14
16
|
});
|
|
15
17
|
|
|
16
|
-
test("drive root collapses to /<
|
|
17
|
-
expect(toPosixPath("C:\\")).toBe("/
|
|
18
|
-
expect(toPosixPath("C:")).toBe("/
|
|
18
|
+
test("drive root collapses to /<Drive>: (no trailing slash)", () => {
|
|
19
|
+
expect(toPosixPath("C:\\")).toBe("/C:");
|
|
20
|
+
expect(toPosixPath("C:")).toBe("/C:");
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
test("forward-slash Windows paths normalize too", () => {
|
|
22
|
-
expect(toPosixPath("C:/ws")).toBe("/
|
|
24
|
+
expect(toPosixPath("C:/ws")).toBe("/C:/ws");
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
test("POSIX absolute paths are unchanged (mac/linux not broken)", () => {
|
|
@@ -28,12 +30,20 @@ describe("toPosixPath", () => {
|
|
|
28
30
|
expect(toPosixPath("/")).toBe("/");
|
|
29
31
|
});
|
|
30
32
|
|
|
31
|
-
test("already-normalized
|
|
32
|
-
expect(toPosixPath("/
|
|
33
|
+
test("already-normalized path is idempotent", () => {
|
|
34
|
+
expect(toPosixPath("/C:/ws")).toBe("/C:/ws");
|
|
33
35
|
});
|
|
34
36
|
|
|
35
37
|
test("trims trailing backslashes/slashes on a drive path", () => {
|
|
36
|
-
expect(toPosixPath("C:\\ws\\")).toBe("/
|
|
38
|
+
expect(toPosixPath("C:\\ws\\")).toBe("/C:/ws");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Regression: the value VS Code web receives via ?folder= (URL-decoded) must
|
|
42
|
+
// be exactly /C:/ws so serve-web resolves it to the real C:\ws on disk.
|
|
43
|
+
test("?folder= round-trip: encode(toPosixPath) decodes back to /C:/ws", () => {
|
|
44
|
+
const folderParam = encodeURIComponent(toPosixPath("C:\\ws"));
|
|
45
|
+
expect(folderParam).toBe("%2FC%3A%2Fws");
|
|
46
|
+
expect(decodeURIComponent(folderParam)).toBe("/C:/ws");
|
|
37
47
|
});
|
|
38
48
|
});
|
|
39
49
|
|
|
@@ -87,8 +97,16 @@ describe("parseDeepLink + repoKey round-trip", () => {
|
|
|
87
97
|
});
|
|
88
98
|
|
|
89
99
|
test("/dev/<path> -> dev target with leading slash", () => {
|
|
90
|
-
const dl = parseDeepLink("/dev/
|
|
91
|
-
expect(dl?.type === "dev" && dl.target.path).toBe("/
|
|
100
|
+
const dl = parseDeepLink("/dev/Users/sno/ws");
|
|
101
|
+
expect(dl?.type === "dev" && dl.target.path).toBe("/Users/sno/ws");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("/dev/<Windows drive path> round-trips (colon in a non-leading segment)", () => {
|
|
105
|
+
// shareableDeepLink -> address bar -> parseDeepLink must preserve /C:/ws.
|
|
106
|
+
const path = shareableDeepLink({ folder: toPosixPath("C:\\ws") })!;
|
|
107
|
+
expect(path).toBe("/dev/C:/ws");
|
|
108
|
+
const dl = parseDeepLink(path);
|
|
109
|
+
expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
|
|
92
110
|
});
|
|
93
111
|
|
|
94
112
|
test("non-deep-link -> null", () => {
|
|
@@ -111,8 +129,9 @@ describe("shareableDeepLink", () => {
|
|
|
111
129
|
);
|
|
112
130
|
});
|
|
113
131
|
|
|
114
|
-
test("no repo -> /dev/<folder>", () => {
|
|
115
|
-
expect(shareableDeepLink({ folder: "/
|
|
132
|
+
test("no repo -> /dev/<folder> (Windows drive path preserved)", () => {
|
|
133
|
+
expect(shareableDeepLink({ folder: "/C:/ws" })).toBe("/dev/C:/ws");
|
|
134
|
+
expect(shareableDeepLink({ folder: "/Users/sno/ws" })).toBe("/dev/Users/sno/ws");
|
|
116
135
|
});
|
|
117
136
|
|
|
118
137
|
test("nothing addressable -> null", () => {
|
package/src/shared/repo.ts
CHANGED
|
@@ -63,19 +63,22 @@ export function repoKey(t: Pick<RepoTarget, "host" | "owner" | "name">): string
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Normalize a served workspace path to the
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* `C:\Users\x` -> `/
|
|
70
|
-
*
|
|
71
|
-
*
|
|
66
|
+
* Normalize a served workspace path to the form VS Code web's `?folder=` query
|
|
67
|
+
* expects. On Windows that's the file-URI authority form: a leading slash, the
|
|
68
|
+
* drive letter and colon preserved, backslashes -> slashes —
|
|
69
|
+
* `C:\ws` -> `/C:/ws`, `C:\Users\x` -> `/C:/Users/x`, `D:\` -> `/D:`. (The
|
|
70
|
+
* git-bash `/c/ws` form does NOT resolve — serve-web reports "workspace does not
|
|
71
|
+
* exist".) POSIX absolute paths (mac/linux) are returned unchanged, and the
|
|
72
|
+
* result is idempotent. Used for `PeerMeta.cwd`, which feeds the `?folder=` URI
|
|
73
|
+
* (URL-encoded in transit, decoded back to this by VS Code) and the `/dev/<path>`
|
|
74
|
+
* deep link — the real OS path is still used for the local VS Code working dir.
|
|
72
75
|
*/
|
|
73
76
|
export function toPosixPath(p: string): string {
|
|
74
77
|
const drive = /^([A-Za-z]):(?:[\\/](.*))?$/.exec(p);
|
|
75
78
|
if (drive) {
|
|
76
|
-
const letter = drive[1]
|
|
79
|
+
const letter = drive[1]; // preserve drive-letter case
|
|
77
80
|
const rest = (drive[2] ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
78
|
-
return rest ? `/${letter}
|
|
81
|
+
return rest ? `/${letter}:/${rest}` : `/${letter}:`;
|
|
79
82
|
}
|
|
80
83
|
// Already POSIX (or a relative path): just unify any stray backslashes.
|
|
81
84
|
return p.replace(/\\/g, "/");
|