codehost 0.3.1 → 0.5.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 +19 -0
- package/README.md +5 -0
- package/package.json +2 -1
- package/public/_redirects +5 -0
- package/public/install.ps1 +31 -15
- package/public/install.sh +40 -15
- package/src/cli/commands/dev.ts +5 -2
- package/src/cli/commands/serve.ts +5 -3
- package/src/cli/daemonize.ts +6 -0
- package/src/cli/git.ts +37 -17
- package/src/cli/self-update.ts +89 -0
- package/src/shared/repo.test.ts +146 -0
- package/src/shared/repo.ts +79 -12
- package/src/shared/signaling.ts +1 -1
- package/src/web/discovery.tsx +104 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
# [0.5.0](https://github.com/snomiao/codehost/compare/v0.4.0...v0.5.0) (2026-06-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* shareable workspace URLs (host-agnostic) + Share button + cross-room search ([f1db806](https://github.com/snomiao/codehost/commit/f1db806a1ec86cdc369b17ccca82057eb9526385))
|
|
7
|
+
|
|
8
|
+
# [0.4.0](https://github.com/snomiao/codehost/compare/v0.3.1...v0.4.0) (2026-06-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* normalize Windows serve path to POSIX-drive form for VS Code web ([8add2d6](https://github.com/snomiao/codehost/commit/8add2d66a6b5ae765f91386f738ad38abcb90057))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* setup.sh/ps1 installer aliases + self-update to latest on serve/setup ([93a5d64](https://github.com/snomiao/codehost/commit/93a5d64170c5851ca0f488348975dae46f467b5e))
|
|
19
|
+
|
|
1
20
|
## [0.3.1](https://github.com/snomiao/codehost/compare/v0.3.0...v0.3.1) (2026-06-07)
|
|
2
21
|
|
|
3
22
|
|
package/README.md
CHANGED
|
@@ -32,6 +32,11 @@ curl -fsSL https://codehost.dev/install.sh | sh
|
|
|
32
32
|
powershell -c "irm codehost.dev/install.ps1 | iex"
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
`/setup.sh` and `/setup.ps1` are aliases of the same script. Re-running it any
|
|
36
|
+
time **upgrades you to the latest codehost** (`bun add -g codehost@latest`), so
|
|
37
|
+
it doubles as the updater — and it bootstraps Bun by absolute path, so it won't
|
|
38
|
+
bail just because your shell hasn't picked Bun up on `PATH` yet.
|
|
39
|
+
|
|
35
40
|
Already have Bun? `bun add -g codehost && codehost setup` does the same. Or, for
|
|
36
41
|
a specific directory/token in one shot:
|
|
37
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codehost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deploy:signal": "cd worker && wrangler deploy",
|
|
21
21
|
"deploy:pages": "vite build && wrangler pages deploy dist/public --project-name codehost",
|
|
22
22
|
"start": "bun src/server.ts",
|
|
23
|
+
"test": "bun test",
|
|
23
24
|
"typecheck": "tsc --noEmit && tsc --noEmit -p src/web/tsconfig.sw.json && tsc --noEmit -p worker/tsconfig.json"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
package/public/_redirects
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
# Friendly aliases for the installer: /setup.sh and /setup.ps1 serve the same
|
|
2
|
+
# scripts as /install.*. Must precede the SPA catch-all below (first match wins).
|
|
3
|
+
/setup.sh /install.sh 200
|
|
4
|
+
/setup.ps1 /install.ps1 200
|
|
5
|
+
|
|
1
6
|
# SPA fallback: deep links like /gh/<owner>/<repo>/tree/<branch> and /dev/<path>
|
|
2
7
|
# have no static file, so serve the app and let it route client-side. Cloudflare
|
|
3
8
|
# Pages serves existing files first (/assets/*, /sw.js, /install.*), so only
|
package/public/install.ps1
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
# codehost installer — https://codehost.dev
|
|
1
|
+
# codehost installer / updater — https://codehost.dev
|
|
2
2
|
#
|
|
3
3
|
# powershell -c "irm codehost.dev/install.ps1 | iex"
|
|
4
|
+
# powershell -c "irm codehost.dev/setup.ps1 | iex" # same script, friendlier name
|
|
4
5
|
#
|
|
5
|
-
# Ensures Bun is installed, installs the `codehost` CLI globally
|
|
6
|
-
# the native WebRTC binary via Bun's lifecycle
|
|
7
|
-
# setup` to pick a token, install VS Code, and
|
|
6
|
+
# Ensures Bun is installed, installs/UPGRADES the `codehost` CLI globally to the
|
|
7
|
+
# latest release (which fetches the native WebRTC binary via Bun's lifecycle
|
|
8
|
+
# scripts), then runs `codehost setup` to pick a token, install VS Code, and
|
|
9
|
+
# start a server daemon. Safe to re-run any time — it always lands you on the
|
|
10
|
+
# newest codehost.
|
|
8
11
|
#
|
|
9
|
-
#
|
|
12
|
+
# Bun is the runtime and can't be skipped — we bootstrap it and then invoke
|
|
13
|
+
# everything by absolute path, so a not-yet-reloaded shell PATH never makes the
|
|
14
|
+
# install "fail" after Bun is actually present.
|
|
15
|
+
#
|
|
16
|
+
# Env override: $env:CODEHOST_NO_SETUP = "1" -> install/update only, skip setup.
|
|
10
17
|
|
|
11
18
|
$ErrorActionPreference = "Stop"
|
|
12
19
|
|
|
@@ -15,29 +22,38 @@ function Fail($m) { Write-Host "[codehost] $m" -ForegroundColor Red; exit 1 }
|
|
|
15
22
|
|
|
16
23
|
# Bun installs its global bin under %USERPROFILE%\.bun\bin by default.
|
|
17
24
|
$bunBin = Join-Path $env:USERPROFILE ".bun\bin"
|
|
25
|
+
$bunExe = Join-Path $bunBin "bun.exe"
|
|
18
26
|
if (Test-Path $bunBin) { $env:Path = "$bunBin;$env:Path" }
|
|
19
27
|
|
|
20
|
-
if (-not (
|
|
28
|
+
if (-not (Test-Path $bunExe)) {
|
|
21
29
|
Info "Bun not found - installing from bun.sh..."
|
|
22
30
|
Invoke-RestMethod https://bun.sh/install.ps1 | Invoke-Expression
|
|
23
31
|
if (Test-Path $bunBin) { $env:Path = "$bunBin;$env:Path" }
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
# Prefer the absolute path; fall back to a PATH lookup in case Bun honored a
|
|
35
|
+
# custom install dir. Either way we never bail just because PATH wasn't reloaded.
|
|
36
|
+
if (-not (Test-Path $bunExe)) {
|
|
37
|
+
$cmd = Get-Command bun -ErrorAction SilentlyContinue
|
|
38
|
+
if ($cmd) { $bunExe = $cmd.Source }
|
|
39
|
+
else { Fail "Bun was installed but bun.exe wasn't found at $bunBin. Open a new terminal and re-run." }
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
# `@latest` makes every re-run an upgrade, so users always end up on the newest
|
|
43
|
+
# codehost instead of a stale globally-pinned copy.
|
|
44
|
+
Info "installing the latest codehost CLI (bun add -g codehost@latest)..."
|
|
45
|
+
& $bunExe add -g codehost@latest
|
|
32
46
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
47
|
+
# Resolve the codehost shim Bun just wrote (extension varies on Windows).
|
|
48
|
+
$codehostCmd = Get-Command codehost -ErrorAction SilentlyContinue
|
|
49
|
+
if ($codehostCmd) { $codehostExe = $codehostCmd.Source }
|
|
50
|
+
elseif (Test-Path (Join-Path $bunBin "codehost.exe")) { $codehostExe = Join-Path $bunBin "codehost.exe" }
|
|
51
|
+
else { Fail "codehost installed but not found in $bunBin. Add it to PATH and run: codehost setup" }
|
|
36
52
|
|
|
37
53
|
if ($env:CODEHOST_NO_SETUP -eq "1") {
|
|
38
|
-
Info "installed. Run ``codehost setup`` in the directory you want to serve."
|
|
54
|
+
Info "installed/updated. Run ``codehost setup`` in the directory you want to serve."
|
|
39
55
|
exit 0
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
Info "running ``codehost setup``..."
|
|
43
|
-
|
|
59
|
+
& $codehostExe setup
|
package/public/install.sh
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# codehost installer — https://codehost.dev
|
|
2
|
+
# codehost installer / updater — https://codehost.dev
|
|
3
3
|
#
|
|
4
4
|
# curl -fsSL https://codehost.dev/install.sh | sh
|
|
5
|
+
# curl -fsSL https://codehost.dev/setup.sh | sh # same script, friendlier name
|
|
5
6
|
#
|
|
6
|
-
# Ensures Bun is installed, installs the `codehost` CLI globally
|
|
7
|
-
# the native WebRTC binary via Bun's lifecycle
|
|
8
|
-
# setup` to pick a token, install VS Code, and
|
|
7
|
+
# Ensures Bun is installed, installs/UPGRADES the `codehost` CLI globally to the
|
|
8
|
+
# latest release (which fetches the native WebRTC binary via Bun's lifecycle
|
|
9
|
+
# scripts), then runs `codehost setup` to pick a token, install VS Code, and
|
|
10
|
+
# start a server daemon. Safe to re-run any time — it always lands you on the
|
|
11
|
+
# newest codehost.
|
|
12
|
+
#
|
|
13
|
+
# Bun is the runtime (the CLI is TypeScript run by Bun + a native addon), so it
|
|
14
|
+
# can't be skipped — but we bootstrap it and then invoke everything by absolute
|
|
15
|
+
# path, so a not-yet-reloaded shell PATH never makes the install "fail" after
|
|
16
|
+
# Bun is actually present.
|
|
9
17
|
#
|
|
10
18
|
# Env overrides:
|
|
11
|
-
# CODEHOST_NO_SETUP=1 install only; don't run `codehost setup`
|
|
19
|
+
# CODEHOST_NO_SETUP=1 install/update only; don't run `codehost setup`
|
|
12
20
|
set -eu
|
|
13
21
|
|
|
14
22
|
info() { printf '\033[1;36m[codehost]\033[0m %s\n' "$1"; }
|
|
@@ -20,7 +28,18 @@ BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
|
|
|
20
28
|
export BUN_INSTALL
|
|
21
29
|
export PATH="$BUN_INSTALL/bin:$PATH"
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
# Resolve a usable bun binary: one already on PATH, or the managed install.
|
|
32
|
+
# Echoes the absolute path (empty if none) so callers can invoke it directly.
|
|
33
|
+
bun_bin() {
|
|
34
|
+
if command -v bun >/dev/null 2>&1; then
|
|
35
|
+
command -v bun
|
|
36
|
+
elif [ -x "$BUN_INSTALL/bin/bun" ]; then
|
|
37
|
+
printf '%s\n' "$BUN_INSTALL/bin/bun"
|
|
38
|
+
fi
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
BUN="$(bun_bin)"
|
|
42
|
+
if [ -z "$BUN" ]; then
|
|
24
43
|
info "Bun not found — installing from bun.sh…"
|
|
25
44
|
if command -v curl >/dev/null 2>&1; then
|
|
26
45
|
curl -fsSL https://bun.sh/install | bash
|
|
@@ -30,26 +49,32 @@ if ! command -v bun >/dev/null 2>&1; then
|
|
|
30
49
|
err "need curl or wget to install Bun. Install one and re-run."
|
|
31
50
|
exit 1
|
|
32
51
|
fi
|
|
33
|
-
|
|
52
|
+
BUN="$(bun_bin)"
|
|
34
53
|
fi
|
|
35
54
|
|
|
36
|
-
if
|
|
37
|
-
err "Bun
|
|
55
|
+
if [ -z "$BUN" ]; then
|
|
56
|
+
err "Bun was installed but its binary isn't at $BUN_INSTALL/bin/bun. Set BUN_INSTALL to its location and re-run."
|
|
38
57
|
exit 1
|
|
39
58
|
fi
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
|
|
60
|
+
# `@latest` makes every re-run an upgrade, so users always end up on the newest
|
|
61
|
+
# codehost instead of a stale globally-pinned copy.
|
|
62
|
+
info "installing the latest codehost CLI ($BUN add -g codehost@latest)…"
|
|
63
|
+
"$BUN" add -g codehost@latest
|
|
43
64
|
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
CODEHOST="$BUN_INSTALL/bin/codehost"
|
|
66
|
+
if [ ! -x "$CODEHOST" ]; then
|
|
67
|
+
CODEHOST="$(command -v codehost || true)"
|
|
68
|
+
fi
|
|
69
|
+
if [ -z "$CODEHOST" ] || [ ! -x "$CODEHOST" ]; then
|
|
70
|
+
err "codehost installed but not found under $BUN_INSTALL/bin. Add it to PATH and run: codehost setup"
|
|
46
71
|
exit 1
|
|
47
72
|
fi
|
|
48
73
|
|
|
49
74
|
if [ "${CODEHOST_NO_SETUP:-}" = "1" ]; then
|
|
50
|
-
info "installed. Run \`codehost setup\` in the directory you want to serve."
|
|
75
|
+
info "installed/updated. Run \`codehost setup\` in the directory you want to serve."
|
|
51
76
|
exit 0
|
|
52
77
|
fi
|
|
53
78
|
|
|
54
79
|
info "running \`codehost setup\`…"
|
|
55
|
-
exec
|
|
80
|
+
exec "$CODEHOST" setup
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { announceConnect } from "../open-url";
|
|
|
8
8
|
import { runServer } from "../run-server";
|
|
9
9
|
import { launchVscode } from "../vscode";
|
|
10
10
|
import { repoIdentity } from "../git";
|
|
11
|
+
import { toPosixPath } from "../../shared/repo";
|
|
11
12
|
import { DEFAULT_SIGNAL_URL } from "./serve";
|
|
12
13
|
|
|
13
14
|
interface DevArgs {
|
|
@@ -22,7 +23,7 @@ interface DevArgs {
|
|
|
22
23
|
export const devCommand: CommandModule<{}, DevArgs> = {
|
|
23
24
|
command: "dev [dir]",
|
|
24
25
|
describe:
|
|
25
|
-
"Serve a single folder over WebRTC; open it at codehost.dev/dev/<path> (or /gh/<owner>/<repo>
|
|
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
27
|
builder: (y) =>
|
|
27
28
|
y
|
|
28
29
|
.positional("dir", {
|
|
@@ -85,7 +86,9 @@ export const devCommand: CommandModule<{}, DevArgs> = {
|
|
|
85
86
|
const id = repoIdentity(dir);
|
|
86
87
|
const meta: PeerMeta = {
|
|
87
88
|
name: argv.name ?? host,
|
|
88
|
-
|
|
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.
|
|
91
|
+
cwd: toPosixPath(dir),
|
|
89
92
|
host,
|
|
90
93
|
kind: "repo",
|
|
91
94
|
repo: id.repo,
|
|
@@ -2,7 +2,7 @@ import { hostname } from "node:os";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import type { CommandModule } from "yargs";
|
|
4
4
|
import type { PeerMeta } from "../../shared/signaling";
|
|
5
|
-
import { DEFAULT_LAYOUT } from "../../shared/repo";
|
|
5
|
+
import { DEFAULT_LAYOUT, toPosixPath } from "../../shared/repo";
|
|
6
6
|
import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
|
|
7
7
|
import { launchServeDaemon } from "../daemonize";
|
|
8
8
|
import { announceConnect } from "../open-url";
|
|
@@ -23,7 +23,7 @@ interface ServeArgs {
|
|
|
23
23
|
export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
24
24
|
command: "serve [dir]",
|
|
25
25
|
describe:
|
|
26
|
-
"Serve a workspace root over WebRTC; repos under it open via codehost.dev/gh/<owner>/<repo>",
|
|
26
|
+
"Serve a workspace root over WebRTC; repos under it open via codehost.dev/gh/<owner>/<repo> (or /git/<host>/<owner>/<repo>)",
|
|
27
27
|
builder: (y) =>
|
|
28
28
|
y
|
|
29
29
|
.positional("dir", {
|
|
@@ -87,7 +87,9 @@ 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
|
+
// POSIX-drive form for the browser ?folder= URI (C:\ws -> /c/ws); the real
|
|
91
|
+
// OS path `dir` is still what we spawn VS Code in.
|
|
92
|
+
cwd: toPosixPath(dir),
|
|
91
93
|
host,
|
|
92
94
|
kind: "root",
|
|
93
95
|
layout: DEFAULT_LAYOUT,
|
package/src/cli/daemonize.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { daemonName, startDaemon } from "./oxmgr";
|
|
2
|
+
import { selfUpdate } from "./self-update";
|
|
2
3
|
|
|
3
4
|
export interface ServeDaemonOptions {
|
|
4
5
|
/** Subcommand to re-launch under oxmgr. */
|
|
@@ -31,6 +32,11 @@ export interface ServeDaemonResult {
|
|
|
31
32
|
* the shell and restarts on failure. Shared by `serve -d` and `setup`.
|
|
32
33
|
*/
|
|
33
34
|
export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<ServeDaemonResult> {
|
|
35
|
+
// Upgrade the global install (if that's how we're running) before spawning, so
|
|
36
|
+
// the fresh daemon runs the latest code. startDaemon does delete+start, so a
|
|
37
|
+
// re-launch replaces any live daemon with the updated one. Non-fatal.
|
|
38
|
+
await selfUpdate();
|
|
39
|
+
|
|
34
40
|
const label = opts.name ?? opts.dir.split("/").pop() ?? opts.host;
|
|
35
41
|
const name = daemonName(label);
|
|
36
42
|
const command = buildForegroundCommand(opts);
|
package/src/cli/git.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
|
|
3
|
-
// Derive a normalized repo identity ("
|
|
4
|
-
// working tree, so a `codehost dev` daemon can be addressed by
|
|
5
|
-
// deep links
|
|
3
|
+
// Derive a normalized, host-agnostic repo identity ("<host>/<owner>/<repo>") +
|
|
4
|
+
// branch from a git working tree, so a `codehost dev` daemon can be addressed by
|
|
5
|
+
// deep links (/gh/<owner>/<repo> for GitHub, /git/<host>/<owner>/<repo> for any
|
|
6
|
+
// other host). Best-effort: returns undefined fields off git / off a recognized
|
|
7
|
+
// remote.
|
|
6
8
|
|
|
7
9
|
export interface RepoIdentity {
|
|
8
|
-
/** Normalized identity, e.g. "
|
|
10
|
+
/** Normalized identity, e.g. "github.com/snomiao/codehost". */
|
|
9
11
|
repo?: string;
|
|
10
12
|
/** Current branch, e.g. "main". */
|
|
11
13
|
branch?: string;
|
|
@@ -24,27 +26,45 @@ export function repoIdentity(dir: string): RepoIdentity {
|
|
|
24
26
|
const branch =
|
|
25
27
|
git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]) || git(dir, ["symbolic-ref", "--short", "HEAD"]);
|
|
26
28
|
return {
|
|
27
|
-
repo:
|
|
29
|
+
repo: parseGitRemote(remote),
|
|
28
30
|
branch: branch && branch !== "HEAD" ? branch : undefined,
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
|
-
* Parse
|
|
35
|
+
* Parse any git remote URL into a host-agnostic "<host>/<owner>/<repo>" key
|
|
36
|
+
* (lowercased host, no trailing `.git`). Handles:
|
|
34
37
|
* https://github.com/owner/repo(.git)
|
|
35
|
-
* git@
|
|
36
|
-
* ssh://git@
|
|
37
|
-
*
|
|
38
|
+
* git@gitlab.com:owner/repo(.git) (scp-like)
|
|
39
|
+
* ssh://git@git.company.com:2222/owner/repo (with optional user/port)
|
|
40
|
+
* git://host/owner/repo
|
|
41
|
+
* Returns undefined for unparseable remotes. Only the first two path segments
|
|
42
|
+
* (owner/repo) are used; deeper paths (e.g. GitLab subgroups) collapse to those.
|
|
38
43
|
*/
|
|
39
|
-
export function
|
|
40
|
-
|
|
44
|
+
export function parseGitRemote(url: string): string | undefined {
|
|
45
|
+
let u = url.trim();
|
|
41
46
|
if (!u) return undefined;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
u = u.replace(/\.git\/?$/i, "");
|
|
48
|
+
|
|
49
|
+
let host: string | undefined;
|
|
50
|
+
let path: string | undefined;
|
|
51
|
+
|
|
52
|
+
// scp-like syntax: [user@]host:owner/repo (no scheme).
|
|
53
|
+
const scp = u.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
|
|
54
|
+
// URL syntax: scheme://[user@]host[:port]/owner/repo
|
|
55
|
+
const uri = u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i);
|
|
56
|
+
if (uri) {
|
|
57
|
+
host = uri[1];
|
|
58
|
+
path = uri[2];
|
|
59
|
+
} else if (scp && !u.includes("://")) {
|
|
60
|
+
host = scp[1];
|
|
61
|
+
path = scp[2];
|
|
62
|
+
}
|
|
63
|
+
if (!host || !path) return undefined;
|
|
64
|
+
|
|
65
|
+
const segs = path.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
66
|
+
if (segs.length < 2) return undefined;
|
|
67
|
+
return `${host.toLowerCase()}/${segs[0]}/${segs[1]}`;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
function git(dir: string, args: string[]): string {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Best-effort self-update, run right before a daemon is (re)spawned (see the top
|
|
6
|
+
// of launchServeDaemon). Re-launching `setup` or `serve -d` replaces the managed
|
|
7
|
+
// daemon (oxmgr delete + start), so upgrading the global package here means the
|
|
8
|
+
// fresh daemon process runs the new code — there's no in-place restart, so this
|
|
9
|
+
// never trips oxmgr's `on-failure` policy and never drops a live session
|
|
10
|
+
// mid-flight. A days-old daemon only updates on the next launcher run.
|
|
11
|
+
|
|
12
|
+
const REGISTRY_LATEST = "https://registry.npmjs.org/codehost/latest";
|
|
13
|
+
const FETCH_TIMEOUT_MS = 5_000;
|
|
14
|
+
const INSTALL_TIMEOUT_MS = 120_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Upgrade the global `codehost` to the latest published version if we're running
|
|
18
|
+
* from a real global install (`bun add -g` / `npm i -g`). A dev checkout or a
|
|
19
|
+
* `bunx` run is left untouched so we never clobber the user's global copy.
|
|
20
|
+
*
|
|
21
|
+
* Fully non-fatal: an offline registry, a slow network, or a failed `bun add`
|
|
22
|
+
* must never stop the server from launching — every failure path just logs and
|
|
23
|
+
* returns. Disable entirely with CODEHOST_NO_SELF_UPDATE=1.
|
|
24
|
+
*/
|
|
25
|
+
export async function selfUpdate(): Promise<void> {
|
|
26
|
+
if (process.env.CODEHOST_NO_SELF_UPDATE === "1") return;
|
|
27
|
+
try {
|
|
28
|
+
if (!isGlobalInstall()) return; // dev checkout or bunx: don't touch the global
|
|
29
|
+
|
|
30
|
+
const installed = currentVersion();
|
|
31
|
+
if (!installed) return; // can't locate our own package.json — don't risk it
|
|
32
|
+
|
|
33
|
+
const latest = await fetchLatest();
|
|
34
|
+
if (!latest || latest === installed) return;
|
|
35
|
+
|
|
36
|
+
console.log(`[codehost] updating codehost ${installed} → ${latest}…`);
|
|
37
|
+
const r = spawnSync(process.execPath, ["add", "-g", `codehost@${latest}`], {
|
|
38
|
+
stdio: "inherit",
|
|
39
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
40
|
+
});
|
|
41
|
+
if (r.status === 0) {
|
|
42
|
+
console.log(`[codehost] updated to ${latest}; the new daemon will run it.`);
|
|
43
|
+
} else {
|
|
44
|
+
console.warn(`[codehost] self-update to ${latest} failed; continuing on ${installed}.`);
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.warn(`[codehost] self-update skipped: ${(err as Error).message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Version from our own package.json (src/cli → package root is two up). */
|
|
52
|
+
function currentVersion(): string | null {
|
|
53
|
+
try {
|
|
54
|
+
const pkg = JSON.parse(
|
|
55
|
+
readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
|
|
56
|
+
) as { version?: unknown };
|
|
57
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* True only when this file lives in a well-known *global* package directory we
|
|
65
|
+
* own. Conservative on purpose: anything unrecognized (dev repo, bunx cache,
|
|
66
|
+
* unusual layout) returns false so we skip rather than risk overwriting the
|
|
67
|
+
* wrong tree.
|
|
68
|
+
*/
|
|
69
|
+
function isGlobalInstall(): boolean {
|
|
70
|
+
const dir = import.meta.dir.replace(/\\/g, "/");
|
|
71
|
+
return (
|
|
72
|
+
dir.includes("/.bun/install/global/node_modules/codehost/") || // bun add -g
|
|
73
|
+
dir.includes("/lib/node_modules/codehost/") // npm i -g (unix default prefix)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Latest published version per the npm registry, or null on any failure. */
|
|
78
|
+
async function fetchLatest(): Promise<string | null> {
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(REGISTRY_LATEST, {
|
|
81
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) return null;
|
|
84
|
+
const data = (await res.json()) as { version?: unknown };
|
|
85
|
+
return typeof data.version === "string" ? data.version : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseDeepLink, pickRoomMatch, repoKey, shareableDeepLink, toPosixPath } from "./repo";
|
|
3
|
+
import { parseGitRemote } from "../cli/git";
|
|
4
|
+
|
|
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");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("lowercases the drive letter", () => {
|
|
12
|
+
expect(toPosixPath("D:\\foo")).toBe("/d/foo");
|
|
13
|
+
expect(toPosixPath("c:\\ws")).toBe("/c/ws");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("drive root collapses to /<letter> (no trailing slash)", () => {
|
|
17
|
+
expect(toPosixPath("C:\\")).toBe("/c");
|
|
18
|
+
expect(toPosixPath("C:")).toBe("/c");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("forward-slash Windows paths normalize too", () => {
|
|
22
|
+
expect(toPosixPath("C:/ws")).toBe("/c/ws");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("POSIX absolute paths are unchanged (mac/linux not broken)", () => {
|
|
26
|
+
expect(toPosixPath("/Users/sno/ws")).toBe("/Users/sno/ws");
|
|
27
|
+
expect(toPosixPath("/home/x/proj")).toBe("/home/x/proj");
|
|
28
|
+
expect(toPosixPath("/")).toBe("/");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("already-normalized POSIX-drive path is a no-op", () => {
|
|
32
|
+
expect(toPosixPath("/c/ws")).toBe("/c/ws");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("trims trailing backslashes/slashes on a drive path", () => {
|
|
36
|
+
expect(toPosixPath("C:\\ws\\")).toBe("/c/ws");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("parseGitRemote", () => {
|
|
41
|
+
test("GitHub https / ssh / git@ -> host-agnostic key", () => {
|
|
42
|
+
expect(parseGitRemote("https://github.com/snomiao/codehost.git")).toBe("github.com/snomiao/codehost");
|
|
43
|
+
expect(parseGitRemote("git@github.com:snomiao/codehost.git")).toBe("github.com/snomiao/codehost");
|
|
44
|
+
expect(parseGitRemote("ssh://git@github.com/snomiao/codehost")).toBe("github.com/snomiao/codehost");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("other hosts work too (gitlab, bitbucket, self-hosted with port)", () => {
|
|
48
|
+
expect(parseGitRemote("https://gitlab.com/group/proj.git")).toBe("gitlab.com/group/proj");
|
|
49
|
+
expect(parseGitRemote("git@bitbucket.org:team/repo.git")).toBe("bitbucket.org/team/repo");
|
|
50
|
+
expect(parseGitRemote("ssh://git@git.company.com:2222/team/svc.git")).toBe("git.company.com/team/svc");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("lowercases host, strips .git, ignores deeper path segments", () => {
|
|
54
|
+
expect(parseGitRemote("https://GitHub.com/Owner/Repo")).toBe("github.com/Owner/Repo");
|
|
55
|
+
expect(parseGitRemote("https://gitlab.com/group/sub/proj.git")).toBe("gitlab.com/group/sub");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns undefined for empty / unparseable / single-segment remotes", () => {
|
|
59
|
+
expect(parseGitRemote("")).toBeUndefined();
|
|
60
|
+
expect(parseGitRemote("not a url")).toBeUndefined();
|
|
61
|
+
expect(parseGitRemote("https://github.com/onlyowner")).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("parseDeepLink + repoKey round-trip", () => {
|
|
66
|
+
test("/gh/owner/repo -> github.com key", () => {
|
|
67
|
+
const dl = parseDeepLink("/gh/snomiao/codehost");
|
|
68
|
+
expect(dl?.type).toBe("repo");
|
|
69
|
+
if (dl?.type === "repo") {
|
|
70
|
+
expect(repoKey(dl.target)).toBe("github.com/snomiao/codehost");
|
|
71
|
+
expect(dl.target.branch).toBeUndefined();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("/gh/owner/repo/tree/branch keeps the branch (slashes allowed)", () => {
|
|
76
|
+
const dl = parseDeepLink("/gh/snomiao/codehost/tree/feat/x");
|
|
77
|
+
expect(dl?.type === "repo" && dl.target.branch).toBe("feat/x");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("/git/<host>/owner/repo -> that host's key", () => {
|
|
81
|
+
const dl = parseDeepLink("/git/gitlab.com/group/proj/tree/dev");
|
|
82
|
+
expect(dl?.type).toBe("repo");
|
|
83
|
+
if (dl?.type === "repo") {
|
|
84
|
+
expect(repoKey(dl.target)).toBe("gitlab.com/group/proj");
|
|
85
|
+
expect(dl.target.branch).toBe("dev");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
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");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("non-deep-link -> null", () => {
|
|
95
|
+
expect(parseDeepLink("/")).toBeNull();
|
|
96
|
+
expect(parseDeepLink("/settings")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("shareableDeepLink", () => {
|
|
101
|
+
test("GitHub repo -> /gh sugar", () => {
|
|
102
|
+
expect(shareableDeepLink({ repo: "github.com/snomiao/codehost", branch: "main" })).toBe(
|
|
103
|
+
"/gh/snomiao/codehost/tree/main",
|
|
104
|
+
);
|
|
105
|
+
expect(shareableDeepLink({ repo: "github.com/snomiao/codehost" })).toBe("/gh/snomiao/codehost");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("other host -> /git/<host>/...", () => {
|
|
109
|
+
expect(shareableDeepLink({ repo: "gitlab.com/group/proj", branch: "dev" })).toBe(
|
|
110
|
+
"/git/gitlab.com/group/proj/tree/dev",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("no repo -> /dev/<folder>", () => {
|
|
115
|
+
expect(shareableDeepLink({ folder: "/c/ws" })).toBe("/dev/c/ws");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("nothing addressable -> null", () => {
|
|
119
|
+
expect(shareableDeepLink({})).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("round-trips: shareableDeepLink output parses back to the same key", () => {
|
|
123
|
+
const path = shareableDeepLink({ repo: "gitlab.com/group/proj", branch: "dev" })!;
|
|
124
|
+
const dl = parseDeepLink(path);
|
|
125
|
+
expect(dl?.type === "repo" && repoKey(dl.target)).toBe("gitlab.com/group/proj");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("pickRoomMatch (cross-room ranking)", () => {
|
|
130
|
+
const exact = { token: "tA", resolution: { peerId: "p1" } }; // no folder = exact
|
|
131
|
+
const root = { token: "tB", resolution: { peerId: "p2", folder: "/work/me/repo" } };
|
|
132
|
+
|
|
133
|
+
test("exact match (no folder) beats a root fallback, regardless of order", () => {
|
|
134
|
+
expect(pickRoomMatch([root, exact])?.token).toBe("tA");
|
|
135
|
+
expect(pickRoomMatch([exact, root])?.token).toBe("tA");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("only root fallbacks -> first root", () => {
|
|
139
|
+
const root2 = { token: "tC", resolution: { peerId: "p3", folder: "/x" } };
|
|
140
|
+
expect(pickRoomMatch([root, root2])?.token).toBe("tB");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("no matches -> null", () => {
|
|
144
|
+
expect(pickRoomMatch([])).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
package/src/shared/repo.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
// Shared helpers for
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Shared helpers for git-shaped deep links and matching them to a daemon. Used
|
|
2
|
+
// by the web resolver (src/web) and conceptually mirrors the daemon's repo
|
|
3
|
+
// identity (src/cli/git.ts). Host-agnostic: GitHub gets the short `/gh/...`
|
|
4
|
+
// form, any other host uses `/git/<host>/...`.
|
|
4
5
|
|
|
5
6
|
import type { PeerInfo, PeerMeta } from "./signaling";
|
|
6
7
|
|
|
7
8
|
export const DEFAULT_LAYOUT = "{owner}/{repo}/tree/{branch}";
|
|
9
|
+
export const GITHUB_HOST = "github.com";
|
|
8
10
|
|
|
9
11
|
export interface RepoTarget {
|
|
10
|
-
|
|
12
|
+
/** Git host, e.g. "github.com" or "gitlab.com". */
|
|
13
|
+
host: string;
|
|
11
14
|
owner: string;
|
|
12
15
|
name: string;
|
|
13
16
|
/** Branch from the deep link, if present. */
|
|
@@ -26,10 +29,10 @@ export type DeepLink =
|
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Parse a deep-link pathname:
|
|
29
|
-
* /gh/<owner>/<repo>
|
|
30
|
-
* /
|
|
31
|
-
* /dev/<fs-path>
|
|
32
|
-
* Anything else -> null (normal app).
|
|
32
|
+
* /gh/<owner>/<repo>(/tree/<branch>) -> GitHub repo target
|
|
33
|
+
* /git/<host>/<owner>/<repo>(/tree/<branch>) -> any-host repo target
|
|
34
|
+
* /dev/<fs-path> -> direct folder mount
|
|
35
|
+
* Branch may contain slashes. Anything else -> null (normal app).
|
|
33
36
|
*/
|
|
34
37
|
export function parseDeepLink(pathname: string): DeepLink {
|
|
35
38
|
const clean = pathname.replace(/\/+$/, "");
|
|
@@ -37,7 +40,14 @@ export function parseDeepLink(pathname: string): DeepLink {
|
|
|
37
40
|
if (gh) {
|
|
38
41
|
return {
|
|
39
42
|
type: "repo",
|
|
40
|
-
target: {
|
|
43
|
+
target: { host: GITHUB_HOST, owner: gh[1], name: gh[2], branch: gh[3] },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const git = clean.match(/^\/git\/([^/]+)\/([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/);
|
|
47
|
+
if (git) {
|
|
48
|
+
return {
|
|
49
|
+
type: "repo",
|
|
50
|
+
target: { host: git[1].toLowerCase(), owner: git[2], name: git[3], branch: git[4] },
|
|
41
51
|
};
|
|
42
52
|
}
|
|
43
53
|
const dev = clean.match(/^\/dev\/(.+)$/);
|
|
@@ -47,9 +57,28 @@ export function parseDeepLink(pathname: string): DeepLink {
|
|
|
47
57
|
return null;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
/** Normalized repo key, e.g. "
|
|
51
|
-
export function repoKey(t: Pick<RepoTarget, "owner" | "name">): string {
|
|
52
|
-
return
|
|
60
|
+
/** Normalized repo key, e.g. "github.com/owner/repo" — matches PeerMeta.repo. */
|
|
61
|
+
export function repoKey(t: Pick<RepoTarget, "host" | "owner" | "name">): string {
|
|
62
|
+
return `${t.host}/${t.owner}/${t.name}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
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.
|
|
72
|
+
*/
|
|
73
|
+
export function toPosixPath(p: string): string {
|
|
74
|
+
const drive = /^([A-Za-z]):(?:[\\/](.*))?$/.exec(p);
|
|
75
|
+
if (drive) {
|
|
76
|
+
const letter = drive[1].toLowerCase();
|
|
77
|
+
const rest = (drive[2] ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
78
|
+
return rest ? `/${letter}/${rest}` : `/${letter}`;
|
|
79
|
+
}
|
|
80
|
+
// Already POSIX (or a relative path): just unify any stray backslashes.
|
|
81
|
+
return p.replace(/\\/g, "/");
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
/** Fill a layout template from a repo target (default branch -> "main"). */
|
|
@@ -60,6 +89,26 @@ export function fillLayout(layout: string, t: RepoTarget): string {
|
|
|
60
89
|
.replace(/\{branch\}/g, t.branch || "main");
|
|
61
90
|
}
|
|
62
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Shareable deep-link pathname for a connected workspace. A git-identified
|
|
94
|
+
* server renders `/gh/<owner>/<repo>` for GitHub or `/git/<host>/<owner>/<repo>`
|
|
95
|
+
* for any other host (with `/tree/<branch>` when known); a non-git workspace is
|
|
96
|
+
* addressed by its opened folder as a `/dev/<path>` mount. Round-trips through
|
|
97
|
+
* parseDeepLink + resolve{Repo,Dev}Target so another room member opening it
|
|
98
|
+
* lands here. Returns null when there's nothing addressable.
|
|
99
|
+
*/
|
|
100
|
+
export function shareableDeepLink(opts: { repo?: string; branch?: string; folder?: string }): string | null {
|
|
101
|
+
if (opts.repo) {
|
|
102
|
+
const [host, owner, name] = opts.repo.split("/");
|
|
103
|
+
if (host && owner && name) {
|
|
104
|
+
const base = host === GITHUB_HOST ? `/gh/${owner}/${name}` : `/git/${host}/${owner}/${name}`;
|
|
105
|
+
return opts.branch ? `${base}/tree/${opts.branch}` : base;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (opts.folder) return `/dev/${opts.folder.replace(/^\/+/, "")}`;
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
63
112
|
export interface Resolution {
|
|
64
113
|
peerId: string;
|
|
65
114
|
/** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
|
|
@@ -93,6 +142,24 @@ export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolu
|
|
|
93
142
|
return hit ? { peerId: hit.peerId } : null;
|
|
94
143
|
}
|
|
95
144
|
|
|
145
|
+
/** A candidate room (its token) plus how the deep link resolved within it. */
|
|
146
|
+
export interface RoomMatch {
|
|
147
|
+
token: string;
|
|
148
|
+
resolution: Resolution;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Rank matches found while searching multiple rooms for a token-less deep link.
|
|
153
|
+
* An *exact* match (a server that genuinely serves this repo/folder — no
|
|
154
|
+
* synthesized `folder`) beats a *root fallback* (a root daemon that would open
|
|
155
|
+
* the repo as a subfolder, which `resolveRepoTarget` returns for ANY repo link).
|
|
156
|
+
* Without this preference, first-responder-wins could pick an unrelated room
|
|
157
|
+
* that merely has a root server. Returns null when there are no matches.
|
|
158
|
+
*/
|
|
159
|
+
export function pickRoomMatch(matches: RoomMatch[]): RoomMatch | null {
|
|
160
|
+
return matches.find((m) => !m.resolution.folder) ?? matches[0] ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
96
163
|
function branchOk(meta: PeerMeta, target: RepoTarget): boolean {
|
|
97
164
|
// No branch requested, or the server doesn't report one -> accept; else exact.
|
|
98
165
|
if (!target.branch || !meta.branch) return true;
|
package/src/shared/signaling.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface PeerMeta {
|
|
|
18
18
|
* Absent is treated as "repo" for backward compatibility.
|
|
19
19
|
*/
|
|
20
20
|
kind?: "repo" | "root";
|
|
21
|
-
/** repo kind:
|
|
21
|
+
/** repo kind: host-agnostic identity, e.g. "github.com/snomiao/codehost". */
|
|
22
22
|
repo?: string;
|
|
23
23
|
/** repo kind: current branch, e.g. "main". */
|
|
24
24
|
branch?: string;
|
package/src/web/discovery.tsx
CHANGED
|
@@ -9,12 +9,15 @@ import { registerTunnelHost } from "./tunnel-host";
|
|
|
9
9
|
import { connBroker } from "./conn-broker";
|
|
10
10
|
import {
|
|
11
11
|
type DeepLink,
|
|
12
|
+
type RoomMatch,
|
|
12
13
|
parseDeepLink,
|
|
14
|
+
pickRoomMatch,
|
|
13
15
|
repoKey,
|
|
14
16
|
resolveDevTarget,
|
|
15
17
|
resolveRepoTarget,
|
|
18
|
+
shareableDeepLink,
|
|
16
19
|
} from "../shared/repo";
|
|
17
|
-
import { addRoom, historyFor, recordConnection } from "./history";
|
|
20
|
+
import { addRoom, getRooms, historyFor, recordConnection } from "./history";
|
|
18
21
|
|
|
19
22
|
const TOKEN_KEY = "codehost.token";
|
|
20
23
|
|
|
@@ -46,6 +49,50 @@ function folderQuery(folder?: string): string {
|
|
|
46
49
|
return folder ? `?folder=${encodeURIComponent(folder)}` : "";
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Find which of the user's saved rooms hosts a server matching a token-less deep
|
|
54
|
+
* link. Opens a short-lived viewer connection to each candidate room in
|
|
55
|
+
* parallel. An *exact* match (a server that truly serves this workspace) wins
|
|
56
|
+
* immediately; *root-fallback* matches (any room with a root daemon, which
|
|
57
|
+
* `resolveRepoTarget` returns for ANY repo link) are only chosen at the timeout,
|
|
58
|
+
* via `pickRoomMatch`, so an unrelated room with a root server can't steal the
|
|
59
|
+
* link. Resolves to the winning room's token (or null on no match). All temp
|
|
60
|
+
* clients are closed.
|
|
61
|
+
*/
|
|
62
|
+
function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000): Promise<string | null> {
|
|
63
|
+
if (!dl || tokens.length === 0) return Promise.resolve(null);
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const clients: SignalingClient[] = [];
|
|
66
|
+
const fallbacks: RoomMatch[] = [];
|
|
67
|
+
let done = false;
|
|
68
|
+
const finish = (tok: string | null) => {
|
|
69
|
+
if (done) return;
|
|
70
|
+
done = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
clients.forEach((c) => c.close());
|
|
73
|
+
resolve(tok);
|
|
74
|
+
};
|
|
75
|
+
const timer = setTimeout(() => finish(pickRoomMatch(fallbacks)?.token ?? null), timeoutMs);
|
|
76
|
+
for (const tok of tokens) {
|
|
77
|
+
const client = new SignalingClient({
|
|
78
|
+
url: getSignalUrl(),
|
|
79
|
+
token: tok,
|
|
80
|
+
role: "viewer",
|
|
81
|
+
onPeers: (peers) => {
|
|
82
|
+
const servers = peers.filter((p) => p.role === "server");
|
|
83
|
+
const res =
|
|
84
|
+
dl.type === "repo" ? resolveRepoTarget(servers, dl.target) : resolveDevTarget(servers, dl.target);
|
|
85
|
+
if (!res) return;
|
|
86
|
+
if (!res.folder) finish(tok); // exact match — take it now
|
|
87
|
+
else if (!fallbacks.some((f) => f.token === tok)) fallbacks.push({ token: tok, resolution: res });
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
clients.push(client);
|
|
91
|
+
client.connect();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
49
96
|
export function Discovery() {
|
|
50
97
|
const [token, setToken] = useState(() => {
|
|
51
98
|
const fromHash = tokenFromHash();
|
|
@@ -80,6 +127,11 @@ export function Discovery() {
|
|
|
80
127
|
const activeFolderRef = useRef<string | undefined>(undefined);
|
|
81
128
|
const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
|
|
82
129
|
|
|
130
|
+
// Shareable deep-link pathname for the live connection (drives the address bar
|
|
131
|
+
// and the Share button); transient "copied" flag for the button.
|
|
132
|
+
const sharePathRef = useRef<string | null>(null);
|
|
133
|
+
const [copied, setCopied] = useState(false);
|
|
134
|
+
|
|
83
135
|
// Register the Service Worker + connection broker once. The broker shares one
|
|
84
136
|
// WebRTC connection per server across tabs; on owner failover it asks us to
|
|
85
137
|
// reload the iframe so it reconnects through the new owner.
|
|
@@ -102,12 +154,22 @@ export function Discovery() {
|
|
|
102
154
|
}
|
|
103
155
|
}
|
|
104
156
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
157
|
+
// Resolve a token-less deep link to a room: first the room that last served
|
|
158
|
+
// this repo, otherwise search all saved rooms for a live server that hosts
|
|
159
|
+
// this workspace and adopt it. Skipped when the link already carries a token.
|
|
107
160
|
const dl = deepLinkRef.current;
|
|
108
|
-
if (dl
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
161
|
+
if (dl && !(urlToken && validateToken(urlToken).ok)) {
|
|
162
|
+
const histToken = dl.type === "repo" ? historyFor(repoKey(dl.target))?.token : undefined;
|
|
163
|
+
if (histToken) {
|
|
164
|
+
setToken(histToken);
|
|
165
|
+
} else {
|
|
166
|
+
const rooms = getRooms();
|
|
167
|
+
if (rooms.length) {
|
|
168
|
+
void findRoomForDeepLink(dl, rooms).then((tok) => {
|
|
169
|
+
if (tok) setToken(tok);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
111
173
|
}
|
|
112
174
|
}, []);
|
|
113
175
|
|
|
@@ -212,11 +274,37 @@ export function Discovery() {
|
|
|
212
274
|
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
213
275
|
setResolving(null);
|
|
214
276
|
recordConnect(server, openFolder);
|
|
277
|
+
updateAddressBar(server, openFolder);
|
|
215
278
|
} catch {
|
|
216
279
|
setConnState("failed");
|
|
217
280
|
}
|
|
218
281
|
}
|
|
219
282
|
|
|
283
|
+
// Reflect the live connection in the address bar as a clean, shareable deep
|
|
284
|
+
// link (no token — Share adds that). If we arrived via a deep link, keep its
|
|
285
|
+
// pathname; otherwise derive one from the server's repo identity or folder.
|
|
286
|
+
function updateAddressBar(server: PeerInfo, folder?: string) {
|
|
287
|
+
const path = deepLinkRef.current
|
|
288
|
+
? window.location.pathname
|
|
289
|
+
: shareableDeepLink({ repo: server.meta?.repo, branch: server.meta?.branch, folder });
|
|
290
|
+
if (!path) return;
|
|
291
|
+
sharePathRef.current = path;
|
|
292
|
+
if (path !== window.location.pathname) history.replaceState(null, "", path);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function shareLink() {
|
|
296
|
+
const path = sharePathRef.current ?? window.location.pathname;
|
|
297
|
+
const url = `${window.location.origin}${path}#t=${encodeURIComponent(token)}`;
|
|
298
|
+
try {
|
|
299
|
+
await navigator.clipboard.writeText(url);
|
|
300
|
+
} catch {
|
|
301
|
+
// clipboard blocked (insecure context / permission) — fall back to prompt
|
|
302
|
+
window.prompt("Copy this share link:", url);
|
|
303
|
+
}
|
|
304
|
+
setCopied(true);
|
|
305
|
+
setTimeout(() => setCopied(false), 1500);
|
|
306
|
+
}
|
|
307
|
+
|
|
220
308
|
// Deep-link auto-connect: when servers arrive, pick the best match (exact repo
|
|
221
309
|
// daemon, else a root daemon's subfolder) and open it once.
|
|
222
310
|
async function tryAutoConnect(list: PeerInfo[]) {
|
|
@@ -261,6 +349,8 @@ export function Discovery() {
|
|
|
261
349
|
setActivePeerId(null);
|
|
262
350
|
activePeerRef.current = null;
|
|
263
351
|
setConnState("idle");
|
|
352
|
+
sharePathRef.current = null;
|
|
353
|
+
if (window.location.pathname !== "/") history.replaceState(null, "", "/");
|
|
264
354
|
}
|
|
265
355
|
|
|
266
356
|
const activeServer = servers.find((s) => s.peerId === activePeerId);
|
|
@@ -275,6 +365,13 @@ export function Discovery() {
|
|
|
275
365
|
<span style={styles.dim}>{activeServer?.meta?.name ?? activePeerId?.slice(0, 8)}</span>
|
|
276
366
|
{activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
|
|
277
367
|
<span style={{ flex: 1 }} />
|
|
368
|
+
<button
|
|
369
|
+
style={styles.shareBtn}
|
|
370
|
+
onClick={shareLink}
|
|
371
|
+
title="Copy a link that opens this workspace (includes the room token)"
|
|
372
|
+
>
|
|
373
|
+
{copied ? "Copied!" : "Share"}
|
|
374
|
+
</button>
|
|
278
375
|
<button style={styles.connectBtn} onClick={disconnect}>
|
|
279
376
|
Disconnect
|
|
280
377
|
</button>
|
|
@@ -389,4 +486,5 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
389
486
|
cwd: { fontFamily: "monospace" },
|
|
390
487
|
echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
|
|
391
488
|
connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
489
|
+
shareBtn: { background: "transparent", border: "1px solid #3d3d3d", color: "#ccc", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
392
490
|
};
|