import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { dirname, join, relative, sep } from "node:path"; export const BUILD_MANIFEST = ".pi-ext-build.json"; interface BuildManifest { files: string[]; } export function listFiles(dir: string, base = dir): string[] { const out: string[] = []; if (!existsSync(dir)) return out; for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) out.push(...listFiles(path, base)); else out.push(relative(base, path).split(sep).join("/")); } return out.sort(); } function readManifest(target: string): BuildManifest | undefined { try { const parsed = JSON.parse( readFileSync(join(target, BUILD_MANIFEST), "utf8"), ) as Partial; return Array.isArray(parsed.files) ? { files: parsed.files.filter((f) => typeof f === "string") } : undefined; } catch { return undefined; } } function replaceFile(source: string, destination: string): void { mkdirSync(dirname(destination), { recursive: true }); const temp = `${destination}.tmp-${process.pid}`; copyFileSync(source, temp); renameSync(temp, destination); } function pruneEmptyDirs(dir: string, root: string): void { if (dir === root || !existsSync(dir)) return; if (readdirSync(dir).length === 0) { rmSync(dir, { recursive: true, force: true }); pruneEmptyDirs(dirname(dir), root); } } /** * Publish a complete staging directory into the canonical package directory. * * Non-entry files land first, then entry files replace the previous entries * atomically, so a loaded extension keeps a consistent package until the * new entrypoint appears. Files from the previous build survive one more * generation so in-flight lazy imports can still resolve their chunks; anything * older is pruned. */ export function publishPackage( staging: string, target: string, entries: readonly string[], ): void { const files = listFiles(staging); if (!files.length) throw new Error(`nothing staged in ${staging}`); if (!existsSync(target)) { mkdirSync(dirname(target), { recursive: true }); writeFileSync( join(staging, BUILD_MANIFEST), `${JSON.stringify({ files } satisfies BuildManifest, null, "\t")}\n`, ); renameSync(staging, target); return; } if (!statSync(target).isDirectory()) throw new Error(`${target} is not a directory`); const previous = new Set(readManifest(target)?.files ?? listFiles(target)); const entrySet = new Set(entries); const late = files.filter((f) => entrySet.has(f) || f === "package.json"); const early = files.filter((f) => !late.includes(f)); for (const file of early) replaceFile(join(staging, file), join(target, file)); for (const file of late.filter((f) => f === "package.json")) replaceFile(join(staging, file), join(target, file)); for (const file of late.filter((f) => f !== "package.json")) replaceFile(join(staging, file), join(target, file)); const keep = new Set([...files, ...previous, BUILD_MANIFEST]); for (const file of listFiles(target)) { if (keep.has(file)) continue; rmSync(join(target, file), { force: true }); pruneEmptyDirs(dirname(join(target, file)), target); } writeFileSync( join(target, BUILD_MANIFEST), `${JSON.stringify({ files } satisfies BuildManifest, null, "\t")}\n`, ); rmSync(staging, { recursive: true, force: true }); }