From 142ed42d90a85da1cc4398034049e1475d7bb4ac Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Mon, 5 Jan 2026 11:38:18 -0800 Subject: [PATCH 1/3] feat(ux): show more helpful output when users encounter permissions issues within the data directory --- apps/server/src/services/data_dir.ts | 38 +++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/data_dir.ts b/apps/server/src/services/data_dir.ts index 4f277736b..1e6de81bf 100644 --- a/apps/server/src/services/data_dir.ts +++ b/apps/server/src/services/data_dir.ts @@ -75,9 +75,45 @@ export function getPlatformAppDataDir(platform: ReturnType, } } +function outputPermissionDiagnostics(targetPath: fs.PathLike) { + const pathStr = targetPath.toString(); + const parentDir = pathJoin(pathStr, ".."); + + console.error("\n========== PERMISSION ERROR DIAGNOSTICS =========="); + console.error(`Failed to create directory: ${pathStr}`); + + // Output current process UID:GID (Unix only) + if (typeof process.getuid === "function" && typeof process.getgid === "function") { + console.error(`Process running as UID:GID = ${process.getuid()}:${process.getgid()}`); + } + + // Try to get parent directory stats + try { + const stats = fs.statSync(parentDir); + console.error(`Parent directory: ${parentDir}`); + console.error(` Owner UID:GID = ${stats.uid}:${stats.gid}`); + console.error(` Permissions = ${(stats.mode & 0o777).toString(8)} (octal)`); + } catch { + console.error(`Parent directory ${parentDir} is not accessible`); + } + + console.error("\nTo fix this issue:"); + console.error(" - Ensure the data directory is owned by the user running Trilium"); + console.error(" - Or set USER_UID and USER_GID environment variables to match the directory owner"); + console.error(" - Example: docker run -e USER_UID=$(id -u) -e USER_GID=$(id -g) ..."); + console.error("====================================================\n"); +} + function createDirIfNotExisting(path: fs.PathLike, permissionMode: fs.Mode = FOLDER_PERMISSIONS) { if (!fs.existsSync(path)) { - fs.mkdirSync(path, permissionMode); + try { + fs.mkdirSync(path, permissionMode); + } catch (err: unknown) { + if (err && typeof err === "object" && "code" in err && err.code === "EACCES") { + outputPermissionDiagnostics(path); + } + throw err; + } } } From 0185dd0d187c2853d05f83d2b63c5b2868784c98 Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Mon, 5 Jan 2026 11:55:14 -0800 Subject: [PATCH 2/3] feat(ux): implement suggestions from gemini just to make sure --- apps/server/src/services/data_dir.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/server/src/services/data_dir.ts b/apps/server/src/services/data_dir.ts index 1e6de81bf..d0f34f01d 100644 --- a/apps/server/src/services/data_dir.ts +++ b/apps/server/src/services/data_dir.ts @@ -105,15 +105,26 @@ function outputPermissionDiagnostics(targetPath: fs.PathLike) { } function createDirIfNotExisting(path: fs.PathLike, permissionMode: fs.Mode = FOLDER_PERMISSIONS) { - if (!fs.existsSync(path)) { - try { - fs.mkdirSync(path, permissionMode); - } catch (err: unknown) { - if (err && typeof err === "object" && "code" in err && err.code === "EACCES") { + try { + fs.mkdirSync(path, permissionMode); + } catch (err: unknown) { + if (err && typeof err === "object" && "code" in err) { + const code = (err as { code: string }).code; + + if (code === "EACCES") { outputPermissionDiagnostics(path); + } else if (code === "EEXIST") { + // Directory already exists - verify it's actually a directory + try { + if (fs.statSync(path).isDirectory()) { + return; + } + } catch { + // If we can't stat it, fall through to re-throw original error + } } - throw err; } + throw err; } } From 2dd541e1d0f65bcb9f42f79b4d7c61b7bda886a7 Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Mon, 5 Jan 2026 14:34:46 -0800 Subject: [PATCH 3/3] fix(tests): update data_dir tests for new EEXIST graceful handling --- apps/server/src/services/data_dir.spec.ts | 77 ++++++++++++----------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/apps/server/src/services/data_dir.spec.ts b/apps/server/src/services/data_dir.spec.ts index f04f16ca0..a58529688 100644 --- a/apps/server/src/services/data_dir.spec.ts +++ b/apps/server/src/services/data_dir.spec.ts @@ -10,6 +10,7 @@ describe("data_dir.ts unit tests", async () => { const mockFn = { existsSyncMock: vi.fn(), mkdirSyncMock: vi.fn(), + statSyncMock: vi.fn(), osHomedirMock: vi.fn(), osPlatformMock: vi.fn(), pathJoinMock: vi.fn() @@ -21,7 +22,8 @@ describe("data_dir.ts unit tests", async () => { return { default: { existsSync: mockFn.existsSyncMock, - mkdirSync: mockFn.mkdirSyncMock + mkdirSync: mockFn.mkdirSyncMock, + statSync: mockFn.statSyncMock } }; }); @@ -109,34 +111,36 @@ describe("data_dir.ts unit tests", async () => { */ describe("case A", () => { - it("when folder exists – it should return the path, without attempting to create the folder", async () => { + it("when folder exists – it should return the path, handling EEXIST gracefully", async () => { const mockTriliumDataPath = "/home/mock/trilium-data-ENV-A1"; process.env.TRILIUM_DATA_DIR = mockTriliumDataPath; - // set fs.existsSync to true, i.e. the folder does exist - mockFn.existsSyncMock.mockImplementation(() => true); + // mkdirSync throws EEXIST when folder already exists (EAFP pattern) + const eexistError = new Error("EEXIST: file already exists") as NodeJS.ErrnoException; + eexistError.code = "EEXIST"; + mockFn.mkdirSyncMock.mockImplementation(() => { throw eexistError; }); + + // statSync confirms it's a directory + mockFn.statSyncMock.mockImplementation(() => ({ isDirectory: () => true })); const result = getTriliumDataDir("trilium-data"); - // createDirIfNotExisting should call existsync 1 time and mkdirSync 0 times -> as it does not need to create the folder - // and return value should be TRILIUM_DATA_DIR value from process.env - expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(1); - expect(mockFn.mkdirSyncMock).toHaveBeenCalledTimes(0); + // createDirIfNotExisting tries mkdirSync first (EAFP), then statSync to verify it's a directory + expect(mockFn.mkdirSyncMock).toHaveBeenCalledTimes(1); + expect(mockFn.statSyncMock).toHaveBeenCalledTimes(1); expect(result).toEqual(process.env.TRILIUM_DATA_DIR); }); - it("when folder does not exist – it should attempt to create the folder and return the path", async () => { + it("when folder does not exist – it should create the folder and return the path", async () => { const mockTriliumDataPath = "/home/mock/trilium-data-ENV-A2"; process.env.TRILIUM_DATA_DIR = mockTriliumDataPath; - // set fs.existsSync mock to return false, i.e. the folder does not exist - mockFn.existsSyncMock.mockImplementation(() => false); + // mkdirSync succeeds when folder doesn't exist + mockFn.mkdirSyncMock.mockImplementation(() => undefined); const result = getTriliumDataDir("trilium-data"); - // createDirIfNotExisting should call existsync 1 time and mkdirSync 1 times -> as it has to create the folder - // and return value should be TRILIUM_DATA_DIR value from process.env - expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(1); + // createDirIfNotExisting calls mkdirSync which succeeds expect(mockFn.mkdirSyncMock).toHaveBeenCalledTimes(1); expect(result).toEqual(process.env.TRILIUM_DATA_DIR); }); @@ -171,19 +175,19 @@ describe("data_dir.ts unit tests", async () => { // use Generator to precisely control order of fs.existSync return values const existsSyncMockGen = (function* () { - // 1) fs.existSync -> case B + // 1) fs.existSync -> case B -> checking if folder exists in home dir yield false; // 2) fs.existSync -> case C -> checking if default OS PlatformAppDataDir exists yield true; - // 3) fs.existSync -> case C -> checking if Trilium Data folder exists - yield false; })(); mockFn.existsSyncMock.mockImplementation(() => existsSyncMockGen.next().value); + // mkdirSync succeeds (folder doesn't exist) + mockFn.mkdirSyncMock.mockImplementation(() => undefined); const result = getTriliumDataDir(dataDirName); - expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(3); + expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(2); expect(mockFn.mkdirSyncMock).toHaveBeenCalledTimes(1); expect(result).toEqual(mockPlatformDataPath); }); @@ -198,21 +202,26 @@ describe("data_dir.ts unit tests", async () => { // use Generator to precisely control order of fs.existSync return values const existsSyncMockGen = (function* () { - // 1) fs.existSync -> case B + // 1) fs.existSync -> case B -> checking if folder exists in home dir yield false; // 2) fs.existSync -> case C -> checking if default OS PlatformAppDataDir exists yield true; - // 3) fs.existSync -> case C -> checking if Trilium Data folder exists - yield true; })(); mockFn.existsSyncMock.mockImplementation(() => existsSyncMockGen.next().value); + // mkdirSync throws EEXIST (folder already exists), statSync confirms it's a directory + const eexistError = new Error("EEXIST: file already exists") as NodeJS.ErrnoException; + eexistError.code = "EEXIST"; + mockFn.mkdirSyncMock.mockImplementation(() => { throw eexistError; }); + mockFn.statSyncMock.mockImplementation(() => ({ isDirectory: () => true })); + const result = getTriliumDataDir(dataDirName); expect(result).toEqual(mockPlatformDataPath); - expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(3); - expect(mockFn.mkdirSyncMock).toHaveBeenCalledTimes(0); + expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(2); + expect(mockFn.mkdirSyncMock).toHaveBeenCalledTimes(1); + expect(mockFn.statSyncMock).toHaveBeenCalledTimes(1); }); it("w/ Platform 'win32' and set process.env.APPDATA behaviour", async () => { @@ -227,20 +236,20 @@ describe("data_dir.ts unit tests", async () => { // use Generator to precisely control order of fs.existSync return values const existsSyncMockGen = (function* () { - // 1) fs.existSync -> case B + // 1) fs.existSync -> case B -> checking if folder exists in home dir yield false; // 2) fs.existSync -> case C -> checking if default OS PlatformAppDataDir exists yield true; - // 3) fs.existSync -> case C -> checking if Trilium Data folder exists - yield false; })(); mockFn.existsSyncMock.mockImplementation(() => existsSyncMockGen.next().value); + // mkdirSync succeeds (folder doesn't exist) + mockFn.mkdirSyncMock.mockImplementation(() => undefined); const result = getTriliumDataDir(dataDirName); expect(result).toEqual(mockPlatformDataPath); - expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(3); + expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(2); expect(mockFn.mkdirSyncMock).toHaveBeenCalledTimes(1); }); }); @@ -253,19 +262,15 @@ describe("data_dir.ts unit tests", async () => { setMockPlatform("aix", homedir, mockPlatformDataPath); - const existsSyncMockGen = (function* () { - // first fs.existSync -> case B -> checking if folder exists in home folder - yield false; - // second fs.existSync -> case D -> triggered by createDirIfNotExisting - yield false; - })(); - - mockFn.existsSyncMock.mockImplementation(() => existsSyncMockGen.next().value); + // fs.existSync -> case B -> checking if folder exists in home folder + mockFn.existsSyncMock.mockImplementation(() => false); + // mkdirSync succeeds (folder doesn't exist) + mockFn.mkdirSyncMock.mockImplementation(() => undefined); const result = getTriliumDataDir(dataDirName); expect(result).toEqual(mockPlatformDataPath); - expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(2); + expect(mockFn.existsSyncMock).toHaveBeenCalledTimes(1); expect(mockFn.mkdirSyncMock).toHaveBeenCalledTimes(1); }); });