From f3ed845923a66df29771f3bb07e05afe6a0c1a1d Mon Sep 17 00:00:00 2001 From: James Date: Sat, 5 Sep 2026 18:18:27 -0400 Subject: [PATCH 1/2] fix(cli): create capture metadata exclusively --- packages/cli/src/capture/scaffolding.test.ts | 104 +++++++++++++++++++ packages/cli/src/capture/scaffolding.ts | 14 ++- 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/capture/scaffolding.test.ts diff --git a/packages/cli/src/capture/scaffolding.test.ts b/packages/cli/src/capture/scaffolding.test.ts new file mode 100644 index 0000000000..e39bb38352 --- /dev/null +++ b/packages/cli/src/capture/scaffolding.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generateProjectScaffold } from "./scaffolding.js"; +import type { DesignTokens } from "./types.js"; + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, existsSync: vi.fn(original.existsSync) }; +}); + +const tokens: DesignTokens = { + title: "Captured site", + description: "", + cssVariables: {}, + fonts: [], + colors: [], + headings: [], + ctas: [], + svgs: [], + sections: [], +}; + +describe("generateProjectScaffold metadata", () => { + let dir: string; + let metaPath: string; + let warnings: string[]; + const progress = vi.fn(); + + beforeEach(() => { + dir = fs.mkdtempSync(join(tmpdir(), "hf-scaffold-")); + metaPath = join(dir, "meta.json"); + warnings = []; + vi.clearAllMocks(); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function generate(url = "https://www.example.com", title = tokens.title) { + return generateProjectScaffold( + dir, + url, + { ...tokens, title }, + undefined, + false, + false, + false, + [], + progress, + warnings, + ); + } + + it.each(["Captured site", ""])( + "creates metadata with title %j and agent instructions", + async (title) => { + await generate(undefined, title); + expect(fs.readFileSync(metaPath, "utf-8")).toBe( + JSON.stringify({ id: "example.com-video", name: title || "example.com" }, null, 2), + ); + expect(fs.readFileSync(join(dir, "AGENTS.md"), "utf-8")).toBe( + fs.readFileSync(join(dir, "CLAUDE.md"), "utf-8"), + ); + expect(fs.existsSync(join(dir, "index.html"))).toBe(false); + expect(progress).toHaveBeenCalledWith("agent", "AGENTS.md + CLAUDE.md generated"); + expect(warnings).toEqual([]); + }, + ); + + it("keeps existing metadata without parsing the unused URL", async () => { + fs.writeFileSync(metaPath, "user metadata"); + await generate("not a URL"); + expect(fs.readFileSync(metaPath, "utf-8")).toBe("user metadata"); + }); + + it("preserves a file created after the existence check and continues scaffolding", async () => { + vi.mocked(fs.existsSync).mockImplementationOnce(() => { + fs.writeFileSync(metaPath, "concurrent metadata"); + return false; + }); + await generate(); + expect(fs.readFileSync(metaPath, "utf-8")).toBe("concurrent metadata"); + expect(progress).toHaveBeenCalledWith("agent", "AGENTS.md + CLAUDE.md generated"); + expect(warnings).toEqual([]); + }); + + it("does not follow a dangling metadata symlink", async () => { + const target = join(dir, "missing-target.json"); + fs.symlinkSync(target, metaPath); + await generate(); + expect(fs.lstatSync(metaPath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(target)).toBe(false); + expect(warnings).toEqual([]); + }); + + it("propagates write errors other than EEXIST", async () => { + fs.rmSync(dir, { recursive: true }); + await expect(generate()).rejects.toMatchObject({ code: "ENOENT" }); + expect(progress).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/capture/scaffolding.ts b/packages/cli/src/capture/scaffolding.ts index befeb210e6..a8cd5c949c 100644 --- a/packages/cli/src/capture/scaffolding.ts +++ b/packages/cli/src/capture/scaffolding.ts @@ -71,11 +71,15 @@ export async function generateProjectScaffold( const metaPath = join(outputDir, "meta.json"); if (!existsSync(metaPath)) { const hostname = new URL(url).hostname.replace(/^www\./, ""); - writeFileSync( - metaPath, - JSON.stringify({ id: hostname + "-video", name: tokens.title || hostname }, null, 2), - "utf-8", - ); + try { + writeFileSync( + metaPath, + JSON.stringify({ id: hostname + "-video", name: tokens.title || hostname }, null, 2), + { encoding: "utf-8", flag: "wx" }, + ); + } catch (err) { + if (!(err instanceof Error && "code" in err && err.code === "EEXIST")) throw err; + } } // Generate AGENTS.md + CLAUDE.md (AI agent instructions — always, regardless of API keys) From baa9fbc8b391c87ff9091416200814c1d81dfba7 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 5 Sep 2026 18:34:08 -0400 Subject: [PATCH 2/2] fix(cli): publish capture metadata without following links --- packages/cli/src/capture/scaffolding.test.ts | 23 +++++++++++++++++++- packages/cli/src/capture/scaffolding.ts | 16 ++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/capture/scaffolding.test.ts b/packages/cli/src/capture/scaffolding.test.ts index e39bb38352..190ba5b368 100644 --- a/packages/cli/src/capture/scaffolding.test.ts +++ b/packages/cli/src/capture/scaffolding.test.ts @@ -7,7 +7,12 @@ import type { DesignTokens } from "./types.js"; vi.mock("node:fs", async (importOriginal) => { const original = await importOriginal(); - return { ...original, existsSync: vi.fn(original.existsSync) }; + return { + ...original, + existsSync: vi.fn(original.existsSync), + writeFileSync: vi.fn(original.writeFileSync), + linkSync: vi.fn(original.linkSync), + }; }); const tokens: DesignTokens = { @@ -36,6 +41,9 @@ describe("generateProjectScaffold metadata", () => { }); afterEach(() => { + if (fs.existsSync(dir)) { + expect(fs.readdirSync(dir).filter((name) => name.startsWith(".hf-meta-"))).toEqual([]); + } fs.rmSync(dir, { recursive: true, force: true }); }); @@ -101,4 +109,17 @@ describe("generateProjectScaffold metadata", () => { await expect(generate()).rejects.toMatchObject({ code: "ENOENT" }); expect(progress).not.toHaveBeenCalled(); }); + + it.each(["writeFileSync", "linkSync"] as const)( + "cleans staging and propagates failures from %s", + async (operation) => { + const error = Object.assign(new Error("injected I/O failure"), { code: "EIO" }); + vi.mocked(fs[operation]).mockImplementationOnce(() => { + throw error; + }); + await expect(generate()).rejects.toBe(error); + expect(fs.existsSync(metaPath)).toBe(false); + expect(progress).not.toHaveBeenCalled(); + }, + ); }); diff --git a/packages/cli/src/capture/scaffolding.ts b/packages/cli/src/capture/scaffolding.ts index a8cd5c949c..585efa545d 100644 --- a/packages/cli/src/capture/scaffolding.ts +++ b/packages/cli/src/capture/scaffolding.ts @@ -5,7 +5,7 @@ * (index.html, meta.json, AGENTS.md, CLAUDE.md). */ -import { existsSync, writeFileSync, readFileSync } from "node:fs"; +import { existsSync, writeFileSync, readFileSync, mkdtempSync, linkSync, rmSync } from "node:fs"; import { join, resolve } from "node:path"; import type { CatalogedAsset } from "./assetCataloger.js"; import type { CaptureResult, DesignTokens } from "./types.js"; @@ -71,14 +71,22 @@ export async function generateProjectScaffold( const metaPath = join(outputDir, "meta.json"); if (!existsSync(metaPath)) { const hostname = new URL(url).hostname.replace(/^www\./, ""); + const stagingDir = mkdtempSync(join(outputDir, ".hf-meta-")); try { + const stagedPath = join(stagingDir, "meta.json"); writeFileSync( - metaPath, + stagedPath, JSON.stringify({ id: hostname + "-video", name: tokens.title || hostname }, null, 2), { encoding: "utf-8", flag: "wx" }, ); - } catch (err) { - if (!(err instanceof Error && "code" in err && err.code === "EEXIST")) throw err; + // Linking publishes without following or replacing an existing destination entry. + try { + linkSync(stagedPath, metaPath); + } catch (err) { + if (!(err instanceof Error && "code" in err && err.code === "EEXIST")) throw err; + } + } finally { + rmSync(stagingDir, { recursive: true, force: true }); } }