From 35e62e7fcba0d1f7487b80a0f53787472d31fb33 Mon Sep 17 00:00:00 2001 From: Alexandros Salapatas <> Date: Mon, 10 Aug 2026 22:46:50 +0300 Subject: [PATCH 1/3] fix: relocate global Rules/Workflows/Hooks from ~/Documents to ~/.dirac (FU-9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~/Documents is TCC-protected on macOS since Catalina. Processes without Full Disk Access get EPERM on readdir/watch, causing ruleLoadErrors on every task run. The existing catch fallback re-resolved the same protected path, so it could never recover. Move ensureRules/Workflows/HooksDirectoryExists to ~/.dirac/{Rules, Workflows,Hooks} (already used by settings/state/cache via getDiracHomePath). Add best-effort migration from the legacy ~/Documents/Dirac/ location — copies files with COPYFILE_EXCL (idempotent, no clobber) and swallows EPERM on the legacy readdir. --- .../__tests__/directoryEnsurers.test.ts | 119 ++++++++++++++++++ src/core/storage/directoryEnsurers.ts | 60 ++++----- 2 files changed, 151 insertions(+), 28 deletions(-) create mode 100644 src/core/storage/__tests__/directoryEnsurers.test.ts diff --git a/src/core/storage/__tests__/directoryEnsurers.test.ts b/src/core/storage/__tests__/directoryEnsurers.test.ts new file mode 100644 index 00000000..e6af1338 --- /dev/null +++ b/src/core/storage/__tests__/directoryEnsurers.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for ensureRules/Workflows/HooksDirectoryExists — FU-9. + * + * Verifies the relocation from the TCC-protected ~/Documents/Dirac/{Rules,Workflows,Hooks} + * to ~/.dirac/{Rules,Workflows,Hooks}, plus the best-effort migration that copies existing + * files without clobbering and swallows EPERM on the legacy dir. + */ +import { expect } from "chai" +import fs from "fs/promises" +import { afterEach, beforeEach, describe, it } from "mocha" +import os from "os" +import path from "path" +import * as sinon from "sinon" +import { ensureHooksDirectoryExists, ensureRulesDirectoryExists, ensureWorkflowsDirectoryExists } from "../directoryEnsurers" +import * as pathsModule from "../paths" + +const SUBDIRS = ["Rules", "Workflows", "Hooks"] as const +type Subdir = (typeof SUBDIRS)[number] + +const ENSURER: Record Promise> = { + Rules: ensureRulesDirectoryExists, + Workflows: ensureWorkflowsDirectoryExists, + Hooks: ensureHooksDirectoryExists, +} + +describe("directoryEnsurers — FU-9 (TCC-protected path relocation)", () => { + let sandbox: sinon.SinonSandbox + let fakeHome: string + let fakeDocuments: string + let realHome: string + + beforeEach(async () => { + sandbox = sinon.createSandbox() + realHome = os.homedir() + const tmpBase = path.join(os.tmpdir(), `dirac-ensurers-${Date.now()}-${Math.random().toString(36).slice(2)}`) + fakeHome = path.join(tmpBase, "home") + fakeDocuments = path.join(tmpBase, "Documents") + await fs.mkdir(fakeHome, { recursive: true }) + await fs.mkdir(fakeDocuments, { recursive: true }) + // Stub getDiracHomePath → ~/.dirac under fakeHome; getDocumentsPath → fakeDocuments + sandbox.stub(pathsModule, "getDiracHomePath").returns(path.join(fakeHome, ".dirac")) + sandbox.stub(pathsModule, "getDocumentsPath").resolves(fakeDocuments) + }) + + afterEach(async () => { + sandbox.restore() + // sanity: real homedir was never touched + if (realHome !== os.homedir()) throw new Error("homedir was mutated") + }) + + for (const subdir of SUBDIRS) { + describe(`ensure${subdir}DirectoryExists`, () => { + it(`returns ~/.dirac/${subdir} (not ~/Documents/Dirac/${subdir})`, async () => { + const result = await ENSURER[subdir]() + expect(result).to.equal(path.join(fakeHome, ".dirac", subdir)) + }) + + it(`creates ~/.dirac/${subdir} if it does not exist`, async () => { + const result = await ENSURER[subdir]() + const stat = await fs.stat(result) + expect(stat.isDirectory()).to.be.true + }) + + it("migrates files from legacy ~/Documents/Dirac/ to the new location", async () => { + const legacyDir = path.join(fakeDocuments, "Dirac", subdir) + await fs.mkdir(legacyDir, { recursive: true }) + await fs.writeFile(path.join(legacyDir, "rule1.md"), "content-1") + await fs.writeFile(path.join(legacyDir, "rule2.md"), "content-2") + + const result = await ENSURER[subdir]() + + const migrated = await fs.readdir(result) + expect(migrated.sort()).to.deep.equal(["rule1.md", "rule2.md"]) + expect(await fs.readFile(path.join(result, "rule1.md"), "utf8")).to.equal("content-1") + }) + + it("does not clobber existing files in the new location (idempotent)", async () => { + const legacyDir = path.join(fakeDocuments, "Dirac", subdir) + await fs.mkdir(legacyDir, { recursive: true }) + await fs.writeFile(path.join(legacyDir, "shared.md"), "legacy-content") + + const newDir = path.join(fakeHome, ".dirac", subdir) + await fs.mkdir(newDir, { recursive: true }) + await fs.writeFile(path.join(newDir, "shared.md"), "new-content") + + await ENSURER[subdir]() + + expect(await fs.readFile(path.join(newDir, "shared.md"), "utf8")).to.equal("new-content") + }) + + it("swallows EPERM on legacy dir readdir (TCC-protected ~/Documents)", async () => { + const legacyDir = path.join(fakeDocuments, "Dirac", subdir) + await fs.mkdir(legacyDir, { recursive: true }) + await fs.writeFile(path.join(legacyDir, "rule.md"), "content") + // Simulate TCC denial on readdir of the legacy dir only + const realReaddir = fs.readdir.bind(fs) + sandbox.stub(fs, "readdir").callsFake(((p: string) => { + if (p === legacyDir) { + return Promise.reject(Object.assign(new Error("EPERM"), { code: "EPERM" })) + } + return realReaddir(p) + }) as typeof fs.readdir) + + const result = await ENSURER[subdir]() + + // Still returns the new dir, just without migration + expect(result).to.equal(path.join(fakeHome, ".dirac", subdir)) + const migrated = await fs.readdir(result) + expect(migrated).to.deep.equal([]) + }) + + it("does nothing when legacy dir does not exist", async () => { + const result = await ENSURER[subdir]() + const migrated = await fs.readdir(result) + expect(migrated).to.deep.equal([]) + }) + }) + } +}) diff --git a/src/core/storage/directoryEnsurers.ts b/src/core/storage/directoryEnsurers.ts index cd12f1ef..93002dff 100644 --- a/src/core/storage/directoryEnsurers.ts +++ b/src/core/storage/directoryEnsurers.ts @@ -1,50 +1,54 @@ import fs from "fs/promises" -import os from "os" import * as path from "path" -import { getDocumentsPath } from "./paths" +import { Logger } from "@/shared/services/Logger" import { getGlobalStorageDir } from "./globalStorageDir" +import { getDiracHomePath, getDocumentsPath } from "./paths" // Ensures the per-task directory exists and returns its path. export async function ensureTaskDirectoryExists(taskId: string): Promise { return getGlobalStorageDir("tasks", taskId) } -// Ensures the global Rules directory exists, falling back to ~/Documents/Dirac/Rules. -export async function ensureRulesDirectoryExists(): Promise { - const userDocumentsPath = await getDocumentsPath() - const diracRulesDir = path.join(userDocumentsPath, "Dirac", "Rules") +// Copies a single file from legacy to dest, skipping if dest already exists. +async function migrateFile(src: string, dest: string): Promise { try { - await fs.mkdir(diracRulesDir, { recursive: true }) - } catch (_error) { - return path.join(os.homedir(), "Documents", "Dirac", "Rules") + await fs.copyFile(src, dest, fs.constants.COPYFILE_EXCL) // don't overwrite existing + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") Logger.warn(`migration: skipping ${src}: ${error}`) } - return diracRulesDir } -// Ensures the global Workflows directory exists, falling back to ~/Documents/Dirac/Workflows. -export async function ensureWorkflowsDirectoryExists(): Promise { - const userDocumentsPath = await getDocumentsPath() - const diracWorkflowsDir = path.join(userDocumentsPath, "Dirac", "Workflows") +// Migrates files from the legacy ~/Documents/Dirac/ to ~/.dirac/. +// Best-effort: swallows EPERM (TCC-protected ~/Documents) and any other read error. +// Idempotent: skips files that already exist at the destination. +async function migrateFromDocumentsDir(subdir: string, destDir: string): Promise { + const legacyDir = path.join(await getDocumentsPath(), "Dirac", subdir) + let entries: string[] try { - await fs.mkdir(diracWorkflowsDir, { recursive: true }) - } catch (_error) { - return path.join(os.homedir(), "Documents", "Dirac", "Workflows") + entries = await fs.readdir(legacyDir) + } catch { + return // legacy dir doesn't exist or is TCC-blocked — nothing to migrate } - return diracWorkflowsDir + await Promise.all(entries.map((entry) => migrateFile(path.join(legacyDir, entry), path.join(destDir, entry)))) } -// Ensures the global Hooks directory exists, falling back to ~/Documents/Dirac/Hooks. -export async function ensureHooksDirectoryExists(): Promise { - const userDocumentsPath = await getDocumentsPath() - const diracHooksDir = path.join(userDocumentsPath, "Dirac", "Hooks") - try { - await fs.mkdir(diracHooksDir, { recursive: true }) - } catch (_error) { - return path.join(os.homedir(), "Documents", "Dirac", "Hooks") - } - return diracHooksDir +// Ensures a ~/.dirac/ directory exists and migrates from the legacy ~/Documents/Dirac/. +async function ensureDiracSubdir(subdir: string): Promise { + const dir = path.join(getDiracHomePath(), subdir) + await fs.mkdir(dir, { recursive: true }) + await migrateFromDocumentsDir(subdir, dir) + return dir } +// Ensures the global Rules directory exists at ~/.dirac/Rules (non-TCC-protected). +export const ensureRulesDirectoryExists = (): Promise => ensureDiracSubdir("Rules") + +// Ensures the global Workflows directory exists at ~/.dirac/Workflows (non-TCC-protected). +export const ensureWorkflowsDirectoryExists = (): Promise => ensureDiracSubdir("Workflows") + +// Ensures the global Hooks directory exists at ~/.dirac/Hooks (non-TCC-protected). +export const ensureHooksDirectoryExists = (): Promise => ensureDiracSubdir("Hooks") + // Ensures the global settings directory exists and returns its path. export async function ensureSettingsDirectoryExists(): Promise { return getGlobalStorageDir("settings") From 2b0161b1237212b784a0a3ca45775d7b02c998ca Mon Sep 17 00:00:00 2001 From: Alexandros Salapatas <> Date: Wed, 12 Aug 2026 19:04:03 +0300 Subject: [PATCH 2/3] fix: cover nested migrate + .dirac hooks path - recurse nested Rules/Workflows/Hooks dirs (no EISDIR skip) - classify ~/.dirac/Hooks as global hooks - swallow only ENOENT/EPERM/EACCES; log unexpected --- src/core/hooks/HookRegistry.ts | 4 +- src/core/hooks/__tests__/HookRegistry.test.ts | 23 ++++++ .../__tests__/directoryEnsurers.test.ts | 78 +++++++++++++++++-- src/core/storage/directoryEnsurers.ts | 37 ++++++--- 4 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 src/core/hooks/__tests__/HookRegistry.test.ts diff --git a/src/core/hooks/HookRegistry.ts b/src/core/hooks/HookRegistry.ts index 34afdf0b..e2eff7f7 100644 --- a/src/core/hooks/HookRegistry.ts +++ b/src/core/hooks/HookRegistry.ts @@ -26,9 +26,9 @@ function isExpectedHookError(error: unknown): boolean { * Windows PowerShell scripts) and expected filesystem errors gracefully. */ export class HookRegistry { - /** Checks if a hooks directory is a global hooks directory (contains Dirac/Hooks path). */ + /** Checks if a hooks directory is a global hooks directory (legacy Dirac/Hooks or new .dirac/Hooks). */ static isGlobalHooksDir(dir: string): boolean { - return /[/\\][Dd]irac[/\\][Hh]ooks/i.test(dir) + return /[/\\](?:\.dirac|[Dd]irac)[/\\][Hh]ooks/i.test(dir) } /** Finds all hook scripts for the given hook name across all hooks directories. */ diff --git a/src/core/hooks/__tests__/HookRegistry.test.ts b/src/core/hooks/__tests__/HookRegistry.test.ts new file mode 100644 index 00000000..c2fae335 --- /dev/null +++ b/src/core/hooks/__tests__/HookRegistry.test.ts @@ -0,0 +1,23 @@ +import { expect } from "chai" +import { describe, it } from "mocha" +import { HookRegistry } from "../HookRegistry" + +describe("HookRegistry.isGlobalHooksDir", () => { + it("recognizes legacy ~/Documents/Dirac/Hooks path", () => { + expect(HookRegistry.isGlobalHooksDir("/Users/user/Documents/Dirac/Hooks")).to.be.true + expect(HookRegistry.isGlobalHooksDir("/Users/user/Documents/dirac/hooks")).to.be.true + expect(HookRegistry.isGlobalHooksDir("C:\\Users\\user\\Documents\\Dirac\\Hooks")).to.be.true + }) + + // Review fix #2: relocated global hooks at ~/.dirac/Hooks must be classified as global. + it("recognizes new ~/.dirac/Hooks path", () => { + expect(HookRegistry.isGlobalHooksDir("/Users/user/.dirac/Hooks")).to.be.true + expect(HookRegistry.isGlobalHooksDir("/Users/user/.dirac/hooks")).to.be.true + expect(HookRegistry.isGlobalHooksDir("C:\\Users\\user\\.dirac\\Hooks")).to.be.true + }) + + it("rejects workspace hooks dirs", () => { + expect(HookRegistry.isGlobalHooksDir("/project/.diracrules/hooks")).to.be.false + expect(HookRegistry.isGlobalHooksDir("/project/hooks")).to.be.false + }) +}) diff --git a/src/core/storage/__tests__/directoryEnsurers.test.ts b/src/core/storage/__tests__/directoryEnsurers.test.ts index e6af1338..9070e3ed 100644 --- a/src/core/storage/__tests__/directoryEnsurers.test.ts +++ b/src/core/storage/__tests__/directoryEnsurers.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, it } from "mocha" import os from "os" import path from "path" import * as sinon from "sinon" +import { Logger } from "@/shared/services/Logger" import { ensureHooksDirectoryExists, ensureRulesDirectoryExists, ensureWorkflowsDirectoryExists } from "../directoryEnsurers" import * as pathsModule from "../paths" @@ -88,16 +89,14 @@ describe("directoryEnsurers — FU-9 (TCC-protected path relocation)", () => { expect(await fs.readFile(path.join(newDir, "shared.md"), "utf8")).to.equal("new-content") }) - it("swallows EPERM on legacy dir readdir (TCC-protected ~/Documents)", async () => { + it("swallows EPERM on legacy dir (TCC-protected ~/Documents)", async () => { const legacyDir = path.join(fakeDocuments, "Dirac", subdir) await fs.mkdir(legacyDir, { recursive: true }) await fs.writeFile(path.join(legacyDir, "rule.md"), "content") - // Simulate TCC denial on readdir of the legacy dir only + // Stub readdir to reject EPERM only for the legacy dir (TCC denial) const realReaddir = fs.readdir.bind(fs) sandbox.stub(fs, "readdir").callsFake(((p: string) => { - if (p === legacyDir) { - return Promise.reject(Object.assign(new Error("EPERM"), { code: "EPERM" })) - } + if (p === legacyDir) return Promise.reject(Object.assign(new Error("EPERM"), { code: "EPERM" })) return realReaddir(p) }) as typeof fs.readdir) @@ -114,6 +113,75 @@ describe("directoryEnsurers — FU-9 (TCC-protected path relocation)", () => { const migrated = await fs.readdir(result) expect(migrated).to.deep.equal([]) }) + + // Review fix #1: nested dirs must be migrated recursively (not skipped as EISDIR). + it("migrates nested subdirectories from legacy dir (recursive)", async () => { + const legacyDir = path.join(fakeDocuments, "Dirac", subdir) + await fs.mkdir(path.join(legacyDir, "nested"), { recursive: true }) + await fs.writeFile(path.join(legacyDir, "top.md"), "top") + await fs.writeFile(path.join(legacyDir, "nested", "deep.md"), "deep") + + const result = await ENSURER[subdir]() + + expect(await fs.readFile(path.join(result, "top.md"), "utf8")).to.equal("top") + expect(await fs.readFile(path.join(result, "nested", "deep.md"), "utf8")).to.equal("deep") + }) + + // One bad sibling must not block the rest (data safety — per-file isolation). + it("continues migrating siblings when one copyFile fails", async () => { + const legacyDir = path.join(fakeDocuments, "Dirac", subdir) + await fs.mkdir(legacyDir, { recursive: true }) + await fs.writeFile(path.join(legacyDir, "good.md"), "good") + await fs.writeFile(path.join(legacyDir, "bad.md"), "bad") + const realCopyFile = fs.copyFile.bind(fs) + sandbox.stub(fs, "copyFile").callsFake(((src: string, dest: string, mode?: number) => { + if (src.endsWith("bad.md")) return Promise.reject(Object.assign(new Error("EIO"), { code: "EIO" })) + return realCopyFile(src, dest, mode) + }) as typeof fs.copyFile) + + const result = await ENSURER[subdir]() + + expect(await fs.readFile(path.join(result, "good.md"), "utf8")).to.equal("good") + await fs.stat(path.join(result, "bad.md")).then( + () => expect.fail("bad.md should not have been migrated"), + () => undefined, + ) + }) + + // Review fix #3: EACCES is an expected legacy-path denial, same class as EPERM. + it("swallows EACCES on legacy dir", async () => { + const legacyDir = path.join(fakeDocuments, "Dirac", subdir) + await fs.mkdir(legacyDir, { recursive: true }) + await fs.writeFile(path.join(legacyDir, "rule.md"), "content") + const realReaddir = fs.readdir.bind(fs) + sandbox.stub(fs, "readdir").callsFake(((p: string) => { + if (p === legacyDir) return Promise.reject(Object.assign(new Error("EACCES"), { code: "EACCES" })) + return realReaddir(p) + }) as typeof fs.readdir) + + const result = await ENSURER[subdir]() + + expect(result).to.equal(path.join(fakeHome, ".dirac", subdir)) + expect(await fs.readdir(result)).to.deep.equal([]) + }) + + // Review fix #3: unexpected errors must be logged, not swallowed. + it("logs unexpected readdir errors (not ENOENT/EPERM/EACCES)", async () => { + const legacyDir = path.join(fakeDocuments, "Dirac", subdir) + await fs.mkdir(legacyDir, { recursive: true }) + const warnStub = sandbox.stub(Logger, "warn") + const realReaddir = fs.readdir.bind(fs) + sandbox.stub(fs, "readdir").callsFake(((p: string) => { + if (p === legacyDir) return Promise.reject(Object.assign(new Error("ENOSYS"), { code: "ENOSYS" })) + return realReaddir(p) + }) as typeof fs.readdir) + + const result = await ENSURER[subdir]() + + expect(result).to.equal(path.join(fakeHome, ".dirac", subdir)) + expect(warnStub.called).to.be.true + expect(String(warnStub.firstCall.args[0])).to.match(/migration: skipping/) + }) }) } }) diff --git a/src/core/storage/directoryEnsurers.ts b/src/core/storage/directoryEnsurers.ts index 93002dff..c6033286 100644 --- a/src/core/storage/directoryEnsurers.ts +++ b/src/core/storage/directoryEnsurers.ts @@ -9,27 +9,44 @@ export async function ensureTaskDirectoryExists(taskId: string): Promise return getGlobalStorageDir("tasks", taskId) } -// Copies a single file from legacy to dest, skipping if dest already exists. +// Expected legacy-path errors: ENOENT (dir missing), EPERM/EACCES (TCC-protected ~/Documents). +function isExpectedMigrationError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code + return code === "ENOENT" || code === "EPERM" || code === "EACCES" +} + +// Copies a single file, skipping if dest exists. Per-file isolation — one failure doesn't block others. async function migrateFile(src: string, dest: string): Promise { try { - await fs.copyFile(src, dest, fs.constants.COPYFILE_EXCL) // don't overwrite existing + await fs.copyFile(src, dest, fs.constants.COPYFILE_EXCL) } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") Logger.warn(`migration: skipping ${src}: ${error}`) } } -// Migrates files from the legacy ~/Documents/Dirac/ to ~/.dirac/. -// Best-effort: swallows EPERM (TCC-protected ~/Documents) and any other read error. -// Idempotent: skips files that already exist at the destination. +// Recursively migrates src → dest. Each file has its own try/catch — one failure doesn't abort the rest. +async function migrateDir(src: string, dest: string): Promise { + await fs.mkdir(dest, { recursive: true }) + const entries = await fs.readdir(src, { withFileTypes: true }) + await Promise.all( + entries.map(async (entry) => { + const s = path.join(src, entry.name) + const d = path.join(dest, entry.name) + if (entry.isDirectory()) await migrateDir(s, d) + else await migrateFile(s, d) + }), + ) +} + +// Migrates legacy ~/Documents/Dirac/ → ~/.dirac/ recursively, skipping existing files. +// Swallows only ENOENT/EPERM/EACCES (TCC-protected ~/Documents); logs unexpected errors. async function migrateFromDocumentsDir(subdir: string, destDir: string): Promise { const legacyDir = path.join(await getDocumentsPath(), "Dirac", subdir) - let entries: string[] try { - entries = await fs.readdir(legacyDir) - } catch { - return // legacy dir doesn't exist or is TCC-blocked — nothing to migrate + await migrateDir(legacyDir, destDir) + } catch (error) { + if (!isExpectedMigrationError(error)) Logger.warn(`migration: skipping ${legacyDir}: ${error}`) } - await Promise.all(entries.map((entry) => migrateFile(path.join(legacyDir, entry), path.join(destDir, entry)))) } // Ensures a ~/.dirac/ directory exists and migrates from the legacy ~/Documents/Dirac/. From ec492045fda8387d439b1d253cc0d55cbf8b2e01 Mon Sep 17 00:00:00 2001 From: Alexandros Salapatas <> Date: Thu, 13 Aug 2026 00:00:49 +0300 Subject: [PATCH 3/3] fix: rethrow unexpected migration errors log + rethrow non-ENOENT/EPERM/EACCES so incomplete migration fails loudly instead of fake success. test now expects rejection. --- .../storage/__tests__/directoryEnsurers.test.ts | 15 +++++++++------ src/core/storage/directoryEnsurers.ts | 6 ++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/core/storage/__tests__/directoryEnsurers.test.ts b/src/core/storage/__tests__/directoryEnsurers.test.ts index 9070e3ed..2d31c640 100644 --- a/src/core/storage/__tests__/directoryEnsurers.test.ts +++ b/src/core/storage/__tests__/directoryEnsurers.test.ts @@ -165,8 +165,8 @@ describe("directoryEnsurers — FU-9 (TCC-protected path relocation)", () => { expect(await fs.readdir(result)).to.deep.equal([]) }) - // Review fix #3: unexpected errors must be logged, not swallowed. - it("logs unexpected readdir errors (not ENOENT/EPERM/EACCES)", async () => { + // Review fix #3: unexpected errors must be logged and rethrown, not swallowed. + it("rejects on unexpected readdir errors (not ENOENT/EPERM/EACCES)", async () => { const legacyDir = path.join(fakeDocuments, "Dirac", subdir) await fs.mkdir(legacyDir, { recursive: true }) const warnStub = sandbox.stub(Logger, "warn") @@ -176,11 +176,14 @@ describe("directoryEnsurers — FU-9 (TCC-protected path relocation)", () => { return realReaddir(p) }) as typeof fs.readdir) - const result = await ENSURER[subdir]() - - expect(result).to.equal(path.join(fakeHome, ".dirac", subdir)) + await ENSURER[subdir]().then( + () => expect.fail("unexpected migration error should reject"), + (error) => { + expect((error as NodeJS.ErrnoException).code).to.equal("ENOSYS") + }, + ) expect(warnStub.called).to.be.true - expect(String(warnStub.firstCall.args[0])).to.match(/migration: skipping/) + expect(String(warnStub.firstCall.args[0])).to.match(/migration: failed/) }) }) } diff --git a/src/core/storage/directoryEnsurers.ts b/src/core/storage/directoryEnsurers.ts index c6033286..84509553 100644 --- a/src/core/storage/directoryEnsurers.ts +++ b/src/core/storage/directoryEnsurers.ts @@ -39,13 +39,15 @@ async function migrateDir(src: string, dest: string): Promise { } // Migrates legacy ~/Documents/Dirac/ → ~/.dirac/ recursively, skipping existing files. -// Swallows only ENOENT/EPERM/EACCES (TCC-protected ~/Documents); logs unexpected errors. +// Swallows only ENOENT/EPERM/EACCES (TCC-protected ~/Documents); logs and rethrows unexpected errors. async function migrateFromDocumentsDir(subdir: string, destDir: string): Promise { const legacyDir = path.join(await getDocumentsPath(), "Dirac", subdir) try { await migrateDir(legacyDir, destDir) } catch (error) { - if (!isExpectedMigrationError(error)) Logger.warn(`migration: skipping ${legacyDir}: ${error}`) + if (isExpectedMigrationError(error)) return + Logger.warn(`migration: failed ${legacyDir}: ${error}`) + throw error } }