| 1 | import { mkdirSync, rmSync, existsSync, renameSync } from "fs"; |
| 2 | import { execFileSync } from "child_process"; |
| 3 | import type Database from "better-sqlite3"; |
| 4 | |
| 5 | export class PagesDeployer { |
| 6 | constructor( |
| 7 | private db: Database.Database, |
| 8 | private bridgeUrl: string, |
| 9 | private sitesDir: string, |
| 10 | private logger: { |
| 11 | info: (...args: any[]) => void; |
| 12 | error: (...args: any[]) => void; |
| 13 | } |
| 14 | ) { |
| 15 | mkdirSync(sitesDir, { recursive: true }); |
| 16 | } |
| 17 | |
| 18 | /** |
| 19 | * Get the deploy path for a repo's pages. |
| 20 | * Custom domain: sitesDir/{domain}/ (Caddy serves via {host} matching) |
| 21 | * Default: sitesDir/_pages/{owner}/{repo}/ (Caddy serves via path-based routing on pages.grove.host) |
| 22 | */ |
| 23 | getDeployPath(ownerName: string, repoName: string): { path: string; isCustomDomain: boolean } | null { |
| 24 | const repo = this.db |
| 25 | .prepare( |
| 26 | `SELECT r.pages_domain, r.pages_enabled, rwo.owner_name, r.name |
| 27 | FROM repos r |
| 28 | JOIN repos_with_owner rwo ON rwo.id = r.id |
| 29 | WHERE rwo.owner_name = ? AND r.name = ? AND r.pages_enabled = 1` |
| 30 | ) |
| 31 | .get(ownerName, repoName) as any; |
| 32 | |
| 33 | if (!repo) return null; |
| 34 | |
| 35 | if (repo.pages_domain) { |
| 36 | return { path: `${this.sitesDir}/${repo.pages_domain}`, isCustomDomain: true }; |
| 37 | } |
| 38 | |
| 39 | return { path: `${this.sitesDir}/_pages/${repo.owner_name}/${repo.name}`, isCustomDomain: false }; |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Deploy a repo's pages content to disk. |
| 44 | * Fetches a tar archive from the bridge and extracts to the deploy path. |
| 45 | * Uses atomic rename so Caddy never sees a half-written site. |
| 46 | */ |
| 47 | async deploy(ownerName: string, repoName: string, ref: string): Promise<void> { |
| 48 | const deployInfo = this.getDeployPath(ownerName, repoName); |
| 49 | if (!deployInfo) return; |
| 50 | |
| 51 | const { path: destDir } = deployInfo; |
| 52 | |
| 53 | this.logger.info({ owner: ownerName, repo: repoName, dest: destDir, ref }, "Deploying pages"); |
| 54 | |
| 55 | const tmpDir = `${destDir}.tmp-${Date.now()}`; |
| 56 | |
| 57 | try { |
| 58 | mkdirSync(tmpDir, { recursive: true }); |
| 59 | |
| 60 | const url = `${this.bridgeUrl}/repos/${repoName}/archive/${ref}`; |
| 61 | const res = await fetch(url); |
| 62 | if (!res.ok) { |
| 63 | throw new Error(`Archive fetch failed: ${res.status} ${res.statusText}`); |
| 64 | } |
| 65 | const tarData = Buffer.from(await res.arrayBuffer()); |
| 66 | execFileSync("tar", ["xf", "-", "-C", tmpDir], { input: tarData }); |
| 67 | |
| 68 | // Atomic swap |
| 69 | const oldDir = `${destDir}.old`; |
| 70 | if (existsSync(destDir)) { |
| 71 | renameSync(destDir, oldDir); |
| 72 | } |
| 73 | // Ensure parent directory exists for nested paths |
| 74 | mkdirSync(destDir.substring(0, destDir.lastIndexOf("/")), { recursive: true }); |
| 75 | renameSync(tmpDir, destDir); |
| 76 | if (existsSync(oldDir)) { |
| 77 | rmSync(oldDir, { recursive: true, force: true }); |
| 78 | } |
| 79 | |
| 80 | this.logger.info( |
| 81 | { owner: ownerName, repo: repoName, dest: destDir }, |
| 82 | "Pages deployed successfully" |
| 83 | ); |
| 84 | } catch (err) { |
| 85 | if (existsSync(tmpDir)) { |
| 86 | rmSync(tmpDir, { recursive: true, force: true }); |
| 87 | } |
| 88 | throw err; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /** Remove a site's deployed files by deploy path. */ |
| 93 | undeploy(deployPath: string): void { |
| 94 | if (existsSync(deployPath)) { |
| 95 | rmSync(deployPath, { recursive: true, force: true }); |
| 96 | this.logger.info({ path: deployPath }, "Pages undeployed"); |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | /** Check if a custom domain is configured for any repo (used by Caddy's on-demand TLS). */ |
| 101 | isDomainConfigured(domain: string): boolean { |
| 102 | const row = this.db |
| 103 | .prepare( |
| 104 | `SELECT id FROM repos WHERE pages_domain = ? AND pages_enabled = 1` |
| 105 | ) |
| 106 | .get(domain); |
| 107 | return !!row; |
| 108 | } |
| 109 | } |
| 110 | |