1/**
2 * Shared filesystem helpers used across webette.
3 *
4 * Small, focused utilities for writing/copying files and cleaning directories.
5 */
6
7import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
8import * as path from "node:path";
9import type { Logger } from "../logging/logger";
10
11const retryableRmCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
12
13const isRetryableRmError = (err: unknown): boolean => {
14 if (!err || typeof err !== "object") return false;
15 const code = (err as { code?: unknown }).code;
16 return typeof code === "string" && retryableRmCodes.has(code);
17};
18
19const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
20
21export async function rmWithRetries(
22 targetPath: string,
23 options?: Parameters<typeof rm>[1],
24 retries = 5,
25 delayMs = 50
26): Promise<void> {
27 let attempt = 0;
28 while (true) {
29 try {
30 await rm(targetPath, options);
31 return;
32 } catch (err) {
33 if (attempt >= retries || !isRetryableRmError(err)) {
34 throw err;
35 }
36 const wait = delayMs * Math.pow(2, attempt);
37 await sleep(wait);
38 attempt += 1;
39 }
40 }
41}
42
43/**
44 * Writes UTF-8 content to `targetPath` only when the bytes differ.
45 * Returns `true` if the file changed, `false` if it was already identical.
46 */
47export async function writeIfChanged(
48 targetPath: string,
49 content: string,
50 logger?: Logger
51): Promise<boolean> {
52 const nextBuffer = Buffer.from(content, "utf8");
53 let current: Buffer | null = null;
54 try {
55 current = await readFile(targetPath);
56 } catch {
57 // file missing or unreadable -> treat as different
58 }
59 if (current && Buffer.compare(current, nextBuffer) === 0) {
60 logger?.debug("build.fileUnchanged", { path: targetPath });
61 return false;
62 }
63 await writeFile(targetPath, nextBuffer, "utf8");
64 logger?.debug("build.fileGenerated", { path: targetPath });
65 return true;
66}
67
68/**
69 * Recursively removes empty directories under `root`, excluding `root` itself.
70 */
71export async function removeEmptyDirectories(dir: string, root: string): Promise<void> {
72 const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
73 for (const entry of entries) {
74 if (!entry.isDirectory()) continue;
75 const child = path.join(dir, entry.name);
76 await removeEmptyDirectories(child, root);
77 }
78 const after = await readdir(dir).catch(() => []);
79 if (dir !== root && after.length === 0) {
80 try {
81 await rmWithRetries(dir, { force: true, recursive: true });
82 } catch {
83 // ignore
84 }
85 }
86}
87
88/**
89 * Recursively copies `sourceDir` into `targetDir`, creating directories as needed.
90 */
91export async function copyDirectory(sourceDir: string, targetDir: string): Promise<void> {
92 const entries = await readdir(sourceDir, { withFileTypes: true }).catch(() => []);
93 await mkdir(targetDir, { recursive: true });
94 for (const entry of entries) {
95 const srcPath = path.join(sourceDir, entry.name);
96 const dstPath = path.join(targetDir, entry.name);
97 if (entry.isDirectory()) {
98 await copyDirectory(srcPath, dstPath);
99 } else if (entry.isFile()) {
100 await copyFile(srcPath, dstPath);
101 }
102 }
103}