diff --git a/packages/cli/src/capture/scaffolding.test.ts b/packages/cli/src/capture/scaffolding.test.ts index 190ba5b368..21016cf08b 100644 --- a/packages/cli/src/capture/scaffolding.test.ts +++ b/packages/cli/src/capture/scaffolding.test.ts @@ -42,7 +42,7 @@ describe("generateProjectScaffold metadata", () => { afterEach(() => { if (fs.existsSync(dir)) { - expect(fs.readdirSync(dir).filter((name) => name.startsWith(".hf-meta-"))).toEqual([]); + expect(fs.readdirSync(dir).filter((name) => name.startsWith(".hf-create-"))).toEqual([]); } fs.rmSync(dir, { recursive: true, force: true }); }); diff --git a/packages/cli/src/capture/scaffolding.ts b/packages/cli/src/capture/scaffolding.ts index 585efa545d..e38a078e8c 100644 --- a/packages/cli/src/capture/scaffolding.ts +++ b/packages/cli/src/capture/scaffolding.ts @@ -5,8 +5,9 @@ * (index.html, meta.json, AGENTS.md, CLAUDE.md). */ -import { existsSync, writeFileSync, readFileSync, mkdtempSync, linkSync, rmSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; +import { writeNewFileSync } from "../utils/writeNewFile.js"; import type { CatalogedAsset } from "./assetCataloger.js"; import type { CaptureResult, DesignTokens } from "./types.js"; @@ -71,23 +72,10 @@ 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( - stagedPath, - JSON.stringify({ id: hostname + "-video", name: tokens.title || hostname }, null, 2), - { encoding: "utf-8", flag: "wx" }, - ); - // 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 }); - } + writeNewFileSync( + metaPath, + JSON.stringify({ id: hostname + "-video", name: tokens.title || hostname }, null, 2), + ); } // Generate AGENTS.md + CLAUDE.md (AI agent instructions — always, regardless of API keys) diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index a0d8574711..c37ea85152 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -26,7 +26,7 @@ import { loadProjectConfig, projectConfigPath, recordProjectRegistryItems, - writeProjectConfig, + createProjectConfig, } from "../utils/projectConfig.js"; import { copyToClipboard } from "../utils/clipboard.js"; import { trackRegistryItemAdded } from "../telemetry/events.js"; @@ -279,7 +279,7 @@ export async function runAdd(opts: RunAddArgs): Promise { let config = loadProjectConfig(projectDir); const hasConfig = existsSync(projectConfigPath(projectDir)); if (!hasConfig && existsSync(resolve(projectDir, "index.html"))) { - writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG); + createProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG); config = DEFAULT_PROJECT_CONFIG; } @@ -499,7 +499,7 @@ export default defineCommand({ !existsSync(projectConfigPath(projectDir)) && existsSync(resolve(projectDir, "index.html")) ) { - writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG); + createProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG); config = DEFAULT_PROJECT_CONFIG; } diff --git a/packages/cli/src/commands/init.package-race.test.ts b/packages/cli/src/commands/init.package-race.test.ts new file mode 100644 index 0000000000..3c30514f46 --- /dev/null +++ b/packages/cli/src/commands/init.package-race.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCommand } from "citty"; +import init from "./init.js"; + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, existsSync: vi.fn(original.existsSync) }; +}); +vi.mock("../telemetry/events.js", () => ({ trackInitTemplate: vi.fn() })); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("init config creation", () => { + it.each( + ["package.json", "hyperframes.json"].flatMap((filename) => + ["existing", "concurrent", "dangling symlink"].map((kind) => ({ filename, kind })), + ), + )("preserves $kind $filename while completing initialization", async ({ filename, kind }) => { + const dir = fs.mkdtempSync(join(tmpdir(), "hf-init-package-")); + const project = join(dir, "project"); + const packagePath = join(project, filename); + const target = join(dir, "missing-target.json"); + const original = await vi.importActual("node:fs"); + let injected = false; + vi.mocked(fs.existsSync).mockImplementation((path) => { + if (path === packagePath && !injected) { + injected = true; + if (kind === "dangling symlink") fs.symlinkSync(target, packagePath); + else fs.writeFileSync(packagePath, '{"name":"preserve-me"}\n'); + return kind === "existing"; + } + return original.existsSync(path); + }); + vi.stubEnv("HYPERFRAMES_SKIP_SKILLS", "1"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await runCommand(init, { + rawArgs: [project, "--example", "blank", "--non-interactive"], + }); + expect(injected).toBe(true); + if (kind === "dangling symlink") { + expect(fs.lstatSync(packagePath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(target)).toBe(false); + } else { + expect(fs.readFileSync(packagePath, "utf-8")).toBe('{"name":"preserve-me"}\n'); + } + expect(fs.existsSync(join(project, "index.html"))).toBe(true); + expect(log.mock.calls.flat().join("\n")).toContain("npm run dev"); + expect(fs.readdirSync(project).filter((name) => name.startsWith(".hf-create-"))).toEqual([]); + } finally { + vi.mocked(fs.existsSync).mockImplementation(original.existsSync); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index b8905f2d3b..47a2f7d156 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -4,6 +4,7 @@ // own task. // fallow-ignore-file complexity import { failCommand, finishCommand } from "../utils/commandResult.js"; +import { writeNewFileSync } from "../utils/writeNewFile.js"; import { defineCommand, runCommand } from "citty"; import type { Example } from "./_examples.js"; @@ -269,7 +270,7 @@ function writeDefaultPackageJson(destDir: string, projectName: string): void { const packageJsonPath = resolve(destDir, "package.json"); if (existsSync(packageJsonPath)) return; - writeFileSync( + writeNewFileSync( packageJsonPath, `${JSON.stringify( { @@ -281,7 +282,6 @@ function writeDefaultPackageJson(destDir: string, projectName: string): void { null, 2, )}\n`, - "utf-8", ); } @@ -592,11 +592,11 @@ async function scaffoldProject( // When the scaffolding workflow declared itself via --skill, stamp the owning // skill here so every later render of this project is attributed to it. if (!existsSync(resolve(destDir, "hyperframes.json"))) { - const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } = + const { createProjectConfig, DEFAULT_PROJECT_CONFIG } = await import("../utils/projectConfig.js"); const { normalizeSkillSlug } = await import("../telemetry/skill.js"); const skill = normalizeSkillSlug(authoringSkill); - writeProjectConfig( + createProjectConfig( destDir, skill ? { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill } : DEFAULT_PROJECT_CONFIG, ); diff --git a/packages/cli/src/utils/projectConfig.create.test.ts b/packages/cli/src/utils/projectConfig.create.test.ts new file mode 100644 index 0000000000..221aeb9d4c --- /dev/null +++ b/packages/cli/src/utils/projectConfig.create.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createProjectConfig, + DEFAULT_PROJECT_CONFIG, + projectConfigPath, + seedProjectAuthoringSkill, + writeProjectConfig, +} from "./projectConfig.js"; + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, readFileSync: vi.fn(original.readFileSync) }; +}); + +const dirs: string[] = []; +function project() { + const dir = fs.mkdtempSync(join(tmpdir(), "hf-config-create-")); + dirs.push(dir); + return dir; +} +afterEach(() => { + for (const dir of dirs.splice(0)) { + expect(fs.readdirSync(dir).filter((name) => name.startsWith(".hf-create-"))).toEqual([]); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("project config creation", () => { + it("preserves existing bytes on create and still permits intentional updates", () => { + const dir = project(); + const path = projectConfigPath(dir); + createProjectConfig(dir); + expect(fs.readFileSync(path, "utf-8")).toBe( + JSON.stringify(DEFAULT_PROJECT_CONFIG, null, 2) + "\n", + ); + const updated = { ...DEFAULT_PROJECT_CONFIG, authoringSkill: "slideshow" }; + createProjectConfig(dir, updated); + expect(JSON.parse(fs.readFileSync(path, "utf-8"))).toEqual(DEFAULT_PROJECT_CONFIG); + writeProjectConfig(dir, updated); + expect(JSON.parse(fs.readFileSync(path, "utf-8"))).toEqual(updated); + }); + + it("preserves a config created after the skill seed reads ENOENT", () => { + const dir = project(); + const path = projectConfigPath(dir); + const winner = '{"registry":"https://custom.example","authoringSkill":"slideshow"}\n'; + vi.mocked(fs.readFileSync).mockImplementationOnce(() => { + fs.writeFileSync(path, winner); + throw Object.assign(new Error("missing before concurrent creation"), { code: "ENOENT" }); + }); + seedProjectAuthoringSkill(dir, "product-launch-video"); + expect(fs.readFileSync(path, "utf-8")).toBe(winner); + }); + + it("does not create a dangling symlink target when seeding an absent config", () => { + const dir = project(); + const target = join(dir, "missing.json"); + fs.symlinkSync(target, projectConfigPath(dir)); + seedProjectAuthoringSkill(dir, "slideshow"); + expect(fs.existsSync(target)).toBe(false); + expect(fs.lstatSync(projectConfigPath(dir)).isSymbolicLink()).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/projectConfig.ts b/packages/cli/src/utils/projectConfig.ts index a1ec4d52fe..1266b76feb 100644 --- a/packages/cli/src/utils/projectConfig.ts +++ b/packages/cli/src/utils/projectConfig.ts @@ -11,6 +11,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { DEFAULT_REGISTRY_URL } from "../registry/index.js"; import { normalizeSkillSlug } from "../telemetry/skill.js"; +import { writeNewFileSync } from "./writeNewFile.js"; export const PROJECT_CONFIG_FILENAME = "hyperframes.json"; const PROJECT_CONFIG_SCHEMA_URL = "https://hyperframes.heygen.com/schema/hyperframes.json"; @@ -187,6 +188,14 @@ export function writeProjectConfig( writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf-8"); } +/** Create `hyperframes.json` without replacing an existing file or following a symlink. */ +export function createProjectConfig( + projectDir: string, + config: ProjectConfig = DEFAULT_PROJECT_CONFIG, +): void { + writeNewFileSync(projectConfigPath(projectDir), JSON.stringify(config, null, 2) + "\n"); +} + /** * Load the project config for the given directory, falling back to defaults * if missing. Mutates nothing on disk. Used by commands that want to operate @@ -234,8 +243,8 @@ function isFileNotFound(error: unknown): boolean { * never fails the render it rode in on. * * One of two writers that touch an ALREADY EXISTING `hyperframes.json` (the - * other is {@link recordProjectRegistryItems}; every plain `writeProjectConfig` - * call site is guarded to write only when the file is absent), so it must not + * other is {@link recordProjectRegistryItems}; absent-only callers use + * {@link createProjectConfig}), so it must not * round-trip through {@link normalizeConfig}: * that rebuilds the object from a field whitelist, which would drop keys it * does not know about and materialize defaults the user never wrote. The file @@ -259,7 +268,7 @@ export function seedProjectAuthoringSkill(projectDir: string, rawSkill: unknown) } catch (error) { if (isFileNotFound(error)) { try { - writeProjectConfig(projectDir, { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill }); + createProjectConfig(projectDir, { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill }); } catch { // Read-only or missing project directory — best effort. } diff --git a/packages/cli/src/utils/writeNewFile.ts b/packages/cli/src/utils/writeNewFile.ts new file mode 100644 index 0000000000..137af84a33 --- /dev/null +++ b/packages/cli/src/utils/writeNewFile.ts @@ -0,0 +1,18 @@ +import { linkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Publish complete content without replacing or following an existing destination entry. */ +export function writeNewFileSync(filePath: string, content: string): void { + const stagingDir = mkdtempSync(join(dirname(filePath), ".hf-create-")); + try { + const stagedPath = join(stagingDir, "content"); + writeFileSync(stagedPath, content, { encoding: "utf-8", flag: "wx" }); + try { + linkSync(stagedPath, filePath); + } catch (err) { + if (!(err instanceof Error && "code" in err && err.code === "EEXIST")) throw err; + } + } finally { + rmSync(stagingDir, { recursive: true, force: true }); + } +}