Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/src/capture/scaffolding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
Expand Down
24 changes: 6 additions & 18 deletions packages/cli/src/capture/scaffolding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -279,7 +279,7 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/commands/init.package-race.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fs>();
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<typeof fs>("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 });
}
});
});
8 changes: 4 additions & 4 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
{
Expand All @@ -281,7 +282,6 @@ function writeDefaultPackageJson(destDir: string, projectName: string): void {
null,
2,
)}\n`,
"utf-8",
);
}

Expand Down Expand Up @@ -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,
);
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/utils/projectConfig.create.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fs>();
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);
});
});
15 changes: 12 additions & 3 deletions packages/cli/src/utils/projectConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
}
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/utils/writeNewFile.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading