fix(cli): init refuses a name too long for a folder in one plain sentence (#5071)

* fix(cli): init refuses a name too long for a folder in one plain sentence

* fix(cli): validate resolved init paths and cover interactive names
This commit is contained in:
Miguel Ángel
2026-10-06 01:52:44 +00:00
committed by GitHub
parent 0221dc1701
commit d45cb34e2f
3 changed files with 154 additions and 0 deletions
@@ -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
View File
@@ -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");
+32
View File
@@ -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?`,