import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises"; import { join, resolve } from "node:path"; import { createManifest, isArtifactManifest } from "./manifest.ts"; import { isPathInside } from "./path-safety.ts"; import { isArtifactId, slugifyTitle, suffixSlug } from "./slug.ts"; import { entryFileForStack } from "./stacks.ts"; import type { ArtifactManifest, ArtifactStack, ScaffoldArtifactDetails, } from "./types.ts"; function artifactPath(id: string, root: string): string { return join(root, id); } function manifestPath(id: string, root: string): string { return join(artifactPath(id, root), "manifest.json"); } function validateArtifactBundlePath(id: string, root: string): string { if (!isArtifactId(id)) { throw new Error(`Invalid artifact id: ${id}`); } const path = artifactPath(id, root); const resolvedRoot = resolve(root); if (!isPathInside(resolvedRoot, path) || resolve(path) === resolvedRoot) { throw new Error(`Invalid artifact id: ${id}`); } return path; } export interface ScaffoldArtifactInput { root: string; title: string; stack: ArtifactStack; cwd: string; now?: Date; sessionFile?: string; sessionKey?: string; } export async function scaffoldArtifact( input: ScaffoldArtifactInput, ): Promise { const title = input.title.trim(); if (!title) { throw new Error("Artifact title must not be empty."); } const { root } = input; const baseSlug = slugifyTitle(title); await mkdir(root, { recursive: true }); const id = await reserveArtifactId(root, baseSlug); const path = artifactPath(id, root); const entryName = entryFileForStack(input.stack); const entry = join(path, entryName); const assetsPath = join(path, "assets"); const manifest = createManifest({ id, title, stack: input.stack, entry: entryName, cwd: input.cwd, now: input.now, sessionFile: input.sessionFile, sessionKey: input.sessionKey, }); await mkdir(assetsPath, { recursive: true }); await writeFile(entry, "", { flag: "wx" }); await writeFile( manifestPath(id, root), `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx", }, ); return { id, path, entry, manifestPath: manifestPath(id, root), }; } async function reserveArtifactId( root: string, baseSlug: string, ): Promise { for (let index = 1; index < Number.MAX_SAFE_INTEGER; index += 1) { const id = suffixSlug(baseSlug, index); try { await mkdir(artifactPath(id, root)); return id; } catch (error) { if (isNodeError(error) && error.code === "EEXIST") { continue; } throw error; } } throw new Error(`Unable to allocate artifact id for ${baseSlug}.`); } export interface LoadedArtifact { id: string; path: string; manifestPath: string; manifest: ArtifactManifest; entryPath: string; } export interface ArtifactRef { root: string; id: string; } export async function loadArtifact({ root, id, }: ArtifactRef): Promise { const path = validateArtifactBundlePath(id, root); const mPath = manifestPath(id, root); const rawManifest = await readFile(mPath, "utf8").catch((error: unknown) => { if (isNodeError(error) && error.code === "ENOENT") { throw new Error(`Artifact "${id}" does not exist.`); } throw error; }); let parsedManifest: unknown; try { parsedManifest = JSON.parse(rawManifest); } catch { throw new Error(`Artifact "${id}" has invalid manifest JSON.`); } if (!isArtifactManifest(parsedManifest)) { throw new Error(`Artifact "${id}" has an invalid manifest shape.`); } if (parsedManifest.id !== id) { throw new Error( `Artifact "${id}" manifest id mismatch: ${parsedManifest.id}.`, ); } const resolvedEntryPath = resolve(path, parsedManifest.entry); if (!isPathInside(path, resolvedEntryPath)) { throw new Error(`Artifact "${id}" entry path escapes its bundle.`); } let realRootPath: string; let realBundlePath: string; let realEntryPath: string; try { [realRootPath, realBundlePath, realEntryPath] = await Promise.all([ realpath(root), realpath(path), realpath(resolvedEntryPath), ]); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { throw new Error(`Artifact "${id}" entry file is missing.`); } throw error; } if ( !isPathInside(realRootPath, realBundlePath) || realBundlePath === realRootPath || !isPathInside(realBundlePath, realEntryPath) || realEntryPath === realBundlePath ) { throw new Error(`Artifact "${id}" entry path escapes its bundle.`); } const entryStats = await stat(realEntryPath); if (!entryStats.isFile()) { throw new Error(`Artifact "${id}" entry is not a file.`); } return { id, path, manifestPath: mPath, manifest: parsedManifest, entryPath: realEntryPath, }; } export async function listArtifacts({ root, }: { root: string; }): Promise { const entries = await readdir(root, { withFileTypes: true }).catch( (error: unknown) => { if (isNodeError(error) && error.code === "ENOENT") { return []; } throw error; }, ); const artifacts = await Promise.all( entries .filter((entry) => entry.isDirectory()) .map(async (entry) => loadArtifact({ root, id: entry.name }).catch(() => undefined), ), ); return artifacts .filter((artifact): artifact is LoadedArtifact => artifact !== undefined) .sort((left, right) => right.manifest.updated.localeCompare(left.manifest.updated), ); } export async function writeManifest({ root, id, manifest, }: ArtifactRef & { manifest: ArtifactManifest }): Promise { // Write-then-rename so a crash mid-write can never leave a truncated // manifest.json behind (the store is shared across concurrent sessions). validateArtifactBundlePath(id, root); const path = manifestPath(id, root); const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; try { await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx", }); await rename(tempPath, path); } finally { await rm(tempPath, { force: true }).catch(() => {}); } } export interface DeleteArtifactsInput { root: string; ids?: string[]; olderThan?: Date; } /** * Bulk delete. `ids` are deleted directly (missing/invalid ids are skipped, * not errors); `olderThan` deletes every listable artifact whose manifest * `updated` timestamp is strictly older. Returns the ids actually deleted. */ export async function deleteArtifacts( input: DeleteArtifactsInput, ): Promise { const { root } = input; if (!input.ids && !input.olderThan) { throw new Error("deleteArtifacts requires ids or olderThan."); } const deleted: string[] = []; for (const id of input.ids ?? []) { try { await deleteArtifact({ root, id }); deleted.push(id); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!/does not exist|Invalid artifact id/.test(message)) { throw error; } } } if (input.olderThan) { const cutoff = input.olderThan.toISOString(); for (const artifact of await listArtifacts({ root })) { if ( artifact.manifest.updated < cutoff && !deleted.includes(artifact.id) ) { await deleteArtifact({ root, id: artifact.id }); deleted.push(artifact.id); } } } return deleted; } export async function deleteArtifact({ root, id }: ArtifactRef): Promise { const path = validateArtifactBundlePath(id, root); const stats = await stat(path).catch((error: unknown) => { if (isNodeError(error) && error.code === "ENOENT") { throw new Error(`Artifact "${id}" does not exist.`); } throw error; }); if (!stats.isDirectory()) { throw new Error(`Artifact "${id}" is not a bundle directory.`); } await rm(path, { recursive: true, force: true }); } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; }