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
58 changes: 58 additions & 0 deletions packages/cli/src/commands/init.interactive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCommand } from "citty";

const prompts = vi.hoisted(() => ({ errors: [] as string[], typedName: "" }));

vi.mock("@clack/prompts", () => ({
intro: () => undefined,
cancel: () => undefined,
isCancel: () => false,
text: async () => prompts.typedName,
log: { error: (message: string) => prompts.errors.push(message) },
}));
vi.mock("../ui/banner.js", () => ({ printBanner: () => undefined }));

describe("interactive init with a name too long for a folder", () => {
let dir: string;
let stdoutTty: PropertyDescriptor | undefined;
const unit = process.platform === "linux" ? "bytes" : "characters";

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-init-interactive-"));
prompts.errors = [];
stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
});

afterEach(() => {
if (stdoutTty) Object.defineProperty(process.stdout, "isTTY", stdoutTty);
else delete (process.stdout as { isTTY?: boolean }).isTTY;
rmSync(dir, { recursive: true, force: true });
});

const runInit = async (rawArgs: string[]) => {
const command = (await import("./init.js")).default;
await runCommand(command, { rawArgs });
};

it("shows the plain sentence for a name given on the command line, before any folder is made", async () => {
const target = `${dir}/${"z".repeat(257)}/../${"a".repeat(256)}`;
await expect(runInit([target])).rejects.toThrow("Command failed");
expect(prompts.errors).toEqual([
`That name is 256 ${unit} long; a folder name can be at most 255 ${unit}.`,
]);
expect(readdirSync(dir)).toEqual([]);
});

it("shows the plain sentence for a name typed into the prompt", async () => {
prompts.typedName = `${dir}/${"z".repeat(301)}/../${"b".repeat(300)}`;
await expect(runInit([])).rejects.toThrow("Command failed");
expect(prompts.errors).toEqual([
`That name is 300 ${unit} long; a folder name can be at most 255 ${unit}.`,
]);
expect(readdirSync(dir)).toEqual([]);
});
});
64 changes: 64 additions & 0 deletions packages/cli/src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url";
import {
applyResolutionPreset,
injectTailwindBrowserScript,
nameTooLongMessage,
resolveVideoDurationSeconds,
} from "./init.js";

Expand Down Expand Up @@ -362,6 +363,69 @@ describe("hyperframes init flag rename", () => {
}
});

it("a name longer than a folder name can be fails in one plain sentence, before any folder is made", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "a".repeat(256));
const unit = process.platform === "linux" ? "bytes" : "characters";
try {
const res = runInit([target, "--example", "blank", "--non-interactive", "--skip-skills"]);
expect(res.status).toBe(1);
expect(res.stderr).toContain(
`That name is 256 ${unit} long; a folder name can be at most 255 ${unit}.`,
);
expect(res.stderr).not.toContain("ENAMETOOLONG");
expect(res.stderr).not.toContain(dir);
expect(existsSync(target)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("a name exactly as long as a folder name can be scaffolds", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "a".repeat(255));
try {
const res = runInit([target, "--example", "blank", "--non-interactive", "--skip-skills"]);
expect(res.status).toBe(0);
expect(existsSync(join(target, "index.html"))).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("measures a name as the disk does: UTF-8 bytes on Linux, UTF-16 units on macOS and Windows", () => {
const accented = "é".repeat(128); // 128 characters, 256 bytes
expect(nameTooLongMessage(accented, "linux")).toBe(
"That name is 256 bytes long; a folder name can be at most 255 bytes.",
);
expect(nameTooLongMessage(accented, "darwin")).toBeNull();
expect(nameTooLongMessage("é".repeat(127), "linux")).toBeNull();
expect(nameTooLongMessage("a".repeat(256), "darwin")).toBe(
"That name is 256 characters long; a folder name can be at most 255 characters.",
);
expect(nameTooLongMessage("a".repeat(255), "win32")).toBeNull();
// Each folder in a nested name is held to the limit, not the whole path.
expect(nameTooLongMessage(`parent/${"a".repeat(255)}`, "linux")).toBeNull();
expect(nameTooLongMessage(`parent/${"a".repeat(256)}`, "linux")).toContain("256 bytes");
});

it("validates the resolved destination after parent segments remove a long folder", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = `${dir}/${"a".repeat(256)}/../project`;
try {
const res = runInit([target, "--example", "blank", "--non-interactive", "--skip-skills"]);
expect(res.status).toBe(0);
expect(existsSync(join(dir, "project", "index.html"))).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("measures Windows backslash-separated folders individually", () => {
expect(nameTooLongMessage(`parent\\${"a".repeat(255)}`, "win32")).toBeNull();
expect(nameTooLongMessage(`parent\\${"a".repeat(256)}`, "win32")).toContain("256 characters");
});

it("-V prints a migration error instead of version fast-path", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "proj");
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,25 @@ function getSharedTemplateDir(): string {
return resolveAssetDir(["..", "templates", "_shared"], ["templates", "_shared"]);
}

/** The longest folder name the common disks hold: 255 UTF-8 bytes on Linux, 255 UTF-16 units on macOS and Windows. */
const MAX_FOLDER_NAME = 255;

/** One plain sentence when a folder in `name` is too long for the disk, null when all fit. Checked before mkdir,
* which otherwise throws Node's raw ENAMETOOLONG and the full path (repro: init a 256-character name). */
export function nameTooLongMessage(
name: string,
platform: NodeJS.Platform = process.platform,
): string | null {
const inBytes = platform === "linux";
const unit = inBytes ? "bytes" : "characters";
for (const folder of name.split(platform === "win32" ? /[\\/]/ : "/")) {
const length = inBytes ? Buffer.byteLength(folder) : folder.length;
if (length > MAX_FOLDER_NAME)
return `That name is ${length} ${unit} long; a folder name can be at most ${MAX_FOLDER_NAME} ${unit}.`;
}
return null;
}

function toPackageName(projectName: string): string {
const normalized = basename(projectName)
.trim()
Expand Down Expand Up @@ -818,6 +837,12 @@ export default defineCommand({
const name = args.name ?? "my-video";
const destDir = resolve(name);

const tooLong = nameTooLongMessage(destDir);
if (tooLong) {
console.error(c.error(tooLong));
failCommand();
}

if (existsSync(destDir) && readdirSync(destDir).length > 0) {
console.error(c.error(`Directory already exists and is not empty: ${name}`));
failCommand();
Expand Down Expand Up @@ -995,6 +1020,13 @@ export default defineCommand({

const destDir = resolve(name);

const tooLong = nameTooLongMessage(destDir);
if (tooLong) {
clack.log.error(tooLong);
clack.cancel("Setup cancelled.");
failCommand();
}

if (existsSync(destDir) && readdirSync(destDir).length > 0) {
const overwrite = await clack.confirm({
message: `Directory ${c.accent(name)} already exists and is not empty. Overwrite?`,
Expand Down
Loading