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 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.1",
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
@@ -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 (which fetches
6
- # the native WebRTC binary via Bun's lifecycle scripts), then runs `codehost
7
- # setup` to pick a token, install VS Code, and start a server daemon.
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
- # Env override: $env:CODEHOST_NO_SETUP = "1" -> install only, skip setup.
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 (Get-Command bun -ErrorAction SilentlyContinue)) {
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
- if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
27
- Fail "Bun install did not land on PATH. Open a new terminal and re-run."
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
- Info "installing the codehost CLI (bun add -g codehost)..."
31
- bun add -g codehost
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
- if (-not (Get-Command codehost -ErrorAction SilentlyContinue)) {
34
- Fail "codehost installed but not on PATH. Add $bunBin to PATH and run: codehost setup"
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
- codehost setup
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 (which fetches
7
- # the native WebRTC binary via Bun's lifecycle scripts), then runs `codehost
8
- # setup` to pick a token, install VS Code, and start a server daemon.
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
- if ! command -v bun >/dev/null 2>&1; then
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
- export PATH="$BUN_INSTALL/bin:$PATH"
52
+ BUN="$(bun_bin)"
34
53
  fi
35
54
 
36
- if ! command -v bun >/dev/null 2>&1; then
37
- err "Bun install did not land on PATH. Open a new shell or add $BUN_INSTALL/bin to PATH, then re-run."
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
- info "installing the codehost CLI (bun add -g codehost)…"
42
- bun add -g codehost
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
- if ! command -v codehost >/dev/null 2>&1; then
45
- err "codehost installed but not on PATH. Add $BUN_INSTALL/bin to your PATH and run: codehost setup"
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 codehost setup
80
+ exec "$CODEHOST" setup
@@ -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> when it's a GitHub 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
- cwd: dir,
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
- cwd: dir,
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,
@@ -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 ("gh/<owner>/<repo>") + branch from a git
4
- // working tree, so a `codehost dev` daemon can be addressed by GitHub-shaped
5
- // deep links. Best-effort: returns undefined fields off git / off GitHub.
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. "gh/snomiao/codehost". */
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: parseGitHubRemote(remote),
29
+ repo: parseGitRemote(remote),
28
30
  branch: branch && branch !== "HEAD" ? branch : undefined,
29
31
  };
30
32
  }
31
33
 
32
34
  /**
33
- * Parse a GitHub remote URL into "gh/<owner>/<repo>". Handles:
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@github.com:owner/repo(.git)
36
- * ssh://git@github.com/owner/repo(.git)
37
- * Returns undefined for non-GitHub or unparseable remotes.
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 parseGitHubRemote(url: string): string | undefined {
40
- const u = url.trim();
44
+ export function parseGitRemote(url: string): string | undefined {
45
+ let u = url.trim();
41
46
  if (!u) return undefined;
42
- const m = u.match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?\/?$/i);
43
- if (!m) return undefined;
44
- const owner = m[1];
45
- const repo = m[2];
46
- if (!owner || !repo) return undefined;
47
- return `gh/${owner}/${repo}`;
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
+ });
@@ -1,13 +1,16 @@
1
- // Shared helpers for GitHub-shaped deep links and matching them to a daemon.
2
- // Used by the web resolver (src/web) and conceptually mirrors the daemon's
3
- // repo identity (src/cli/git.ts).
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
- provider: "gh";
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> -> repo target (default branch)
30
- * /gh/<owner>/<repo>/tree/<branch> -> repo target (branch; may contain slashes)
31
- * /dev/<fs-path> -> direct folder mount
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: { provider: "gh", owner: gh[1], name: gh[2], branch: gh[3] },
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. "gh/owner/repo". */
51
- export function repoKey(t: Pick<RepoTarget, "owner" | "name">): string {
52
- return `gh/${t.owner}/${t.name}`;
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;
@@ -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: normalized identity, e.g. "gh/snomiao/codehost". */
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;
@@ -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
- // For a repo deep link, adopt the room that last served it so it resolves
106
- // without re-entering a token.
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?.type === "repo") {
109
- const h = historyFor(repoKey(dl.target));
110
- if (h?.token) setToken(h.token);
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
  };