diff --git a/packages/cli/src/commands/init.interactive.test.ts b/packages/cli/src/commands/init.interactive.test.ts new file mode 100644 index 0000000000..e1a4d568c4 --- /dev/null +++ b/packages/cli/src/commands/init.interactive.test.ts @@ -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([]); + }); +}); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index fe0f99220b..0eb27cbf52 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url"; import { applyResolutionPreset, injectTailwindBrowserScript, + nameTooLongMessage, resolveVideoDurationSeconds, } from "./init.js"; @@ -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"); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 9fd043e3bd..2841470777 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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() @@ -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(); @@ -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?`,