mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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?`,
|
||||
|
||||
Reference in New Issue
Block a user