diff --git a/api/docker-compose.yml b/api/docker-compose.yml index 2e5aa463..89312c12 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -58,6 +58,12 @@ services: # 17900 → 7900 auth-proxy /live + /websockify + /vnc/* (open this in a browser) browserless: image: ghcr.io/cleanslice/browser-pool:latest + build: + context: ../k8s/browser-pool-image + dockerfile: Dockerfile + # Pull only when the image is absent — ranch logs into GHCR first, and + # docker-up.sh builds locally if the private package is unreachable. + pull_policy: missing # Only published for linux/amd64 (see k8s/browser-pool-image/README.md) — # without this, `docker compose up` on Apple Silicon fails with # "no matching manifest for linux/arm64/v8". Runs fine under Docker diff --git a/cli/package.json b/cli/package.json index 577f47ce..4c08544a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@cleanslice/ranch", - "version": "0.1.11", + "version": "0.1.12", "type": "module", "description": "Ranch project CLI", "license": "MIT", diff --git a/cli/src/commands/dev.ts b/cli/src/commands/dev.ts index d305a662..c065243c 100644 --- a/cli/src/commands/dev.ts +++ b/cli/src/commands/dev.ts @@ -7,6 +7,7 @@ import { ensureK3dRunning } from "../utils/k3d"; import { ensurePortForwards } from "../utils/port-forward"; import { ensureDepsInstalled } from "../utils/deps"; import { ensureDockerRunning } from "../utils/docker"; +import { ensureBrowserPoolImage } from "../utils/ghcr"; import { maybeUpdatePlatform } from "../utils/platform-update"; export const devCommand = defineCommand({ @@ -47,6 +48,7 @@ export const devCommand = defineCommand({ const needsDocker = !target || target === "api"; if (needsDocker) { ensureDockerRunning(); + await ensureBrowserPoolImage(root); } if (!args["no-install"]) { diff --git a/cli/src/utils/ghcr.test.ts b/cli/src/utils/ghcr.test.ts new file mode 100644 index 00000000..cdf9c159 --- /dev/null +++ b/cli/src/utils/ghcr.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { parseDotEnv, pickGithubToken, pickGithubUser } from "./ghcr"; + +describe("parseDotEnv", () => { + test("reads KEY=value, skips comments, strips quotes", () => { + const env = parseDotEnv( + [ + "# comment", + "GITHUB_TOKEN=abc", + "GH_USER=\"someone\"", + "EMPTY=", + " SPACED = 'quoted' ", + ].join("\n"), + ); + expect(env.GITHUB_TOKEN).toBe("abc"); + expect(env.GH_USER).toBe("someone"); + expect(env.EMPTY).toBe(""); + expect(env.SPACED).toBe("quoted"); + }); +}); + +describe("pickGithubToken", () => { + test("prefers GITHUB_TOKEN over GH_TOKEN", () => { + expect(pickGithubToken({ GITHUB_TOKEN: "a", GH_TOKEN: "b" })).toBe("a"); + expect(pickGithubToken({ GH_TOKEN: " b " })).toBe("b"); + expect(pickGithubToken({ GHCR_PAT: "c" })).toBe("c"); + expect(pickGithubToken({})).toBeUndefined(); + }); +}); + +describe("pickGithubUser", () => { + test("reads GITHUB_USER / GH_USER", () => { + expect(pickGithubUser({ GITHUB_USER: "me" })).toBe("me"); + expect(pickGithubUser({ GH_USER: "you" })).toBe("you"); + expect(pickGithubUser({})).toBeUndefined(); + }); +}); diff --git a/cli/src/utils/ghcr.ts b/cli/src/utils/ghcr.ts new file mode 100644 index 00000000..dc2652af --- /dev/null +++ b/cli/src/utils/ghcr.ts @@ -0,0 +1,176 @@ +import { existsSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { consola } from "consola"; +import { hasBinary } from "./bin"; +import { run } from "./exec"; + +export const BROWSER_POOL_IMAGE = "ghcr.io/cleanslice/browser-pool:latest"; + +const TOKEN_KEYS = ["GITHUB_TOKEN", "GH_TOKEN", "GHCR_PAT", "CR_PAT"] as const; +const USER_KEYS = ["GITHUB_USER", "GH_USER", "GITHUB_USERNAME"] as const; + +export function parseDotEnv(contents: string): Record { + const out: Record = {}; + for (const raw of contents.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +export function pickGithubToken( + env: Record, +): string | undefined { + for (const key of TOKEN_KEYS) { + const value = env[key]?.trim(); + if (value) return value; + } + return undefined; +} + +export function pickGithubUser( + env: Record, +): string | undefined { + for (const key of USER_KEYS) { + const value = env[key]?.trim(); + if (value) return value; + } + return undefined; +} + +function loadProjectEnv(root: string): Record { + const file = join(root, ".env.project"); + if (!existsSync(file)) return {}; + try { + return parseDotEnv(readFileSync(file, "utf8")); + } catch { + return {}; + } +} + +function spawnText(cmd: string, args: string[]): string | undefined { + if (!hasBinary(cmd)) return undefined; + const result = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true }); + if (result.status !== 0) return undefined; + const text = (result.stdout ?? "").trim(); + return text || undefined; +} + +async function githubUserFromToken(token: string): Promise { + try { + const res = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "User-Agent": "ranch-cli", + }, + }); + if (!res.ok) return undefined; + const body = (await res.json()) as { login?: string }; + return body.login?.trim() || undefined; + } catch { + return undefined; + } +} + +export function imageExists(image: string): boolean { + const result = spawnSync("docker", ["image", "inspect", image], { + stdio: "ignore", + windowsHide: true, + }); + return result.status === 0; +} + +function loginGhcr(user: string, token: string): boolean { + consola.start(`Logging into ghcr.io as ${user}...`); + const result = spawnSync( + "docker", + ["login", "ghcr.io", "-u", user, "--password-stdin"], + { input: token, encoding: "utf8", windowsHide: true }, + ); + if (result.status === 0) { + consola.success("Logged into ghcr.io"); + return true; + } + consola.warn("docker login ghcr.io failed — will build browser-pool locally if needed."); + return false; +} + +/** + * Make sure `ghcr.io/cleanslice/browser-pool` is on the machine before + * compose tries to pull it. New users are not logged into GHCR, and the + * package is private — that used to abort `ranch dev` with unauthorized. + * + * Fast path: image already present. Else login from GITHUB_TOKEN / + * `.env.project` / `gh auth` and pull. If GHCR still refuses, build from + * `k8s/browser-pool-image` and tag it with the GHCR name so compose stays + * offline for this image. + */ +export async function ensureBrowserPoolImage(root: string): Promise { + if (imageExists(BROWSER_POOL_IMAGE)) return; + + consola.start("Preparing the private browser-pool image..."); + + const fileEnv = loadProjectEnv(root); + const token = + pickGithubToken(process.env) ?? pickGithubToken(fileEnv) ?? spawnText("gh", ["auth", "token"]); + let user = + pickGithubUser(process.env) ?? + pickGithubUser(fileEnv) ?? + spawnText("gh", ["api", "user", "-q", ".login"]); + if (token && !user) { + user = await githubUserFromToken(token); + } + + const loggedIn = Boolean(token && user && loginGhcr(user, token)); + if (loggedIn) { + consola.start(`Pulling ${BROWSER_POOL_IMAGE}...`); + const pullCode = await run("docker", ["pull", BROWSER_POOL_IMAGE]); + if (pullCode === 0 && imageExists(BROWSER_POOL_IMAGE)) { + consola.success("browser-pool image ready"); + return; + } + consola.warn("GHCR pull failed (token may lack package access). Building locally."); + } else { + consola.warn( + "No GHCR credentials (GITHUB_TOKEN, .env.project, or `gh auth`). Building browser-pool locally.", + ); + } + + const context = join(root, "k8s", "browser-pool-image"); + if (!existsSync(join(context, "Dockerfile"))) { + consola.error( + `Cannot pull ${BROWSER_POOL_IMAGE} and no local Dockerfile at ${context}.`, + ); + process.exit(1); + } + + consola.start("Building browser-pool locally (first time is slow)..."); + const buildCode = await run("docker", [ + "build", + "--platform", + "linux/amd64", + "-t", + BROWSER_POOL_IMAGE, + context, + ]); + if (buildCode !== 0 || !imageExists(BROWSER_POOL_IMAGE)) { + consola.error( + `Failed to pull or build ${BROWSER_POOL_IMAGE}. Create a GitHub PAT with read:packages, run \`docker login ghcr.io\`, and retry.`, + ); + process.exit(1); + } + consola.success("browser-pool image built locally"); +} diff --git a/scripts/docker-up.sh b/scripts/docker-up.sh index b43737b6..1485e2a9 100755 --- a/scripts/docker-up.sh +++ b/scripts/docker-up.sh @@ -13,24 +13,27 @@ echo "$output" >&2 if [ "$code" -ne 0 ]; then if echo "$output" | grep -qiE "unauthorized|denied|403 Forbidden"; then + echo >&2 "" + echo >&2 "✖ GHCR refused ghcr.io/cleanslice/browser-pool — building it locally." + echo >&2 " (private package, or Docker isn't logged into ghcr.io)" + echo >&2 "" + if docker compose build browserless && docker compose up -d; then + exit 0 + fi cat >&2 <<'EOF' -✖ `docker compose up -d` failed — registry auth error above. +✖ Local build also failed. To pull the private image instead: - Most likely cause: ghcr.io/cleanslice/browser-pool is a private image and - Docker isn't logged into ghcr.io (or the account lacks package access). - - Fix: - 1. Create a GitHub PAT with the `read:packages` scope: - https://github.com/settings/tokens - 2. docker login ghcr.io -u - (paste the PAT as the password) - 3. If you still get 403 after logging in, the token is valid but your - account lacks read access to the package itself — ask whoever - administers ghcr.io/cleanslice/browser-pool to grant you access - (package Settings → Manage Actions access / Collaborators). Repo - access does not automatically grant package access on GHCR. - 4. Re-run `ranch dev`. + 1. Create a GitHub PAT with the `read:packages` scope: + https://github.com/settings/tokens + 2. docker login ghcr.io -u + (paste the PAT as the password) + 3. If you still get 403 after logging in, the token is valid but your + account lacks read access to the package itself — ask whoever + administers ghcr.io/cleanslice/browser-pool to grant you access + (package Settings → Manage Actions access / Collaborators). Repo + access does not automatically grant package access on GHCR. + 4. Re-run `ranch dev`. EOF elif echo "$output" | grep -qi "no matching manifest"; then cat >&2 <<'EOF'