// Shared helpers for git-shaped deep links and matching them to a daemon. Used // by the web resolver (src/web) and conceptually mirrors the daemon's repo // identity (src/cli/git.ts). // // One grammar, keyed off the first path segment: // contains "@" -> machine scope (`@` or `@`) // looks like a hostname (dotted) -> git host // neither -> not a deep link (a normal app route) // A machine scope is followed by either a git coordinate or an absolute // filesystem path, decided by the same dotted-hostname test. So: // /github.com///tree/ lookup, any machine // /sno@Mac/github.com///tree/ pinned to Mac (provisions) // /sno@Mac/Users/sno/ws folder mount on Mac // The git-coordinate form is exactly `repoKey()` (the `PeerMeta.repo` wire // value) with the branch appended — path and wire identity stay in step. // // The older `/gh/…`, `/git/…`, `/host/…`, `/dev/…` forms are still parsed // (live links, and daemons of many npm versions print them); only the emitted // form changed. import type { PeerInfo, PeerMeta } from "./signaling"; export const DEFAULT_LAYOUT = "{owner}/{repo}/tree/{branch}"; export const GITHUB_HOST = "github.com"; /** Branch assumed when a repo link/target carries none — what `fillLayout` opens * and what the address bar shows, so a bare `/gh//` canonicalizes * to `/gh///tree/`. */ export const DEFAULT_BRANCH = "main"; export interface RepoTarget { /** Git host, e.g. "github.com" or "gitlab.com". */ host: string; owner: string; name: string; /** Branch from the deep link, if present. */ branch?: string; /** * Pin resolution to one machine (PeerMeta.host, e.g. "Mac"), from a * `@` path prefix or a legacy `?machine=` query * param — deliberately not named `host` since that already means the *git* * host above. When set, resolveRepoTarget only considers daemons on that * machine and fails (null) rather than silently picking a different one. * * It also flips the link's meaning from *lookup* to *provision*: pinned to a * machine, "the repo isn't checked out there yet" is an instruction to clone * it (the `.codehost/` setup hook, see src/cli/init.ts), not a miss. */ machine?: string; /** Optional account from a `@` prefix (PeerMeta.user). A * disambiguating hint only — matching is by machine, since usernames change * and the stable identity is PeerMeta.hostId. */ user?: string; } /** A direct folder mount address: host-scoped `/host//`, or the * legacy host-agnostic `/dev/` (a bare path collides across machines, so * new links carry the host). */ export interface DevTarget { /** Hostname the workspace lives on; undefined for a legacy host-agnostic link. */ host?: string; /** Optional account from a `@` prefix (see RepoTarget.user). */ user?: string; path: string; } export type DeepLink = | { type: "repo"; target: RepoTarget } | { type: "dev"; target: DevTarget } | { type: "hostSettings"; host: string } | null; /** A dotted, hostname-shaped segment: "github.com", "git.example.co.uk". Used to * tell a git host from a filesystem segment — a leading-dot name (".config") or * a Windows drive ("C:") deliberately fails. */ const HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i; /** `@` or `@` — the machine-scope sigil. */ const MACHINE_RE = /^([^/@]*)@([^/@]+)$/; /** * Parse a deep-link pathname. Current forms: * ///(/tree/) -> repo lookup * /@///(/tree/…) -> repo pinned to a machine * /@/ -> folder mount on a machine * /@ -> that machine's settings page * The `` half may be empty ("/@Mac/…"). Legacy forms still parsed: * /gh//(/tree/), /git///(/tree/), * /host/(/), /dev/ * Branch may contain slashes. Anything else -> null (normal app). * * `search` (the URL's query string, e.g. from location.search) is only * consulted for a legacy `machine=` param on repo links — a * `?`-suffixed pathname also works since everything after `?` is ignored by the * path regexes. A `@` prefix wins over the query param. */ export function parseDeepLink(pathname: string, search = ""): DeepLink { const clean = pathname.replace(/\/+$/, ""); const machine = new URLSearchParams(search).get("machine") || undefined; // Machine-scoped: peel the `@` prefix, then re-run the // git-host / fs-path decision on the remainder. const scoped = clean.match(/^\/([^/]+)(?:\/(.*))?$/); const mach = scoped && MACHINE_RE.exec(decodeURIComponent(scoped[1])); if (scoped && mach) { const user = mach[1] || undefined; const host = mach[2]; const rest = scoped[2] ?? ""; if (!rest) return { type: "hostSettings", host }; const repo = matchRepoPath(rest); if (repo) return { type: "repo", target: { ...repo, machine: host, user } }; return { type: "dev", target: { host, user, path: `/${rest.replace(/^\/+/, "")}` } }; } // Unscoped git coordinate: ///(/tree/). const bare = matchRepoPath(clean.replace(/^\/+/, "")); if (bare) return { type: "repo", target: { ...bare, machine } }; const gh = clean.match(/^\/gh\/([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/); if (gh) { return { type: "repo", target: { host: GITHUB_HOST, owner: gh[1], name: gh[2], branch: gh[3], machine }, }; } const git = clean.match(/^\/git\/([^/]+)\/([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/); if (git) { return { type: "repo", target: { host: git[1].toLowerCase(), owner: git[2], name: git[3], branch: git[4], machine }, }; } // Host-scoped folder mount: first segment is the hostname, the rest is the // served path (which itself may contain slashes and a Windows drive colon). const host = clean.match(/^\/host\/([^/]+)\/(.+)$/); if (host) { return { type: "dev", target: { host: host[1], path: `/${host[2].replace(/^\/+/, "")}` } }; } // Bare hostname, no path: that host's settings page. const hostSettings = clean.match(/^\/host\/([^/]+)$/); if (hostSettings) return { type: "hostSettings", host: hostSettings[1] }; // Legacy host-agnostic folder mount. const dev = clean.match(/^\/dev\/(.+)$/); if (dev) { return { type: "dev", target: { path: `/${dev[1].replace(/^\/+/, "")}` } }; } return null; } /** `//(/tree/)` (no leading slash) -> its repo * coordinate, or null when the first segment isn't a hostname — which is how a * filesystem path under a machine scope is told apart from a git coordinate. */ function matchRepoPath(rest: string): Pick | null { const m = rest.match(/^([^/]+)\/([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/); if (!m || !HOSTNAME_RE.test(m[1])) return null; return { host: m[1].toLowerCase(), owner: m[2], name: m[3], branch: m[4] }; } /** Normalized repo key, e.g. "github.com/owner/repo" — matches PeerMeta.repo. */ export function repoKey(t: Pick): string { return `${t.host}/${t.owner}/${t.name}`; } /** * Normalize a served workspace path to the form VS Code web's `?folder=` query * expects. On Windows that's the file-URI authority form: a leading slash, the * drive letter and colon preserved, backslashes -> slashes — * `C:\ws` -> `/C:/ws`, `C:\Users\x` -> `/C:/Users/x`, `D:\` -> `/D:`. (The * git-bash `/c/ws` form does NOT resolve — serve-web reports "workspace does not * exist".) POSIX absolute paths (mac/linux) are returned unchanged, and the * result is idempotent. Used for `PeerMeta.cwd`, which feeds the `?folder=` URI * (URL-encoded in transit, decoded back to this by VS Code) and the `/dev/` * deep link — the real OS path is still used for the local VS Code working dir. */ export function toPosixPath(p: string): string { const drive = /^([A-Za-z]):(?:[\\/](.*))?$/.exec(p); if (drive) { const letter = drive[1]; // preserve drive-letter case const rest = (drive[2] ?? "").replace(/\\/g, "/").replace(/\/+$/, ""); return rest ? `/${letter}:/${rest}` : `/${letter}:`; } // Already POSIX (or a relative path): just unify any stray backslashes. return p.replace(/\\/g, "/"); } /** Inverse of `toPosixPath` for the host OS: the VS Code `?folder=` form back to * a real filesystem path. `/C:/ws` -> `C:\ws` (Windows); a POSIX path is * returned unchanged (only Windows cwds carry the `/:/` shape). */ export function fromPosixPath(p: string): string { const drive = /^\/([A-Za-z]):(\/.*)?$/.exec(p); if (!drive) return p; const rest = (drive[2] ?? "").replace(/\//g, "\\"); return `${drive[1]}:${rest || "\\"}`; } /** Fill a layout template from a repo target (default branch -> DEFAULT_BRANCH). */ export function fillLayout(layout: string, t: RepoTarget): string { return layout .replace(/\{owner\}/g, t.owner) .replace(/\{repo\}/g, t.name) .replace(/\{branch\}/g, t.branch || DEFAULT_BRANCH); } /** * Shareable deep-link pathname for a connected workspace. A git-identified * server renders `///` (with `/tree/` when * known); a non-git workspace is addressed by its opened folder under its * machine scope, `/@/` (or the legacy `/dev/` when * no machine is known). A repo link gains the same `@` prefix * only when `machine` is set — unpinned it resolves against whichever machine * has the repo, pinned it says "this machine, cloning if need be". * Round-trips through parseDeepLink + resolve{Repo,Dev}Target. Returns null * when there's nothing addressable. */ export function shareableDeepLink(opts: { repo?: string; branch?: string; folder?: string; host?: string; /** Machine to pin to (see RepoTarget.machine). */ machine?: string; /** Account shown in the `@` prefix (PeerMeta.user); the * machine alone is enough, so this is cosmetic. */ user?: string; }): string | null { if (opts.repo) { const [host, owner, name] = opts.repo.split("/"); if (host && owner && name) { const base = `/${host}/${owner}/${name}`; const path = opts.branch ? `${base}/tree/${opts.branch}` : base; return opts.machine ? `/${machineScope(opts.machine, opts.user)}${path}` : path; } } if (opts.folder) { const path = opts.folder.replace(/^\/+/, ""); const machine = opts.machine ?? opts.host; return machine ? `/${machineScope(machine, opts.user)}/${path}` : `/dev/${path}`; } return null; } /** The `@` path segment; the user half is optional ("@Mac"). */ function machineScope(machine: string, user?: string): string { return `${encodeURIComponent(user ?? "")}@${encodeURIComponent(machine)}`; } /** * Turn a pasted git repo URL into a codehost deep-link path — which, with the * host in the path, is near-identity: strip the scheme and keep * `//`, preserving `/tree/`. Accepts with or without * a scheme, an `scp`-style * `git@host:owner/repo`, a trailing `.git`, query/hash, and a branch containing * slashes. Returns null when it isn't a recognizable repo URL — lets the "open a * GitHub URL" box reuse the same resolution as a typed deep link. */ export function gitUrlToPath(input: string): string | null { let s = input.trim(); if (!s) return null; s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); // scheme:// s = s.replace(/^[^@/]+@/, ""); // user@ (incl. git@) s = s.replace(/^([^/:]+):(?!\d)/, "$1/"); // scp-style host:owner/repo -> host/owner/repo s = s.split(/[?#]/)[0].replace(/\/+$/, ""); // drop query/hash + trailing slash const m = s.match(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/(.+))?$/); if (!m) return null; const host = m[1].toLowerCase(); if (!host.includes(".")) return null; // require a real hostname (github.com) return shareableDeepLink({ repo: `${host}/${m[2]}/${m[3]}`, branch: m[4] }); } export interface Resolution { peerId: string; /** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */ folder?: string; /** The folder is a checkout the daemon *enumerated* (it exists on disk), not * an optimistic layout-synthesized path — rank it like an exact match. */ exact?: boolean; } /** Machine preference (from history) used to break ties between matches. */ export interface ResolvePrefs { /** Stable machine id — prefer servers advertising it. */ hostId?: string; /** Hostname fallback for pre-hostId daemons/entries. */ host?: string; } function prefers(meta: PeerMeta | null | undefined, prefer?: ResolvePrefs): boolean { if (!prefer || !meta) return false; if (prefer.hostId && meta.hostId) return meta.hostId === prefer.hostId; return !!prefer.host && meta.host === prefer.host; } /** * Pick the best live server for a repo deep link. Prefers an exact `repo` * daemon; otherwise falls back to a `root` daemon that can open the subfolder. * Ties (several repo daemons, or several roots) break toward the machine in * `prefer` — the one history says served this repo last. Among several roots, * then prefers the **deepest** (longest cwd) — with a nested setup like * /Users/sno and /Users/sno/ws both serving, the layout subfolder exists under * the deeper one (observed: /gh/snomiao/codehost belongs to /Users/sno/ws/..., * not /Users/sno/...). Returns null if nothing matches. * * `target.machine`, when set, pins resolution to that one machine — a * daemon-on-a-different-host is not a candidate at all (not even as a * fallback), so a pinned link fails loud instead of silently landing * elsewhere. */ export function resolveRepoTarget( servers: PeerInfo[], target: RepoTarget, prefer?: ResolvePrefs, ): Resolution | null { const pool = target.machine ? servers.filter((s) => s.meta?.host === target.machine) : servers; const key = repoKey(target); const repoMatches = pool.filter( (s) => s.meta?.kind !== "root" && s.meta?.repo === key && branchOk(s.meta, target), ); const repoMatch = repoMatches.find((s) => prefers(s.meta, prefer)) ?? repoMatches[0]; if (repoMatch) return { peerId: repoMatch.peerId }; const roots = pool .filter((s) => s.meta?.kind === "root") .sort( (a, b) => Number(prefers(b.meta, prefer)) - Number(prefers(a.meta, prefer)) || (b.meta?.cwd?.length ?? 0) - (a.meta?.cwd?.length ?? 0), ); // A root that *enumerated* a matching checkout knows it exists on disk — // rank it exact, ahead of any synthesized fallback. const wantBranch = target.branch || DEFAULT_BRANCH; for (const s of roots) { const ws = s.meta?.workspaces?.find( (w) => w.repo === key && (!w.branch || w.branch === wantBranch), ); if (ws) return { peerId: s.peerId, folder: ws.path, exact: true }; } const root = roots[0]; if (root && root.meta?.cwd) { const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`; return { peerId: root.peerId, folder }; } return null; } /** Pick a folder-mount server whose served cwd matches the target path, scoped * to `target.host` when the link carries one (a bare path is ambiguous across * machines). Compares with leading + trailing slashes stripped: `parseDeepLink` * forces a leading "/" on the path, but a served cwd may lack one (e.g. an * `expose` server's `localhost:`), so a trailing-only trim never matches. * A root daemon whose *advertised workspaces* include the path matches too — * that's how directories registered with the host daemon resolve. */ export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null { const want = stripEnds(target.path); const hostOk = (meta: PeerMeta) => !target.host || meta.host === target.host; const hit = servers.find((s) => s.meta?.cwd && stripEnds(s.meta.cwd) === want && hostOk(s.meta)); if (hit) return { peerId: hit.peerId }; for (const s of servers) { if (!s.meta || !hostOk(s.meta)) continue; const ws = s.meta.workspaces?.find((w) => stripEnds(w.path) === want); if (ws) return { peerId: s.peerId, folder: ws.path, exact: true }; } return null; } /** Pick the live root daemon (`serve`, kind "root") advertising this hostname — * the only kind with `.codehost` provisioning wired (see run-server.ts / * commands/serve.ts). A `dev`-kind daemon on the same host has no * provision-config route, so it's deliberately not matched here. */ export function resolveHostTarget(servers: PeerInfo[], hostname: string): Resolution | null { const root = servers.find((s) => s.meta?.host === hostname && s.meta?.kind === "root"); return root ? { peerId: root.peerId } : null; } /** A candidate room (its token) plus how the deep link resolved within it. */ export interface RoomMatch { token: string; resolution: Resolution; } /** * Rank matches found while searching multiple rooms for a token-less deep link. * An *exact* match (a server that genuinely serves this repo/folder — a repo * daemon, or a root whose enumerated checkout matched) beats a *root fallback* * (a root daemon that would open the repo as a synthesized subfolder, which * `resolveRepoTarget` returns for ANY repo link). Without this preference, * first-responder-wins could pick an unrelated room that merely has a root * server. Returns null when there are no matches. */ export function pickRoomMatch(matches: RoomMatch[]): RoomMatch | null { return matches.find((m) => !m.resolution.folder || m.resolution.exact) ?? matches[0] ?? null; } function branchOk(meta: PeerMeta, target: RepoTarget): boolean { // No branch requested, or the server doesn't report one -> accept; else exact. if (!target.branch || !meta.branch) return true; return meta.branch === target.branch; } function trimSlash(p: string): string { return p.replace(/\/+$/, ""); } /** Strip leading and trailing slashes — for comparing a `/dev/` target to * a served cwd that may or may not carry a leading slash. */ function stripEnds(p: string): string { return p.replace(/^\/+|\/+$/g, ""); }