codehost 0.13.0 → 0.15.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,17 @@
1
+ # [0.15.0](https://github.com/snomiao/codehost/compare/v0.14.0...v0.15.0) (2026-06-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * **web:** 'open a GitHub URL' box + deepest-root repo resolution ([0d9ebec](https://github.com/snomiao/codehost/commit/0d9ebec4409430c327e2945657b6803ca704fd53))
7
+
8
+ # [0.14.0](https://github.com/snomiao/codehost/compare/v0.13.0...v0.14.0) (2026-06-10)
9
+
10
+
11
+ ### Features
12
+
13
+ * **web:** always show /tree/<branch> for repo workspaces ([ad34bc2](https://github.com/snomiao/codehost/commit/ad34bc2e2e8acea98ac392b90766488c6f3b4e52))
14
+
1
15
  # [0.13.0](https://github.com/snomiao/codehost/compare/v0.12.0...v0.13.0) (2026-06-09)
2
16
 
3
17
 
@@ -0,0 +1,130 @@
1
+ # Workspace provisioning
2
+
3
+ Status: **design / proposal** (not implemented). Captures the design discussed
4
+ for "open a repo link → the workspace materializes on the host".
5
+
6
+ ## Goal
7
+
8
+ Opening a repo deep link should *materialize* the workspace on the serving host
9
+ on demand — clone / worktree-add / install — instead of requiring it to already
10
+ exist. Today, opening `/gh/<owner>/<repo>/tree/<branch>` when that worktree isn't
11
+ present just fails (`serve-web` reports "workspace does not exist").
12
+
13
+ The host owner stays in control: all provisioning is a **user-authored,
14
+ idempotent script** on the host. codehost never invents commands.
15
+
16
+ ## Model
17
+
18
+ `codehost serve` runs in a *home* directory (`$HOME_ROOT`):
19
+
20
+ ```
21
+ $HOME_ROOT/
22
+ ├── .codehost/ # config dir — edited in VS Code itself
23
+ │ ├── config.yaml # settings (workspace path template, allowlist…)
24
+ │ ├── setup.sh / setup.bat # idempotent provisioning, run on every open
25
+ │ └── … # any other files the user keeps here
26
+ └── ws/ # default workspaces root (redefinable in config.yaml)
27
+ └── <owner>/<repo>/tree/<branch>/
28
+ ```
29
+
30
+ - `.codehost/` is the config folder. `ws/` is where github-shaped paths land.
31
+ - Default layout: `ws/{owner}/{repo}/tree/{branch}` (the `tree/<branch>` worktree
32
+ convention — branches are real side-by-side directories).
33
+ - The workspace path is redefinable in `config.yaml`.
34
+
35
+ ## Open flow
36
+
37
+ ```
38
+ open /gh/<owner>/<repo>/tree/<branch>
39
+ → daemon runs .codehost/setup.sh with owner/repo/branch (+ host)
40
+ setup.sh decides: clone / git worktree add / pull / rebase / skip
41
+ (idempotent — the policy, incl. auto-upgrade/rebase, is the user's)
42
+ → VS Code opens the resulting workspace path
43
+ ```
44
+
45
+ Because `setup.sh` is idempotent and runs **every** time, codehost does not track
46
+ clone state at all — the script clones if missing, fast-skips if present, and the
47
+ user decides whether to auto-pull/rebase. This removes a lot of codehost logic.
48
+
49
+ ## `setup.sh` contract
50
+
51
+ - **Input** (env, never interpolated into a command string):
52
+ `CODEHOST_OWNER`, `CODEHOST_REPO`, `CODEHOST_BRANCH`, `CODEHOST_HOST`,
53
+ `CODEHOST_HOME` (the home root), `CODEHOST_WS` (the resolved default path from
54
+ the `config.yaml` template).
55
+ - **Output**: the absolute workspace path on stdout (last line). If it prints
56
+ nothing, codehost uses `CODEHOST_WS` (the template default).
57
+ - **Exit non-zero** → provisioning failed; surface the error to the browser.
58
+ - Must be **idempotent** and fast on an already-provisioned path (skip).
59
+ - **Windows**: `setup.bat` (or pwsh) with the same env contract.
60
+
61
+ ## `config.yaml` (sketch)
62
+
63
+ ```yaml
64
+ workspace: "ws/{owner}/{repo}/tree/{branch}" # path template, relative to home
65
+ allowlist: # which repos may auto-provision
66
+ - github.com/snomiao/*
67
+ # …future: default branch, install hook, etc.
68
+ ```
69
+
70
+ ## Reuse VS Code for config (no custom UI)
71
+
72
+ Editing config = open `$HOME_ROOT/.codehost/` in the **existing VS Code iframe**.
73
+ A "Config" affordance in the header opens `?folder=<home>/.codehost`. No
74
+ `/p/<peer-id>/` settings page, no re-invented editor. (Peer ids are ephemeral —
75
+ they change on every daemon restart — so config is keyed by the host/home, not
76
+ the peer.)
77
+
78
+ ## Create-from-GitHub input box
79
+
80
+ On the workspace list page, an input: **paste a GitHub URL**.
81
+
82
+ ```
83
+ https://github.com/snomiao/codehost/tree/main
84
+ → parse → /gh/snomiao/codehost/tree/main → open (triggers provisioning)
85
+ ```
86
+
87
+ One paste opens any GitHub repo on a connected host (= "create workspace from
88
+ GitHub"). Accepts bare `github.com/<owner>/<repo>` and `…/tree/<branch>` forms.
89
+
90
+ ## Provisioning UX
91
+
92
+ - While `setup.sh` runs, show a **"provisioning…"** state with the script's
93
+ stdout/stderr **streamed over the tunnel** (clone/install can take a while).
94
+ - Idempotent re-opens skip and are near-instant.
95
+
96
+ ## Security (the crux)
97
+
98
+ - **Commands are host-authored** (`setup.sh` lives on the host). The link/viewer
99
+ supplies only `owner/repo/branch` identity — never commands.
100
+ - codehost **sanitizes** `owner/repo/branch` before handing them to `setup.sh`
101
+ (reject `;`, `$()`, backticks, newlines, `..`, path separators where not
102
+ expected) and passes them as **env**, not string-interpolated into a command.
103
+ - **allowlist** (`config.yaml` / `setup.sh`) gates which repos auto-provision;
104
+ others prompt or are denied.
105
+ - Any room member can trigger `setup.sh` with any identity, so the **room token
106
+ is the trust boundary** and `setup.sh`/allowlist owns repo policy. Editing
107
+ config (= changing what runs on the host) may warrant owner auth beyond the
108
+ room token — open question.
109
+
110
+ ## Defaults / scaffolding
111
+
112
+ - No `.codehost/setup.sh` → fall back to today's behavior (resolve to the layout
113
+ path, no clone).
114
+ - `codehost init` scaffolds a starter `.codehost/` (`config.yaml` + a `setup.sh`
115
+ that does `gh repo clone` + `git worktree add` + the `ws/` convention).
116
+
117
+ ## Related fix (independent, same path)
118
+
119
+ `resolveRepoTarget` picks the *first* root daemon without checking which one
120
+ actually contains the repo, so with multiple roots it can pick the wrong one
121
+ (observed live: it chose `/Users/sno` over the correct `/Users/sno/ws`, yielding
122
+ a non-existent subpath). Prefer the **deepest matching root** (longest `cwd`
123
+ prefix). Not part of provisioning, but it gates the same "open a repo" path.
124
+
125
+ ## Open questions
126
+
127
+ 1. Source of truth for the path: `setup.sh` stdout vs `config.yaml` template.
128
+ (Proposed: stdout wins; the template is the default handed in as `CODEHOST_WS`.)
129
+ 2. Owner auth for editing config beyond the room token?
130
+ 3. Log streaming transport: a new tunnel channel vs reuse of an existing one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, shareableDeepLink, toPosixPath } from "./repo";
2
+ import { gitUrlToPath, parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, resolveRepoTarget, shareableDeepLink, toPosixPath } from "./repo";
3
3
  import type { PeerInfo } from "./signaling";
4
4
  import { parseGitRemote } from "../cli/git";
5
5
 
@@ -194,6 +194,53 @@ describe("shareableDeepLink", () => {
194
194
  });
195
195
  });
