From afc7ea975f52566e9a877108a72af06eecbf862f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 21:11:37 +0000 Subject: [PATCH 1/4] Initial plan From f1359a8623fcacda8a212e83906c2cca85e23fa2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 21:23:17 +0000 Subject: [PATCH 2/4] Add temporaryFiles API with explicit resource management support Agent-Logs-Url: https://github.com/pmcelhaney/using-temporary-files/sessions/40aa27d0-be47-49f0-b681-30158ca3f5e3 Co-authored-by: pmcelhaney <51504+pmcelhaney@users.noreply.github.com> --- README.md | 11 ++ dist/using-temporary-files.d.ts | 9 +- dist/using-temporary-files.js | 126 ++++++++++++++++----- src/using-temporary-files.ts | 170 +++++++++++++++++++++++------ test/using-temporary-files.test.ts | 51 ++++++++- 5 files changed, 303 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 948a6fc..e61b973 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,17 @@ await usingTemporaryFiles(async ({ path, add, addDirectory, read, remove }) => { }); ``` +### Explicit resource management (`using`) + +```js copy +import { temporaryFiles } from "using-temporary-files"; + +using api = temporaryFiles(); +await api.add("file.txt", "Hello, world!"); +``` + +`temporaryFiles()` returns an object with the same file operations as `usingTemporaryFiles()`, plus `Symbol.dispose` and `Symbol.asyncDispose` so it can be used with `using` / `await using`. + ### Multiple callbacks `usingTemporaryFiles()` accepts any number of callbacks. They share the same temporary directory and are called in order. diff --git a/dist/using-temporary-files.d.ts b/dist/using-temporary-files.d.ts index 91a97b8..c994e82 100644 --- a/dist/using-temporary-files.d.ts +++ b/dist/using-temporary-files.d.ts @@ -1,10 +1,17 @@ interface Operations { add: (path: string, contents: string) => Promise; addDirectory: (path: string) => Promise; - path: (relativePaths: string) => string; + path: (...relativePaths: Readonly) => string; read: (path: string) => Promise; remove: (path: string) => Promise; } +interface DisposableOperations extends Readonly { + [Symbol.asyncDispose]: () => Promise; + [Symbol.dispose]: () => void; + asyncDispose: () => Promise; + dispose: () => void; +} type Callback = (operations: Readonly) => Promise; +export declare function temporaryFiles(): DisposableOperations; export declare function usingTemporaryFiles(...callbacks: Readonly): Promise; export {}; diff --git a/dist/using-temporary-files.js b/dist/using-temporary-files.js index 1f95749..20966c6 100644 --- a/dist/using-temporary-files.js +++ b/dist/using-temporary-files.js @@ -1,6 +1,7 @@ /* eslint-disable total-functions/no-unsafe-readonly-mutable-assignment */ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable no-await-in-loop */ +import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; @@ -48,42 +49,111 @@ function createReadFunction(basePath) { return await fs.readFile(fullPath, encoding); }; } -// eslint-disable-next-line max-statements -export async function usingTemporaryFiles(...callbacks) { +function createTemporaryDirectory() { const baseDirectory = DEBUG ? nodePath.resolve(process.cwd(), "./") : os.tmpdir(); - const temporaryDirectory = String(await fs.mkdtemp(nodePath.join(baseDirectory, "utf-"))); + return nodePath.join(baseDirectory, `utf-${randomUUID()}`); +} +function createOperations(temporaryDirectory, ready) { + return { + async add(filePath, contents) { + await ready; + await createAddFunction(temporaryDirectory)(filePath, contents); + }, + async addDirectory(filePath) { + await ready; + await createAddDirectoryFunction(temporaryDirectory)(filePath); + }, + path(...relativePaths) { + return nodePath.join(temporaryDirectory, ...relativePaths); + }, + async read(filePath) { + await ready; + return await createReadFunction(temporaryDirectory)(filePath); + }, + async remove(filePath) { + await ready; + await createRemoveFunction(temporaryDirectory)(filePath); + }, + }; +} +async function removeTemporaryDirectory(temporaryDirectory) { + let retries = RETRIES; + while (retries > 0) { + try { + await fs.rm(temporaryDirectory, { + recursive: true, + }); + break; + } + catch { + // eslint-disable-next-line promise/avoid-new, compat/compat + await new Promise((resolve) => { + setTimeout(resolve, RETRY_TIMEOUT_MILLISECONDS); + }); + retries -= 1; + } + } +} +async function removeTemporaryDirectoryWhenReady(ready, temporaryDirectory) { + await ready; + await removeTemporaryDirectory(temporaryDirectory); +} +// eslint-disable-next-line max-statements +function createTemporaryFilesResource() { + const temporaryDirectory = createTemporaryDirectory(); + const ready = fs.mkdir(temporaryDirectory, { + recursive: true, + }); + let disposed = false; + let cleanupPromise = ready; + let cleanupQueued = false; + const operations = createOperations(temporaryDirectory, ready); + function queueCleanup() { + if (cleanupQueued) { + return; + } + cleanupQueued = true; + cleanupPromise = removeTemporaryDirectoryWhenReady(ready, temporaryDirectory); + } + async function asyncDispose() { + disposed ||= true; + queueCleanup(); + await cleanupPromise; + } + function dispose() { + if (disposed) { + return; + } + disposed = true; + queueCleanup(); + } + const resource = { + ...operations, + asyncDispose, + dispose, + [Symbol.asyncDispose]: asyncDispose, + [Symbol.dispose]: dispose, + }; + return { + ready, + resource, + }; +} +export function temporaryFiles() { + return createTemporaryFilesResource().resource; +} +export async function usingTemporaryFiles(...callbacks) { + const { ready, resource: operations } = createTemporaryFilesResource(); + await ready; try { for (const callback of callbacks) { // eslint-disable-next-line n/callback-return - await callback({ - add: createAddFunction(temporaryDirectory), - addDirectory: createAddDirectoryFunction(temporaryDirectory), - path(...relativePaths) { - return nodePath.join(temporaryDirectory, ...relativePaths); - }, - read: createReadFunction(temporaryDirectory), - remove: createRemoveFunction(temporaryDirectory), - }); + await callback(operations); } } finally { - let retries = RETRIES; - while (retries > 0) { - try { - await fs.rm(temporaryDirectory, { - recursive: true, - }); - break; - } - catch { - // eslint-disable-next-line promise/avoid-new, compat/compat - await new Promise((resolve) => { - setTimeout(resolve, RETRY_TIMEOUT_MILLISECONDS); - }); - retries -= 1; - } - } + await operations.asyncDispose(); } } diff --git a/src/using-temporary-files.ts b/src/using-temporary-files.ts index a9b698f..1396c05 100644 --- a/src/using-temporary-files.ts +++ b/src/using-temporary-files.ts @@ -1,6 +1,7 @@ /* eslint-disable total-functions/no-unsafe-readonly-mutable-assignment */ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable no-await-in-loop */ +import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; @@ -30,11 +31,18 @@ const DEBUG = process.env.USING_TEMPORARY_FILES_DEBUG === "1"; interface Operations { add: (path: string, contents: string) => Promise; addDirectory: (path: string) => Promise; - path: (relativePaths: string) => string; + path: (...relativePaths: Readonly) => string; read: (path: string) => Promise; remove: (path: string) => Promise; } +interface DisposableOperations extends Readonly { + [Symbol.asyncDispose]: () => Promise; + [Symbol.dispose]: () => void; + asyncDispose: () => Promise; + dispose: () => void; +} + // eslint-disable-next-line etc/prefer-interface type Callback = (operations: Readonly) => Promise; @@ -89,48 +97,142 @@ function createReadFunction(basePath: string) { }; } -// eslint-disable-next-line max-statements -export async function usingTemporaryFiles(...callbacks: Readonly) { +function createTemporaryDirectory() { const baseDirectory = DEBUG ? nodePath.resolve(process.cwd(), "./") : os.tmpdir(); - const temporaryDirectory = String( - await fs.mkdtemp(nodePath.join(baseDirectory, "utf-")) - ); + return nodePath.join(baseDirectory, `utf-${randomUUID()}`); +} - try { - for (const callback of callbacks) { - // eslint-disable-next-line n/callback-return - await callback({ - add: createAddFunction(temporaryDirectory), - addDirectory: createAddDirectoryFunction(temporaryDirectory), +function createOperations( + temporaryDirectory: string, + ready: Readonly> +): Readonly { + return { + async add(filePath: string, contents: string) { + await ready; + await createAddFunction(temporaryDirectory)(filePath, contents); + }, + + async addDirectory(filePath: string) { + await ready; + await createAddDirectoryFunction(temporaryDirectory)(filePath); + }, + + path(...relativePaths: Readonly) { + return nodePath.join(temporaryDirectory, ...relativePaths); + }, + + async read(filePath: string) { + await ready; + + return await createReadFunction(temporaryDirectory)(filePath); + }, + + async remove(filePath: string) { + await ready; + await createRemoveFunction(temporaryDirectory)(filePath); + }, + }; +} + +async function removeTemporaryDirectory(temporaryDirectory: string) { + let retries = RETRIES; - path(...relativePaths: Readonly) { - return nodePath.join(temporaryDirectory, ...relativePaths); - }, + while (retries > 0) { + try { + await fs.rm(temporaryDirectory, { + recursive: true, + }); - read: createReadFunction(temporaryDirectory), - remove: createRemoveFunction(temporaryDirectory), + break; + } catch { + // eslint-disable-next-line promise/avoid-new, compat/compat + await new Promise((resolve) => { + setTimeout(resolve, RETRY_TIMEOUT_MILLISECONDS); }); + retries -= 1; } - } finally { - let retries = RETRIES; - - while (retries > 0) { - try { - await fs.rm(temporaryDirectory, { - recursive: true, - }); - - break; - } catch { - // eslint-disable-next-line promise/avoid-new, compat/compat - await new Promise((resolve) => { - setTimeout(resolve, RETRY_TIMEOUT_MILLISECONDS); - }); - retries -= 1; - } + } +} + +async function removeTemporaryDirectoryWhenReady( + ready: Readonly>, + temporaryDirectory: string +) { + await ready; + await removeTemporaryDirectory(temporaryDirectory); +} + +// eslint-disable-next-line max-statements +function createTemporaryFilesResource() { + const temporaryDirectory = createTemporaryDirectory(); + const ready = fs.mkdir(temporaryDirectory, { + recursive: true, + }); + + let disposed = false; + let cleanupPromise: Promise = ready; + let cleanupQueued = false; + + const operations = createOperations(temporaryDirectory, ready); + + function queueCleanup() { + if (cleanupQueued) { + return; + } + + cleanupQueued = true; + cleanupPromise = removeTemporaryDirectoryWhenReady(ready, temporaryDirectory); + } + + async function asyncDispose() { + disposed ||= true; + + queueCleanup(); + await cleanupPromise; + } + + function dispose() { + if (disposed) { + return; + } + + disposed = true; + queueCleanup(); + } + + + const resource: DisposableOperations = { + ...operations, + asyncDispose, + dispose, + [Symbol.asyncDispose]: asyncDispose, + [Symbol.dispose]: dispose, + }; + + return { + ready, + resource, + }; +} + +export function temporaryFiles(): DisposableOperations { + return createTemporaryFilesResource().resource; +} + +export async function usingTemporaryFiles(...callbacks: Readonly) { + const { ready, resource: operations } = createTemporaryFilesResource(); + + await ready; + + try { + for (const callback of callbacks) { + // eslint-disable-next-line n/callback-return + await callback(operations); } + } finally { + await operations.asyncDispose(); } } diff --git a/test/using-temporary-files.test.ts b/test/using-temporary-files.test.ts index bc6c019..b0c174f 100644 --- a/test/using-temporary-files.test.ts +++ b/test/using-temporary-files.test.ts @@ -2,7 +2,16 @@ import fs from "node:fs"; import nodePath from "node:path"; -import { usingTemporaryFiles } from "../src/using-temporary-files.js"; +import { + temporaryFiles, + usingTemporaryFiles, +} from "../src/using-temporary-files.js"; + +const symbolConstructor: Readonly<{ asyncDispose?: symbol; dispose?: symbol }> = + Symbol; +const asyncDisposeSymbol = + symbolConstructor.asyncDispose ?? Symbol.for("Symbol.asyncDispose"); +const disposeSymbol = symbolConstructor.dispose ?? Symbol.for("Symbol.dispose"); describe("usingTemporaryFiles", () => { it("add a file", async () => { @@ -131,3 +140,43 @@ describe("usingTemporaryFiles", () => { expect(deepPath2).toBe(expected); }); }); + +describe("temporaryFiles", () => { + it("supports explicit resource management", async () => { + const files = temporaryFiles(); + const temporaryDirectory = files.path("."); + + expect(typeof files[disposeSymbol]).toBe("function"); + expect(typeof files[asyncDisposeSymbol]).toBe("function"); + expect(files[disposeSymbol]).toBe(files.dispose); + expect(files[asyncDisposeSymbol]).toBe(files.asyncDispose); + + await files.add("file.txt", "Hello, world!"); + expect(fs.readFileSync(files.path("file.txt"), "utf8")).toBe("Hello, world!"); + + await files.asyncDispose(); + expect(fs.existsSync(temporaryDirectory)).toBe(false); + }); + + it("supports synchronous disposal", async () => { + const files = temporaryFiles(); + const temporaryDirectory = files.path("."); + + await files.add("file.txt", "Hello, world!"); + files.dispose(); + await files.asyncDispose(); + + expect(fs.existsSync(temporaryDirectory)).toBe(false); + }); + + it("is safe to dispose more than once", async () => { + const files = temporaryFiles(); + const temporaryDirectory = files.path("."); + + files.dispose(); + files.dispose(); + await files.asyncDispose(); + + expect(fs.existsSync(temporaryDirectory)).toBe(false); + }); +}); From 31df2b165eb6c33b62f2005859c37719dee74c07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 21:26:39 +0000 Subject: [PATCH 3/4] Refine disposal behavior and tests for temporaryFiles API Agent-Logs-Url: https://github.com/pmcelhaney/using-temporary-files/sessions/40aa27d0-be47-49f0-b681-30158ca3f5e3 Co-authored-by: pmcelhaney <51504+pmcelhaney@users.noreply.github.com> --- dist/using-temporary-files.js | 6 +++++- src/using-temporary-files.ts | 9 +++++++-- test/using-temporary-files.test.ts | 15 ++++++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/dist/using-temporary-files.js b/dist/using-temporary-files.js index 20966c6..01a93fa 100644 --- a/dist/using-temporary-files.js +++ b/dist/using-temporary-files.js @@ -118,7 +118,11 @@ function createTemporaryFilesResource() { cleanupPromise = removeTemporaryDirectoryWhenReady(ready, temporaryDirectory); } async function asyncDispose() { - disposed ||= true; + if (disposed) { + await cleanupPromise; + return; + } + disposed = true; queueCleanup(); await cleanupPromise; } diff --git a/src/using-temporary-files.ts b/src/using-temporary-files.ts index 1396c05..71dede0 100644 --- a/src/using-temporary-files.ts +++ b/src/using-temporary-files.ts @@ -188,7 +188,13 @@ function createTemporaryFilesResource() { } async function asyncDispose() { - disposed ||= true; + if (disposed) { + await cleanupPromise; + + return; + } + + disposed = true; queueCleanup(); await cleanupPromise; @@ -203,7 +209,6 @@ function createTemporaryFilesResource() { queueCleanup(); } - const resource: DisposableOperations = { ...operations, asyncDispose, diff --git a/test/using-temporary-files.test.ts b/test/using-temporary-files.test.ts index b0c174f..a1e56d9 100644 --- a/test/using-temporary-files.test.ts +++ b/test/using-temporary-files.test.ts @@ -13,6 +13,19 @@ const asyncDisposeSymbol = symbolConstructor.asyncDispose ?? Symbol.for("Symbol.asyncDispose"); const disposeSymbol = symbolConstructor.dispose ?? Symbol.for("Symbol.dispose"); +async function waitForPathToDisappear(path: string, retries = 20) { + if (!fs.existsSync(path) || retries === 0) { + return; + } + + // eslint-disable-next-line promise/avoid-new, compat/compat + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + + await waitForPathToDisappear(path, retries - 1); +} + describe("usingTemporaryFiles", () => { it("add a file", async () => { let timesCallbackCalled = 0; @@ -164,7 +177,7 @@ describe("temporaryFiles", () => { await files.add("file.txt", "Hello, world!"); files.dispose(); - await files.asyncDispose(); + await waitForPathToDisappear(temporaryDirectory); expect(fs.existsSync(temporaryDirectory)).toBe(false); }); From 45e96a6d0a883d72eb42090ee2d1ee335b1f9920 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:46:12 +0000 Subject: [PATCH 4/4] Upgrade workflows to Node 22 and modernize outputs --- .github/workflows/ci.yml | 6 ++---- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cd2626..210eb13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,7 @@ jobs: strategy: matrix: node-version: - - 16.x - - 18.x - - 20.x + - 22.x steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} @@ -20,7 +18,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: Get Node Version - run: echo "::set-output name=version::$(node -v)" + run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT" id: node-version - name: Cache node_modules uses: actions/cache@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 220a409..63a91ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,10 +17,10 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 20.x + node-version: 22.x cache: npm - name: Get Node Version - run: echo "::set-output name=version::$(node -v)" + run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT" id: node-version - name: Cache node_modules uses: actions/cache@v3