repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
scripts/build/publish.ts
Rawimport {
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<BuildManifest>;
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 });
}