#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; function arg(name, def) { const i = process.argv.indexOf(name); return i === -1 ? def : process.argv[i + 1]; } function has(name) { return process.argv.includes(name); } function json(x) { return JSON.stringify(x, null, 2); } function msg(s, style = "info") { return `${{ info: "ℹ", success: "✓", error: "✗", warning: "⚠" }[style] ?? ""} ${s}`; } function checkAuth(run = spawnSync) { const r = run("gh", ["auth", "status"], { encoding: "utf8" }); return !r.error && r.status === 0; } function errorMessage(res) { return res.message ?? res.raw ?? "Unknown GitHub CLI error"; } class Pages { constructor(repo, dryRun = false, jsonOut = false, run = spawnSync) { this.repo = repo; this.dryRun = dryRun; this.jsonOut = jsonOut; this.run = run; if (!checkAuth(run)) throw new Error("GitHub CLI is not authenticated; run 'gh auth login'"); } api(endpoint, method = "GET", data) { if (this.dryRun && method !== "GET") { if (!this.jsonOut) { console.error(`[DRY RUN] Would execute: ${method} ${endpoint}`); if (data) console.error(`[DRY RUN] With data: ${JSON.stringify(data)}`); } return [0, { dry_run: true, endpoint, method }]; } const args = [ "api", "-X", method, "-H", "Accept: application/vnd.github+json", "-H", "X-GitHub-Api-Version: 2022-11-28", endpoint, ]; if (data) for (const [key, value] of Object.entries(data)) if (typeof value === "object" && value) for (const [subkey, subvalue] of Object.entries(value)) args.push("-f", `${key}[${subkey}]=${subvalue}`); else args.push("-f", `${key}=${typeof value === "boolean" ? String(value) : value}`); const r = this.run("gh", args, { encoding: "utf8", timeout: 30000, maxBuffer: 10 * 1024 * 1024, }); let body = {}; if (r.stdout?.trim()) { try { body = JSON.parse(r.stdout); } catch { body = { raw: r.stdout.trim() }; } } if ((r.status ?? 1) !== 0 && !body.message) body.message = r.error?.message ?? r.stderr?.trim() ?? "GitHub CLI command failed"; return [r.status ?? 1, body]; } emit(out, success) { if (this.jsonOut) console.log(json(out)); else if (out.ok) console.log(msg(success, "success")); else console.error(msg(out.error, "error")); return out; } enable(branch = "main", path = "/", buildType = "workflow", https = true) { if (!this.jsonOut) { console.log(`Enabling GitHub Pages for ${this.repo}...`); console.log(` Branch: ${branch}`); console.log(` Path: ${path}`); console.log(` Build type: ${buildType}`); } const endpoint = `/repos/${this.repo}/pages`; const [status, res] = this.api(endpoint, "POST", { source: { branch, path }, build_type: buildType, }); if (status !== 0) return this.emit( { ok: false, status: "error", action: "enable", error: errorMessage(res) }, "", ); if (res.dry_run) return this.emit( { ok: true, status: "dry_run", action: "pages_enabled", repository: this.repo, config: { branch, path, build_type: buildType, https_enforced: https }, }, "GitHub Pages would be enabled (dry-run)", ); if (https) { const [httpsStatus, httpsRes] = this.api(endpoint, "PUT", { https_enforced: true }); if (httpsStatus !== 0) return this.emit( { ok: false, status: "error", action: "enforce_https", pages_enabled: true, error: errorMessage(httpsRes), }, "", ); } return this.emit( { ok: true, status: "success", action: "pages_enabled", repository: this.repo, config: { branch, path, build_type: buildType, https_enforced: https }, }, "GitHub Pages enabled successfully", ); } status() { if (!this.jsonOut) console.log(`Checking GitHub Pages status for ${this.repo}...`); const [status, res] = this.api(`/repos/${this.repo}/pages`); if (status !== 0) return this.emit( { ok: false, status: "error", action: "status", error: errorMessage(res) }, "", ); const out = { ok: true, status: "enabled", repository: this.repo, url: res.html_url ?? "N/A", pages_status: res.status ?? "N/A", build_type: res.build_type ?? "N/A", source: res.source ?? {}, https_enforced: res.https_enforced ?? false, custom_domain: res.cname, }; if (this.jsonOut) console.log(json(out)); else { console.log(msg("GitHub Pages is enabled", "success")); console.log(` URL: ${out.url}`); console.log(` Status: ${out.pages_status}`); console.log(` Build type: ${out.build_type}`); console.log(` Source branch: ${out.source.branch ?? "N/A"}`); console.log(` Source path: ${out.source.path ?? "N/A"}`); console.log(` HTTPS enforced: ${out.https_enforced}`); if (out.custom_domain) console.log(` Custom domain: ${out.custom_domain}`); } return out; } rebuild() { if (!this.jsonOut) console.log(`Triggering rebuild for ${this.repo}...`); const [status, res] = this.api(`/repos/${this.repo}/pages/builds`, "POST"); if (status !== 0) return this.emit( { ok: false, status: "error", action: "rebuild", error: errorMessage(res) }, "", ); return this.emit( { ok: true, status: res.dry_run ? "dry_run" : "triggered", repository: this.repo, build_url: res.url, }, res.dry_run ? "Build would be triggered (dry-run)" : "Build triggered successfully", ); } latest() { const [status, res] = this.api(`/repos/${this.repo}/pages/builds/latest`); if (status !== 0) return this.emit( { ok: false, status: "error", action: "latest", error: errorMessage(res) }, "", ); const out = { ok: true, status: res.status ?? "N/A", commit: res.commit ?? "N/A", created_at: res.created_at, error: res.error ?? {}, }; if (this.jsonOut) console.log(json(out)); else { console.log("\nLatest build:"); console.log(` Status: ${out.status}`); console.log(` Commit: ${out.commit}`); if (out.created_at) console.log(` Created: ${out.created_at}`); if (out.error?.message) console.log(` Error: ${out.error.message}`); } return out; } } function validateSiteDir(siteDir) { if (!siteDir?.trim()) throw new Error("An explicit site directory is required via --site-dir"); const normalized = siteDir.replaceAll("\\", "/"); if ( isAbsolute(siteDir) || /^[A-Za-z]:/.test(siteDir) || normalized.startsWith("~") || normalized.split("/").some((part) => part === ".." || part === "") || !/^[A-Za-z0-9._/-]+$/.test(normalized) || normalized === "." ) throw new Error("Site directory must be a safe repository-relative path"); return normalized; } function createWorkflow(output = ".github/workflows/pages.yml", force = false, dryRun = false, siteDir) { const artifactPath = validateSiteDir(siteDir); const existing = existsSync(output); if (existing && !force) { const out = { ok: false, status: "skipped", reason: "file_exists", path: resolve(output) }; console.log(json(out)); return out; } if (dryRun) { const out = { ok: true, status: "dry_run", action: existing ? "replace_workflow" : "create_workflow", path: resolve(output), site_directory: artifactPath, }; console.log(json(out)); return out; } const content = `name: Deploy to GitHub Pages on: push: branches: ["main"] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: "pages" cancel-in-progress: false jobs: deploy: environment: name: github-pages url: \${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: '${artifactPath}' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 `; mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, content); const out = { ok: true, status: "created", path: resolve(output), site_directory: artifactPath, }; console.log(json(out)); console.log( "\nReview the workflow, then commit and push it. The site directory must contain only publishable artifacts.", ); return out; } function main() { const cmd = process.argv.find((value) => ["enable", "status", "rebuild", "create-workflow"].includes(value), ); if (!cmd) { console.error("Usage: gh_pages_deploy.mjs "); return 1; } const dryRun = has("--dry-run"); const jsonOut = has("--json"); if (cmd === "create-workflow") { const result = createWorkflow(arg("--output"), has("--force"), dryRun, arg("--site-dir")); return result.ok ? 0 : 1; } const repo = process.argv[process.argv.indexOf(cmd) + 1]; if (!repo) throw new Error("repo required"); const pages = new Pages(repo, dryRun, jsonOut); let result; if (cmd === "enable") { result = pages.enable( arg("--branch", "main"), arg("--path", "/"), arg("--build-type", "workflow"), !has("--no-https"), ); if (result.ok && arg("--build-type", "workflow") === "workflow" && !jsonOut) console.log( "\nTip: scaffold deployment with:\n node scripts/gh_pages_deploy.mjs create-workflow --site-dir ", ); } else if (cmd === "status") { result = pages.status(); if (result.ok && has("--build-info")) result = pages.latest(); } else result = pages.rebuild(); return result.ok ? 0 : 1; } export const __test = { Pages, createWorkflow, json, msg, validateSiteDir, }; if (process.argv[1] === fileURLToPath(import.meta.url)) { try { process.exit(main()); } catch (error) { console.error(json({ ok: false, status: "error", error: error.message })); process.exit(1); } }