3.5 KB110 lines
Blame
1import { mkdirSync, rmSync, existsSync, renameSync } from "fs";
2import { execFileSync } from "child_process";
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(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