codehost 0.14.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 +7 -0
- package/docs/provisioning.md +130 -0
- package/package.json +1 -1
- package/src/shared/repo.test.ts +48 -1
- package/src/shared/repo.ts +29 -2
- package/src/web/discovery.tsx +44 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
# [0.14.0](https://github.com/snomiao/codehost/compare/v0.13.0...v0.14.0) (2026-06-10)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -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
package/src/shared/repo.test.ts
CHANGED
|
@@ -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" } };
|
package/src/shared/repo.ts
CHANGED
|
@@ -137,6 +137,28 @@ export function shareableDeepLink(opts: {
|
|
|
137
137
|
return null;
|
|
138
138
|
}
|
|
139
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
|
+
|
|
140
162
|
export interface Resolution {
|
|
141
163
|
peerId: string;
|
|
142
164
|
/** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
|
|
@@ -146,7 +168,10 @@ export interface Resolution {
|
|
|
146
168
|
/**
|
|
147
169
|
* Pick the best live server for a repo deep link. Prefers an exact `repo`
|
|
148
170
|
* daemon; otherwise falls back to a `root` daemon that can open the subfolder.
|
|
149
|
-
*
|
|
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.
|
|
150
175
|
*/
|
|
151
176
|
export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Resolution | null {
|
|
152
177
|
const key = repoKey(target);
|
|
@@ -155,7 +180,9 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
|
|
|
155
180
|
);
|
|
156
181
|
if (repoMatch) return { peerId: repoMatch.peerId };
|
|
157
182
|
|
|
158
|
-
const root = servers
|
|
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];
|
|
159
186
|
if (root && root.meta) {
|
|
160
187
|
const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
|
|
161
188
|
return { peerId: root.peerId, folder };
|
package/src/web/discovery.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
DEFAULT_BRANCH,
|
|
12
12
|
type DeepLink,
|
|
13
13
|
type RoomMatch,
|
|
14
|
+
gitUrlToPath,
|
|
14
15
|
parseDeepLink,
|
|
15
16
|
pickRoomMatch,
|
|
16
17
|
repoKey,
|
|
@@ -162,6 +163,12 @@ export function Discovery() {
|
|
|
162
163
|
const [editingToken, setEditingToken] = useState(false);
|
|
163
164
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
164
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
|
+
|
|
165
172
|
// Fake-tag filter over the merged workspace list: a free-text box plus a set
|
|
166
173
|
// of pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
|
|
167
174
|
const [filter, setFilter] = useState("");
|
|
@@ -312,6 +319,22 @@ export function Discovery() {
|
|
|
312
319
|
setEditingToken(false);
|
|
313
320
|
}
|
|
314
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
|
+
|
|
315
338
|
function leaveRoom(t: string) {
|
|
316
339
|
if (activeRoomRef.current === t) disconnect();
|
|
317
340
|
setTokens((prev) => prev.filter((x) => x !== t));
|
|
@@ -735,6 +758,26 @@ export function Discovery() {
|
|
|
735
758
|
</>
|
|
736
759
|
)}
|
|
737
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
|
+
|
|
738
781
|
<div style={styles.listHead}>
|
|
739
782
|
<h2 style={styles.h2}>Workspaces</h2>
|
|
740
783
|
{serverCount > 0 && (
|
|
@@ -836,6 +879,7 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
836
879
|
roomChipX: { background: "transparent", border: "none", color: "inherit", cursor: "pointer", fontSize: 11, padding: 0, lineHeight: 1 },
|
|
837
880
|
input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
|
|
838
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" },
|
|
839
883
|
listHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 12px" },
|
|
840
884
|
h2: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: 0 },
|
|
841
885
|
count: { fontSize: 12, color: "#888", fontFamily: "monospace" },
|