196
196
 
197
+ describe("resolveRepoTarget root selection", () => {
198
+ const root = (peerId: string, cwd: string): PeerInfo => ({
199
+ peerId,
200
+ role: "server",
201
+ meta: { name: peerId, host: "Mac", cwd, kind: "root" },
202
+ });
203
+
204
+ test("among nested roots, picks the deepest (longest cwd)", () => {
205
+ const servers = [root("shallow", "/Users/sno"), root("deep", "/Users/sno/ws")];
206
+ const res = resolveRepoTarget(servers, { host: "github.com", owner: "snomiao", name: "codehost" });
207
+ expect(res?.peerId).toBe("deep");
208
+ expect(res?.folder).toBe("/Users/sno/ws/snomiao/codehost/tree/main");
209
+ });
210
+
211
+ test("an exact repo daemon still wins over any root", () => {
212
+ const servers: PeerInfo[] = [
213
+ root("deep", "/Users/sno/ws"),
214
+ { peerId: "exact", role: "server", meta: { name: "x", host: "Mac", cwd: "/x", repo: "github.com/snomiao/codehost" } },
215
+ ];
216
+ expect(resolveRepoTarget(servers, { host: "github.com", owner: "snomiao", name: "codehost" })?.peerId).toBe("exact");
217
+ });
218
+ });
219
+
220
+ describe("gitUrlToPath", () => {
221
+ test("github URLs -> /gh, preserving branch (incl. slashes)", () => {
222
+ expect(gitUrlToPath("https://github.com/snomiao/codehost")).toBe("/gh/snomiao/codehost");
223
+ expect(gitUrlToPath("https://github.com/snomiao/codehost/tree/main")).toBe("/gh/snomiao/codehost/tree/main");
224
+ expect(gitUrlToPath("github.com/snomiao/codehost")).toBe("/gh/snomiao/codehost");
225
+ expect(gitUrlToPath("https://github.com/snomiao/codehost.git")).toBe("/gh/snomiao/codehost");
226
+ expect(gitUrlToPath("https://github.com/snomiao/codehost/tree/feat/x")).toBe("/gh/snomiao/codehost/tree/feat/x");
227
+ expect(gitUrlToPath("https://github.com/snomiao/codehost/")).toBe("/gh/snomiao/codehost");
228
+ expect(gitUrlToPath("https://github.com/snomiao/codehost?tab=readme#x")).toBe("/gh/snomiao/codehost");
229
+ });
230
+
231
+ test("other hosts + scp form -> /git/<host>/...", () => {
232
+ expect(gitUrlToPath("https://gitlab.com/group/proj/tree/dev")).toBe("/git/gitlab.com/group/proj/tree/dev");
233
+ expect(gitUrlToPath("git@github.com:snomiao/codehost.git")).toBe("/gh/snomiao/codehost");
234
+ });
235
+
236
+ test("non-repo / junk -> null", () => {
237
+ expect(gitUrlToPath("")).toBeNull();
238
+ expect(gitUrlToPath("not a url")).toBeNull();
239
+ expect(gitUrlToPath("https://github.com/snomiao")).toBeNull(); // no repo
240
+ expect(gitUrlToPath("/gh/snomiao/codehost")).toBeNull(); // already a deep link, no host
241
+ });
242
+ });
243
+
197
244
  describe("pickRoomMatch (cross-room ranking)", () => {
198
245
  const exact = { token: "tA", resolution: { peerId: "p1" } }; // no folder = exact
199
246
  const root = { token: "tB", resolution: { peerId: "p2", folder: "/work/me/repo" } };
@@ -7,6 +7,10 @@ import type { PeerInfo, PeerMeta } from "./signaling";
7
7
 
8
8
  export const DEFAULT_LAYOUT = "{owner}/{repo}/tree/{branch}";
9
9
  export const GITHUB_HOST = "github.com";
10
+ /** Branch assumed when a repo link/target carries none — what `fillLayout` opens
11
+ * and what the address bar shows, so a bare `/gh/<owner>/<repo>` canonicalizes
12
+ * to `/gh/<owner>/<repo>/tree/<DEFAULT_BRANCH>`. */
13
+ export const DEFAULT_BRANCH = "main";
10
14
 
11
15
  export interface RepoTarget {
12
16
  /** Git host, e.g. "github.com" or "gitlab.com". */
@@ -96,12 +100,12 @@ export function toPosixPath(p: string): string {
96
100
  return p.replace(/\\/g, "/");
97
101
  }
98
102
 
99
- /** Fill a layout template from a repo target (default branch -> "main"). */
103
+ /** Fill a layout template from a repo target (default branch -> DEFAULT_BRANCH). */
100
104
  export function fillLayout(layout: string, t: RepoTarget): string {
101
105
  return layout
102
106
  .replace(/\{owner\}/g, t.owner)
103
107
  .replace(/\{repo\}/g, t.name)
104
- .replace(/\{branch\}/g, t.branch || "main");
108
+ .replace(/\{branch\}/g, t.branch || DEFAULT_BRANCH);
105
109
  }
106
110
 
107
111
  /**
@@ -133,6 +137,28 @@ export function shareableDeepLink(opts: {
133
137
  return null;
134
138
  }
135
139
 
140
+ /**
141
+ * Turn a pasted git repo URL into a codehost deep-link path: github.com ->
142
+ * `/gh/<owner>/<repo>`, any other host -> `/git/<host>/<owner>/<repo>`,
143
+ * preserving `/tree/<branch>`. Accepts with or without a scheme, an `scp`-style
144
+ * `git@host:owner/repo`, a trailing `.git`, query/hash, and a branch containing
145
+ * slashes. Returns null when it isn't a recognizable repo URL — lets the "open a
146
+ * GitHub URL" box reuse the same resolution as a typed deep link.
147
+ */
148
+ export function gitUrlToPath(input: string): string | null {
149
+ let s = input.trim();
150
+ if (!s) return null;
151
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); // scheme://
152
+ s = s.replace(/^[^@/]+@/, ""); // user@ (incl. git@)
153
+ s = s.replace(/^([^/:]+):(?!\d)/, "$1/"); // scp-style host:owner/repo -> host/owner/repo
154
+ s = s.split(/[?#]/)[0].replace(/\/+$/, ""); // drop query/hash + trailing slash
155
+ const m = s.match(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/(.+))?$/);
156
+ if (!m) return null;
157
+ const host = m[1].toLowerCase();
158
+ if (!host.includes(".")) return null; // require a real hostname (github.com)
159
+ return shareableDeepLink({ repo: `${host}/${m[2]}/${m[3]}`, branch: m[4] });
160
+ }
161
+
136
162
  export interface Resolution {
137
163
  peerId: string;
138
164
  /** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
@@ -142,7 +168,10 @@ export interface Resolution {
142
168
  /**
143
169
  * Pick the best live server for a repo deep link. Prefers an exact `repo`
144
170
  * daemon; otherwise falls back to a `root` daemon that can open the subfolder.
145
- * Returns null if nothing matches.
171
+ * Among several roots, prefers the **deepest** (longest cwd) — with a nested
172
+ * setup like /Users/sno and /Users/sno/ws both serving, the layout subfolder
173
+ * exists under the deeper one (observed: /gh/snomiao/codehost belongs to
174
+ * /Users/sno/ws/..., not /Users/sno/...). Returns null if nothing matches.
146
175
  */
147
176
  export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Resolution | null {
148
177
  const key = repoKey(target);
@@ -151,7 +180,9 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
151
180
  );
152
181
  if (repoMatch) return { peerId: repoMatch.peerId };
153
182
 
154
- const root = servers.find((s) => s.meta?.kind === "root");
183
+ const root = servers
184
+ .filter((s) => s.meta?.kind === "root")
185
+ .sort((a, b) => (b.meta?.cwd.length ?? 0) - (a.meta?.cwd.length ?? 0))[0];
155
186
  if (root && root.meta) {
156
187
  const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
157
188
  return { peerId: root.peerId, folder };
@@ -8,8 +8,10 @@ import { getSignalUrl } from "./config";
8
8
  import { registerTunnelHost } from "./tunnel-host";
9
9
  import { connBroker } from "./conn-broker";
10
10
  import {
11
+ DEFAULT_BRANCH,
11
12
  type DeepLink,
12
13
  type RoomMatch,
14
+ gitUrlToPath,
13
15
  parseDeepLink,
14
16
  pickRoomMatch,
15
17
  repoKey,
@@ -161,6 +163,12 @@ export function Discovery() {
161
163
  const [editingToken, setEditingToken] = useState(false);
162
164
  const [tokenError, setTokenError] = useState<string | null>(null);
163
165
 
166
+ // "Open a GitHub repo" box: paste a github.com URL -> navigate to its /gh/
167
+ // deep link, which resolves/opens (and, once provisioning lands, materializes)
168
+ // the workspace.
169
+ const [ghUrl, setGhUrl] = useState("");
170
+ const [ghError, setGhError] = useState<string | null>(null);
171
+
164
172
  // Fake-tag filter over the merged workspace list: a free-text box plus a set
165
173
  // of pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
166
174
  const [filter, setFilter] = useState("");
@@ -311,6 +319,22 @@ export function Discovery() {
311
319
  setEditingToken(false);
312
320
  }
313
321
 
322
+ function openGithubUrl(e: React.FormEvent) {
323
+ e.preventDefault();
324
+ const path = gitUrlToPath(ghUrl);
325
+ if (!path) {
326
+ setGhError("not a recognizable git repo URL");
327
+ return;
328
+ }
329
+ setGhError(null);
330
+ setGhUrl("");
331
+ // Navigate to the deep link and reconcile: connect if a daemon already
332
+ // serves it (provisioning, later, will materialize it when it doesn't).
333
+ history.pushState(null, "", path);
334
+ setResolving(deepLinkLabel(parseDeepLink(path)));
335
+ syncToUrl();
336
+ }
337
+
314
338
  function leaveRoom(t: string) {
315
339
  if (activeRoomRef.current === t) disconnect();
316
340
  setTokens((prev) => prev.filter((x) => x !== t));
@@ -356,10 +380,19 @@ export function Discovery() {
356
380
  sharePathRef.current = window.location.pathname;
357
381
  } else {
358
382
  const targetPath = shareablePathFor(server, openFolder);
359
- didPush = !!targetPath && targetPath !== window.location.pathname;
360
- if (didPush) history.pushState(null, "", targetPath);
361
- pushedRef.current = didPush;
362
383
  sharePathRef.current = targetPath ?? window.location.pathname;
384
+ if (targetPath && targetPath !== window.location.pathname) {
385
+ if (deepLinkRef.current) {
386
+ // Arrived via a deep link — canonicalize the URL in place (e.g. add
387
+ // /tree/<branch>). Same destination, so replace, don't push a
388
+ // back-to-the-list entry.
389
+ history.replaceState(null, "", targetPath);
390
+ } else {
391
+ history.pushState(null, "", targetPath);
392
+ didPush = true;
393
+ }
394
+ }
395
+ pushedRef.current = didPush;
363
396
  }
364
397
 
365
398
  // The broker decides whether this tab owns the connection. `establish` is
@@ -417,14 +450,20 @@ export function Discovery() {
417
450
  // token — Share adds that). Keeps an existing deep-link path as-is; otherwise
418
451
  // derives /gh|/git|/dev from the server's repo identity or opened folder.
419
452
  function shareablePathFor(server: PeerInfo, folder?: string): string | null {
420
- return deepLinkRef.current
421
- ? window.location.pathname
422
- : shareableDeepLink({
423
- repo: server.meta?.repo,
424
- branch: server.meta?.branch,
425
- folder,
426
- host: server.meta?.host,
427
- });
453
+ const dl = deepLinkRef.current;
454
+ // A repo workspace always shows /tree/<branch> (GitHub-style, and it pins the
455
+ // worktree in snomiao's /tree/<branch> layout). Branch source, in order: the
456
+ // deep link's branch, the server's reported branch, else the layout default —
457
+ // matching the worktree fillLayout actually opened.
458
+ if (dl?.type === "repo") {
459
+ const branch = dl.target.branch ?? server.meta?.branch ?? DEFAULT_BRANCH;
460
+ return shareableDeepLink({ repo: repoKey(dl.target), branch });
461
+ }
462
+ if (server.meta?.repo) {
463
+ return shareableDeepLink({ repo: server.meta.repo, branch: server.meta.branch ?? DEFAULT_BRANCH });
464
+ }
465
+ // Folder mount: keep the deep-link path as-is, else derive the host-scoped one.
466
+ return dl ? window.location.pathname : shareableDeepLink({ folder, host: server.meta?.host });
428
467
  }
429
468
 
430
469
  async function shareLink() {
@@ -719,6 +758,26 @@ export function Discovery() {
719
758
  </>
720
759
  )}
721
760
 
761
+ {tokens.length > 0 && (
762
+ <>
763
+ <form onSubmit={openGithubUrl} style={styles.ghForm}>
764
+ <input
765
+ value={ghUrl}
766
+ onChange={(e) => {
767
+ setGhUrl(e.target.value);
768
+ if (ghError) setGhError(null);
769
+ }}
770
+ placeholder="open a repo… paste a github.com URL"
771
+ style={styles.input}
772
+ />
773
+ <button type="submit" style={styles.button}>
774
+ Open
775
+ </button>
776
+ </form>
777
+ {ghError && <p style={styles.tokenError}>{ghError}</p>}
778
+ </>
779
+ )}
780
+
722
781
  <div style={styles.listHead}>
723
782
  <h2 style={styles.h2}>Workspaces</h2>
724
783
  {serverCount > 0 && (
@@ -820,6 +879,7 @@ const styles: Record<string, React.CSSProperties> = {
820
879
  roomChipX: { background: "transparent", border: "none", color: "inherit", cursor: "pointer", fontSize: 11, padding: 0, lineHeight: 1 },
821
880
  input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
822
881
  button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
882
+ ghForm: { display: "flex", alignItems: "center", gap: 8, margin: "0 0 14px" },
823
883
  listHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 12px" },
824
884
  h2: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: 0 },
825
885
  count: { fontSize: 12, color: "#888", fontFamily: "monospace" },