/** * Workspace provisioning — the codehost standard. * * Maps a GitHub-style path `//tree/` to a local * worktree under `~/ws///tree/` and ensures it * exists & is reasonably fresh: * * - missing -> `git clone --branch --single-branch * --recurse-submodules https://github.com//` * into that dir (independent clone per branch) * - present -> `git fetch --prune`; then `git pull --ff-only` **only * if** the worktree is clean and fast-forwardable — * otherwise fetch-only (never clobber local work) * * After a clone, branch creation, or a pull that advanced the checkout, the * cross-platform `setup-repo.sh` runs via Bun Shell (`bun setup-repo.sh`): * it updates submodules and installs dependencies for whichever ecosystem(s) * the repo uses (JS via its pinned lockfile, Rust, Go, Python, Ruby). For any * non-`main` branch we also seed `.env.local` from the sibling `tree/main` * worktree (seed-once: never overwrites one already in the branch). * * All git invocations use `execFile` (argv array, no shell) and every * path segment is validated, so a hostile `owner`/`repo`/`branch` can't * inject options or escape `~/ws`. * * This module is the provisioning CORE: it imports ONLY node builtins + * subprocess `git`/`bun`, so consumers (e.g. an agent spawner) can depend on * `codehost/provision` without pulling in any native transport * (node-datachannel) / terminal (bun-pty) / UI (react, hono, vite) deps. The * live filesystem watcher (which needs the native `@parcel/watcher`) lives in * the sibling `codehost/provision/watch` module. */ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { copyFile, mkdir } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { GIT_NO_PROMPT_ENV, hasGitCredentials } from "./git-auth"; // Auth probes/hints are part of the provision surface: consumers (agent-yes's // `ay ws`, embedders) need the same "is this a private repo or a missing // login?" diagnosis the daemon streams. export { GIT_AUTH_ERROR_RE, GIT_NO_PROMPT_ENV, gitAuthHint, hasGhCli, hasGitCredentials } from "./git-auth"; const execFileP = promisify(execFile); /** Default workspace root when nothing else is configured (`~/ws`). */ export const WS_ROOT = path.join(os.homedir(), "ws"); /** * Resolve the workspace root at CALL TIME (never a captured module const), so a * consumer can point provisioning at a non-default layout — e.g. this machine * keeps repos under `/code///tree/`. Precedence: * 1. an explicit `wsRoot` argument (per-call override) * 2. `process.env.CODEHOST_WS_ROOT` (per-process override) * 3. `~/ws` (the default standard layout) */ export function resolveWsRoot(wsRoot?: string): string { return wsRoot ?? process.env.CODEHOST_WS_ROOT ?? path.join(os.homedir(), "ws"); } const GIT_TIMEOUT_MS = 120_000; // Dependency installs / builds can be slow; give the setup script its own // generous budget. const SETUP_TIMEOUT_MS = 600_000; const HERE = path.dirname(fileURLToPath(import.meta.url)); const SETUP_SCRIPT = path.join(HERE, "setup-repo.sh"); export type RepoSpec = { owner: string; repo: string; branch: string }; export type GitStatus = { branch: string; head: string; ahead: number; behind: number; dirty: boolean; hasUpstream: boolean; }; /** * Why a provision failed, when we can tell: * - "branch-not-found": repo exists on the remote but the branch does * not — the shell offers a "Create branch" action for this. * - "repo-not-found": the remote repo itself is missing/inaccessible. * - "other": anything else (network, auth, disk, …). */ export type FailReason = "branch-not-found" | "repo-not-found" | "auth-missing" | "other"; export type ProvisionResult = { ok: boolean; spec: RepoSpec; /** Absolute local worktree path (the VS Code `?folder=` target). */ folder: string; existed: boolean; action: "cloned" | "pulled" | "fetched" | "created" | "forked" | "none" | "error"; git?: GitStatus; error?: string; reason?: FailReason; }; function classifyError(msg: string): FailReason { if (/remote branch .* not found/i.test(msg)) return "branch-not-found"; // With prompts disabled, a missing-credentials clone fails on the username // read — unambiguously an auth gap, not a bad URL. if (/could not read Username|terminal prompts disabled/i.test(msg)) return "auth-missing"; if (/repository .* not found|could not read from remote/i.test(msg)) { // GitHub answers "not found" for private repos it won't admit exist. If // this machine holds no credentials at all, missing auth is the likelier // (and actionable) diagnosis. return hasGitCredentials() ? "repo-not-found" : "auth-missing"; } return "other"; } /** Parse `//tree/` (branch may contain slashes). */ export function parseSpec(p: string): RepoSpec | null { let decoded: string; try { decoded = decodeURIComponent(p); } catch { return null; // malformed percent-encoding (URIError) → not a spec } const clean = decoded.replace(/^\/+/, "").replace(/\/+$/, ""); const m = clean.match(/^([^/]+)\/([^/]+)\/tree\/(.+)$/); if (!m) return null; const [, owner, repo, branch] = m; // Narrow the regex groups to `string` (strict consumers compile this under // noUncheckedIndexedAccess, where match groups are `string | undefined`). if (!owner || !repo || !branch) return null; if (![owner, repo, ...branch.split("/")].every(isSafeSegment)) return null; return { owner, repo, branch }; } /** * Normalize any of the common ways a repo gets referenced into a `RepoSpec`, * so every consumer of the standard parses identically. Accepts: * - `https://github.com///tree/` and `github.com///tree/` * - `//tree/` (delegates to `parseSpec`) * - `/@` → `//tree/` * - `/` → default branch `main` * A trailing `.git` and any `#`/`?` fragment are stripped first. The branch may * contain `/`. Every segment is validated via `parseSpec`'s `isSafeSegment`, so * a hostile input can't traverse or inject options. Returns null when the input * doesn't name a repo. */ export function parseSource(input: string): RepoSpec | null { let s = input.trim(); if (!s) return null; // Drop URL scheme and a leading host, so github URLs and bare paths converge. s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); s = s.replace(/^(?:www\.)?github\.com\//i, ""); // Strip `#fragment` / `?query` tails. s = s.replace(/[#?].*$/, ""); // Strip a trailing `.git` (before or after a `/tree/...` suffix is unusual, // but `/.git` and `/.git/tree/` both normalize cleanly). s = s.replace(/^\/+/, "").replace(/\/+$/, ""); // `//tree/` — hand straight to the canonical parser. if (/^[^/]+\/[^/]+\/tree\/.+$/.test(s)) { return parseSpec(s.replace(/\.git(?=\/tree\/)/, "")); } // `/@` — normalize the `@` form to `tree/`. const at = s.match(/^([^/]+)\/([^/@]+?)(?:\.git)?@(.+)$/); if (at) { const [, owner, repo, branch] = at; if (!owner || !repo || !branch) return null; return parseSpec(`${owner}/${repo}/tree/${branch}`); } // `/` — default branch `main`. const bare = s.match(/^([^/]+)\/([^/]+?)(?:\.git)?$/); if (bare) { const [, owner, repo] = bare; if (!owner || !repo) return null; return parseSpec(`${owner}/${repo}/tree/main`); } return null; } /** A path segment that can't traverse, hide options, or inject control. */ function isSafeSegment(s: string): boolean { return ( s.length > 0 && s !== "." && s !== ".." && !s.startsWith("-") && // no option injection (e.g. branch "--upload-pack=…") !/[/\\\0]/.test(s) && !/[\x00-\x1f]/.test(s) ); } export function folderFor(spec: RepoSpec, wsRoot?: string): string { return path.join( resolveWsRoot(wsRoot), spec.owner, spec.repo, "tree", spec.branch, ); } async function git( cwd: string, args: string[], ): Promise<{ stdout: string; stderr: string }> { return execFileP("git", args, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, // Force git's error messages to stable English regardless of the daemon's // locale, so `classifyError`'s regexes match (a zh_TW/ja_JP daemon emits // e.g. "找不到遠端分支" instead of "Remote branch ... not found", which // would otherwise be misclassified as "other" and hide the Create-branch // affordance). LC_ALL=C is load-bearing here — gettext ignores LANGUAGE // once the locale resolves to C. `env` replaces (not merges), so spread. // GIT_NO_PROMPT_ENV: callers are daemons/servers with no terminal — a // private-repo clone must fail fast, not hang on an invisible prompt. env: { ...process.env, LC_ALL: "C", LANG: "C", LANGUAGE: "C", ...GIT_NO_PROMPT_ENV }, }); } /** * Read ahead/behind/dirty for a worktree (assumes it is a git dir). Exported * for the sibling `watch` module, which recomputes status on every filesystem * burst; consumers normally use `statusOf` (which guards for provisioned-ness). */ export async function readStatus(dir: string): Promise { // porcelain=v2 --branch gives `# branch.*` headers + entries. const { stdout } = await git(dir, ["status", "--porcelain=v2", "--branch"]); let branch = ""; let head = ""; let ahead = 0; let behind = 0; let hasUpstream = false; let dirty = false; for (const line of stdout.split("\n")) { if (line.startsWith("# branch.head ")) branch = line.slice(14).trim(); else if (line.startsWith("# branch.oid ")) head = line.slice(13).trim(); else if (line.startsWith("# branch.ab ")) { hasUpstream = true; const m = line.match(/\+(\d+)\s+-(\d+)/); if (m) { ahead = Number(m[1]); behind = Number(m[2]); } } else if (line && !line.startsWith("#")) { dirty = true; // any tracked/untracked entry } } return { branch, head: head.slice(0, 12), ahead, behind, dirty, hasUpstream }; } /** Git status for an existing worktree, or null if it isn't provisioned. */ export async function statusOf( spec: RepoSpec, wsRoot?: string, ): Promise { const folder = folderFor(spec, wsRoot); if (!existsSync(path.join(folder, ".git"))) return null; try { return await readStatus(folder); } catch { return null; } } /** * Run the cross-platform repo setup script (`setup-repo.sh`) in a worktree * via Bun Shell — `bun