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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- // POSIX-drive form for the browser (C:\ws -> /c/ws); `dir` stays the real
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",
@@ -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 running under oxmgr",
8
+ describe: "List codehost servers (oxmgr-managed and detached)",
8
9
  handler: async () => {
9
- process.exit(await listDaemons());
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
- // POSIX-drive form for the browser ?folder= URI (C:\ws -> /c/ws); the real
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,
@@ -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
+ };
@@ -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) under oxmgr so it survives
32
- * the shell and restarts on failure. Shared by `serve -d` and `setup`.
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 command = buildForegroundCommand(opts);
43
- console.log(`[codehost] starting daemon "${name}" via oxmgr`);
44
- const ok = await startDaemon({ name, command, cwd: opts.dir });
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) for oxmgr to
53
- * run. Uses the same runtime + entry script that launched us, so it works both
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 buildForegroundCommand(opts: ServeDaemonOptions): string {
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.map(quote).join(" ");
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, 30_000);
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 deadline = Date.now() + timeoutMs;
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(`VS Code did not become ready at ${url} within ${timeoutMs}ms`);
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> {
@@ -3,23 +3,25 @@ import { parseDeepLink, pickRoomMatch, repoKey, shareableDeepLink, toPosixPath }
3
3
  import { parseGitRemote } from "../cli/git";
4
4
 
5
5
  describe("toPosixPath", () => {
6
- test("Windows drive path -> POSIX drive form", () => {
7
- expect(toPosixPath("C:\\ws")).toBe("/c/ws");
8
- expect(toPosixPath("C:\\Users\\x")).toBe("/c/Users/x");
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("lowercases the drive letter", () => {
12
- expect(toPosixPath("D:\\foo")).toBe("/d/foo");
13
- expect(toPosixPath("c:\\ws")).toBe("/c/ws");
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 /<letter> (no trailing slash)", () => {
17
- expect(toPosixPath("C:\\")).toBe("/c");
18
- expect(toPosixPath("C:")).toBe("/c");
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("/c/ws");
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 POSIX-drive path is a no-op", () => {
32
- expect(toPosixPath("/c/ws")).toBe("/c/ws");
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("/c/ws");
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/c/ws");
91
- expect(dl?.type === "dev" && dl.target.path).toBe("/c/ws");
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: "/c/ws" })).toBe("/dev/c/ws");
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", () => {
@@ -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 POSIX-drive form the browser side
67
- * and VS Code web expect. A Windows drive path becomes a `/c/...` style path
68
- * (lowercased drive, backslashes -> slashes): `C:\ws` -> `/c/ws`,
69
- * `C:\Users\x` -> `/c/Users/x`, `D:\` -> `/d`. POSIX absolute paths (mac/linux)
70
- * are returned unchanged. Used for `PeerMeta.cwd`, which feeds the `?folder=`
71
- * URI the real OS path is still used for the local VS Code working dir.
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].toLowerCase();
79
+ const letter = drive[1]; // preserve drive-letter case
77
80
  const rest = (drive[2] ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
78
- return rest ? `/${letter}/${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, "/");