diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index f7db3c2778102..18813eb9025ae 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -357,6 +357,34 @@ describe("DesktopSettings", () => { ), ); + it.effect("saves through a symlinked settings file without replacing the link", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const dotfiles = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-settings-dotfiles-", + }); + const linkedSettingsPath = `${dotfiles}/desktop-settings.json`; + yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n"); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.symlink(linkedSettingsPath, environment.desktopSettingsPath); + + yield* settings.setServerExposureMode("network-accessible"); + + assert.equal( + yield* fileSystem.readLink(environment.desktopSettingsPath), + linkedSettingsPath, + ); + const persisted = yield* decodeDesktopSettingsPatch( + yield* fileSystem.readFileString(linkedSettingsPath), + ); + assert.equal(persisted.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("migrates legacy implicit update channels to the runtime default", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 19fcf0e75962e..343a498d26ef1 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -5,6 +5,7 @@ import { type DesktopUpdateChannel, } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -421,8 +422,12 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp readonly defaultSettings: DesktopSettings; readonly suffix: string; }): Effect.fn.Return { - const directory = input.path.dirname(input.settingsPath); - const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; + const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe( + Effect.provideService(FileSystem.FileSystem, input.fileSystem), + Effect.provideService(Path.Path, input.path), + ); + const directory = input.path.dirname(targetPath); + const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeDesktopSettingsJson( toDesktopSettingsDocument(input.settings, input.defaultSettings), ).pipe( @@ -455,7 +460,7 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp }), ), ); - yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + yield* input.fileSystem.rename(tempPath, targetPath).pipe( Effect.mapError( (cause) => new DesktopSettingsWriteError({ diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index e77d083446444..91b64da41dd1e 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -149,6 +149,34 @@ describe("DesktopClientSettings", () => { ), ); + it.effect("saves through a symlinked client settings file without replacing the link", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const dotfiles = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-client-settings-dotfiles-", + }); + const linkedSettingsPath = `${dotfiles}/client-settings.json`; + yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n"); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.symlink(linkedSettingsPath, environment.clientSettingsPath); + + yield* settings.set(clientSettings); + + assert.equal( + yield* fileSystem.readLink(environment.clientSettingsPath), + linkedSettingsPath, + ); + assert.deepEqual( + yield* decodeClientSettingsJson(yield* fileSystem.readFileString(linkedSettingsPath)), + clientSettings, + ); + }), + ), + ); + for (const failure of [ { label: "permission", reason: "PermissionDenied" }, { label: "I/O", reason: "Unknown" }, diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index cd7fea6ca116f..bbac7b4ff3d08 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -1,5 +1,6 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -126,8 +127,12 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { readonly settings: ClientSettings; readonly suffix: string; }): Effect.fn.Return { - const directory = input.path.dirname(input.settingsPath); - const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; + const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe( + Effect.provideService(FileSystem.FileSystem, input.fileSystem), + Effect.provideService(Path.Path, input.path), + ); + const directory = input.path.dirname(targetPath); + const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeClientSettingsJson(input.settings).pipe( Effect.mapError( (cause) => @@ -158,7 +163,7 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { }), ), ); - yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + yield* input.fileSystem.rename(tempPath, targetPath).pipe( Effect.mapError( (cause) => new DesktopClientSettingsWriteError({ diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts index 20b2fab6374f8..8a7fb20f2aea6 100644 --- a/apps/server/src/atomicWrite.ts +++ b/apps/server/src/atomicWrite.ts @@ -1,27 +1,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; - -const MAX_SYMLINK_HOPS = 40; - -/** - * Follows a chain of symlinks to the file it finally names, which may not exist - * yet. Any path that is not a symlink resolves to itself. - */ -export const resolveSymlinkTarget = (filePath: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - let current = path.resolve(filePath); - for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) { - const link = yield* fs.readLink(current).pipe(Effect.option); - if (link._tag === "None") { - return current; - } - current = path.resolve(path.dirname(current), link.value); - } - return current; - }); +import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; /** * Replaces a file's contents via a sibling temp file and rename. A symlinked diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index eeb173e2e1301..55754dcde0bef 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -51,7 +51,8 @@ import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { resolveSymlinkTarget, writeFileStringAtomically } from "./atomicWrite.ts"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; diff --git a/docs/fork/0027-symlinked-settings-stay-linked.md b/docs/fork/0027-symlinked-settings-stay-linked.md index 46905611fa1c1..5147bda9e946e 100644 --- a/docs/fork/0027-symlinked-settings-stay-linked.md +++ b/docs/fork/0027-symlinked-settings-stay-linked.md @@ -1,6 +1,6 @@ # 0027: Symlinked settings stay linked -- PR: [TrogonStack/t3code#73](https://github.com/TrogonStack/t3code/pull/73) +- PRs: [TrogonStack/t3code#73](https://github.com/TrogonStack/t3code/pull/73), [TrogonStack/t3code#74](https://github.com/TrogonStack/t3code/pull/74) - Status: active ## What you can do now diff --git a/docs/fork/README.md b/docs/fork/README.md index 06f796a7635fb..b532fcc659586 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -44,4 +44,4 @@ Each entry uses these sections: - **0025** [A test run leaves no processes behind](./0025-a-test-run-leaves-no-processes-behind.md) active, [#57](https://github.com/TrogonStack/t3code/pull/57) - **0027** [Symlinked settings stay linked](./0027-symlinked-settings-stay-linked.md) - active, [#73](https://github.com/TrogonStack/t3code/pull/73) + active, [#73](https://github.com/TrogonStack/t3code/pull/73), [#74](https://github.com/TrogonStack/t3code/pull/74) diff --git a/packages/shared/package.json b/packages/shared/package.json index 478c757c76c90..b67a205e5bb33 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -195,6 +195,10 @@ "types": "./src/connectAuth.ts", "import": "./src/connectAuth.ts" }, + "./symlink": { + "types": "./src/symlink.ts", + "import": "./src/symlink.ts" + }, "./path": { "types": "./src/path.ts", "import": "./src/path.ts" diff --git a/packages/shared/src/symlink.ts b/packages/shared/src/symlink.ts new file mode 100644 index 0000000000000..a3a4841fec17d --- /dev/null +++ b/packages/shared/src/symlink.ts @@ -0,0 +1,25 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +const MAX_SYMLINK_HOPS = 40; + +/** + * Follows a chain of symlinks to the file it finally names, which may not exist + * yet. Any path that is not a symlink resolves to itself. Atomic writers rename + * onto this path so a linked file keeps its link. + */ +export const resolveSymlinkTarget = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(filePath); + for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) { + const link = yield* fs.readLink(current).pipe(Effect.option); + if (link._tag === "None") { + return current; + } + current = path.resolve(path.dirname(current), link.value); + } + return current; + });