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