codehost 0.12.0 → 0.13.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,10 @@
1
+ # [0.13.0](https://github.com/snomiao/codehost/compare/v0.12.0...v0.13.0) (2026-06-09)
2
+
3
+
4
+ ### Features
5
+
6
+ * **web:** host-scoped folder deep links — /host/<hostname>/<path> ([f6955ab](https://github.com/snomiao/codehost/commit/f6955ab37517f312ee780795efc503cb74ccdc36))
7
+
1
8
  # [0.12.0](https://github.com/snomiao/codehost/compare/v0.11.1...v0.12.0) (2026-06-09)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,7 +23,7 @@ interface DevArgs {
23
23
  export const devCommand: CommandModule<{}, DevArgs> = {
24
24
  command: "dev [dir]",
25
25
  describe:
26
- "Serve a single folder over WebRTC; open it at codehost.dev/dev/<path> (or /gh/<owner>/<repo>, /git/<host>/<owner>/<repo> for a git repo)",
26
+ "Serve a single folder over WebRTC; open it at codehost.dev/host/<hostname>/<path> (or /gh/<owner>/<repo>, /git/<host>/<owner>/<repo> for a git repo)",
27
27
  builder: (y) =>
28
28
  y
29
29
  .positional("dir", {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { parseDeepLink, pickRoomMatch, repoKey, shareableDeepLink, toPosixPath } from "./repo";
2
+ import { parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, shareableDeepLink, toPosixPath } from "./repo";
3
+ import type { PeerInfo } from "./signaling";
3
4
  import { parseGitRemote } from "../cli/git";
4
5
 
5
6
  describe("toPosixPath", () => {
@@ -109,12 +110,60 @@ describe("parseDeepLink + repoKey round-trip", () => {
109
110
  expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
110
111
  });
111
112
 
113
+ test("/host/<hostname>/<path> -> host-scoped dev target", () => {
114
+ const dl = parseDeepLink("/host/Mac/Users/taku");
115
+ expect(dl?.type === "dev" && dl.target.host).toBe("Mac");
116
+ expect(dl?.type === "dev" && dl.target.path).toBe("/Users/taku");
117
+ });
118
+
119
+ test("/host/<hostname>/<Windows drive path> round-trips", () => {
120
+ const path = shareableDeepLink({ folder: "/C:/ws", host: "EC2AMAZ-PH8C4K1" })!;
121
+ expect(path).toBe("/host/EC2AMAZ-PH8C4K1/C:/ws");
122
+ const dl = parseDeepLink(path);
123
+ expect(dl?.type === "dev" && dl.target.host).toBe("EC2AMAZ-PH8C4K1");
124
+ expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
125
+ });
126
+
127
+ test("legacy /dev/<path> still parses host-agnostic (no host)", () => {
128
+ const dl = parseDeepLink("/dev/C:/ws");
129
+ expect(dl?.type === "dev" && dl.target.host).toBeUndefined();
130
+ expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
131
+ });
132
+
112
133
  test("non-deep-link -> null", () => {
113
134
  expect(parseDeepLink("/")).toBeNull();
114
135
  expect(parseDeepLink("/settings")).toBeNull();
115
136
  });
116
137
  });
117
138
 
139
+ describe("resolveDevTarget host scoping", () => {
140
+ const mk = (peerId: string, host: string, cwd: string): PeerInfo => ({
141
+ peerId,
142
+ role: "server",
143
+ meta: { name: host, host, cwd },
144
+ });
145
+ // Same served path on two different machines — the ambiguity host scoping fixes.
146
+ const servers = [mk("pA", "boxA", "/C:/ws"), mk("pB", "boxB", "/C:/ws")];
147
+
148
+ test("host-scoped target picks the matching host", () => {
149
+ expect(resolveDevTarget(servers, { host: "boxB", path: "/C:/ws" })?.peerId).toBe("pB");
150
+ expect(resolveDevTarget(servers, { host: "boxA", path: "/C:/ws" })?.peerId).toBe("pA");
151
+ });
152
+
153
+ test("host-scoped target with no matching host -> null (won't cross machines)", () => {
154
+ expect(resolveDevTarget(servers, { host: "boxC", path: "/C:/ws" })).toBeNull();
155
+ });
156
+
157
+ test("legacy host-agnostic target matches by path alone", () => {
158
+ expect(resolveDevTarget(servers, { path: "/C:/ws" })?.peerId).toBe("pA");
159
+ });
160
+
161
+ test("leading/trailing slash differences still match (e.g. expose cwd)", () => {
162
+ const ex = [mk("pE", "boxE", "localhost:8090")];
163
+ expect(resolveDevTarget(ex, { host: "boxE", path: "/localhost:8090" })?.peerId).toBe("pE");
164
+ });
165
+ });
166
+
118
167
  describe("shareableDeepLink", () => {
119
168
  test("GitHub repo -> /gh sugar", () => {
120
169
  expect(shareableDeepLink({ repo: "github.com/snomiao/codehost", branch: "main" })).toBe(
@@ -17,8 +17,12 @@ export interface RepoTarget {
17
17
  branch?: string;
18
18
  }
19
19
 
20
- /** A direct folder mount address: `/dev/<absolute-fs-path>`. */
20
+ /** A direct folder mount address: host-scoped `/host/<hostname>/<path>`, or the
21
+ * legacy host-agnostic `/dev/<path>` (a bare path collides across machines, so
22
+ * new links carry the host). */
21
23
  export interface DevTarget {
24
+ /** Hostname the workspace lives on; undefined for a legacy host-agnostic link. */
25
+ host?: string;
22
26
  path: string;
23
27
  }
24
28
 
@@ -31,7 +35,8 @@ export type DeepLink =
31
35
  * Parse a deep-link pathname:
32
36
  * /gh/<owner>/<repo>(/tree/<branch>) -> GitHub repo target
33
37
  * /git/<host>/<owner>/<repo>(/tree/<branch>) -> any-host repo target
34
- * /dev/<fs-path> -> direct folder mount
38
+ * /host/<hostname>/<fs-path> -> host-scoped folder mount
39
+ * /dev/<fs-path> -> legacy host-agnostic folder mount
35
40
  * Branch may contain slashes. Anything else -> null (normal app).
36
41
  */
37
42
  export function parseDeepLink(pathname: string): DeepLink {
@@ -50,6 +55,13 @@ export function parseDeepLink(pathname: string): DeepLink {
50
55
  target: { host: git[1].toLowerCase(), owner: git[2], name: git[3], branch: git[4] },
51
56
  };
52
57
  }
58
+ // Host-scoped folder mount: first segment is the hostname, the rest is the
59
+ // served path (which itself may contain slashes and a Windows drive colon).
60
+ const host = clean.match(/^\/host\/([^/]+)\/(.+)$/);
61
+ if (host) {
62
+ return { type: "dev", target: { host: host[1], path: `/${host[2].replace(/^\/+/, "")}` } };
63
+ }
64
+ // Legacy host-agnostic folder mount.
53
65
  const dev = clean.match(/^\/dev\/(.+)$/);
54
66
  if (dev) {
55
67
  return { type: "dev", target: { path: `/${dev[1].replace(/^\/+/, "")}` } };
@@ -96,11 +108,17 @@ export function fillLayout(layout: string, t: RepoTarget): string {
96
108
  * Shareable deep-link pathname for a connected workspace. A git-identified
97
109
  * server renders `/gh/<owner>/<repo>` for GitHub or `/git/<host>/<owner>/<repo>`
98
110
  * for any other host (with `/tree/<branch>` when known); a non-git workspace is
99
- * addressed by its opened folder as a `/dev/<path>` mount. Round-trips through
111
+ * addressed by its opened folder, scoped to its hostname as `/host/<host>/<path>`
112
+ * (or the legacy `/dev/<path>` when no host is known). Round-trips through
100
113
  * parseDeepLink + resolve{Repo,Dev}Target so another room member opening it
101
114
  * lands here. Returns null when there's nothing addressable.
102
115
  */
103
- export function shareableDeepLink(opts: { repo?: string; branch?: string; folder?: string }): string | null {
116
+ export function shareableDeepLink(opts: {
117
+ repo?: string;
118
+ branch?: string;
119
+ folder?: string;
120
+ host?: string;
121
+ }): string | null {
104
122
  if (opts.repo) {
105
123
  const [host, owner, name] = opts.repo.split("/");
106
124
  if (host && owner && name) {
@@ -108,7 +126,10 @@ export function shareableDeepLink(opts: { repo?: string; branch?: string; folder
108
126
  return opts.branch ? `${base}/tree/${opts.branch}` : base;
109
127
  }
110
128
  }
111
- if (opts.folder) return `/dev/${opts.folder.replace(/^\/+/, "")}`;
129
+ if (opts.folder) {
130
+ const path = opts.folder.replace(/^\/+/, "");
131
+ return opts.host ? `/host/${opts.host}/${path}` : `/dev/${path}`;
132
+ }
112
133
  return null;
113
134
  }
114
135
 
@@ -138,13 +159,16 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
138
159
  return null;
139
160
  }
140
161
 
141
- /** Pick a `dev`/repo server whose served cwd matches a /dev/<path> target.
142
- * Compares with leading + trailing slashes stripped: `parseDeepLink` forces a
143
- * leading "/" on the path, but a served cwd may lack one (e.g. an `expose`
144
- * server's `localhost:<port>`), so a trailing-only trim would never match it. */
162
+ /** Pick a folder-mount server whose served cwd matches the target path, scoped
163
+ * to `target.host` when the link carries one (a bare path is ambiguous across
164
+ * machines). Compares with leading + trailing slashes stripped: `parseDeepLink`
165
+ * forces a leading "/" on the path, but a served cwd may lack one (e.g. an
166
+ * `expose` server's `localhost:<port>`), so a trailing-only trim never matches. */
145
167
  export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
146
168
  const want = stripEnds(target.path);
147
- const hit = servers.find((s) => s.meta && stripEnds(s.meta.cwd) === want);
169
+ const hit = servers.find(
170
+ (s) => s.meta && stripEnds(s.meta.cwd) === want && (!target.host || s.meta.host === target.host),
171
+ );
148
172
  return hit ? { peerId: hit.peerId } : null;
149
173
  }
150
174
 
@@ -46,7 +46,8 @@ function tokenFromHash(): string {
46
46
  /** Short label for the "looking for…" state from a deep link. */
47
47
  function deepLinkLabel(dl: DeepLink): string | null {
48
48
  if (!dl) return null;
49
- return dl.type === "repo" ? `${dl.target.owner}/${dl.target.name}` : dl.target.path;
49
+ if (dl.type === "repo") return `${dl.target.owner}/${dl.target.name}`;
50
+ return dl.target.host ? `${dl.target.host}:${dl.target.path}` : dl.target.path;
50
51
  }
51
52
 
52
53
  function folderQuery(folder?: string): string {
@@ -418,7 +419,12 @@ export function Discovery() {
418
419
  function shareablePathFor(server: PeerInfo, folder?: string): string | null {
419
420
  return deepLinkRef.current
420
421
  ? window.location.pathname
421
- : shareableDeepLink({ repo: server.meta?.repo, branch: server.meta?.branch, folder });
422
+ : shareableDeepLink({
423
+ repo: server.meta?.repo,
424
+ branch: server.meta?.branch,
425
+ folder,
426
+ host: server.meta?.host,
427
+ });
422
428
  }
423
429
 
424
430
  async function shareLink() {