From 8f4da02e1395791b3e22c95667de61770e718eb7 Mon Sep 17 00:00:00 2001 From: sufiyan733 Date: Mon, 21 Sep 2026 00:25:59 +0530 Subject: [PATCH] fix(cli): ensure store directory exists in createFileWithStore (#4286) --- .changeset/cli-dev-store-dir-enoent.md | 5 + .../cli-v3/src/utilities/fileSystem.test.ts | 108 ++++++++++++++++++ packages/cli-v3/src/utilities/fileSystem.ts | 3 +- 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-dev-store-dir-enoent.md create mode 100644 packages/cli-v3/src/utilities/fileSystem.test.ts diff --git a/.changeset/cli-dev-store-dir-enoent.md b/.changeset/cli-dev-store-dir-enoent.md new file mode 100644 index 00000000000..440787e79ea --- /dev/null +++ b/.changeset/cli-dev-store-dir-enoent.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Ensure store directory exists before writing in createFileWithStore to prevent ENOENT crashes during dev session handover. diff --git a/packages/cli-v3/src/utilities/fileSystem.test.ts b/packages/cli-v3/src/utilities/fileSystem.test.ts new file mode 100644 index 00000000000..1c874b895ea --- /dev/null +++ b/packages/cli-v3/src/utilities/fileSystem.test.ts @@ -0,0 +1,108 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createFile, createFileWithStore, sanitizeHashForFilename } from "./fileSystem.js"; + +describe("fileSystem", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), "trigger-fs-test-")); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + describe("sanitizeHashForFilename", () => { + it("replaces forward slashes with underscores and pluses with hyphens", () => { + expect(sanitizeHashForFilename("abc/def+ghi")).toBe("abc_def-ghi"); + expect(sanitizeHashForFilename("a/b/c+d+e")).toBe("a_b_c-d-e"); + expect(sanitizeHashForFilename("clean-hash_123")).toBe("clean-hash_123"); + }); + }); + + describe("createFileWithStore", () => { + it("succeeds when storeDir does not exist yet", async () => { + const storeDir = join(testDir, "store"); + const buildDir = join(testDir, "build"); + const filePath = join(buildDir, "index.js"); + const content = "console.log('hello world');"; + const hash = "hash123/abc+def"; + + expect(existsSync(storeDir)).toBe(false); + + const result = await createFileWithStore(filePath, content, storeDir, hash); + + expect(result).toBe(filePath); + expect(existsSync(storeDir)).toBe(true); + expect(existsSync(filePath)).toBe(true); + expect(await readFile(filePath, "utf8")).toBe(content); + + const storeFile = join(storeDir, sanitizeHashForFilename(hash)); + expect(existsSync(storeFile)).toBe(true); + expect(await readFile(storeFile, "utf8")).toBe(content); + }); + + it("uses content-addressable caching when storeDir and file already exist", async () => { + const storeDir = join(testDir, "store"); + const buildDir = join(testDir, "build"); + const filePath1 = join(buildDir, "first.js"); + const filePath2 = join(buildDir, "second.js"); + const content = "export const answer = 42;"; + const hash = "shared-hash-xyz"; + + // First run: writes to store and destination + await createFileWithStore(filePath1, content, storeDir, hash); + expect(existsSync(filePath1)).toBe(true); + + const storeFile = join(storeDir, sanitizeHashForFilename(hash)); + const storeStatBefore = await stat(storeFile); + + // Second run: re-running with existing store uses cached storePath (hardlink or copy) + await createFileWithStore(filePath2, content, storeDir, hash); + expect(existsSync(filePath2)).toBe(true); + expect(await readFile(filePath2, "utf8")).toBe(content); + + const storeStatAfter = await stat(storeFile); + // Store file modified time should not have been updated because it was not rewritten + expect(storeStatAfter.mtimeMs).toBe(storeStatBefore.mtimeMs); + }); + + it("replaces existing file at destination path if already present", async () => { + const storeDir = join(testDir, "store"); + const buildDir = join(testDir, "build"); + const filePath = join(buildDir, "output.js"); + const oldContent = "old content"; + const newContent = "new content"; + + // Write initial file at destination + await createFile(filePath, oldContent); + expect(await readFile(filePath, "utf8")).toBe(oldContent); + + // Now create with store replacing it + await createFileWithStore(filePath, newContent, storeDir, "new-hash"); + + expect(await readFile(filePath, "utf8")).toBe(newContent); + }); + + it("handles deeply nested non-existent store and build paths", async () => { + const storeDir = join(testDir, "deeply", "nested", "custom", "store"); + const buildDir = join(testDir, "another", "nested", "out"); + const filePath = join(buildDir, "chunk.js"); + const content = "export default 1;"; + const hash = "deep/hash+test"; + + expect(existsSync(storeDir)).toBe(false); + expect(existsSync(buildDir)).toBe(false); + + await createFileWithStore(filePath, content, storeDir, hash); + + expect(existsSync(filePath)).toBe(true); + expect(existsSync(storeDir)).toBe(true); + expect(await readFile(filePath, "utf8")).toBe(content); + }); + }); +}); diff --git a/packages/cli-v3/src/utilities/fileSystem.ts b/packages/cli-v3/src/utilities/fileSystem.ts index 6f287fe7027..120ff45a6a8 100644 --- a/packages/cli-v3/src/utilities/fileSystem.ts +++ b/packages/cli-v3/src/utilities/fileSystem.ts @@ -47,7 +47,8 @@ export async function createFileWithStore( // Store files by their content hash for true content-addressable storage const storePath = pathModule.join(storeDir, safeHash); - // Ensure build directory exists + // Ensure build directory and store directory exist + await fsModule.mkdir(storeDir, { recursive: true }); await fsModule.mkdir(pathModule.dirname(filePath), { recursive: true }); // Remove existing file at destination if it exists (hardlinks fail on existing files)