Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cleanslice/ranch",
"version": "0.1.11",
"version": "0.1.12",
"type": "module",
"description": "Ranch project CLI",
"license": "MIT",
Expand Down
2 changes: 2 additions & 0 deletions cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -47,6 +48,7 @@ export const devCommand = defineCommand({
const needsDocker = !target || target === "api";
if (needsDocker) {
ensureDockerRunning();
await ensureBrowserPoolImage(root);
}

if (!args["no-install"]) {
Expand Down
37 changes: 37 additions & 0 deletions cli/src/utils/ghcr.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
176 changes: 176 additions & 0 deletions cli/src/utils/ghcr.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const out: Record<string, string> = {};
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, string | undefined>,
): 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, string | undefined>,
): string | undefined {
for (const key of USER_KEYS) {
const value = env[key]?.trim();
if (value) return value;
}
return undefined;
}

function loadProjectEnv(root: string): Record<string, string> {
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<string | undefined> {
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<void> {
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");
}
33 changes: 18 additions & 15 deletions scripts/docker-up.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <your-github-username>
(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 <your-github-username>
(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'
Expand Down
Loading