api/src/services/pages-deployer.tsblame
View source
e5b523e1import { mkdirSync, rmSync, existsSync, renameSync } from "fs";
50659952import { execFileSync } from "child_process";
e5b523e3import type Database from "better-sqlite3";
e5b523e4
e5b523e5export class PagesDeployer {
e5b523e6 constructor(
e5b523e7 private db: Database.Database,
e5b523e8 private bridgeUrl: string,
e5b523e9 private sitesDir: string,
e5b523e10 private logger: {
e5b523e11 info: (...args: any[]) => void;
e5b523e12 error: (...args: any[]) => void;
e5b523e13 }
e5b523e14 ) {
e5b523e15 mkdirSync(sitesDir, { recursive: true });
e5b523e16 }
e5b523e17
e5b523e18 /**
ff50d0319 * Get the deploy path for a repo's pages.
ff50d0320 * Custom domain: sitesDir/{domain}/ (Caddy serves via {host} matching)
ff50d0321 * Default: sitesDir/_pages/{owner}/{repo}/ (Caddy serves via path-based routing on pages.grove.host)
e5b523e22 */
506599523 getDeployPath(ownerName: string, repoName: string): { path: string; isCustomDomain: boolean } | null {
e5b523e24 const repo = this.db
e5b523e25 .prepare(
ff50d0326 `SELECT r.pages_domain, r.pages_enabled, rwo.owner_name, r.name
ff50d0327 FROM repos r
ff50d0328 JOIN repos_with_owner rwo ON rwo.id = r.id
506599529 WHERE rwo.owner_name = ? AND r.name = ? AND r.pages_enabled = 1`
e5b523e30 )
506599531 .get(ownerName, repoName) as any;
e5b523e32
ff50d0333 if (!repo) return null;
e5b523e34
ff50d0335 if (repo.pages_domain) {
ff50d0336 return { path: `${this.sitesDir}/${repo.pages_domain}`, isCustomDomain: true };
e5b523e37 }
e5b523e38
ff50d0339 return { path: `${this.sitesDir}/_pages/${repo.owner_name}/${repo.name}`, isCustomDomain: false };
ff50d0340 }
ff50d0341
ff50d0342 /**
ff50d0343 * Deploy a repo's pages content to disk.
506599544 * Fetches a tar archive from the bridge and extracts to the deploy path.
ff50d0345 * Uses atomic rename so Caddy never sees a half-written site.
ff50d0346 */
506599547 async deploy(ownerName: string, repoName: string, ref: string): Promise<void> {
506599548 const deployInfo = this.getDeployPath(ownerName, repoName);
ff50d0349 if (!deployInfo) return;
ff50d0350
ff50d0351 const { path: destDir } = deployInfo;
ff50d0352
506599553 this.logger.info({ owner: ownerName, repo: repoName, dest: destDir, ref }, "Deploying pages");
e5b523e54
ff50d0355 const tmpDir = `${destDir}.tmp-${Date.now()}`;
e5b523e56
e5b523e57 try {
506599558 mkdirSync(tmpDir, { recursive: true });
506599559
e5b523e60 const url = `${this.bridgeUrl}/repos/${repoName}/archive/${ref}`;
e5b523e61 const res = await fetch(url);
e5b523e62 if (!res.ok) {
506599563 throw new Error(`Archive fetch failed: ${res.status} ${res.statusText}`);
e5b523e64 }
e5b523e65 const tarData = Buffer.from(await res.arrayBuffer());
e5b523e66 execFileSync("tar", ["xf", "-", "-C", tmpDir], { input: tarData });
e5b523e67
e5b523e68 // Atomic swap
e5b523e69 const oldDir = `${destDir}.old`;
e5b523e70 if (existsSync(destDir)) {
e5b523e71 renameSync(destDir, oldDir);
e5b523e72 }
ff50d0373 // Ensure parent directory exists for nested paths
ff50d0374 mkdirSync(destDir.substring(0, destDir.lastIndexOf("/")), { recursive: true });
e5b523e75 renameSync(tmpDir, destDir);
e5b523e76 if (existsSync(oldDir)) {
e5b523e77 rmSync(oldDir, { recursive: true, force: true });
e5b523e78 }
e5b523e79
e5b523e80 this.logger.info(
506599581 { owner: ownerName, repo: repoName, dest: destDir },
e5b523e82 "Pages deployed successfully"
e5b523e83 );
e5b523e84 } catch (err) {
e5b523e85 if (existsSync(tmpDir)) {
e5b523e86 rmSync(tmpDir, { recursive: true, force: true });
e5b523e87 }
e5b523e88 throw err;
e5b523e89 }
e5b523e90 }
e5b523e91
ff50d0392 /** Remove a site's deployed files by deploy path. */
ff50d0393 undeploy(deployPath: string): void {
ff50d0394 if (existsSync(deployPath)) {
ff50d0395 rmSync(deployPath, { recursive: true, force: true });
ff50d0396 this.logger.info({ path: deployPath }, "Pages undeployed");
e5b523e97 }
e5b523e98 }
e5b523e99
ff50d03100 /** Check if a custom domain is configured for any repo (used by Caddy's on-demand TLS). */
e5b523e101 isDomainConfigured(domain: string): boolean {
e5b523e102 const row = this.db
e5b523e103 .prepare(
e5b523e104 `SELECT id FROM repos WHERE pages_domain = ? AND pages_enabled = 1`
e5b523e105 )
e5b523e106 .get(domain);
e5b523e107 return !!row;
e5b523e108 }
e5b523e109}