fix(cli): snapshot staging folders no longer fail past 260 characters on Windows

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-26 20:30:39 -04:00
parent 95e3930faf
commit e349cf44da
5 changed files with 82 additions and 14 deletions
+3 -10
View File
@@ -1,14 +1,7 @@
import {
mkdirSync,
mkdtempSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { mkdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { CaptureDirRefusedError } from "./captureErrors.js";
import { createStagingDir } from "../utils/stagingDir.js";
type WriteOptions = Parameters<typeof writeFileSync>[2];
@@ -25,7 +18,7 @@ function publishCaptureFileSync(
data: string | NodeJS.ArrayBufferView,
options: Exclude<WriteOptions, string>,
): void {
const stagingDir = mkdtempSync(join(dirname(path), ".capture-"));
const stagingDir = createStagingDir(dirname(path), ".capture-");
try {
const staged = join(stagingDir, basename(path));
writeCaptureFileSync(staged, data, { ...options, flag: "wx" });
+3 -2
View File
@@ -13,11 +13,12 @@
* existing destination is replaced only after the download succeeds.
*/
import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync } from "node:fs";
import { chmodSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
import { open } from "node:fs/promises";
import { dirname, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { resolveWritePath } from "@hyperframes/core/atomic-file";
import { createStagingDir } from "../utils/stagingDir.js";
// The first request asks for PART_BYTES; a larger file is split into up to
// CONNECTIONS ranged GETs. A server that ignores Range answers 200 and is streamed.
@@ -78,7 +79,7 @@ export async function downloadToFile(
// Writes through a caller-selected symlink, as before.
const destination = resolveWritePath(destPath);
const previous = statSync(destination, { throwIfNoEntry: false });
const stage = mkdtempSync(join(dirname(destination), ".hf-download-"));
const stage = createStagingDir(dirname(destination), ".hf-download-");
const stagedFile = join(stage, "download");
let bytes = 0;
let firstError: unknown;
+3 -2
View File
@@ -1,6 +1,7 @@
import { lstatSync, chmodSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { lstatSync, chmodSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { mkdirWithinProject, realpath } from "@hyperframes/core";
import { createStagingDir } from "../utils/stagingDir.js";
export { realProjectRoot as registryRoot } from "@hyperframes/core";
@@ -46,7 +47,7 @@ export function publishRegistryFile(
const parent = realpath(dirname(path));
assertContained(root, parent);
const destination = resolve(parent, basename(path));
const stage = mkdtempSync(join(parent, ".hf-install-"));
const stage = createStagingDir(parent, ".hf-install-");
try {
const stagedFile = join(stage, "file");
writeFileSync(stagedFile, bytes, { flag: "wx" });
+51
View File
@@ -0,0 +1,51 @@
import { rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import { createStagingDir } from "./stagingDir.js";
vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof fs>();
return {
...original,
mkdtempSync: vi.fn(() => {
throw Object.assign(new Error("path too long"), { code: "ENOENT" });
}),
};
});
const roots: string[] = [];
const root = () => {
const made = join(tmpdir(), `hf-staging-${process.pid}-${roots.length}`);
fs.mkdirSync(made, { recursive: true });
roots.push(made);
return made;
};
afterEach(() => {
for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe("createStagingDir", () => {
it("makes a fresh private directory without mkdtemp, which fails on long Windows paths", () => {
const parent = root();
const first = createStagingDir(parent, ".stage-");
const second = createStagingDir(parent, ".stage-");
expect(first).not.toBe(second);
expect(basename(first)).toMatch(/^\.stage-[0-9a-f]{8}$/);
expect(statSync(first).isDirectory()).toBe(true);
if (process.platform !== "win32") expect(statSync(first).mode & 0o777).toBe(0o700);
expect(fs.mkdtempSync).not.toHaveBeenCalled();
});
it("tries another name when one is taken, and gives up after five", () => {
const parent = root();
const taken = createStagingDir(parent, ".s-", () => "same");
expect(taken).toBe(join(parent, ".s-same"));
const names = ["same", "same", "next"];
expect(createStagingDir(parent, ".s-", () => names.shift() ?? "x")).toBe(
join(parent, ".s-next"),
);
expect(() => createStagingDir(parent, ".s-", () => "same")).toThrow(/EEXIST/);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { randomBytes } from "node:crypto";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
const TRIES = 5;
/** A fresh private directory in `parent`. Not mkdtemp: on Windows that call fails once its path passes 260 characters. */
export function createStagingDir(
parent: string,
prefix: string,
suffix = () => randomBytes(4).toString("hex"),
): string {
for (let attempt = 1; ; attempt++) {
const dir = join(parent, `${prefix}${suffix()}`);
try {
mkdirSync(dir, { mode: 0o700 });
return dir;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || attempt >= TRIES) throw error;
}
}
}