3.4 KB112 lines
Blame
1import { execFileSync } from "child_process";
2import { mkdirSync, rmSync, existsSync, renameSync } from "fs";
3import type Database from "better-sqlite3";
4
5export 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(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 r.name = ? AND r.pages_enabled = 1`
30 )
31 .get(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 * Archives the repo from bridge and extracts to the deploy path.
45 * Uses atomic rename so Caddy never sees a half-written site.
46 */
47 async deploy(repoName: string, ref: string): Promise<void> {
48 const deployInfo = this.getDeployPath(repoName);
49 if (!deployInfo) return;
50
51 const { path: destDir } = deployInfo;
52
53 this.logger.info({ repo: repoName, dest: destDir, ref }, "Deploying pages");
54
55 const tmpDir = `${destDir}.tmp-${Date.now()}`;
56
57 try {
58 const url = `${this.bridgeUrl}/repos/${repoName}/archive/${ref}`;
59 const res = await fetch(url);
60 if (!res.ok) {
61 throw new Error(
62 `Archive fetch failed: ${res.status} ${res.statusText}`
63 );
64 }
65
66 mkdirSync(tmpDir, { recursive: true });
67 const tarData = Buffer.from(await res.arrayBuffer());
68 execFileSync("tar", ["xf", "-", "-C", tmpDir], { input: tarData });
69
70 // Atomic swap
71 const oldDir = `${destDir}.old`;
72 if (existsSync(destDir)) {
73 renameSync(destDir, oldDir);
74 }
75 // Ensure parent directory exists for nested paths
76 mkdirSync(destDir.substring(0, destDir.lastIndexOf("/")), { recursive: true });
77 renameSync(tmpDir, destDir);
78 if (existsSync(oldDir)) {
79 rmSync(oldDir, { recursive: true, force: true });
80 }
81
82 this.logger.info(
83 { repo: repoName, dest: destDir },
84 "Pages deployed successfully"
85 );
86 } catch (err) {
87 if (existsSync(tmpDir)) {
88 rmSync(tmpDir, { recursive: true, force: true });
89 }
90 throw err;
91 }
92 }
93
94 /** Remove a site's deployed files by deploy path. */
95 undeploy(deployPath: string): void {
96 if (existsSync(deployPath)) {
97 rmSync(deployPath, { recursive: true, force: true });
98 this.logger.info({ path: deployPath }, "Pages undeployed");
99 }
100 }
101
102 /** Check if a custom domain is configured for any repo (used by Caddy's on-demand TLS). */
103 isDomainConfigured(domain: string): boolean {
104 const row = this.db
105 .prepare(
106 `SELECT id FROM repos WHERE pages_domain = ? AND pages_enabled = 1`
107 )
108 .get(domain);
109 return !!row;
110 }
111}
112