| author | |
| committer | |
| log | 00eb8f6183ba4b36a429db08767031ad8ca9d81d |
| tree | e89800d856e5ae5d0b9157048b896aea22c055c6 |
| parent | 8d846660cecfc69ef4e192322fced29fa07f374c |
| signature | Signed by PGP key B5690EEEBB952194 |
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>10 files changed, 453 insertions(+), 21 deletions(-)
apps/desktop/src/settings/DesktopAppSettings.test.ts+28| ... | @@ -357,6 +357,34 @@ describe("DesktopSettings", () => { | ... | @@ -357,6 +357,34 @@ describe("DesktopSettings", () => { |
| 357 | ), | 357 | ), |
| 358 | ); | 358 | ); |
| 359 | 359 | ||
| 360 | it.effect("saves through a symlinked settings file without replacing the link", () => | ||
| 361 | withSettings( | ||
| 362 | Effect.gen(function* () { | ||
| 363 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 364 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 365 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | ||
| 366 | const dotfiles = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 367 | prefix: "t3-desktop-settings-dotfiles-", | ||
| 368 | }); | ||
| 369 | const linkedSettingsPath = `${dotfiles}/desktop-settings.json`; | ||
| 370 | yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n"); | ||
| 371 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | ||
| 372 | yield* fileSystem.symlink(linkedSettingsPath, environment.desktopSettingsPath); | ||
| 373 | |||
| 374 | yield* settings.setServerExposureMode("network-accessible"); | ||
| 375 | |||
| 376 | assert.equal( | ||
| 377 | yield* fileSystem.readLink(environment.desktopSettingsPath), | ||
| 378 | linkedSettingsPath, | ||
| 379 | ); | ||
| 380 | const persisted = yield* decodeDesktopSettingsPatch( | ||
| 381 | yield* fileSystem.readFileString(linkedSettingsPath), | ||
| 382 | ); | ||
| 383 | assert.equal(persisted.serverExposureMode, "network-accessible"); | ||
| 384 | }), | ||
| 385 | ), | ||
| 386 | ); | ||
| 387 | |||
| 360 | it.effect("migrates legacy implicit update channels to the runtime default", () => | 388 | it.effect("migrates legacy implicit update channels to the runtime default", () => |
| 361 | withSettings( | 389 | withSettings( |
| 362 | Effect.gen(function* () { | 390 | Effect.gen(function* () { |
apps/desktop/src/settings/DesktopAppSettings.ts+17-3| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | type DesktopUpdateChannel, | 5 | type DesktopUpdateChannel, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | 7 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 8 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | ||
| 8 | import * as Context from "effect/Context"; | 9 | import * as Context from "effect/Context"; |
| 9 | import * as Crypto from "effect/Crypto"; | 10 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| ... | @@ -130,6 +131,7 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet | ... | @@ -130,6 +131,7 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet |
| 130 | 131 | ||
| 131 | const DesktopSettingsWriteOperation = Schema.Literals([ | 132 | const DesktopSettingsWriteOperation = Schema.Literals([ |
| 132 | "create-temporary-file-name", | 133 | "create-temporary-file-name", |
| 134 | "resolve-symlink", | ||
| 133 | "encode-document", | 135 | "encode-document", |
| 134 | "create-directory", | 136 | "create-directory", |
| 135 | "write-temporary-file", | 137 | "write-temporary-file", |
| ... | @@ -421,8 +423,20 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp | ... | @@ -421,8 +423,20 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp |
| 421 | readonly defaultSettings: DesktopSettings; | 423 | readonly defaultSettings: DesktopSettings; |
| 422 | readonly suffix: string; | 424 | readonly suffix: string; |
| 423 | }): Effect.fn.Return<void, DesktopSettingsWriteError> { | 425 | }): Effect.fn.Return<void, DesktopSettingsWriteError> { |
| 424 | const directory = input.path.dirname(input.settingsPath); | 426 | const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe( |
| 425 | const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; | 427 | Effect.provideService(FileSystem.FileSystem, input.fileSystem), |
| 428 | Effect.provideService(Path.Path, input.path), | ||
| 429 | Effect.mapError( | ||
| 430 | (cause) => | ||
| 431 | new DesktopSettingsWriteError({ | ||
| 432 | operation: "resolve-symlink", | ||
| 433 | path: input.settingsPath, | ||
| 434 | cause, | ||
| 435 | }), | ||
| 436 | ), | ||
| 437 | ); | ||
| 438 | const directory = input.path.dirname(targetPath); | ||
| 439 | const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; | ||
| 426 | const encoded = yield* encodeDesktopSettingsJson( | 440 | const encoded = yield* encodeDesktopSettingsJson( |
| 427 | toDesktopSettingsDocument(input.settings, input.defaultSettings), | 441 | toDesktopSettingsDocument(input.settings, input.defaultSettings), |
| 428 | ).pipe( | 442 | ).pipe( |
| ... | @@ -455,7 +469,7 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp | ... | @@ -455,7 +469,7 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp |
| 455 | }), | 469 | }), |
| 456 | ), | 470 | ), |
| 457 | ); | 471 | ); |
| 458 | yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( | 472 | yield* input.fileSystem.rename(tempPath, targetPath).pipe( |
| 459 | Effect.mapError( | 473 | Effect.mapError( |
| 460 | (cause) => | 474 | (cause) => |
| 461 | new DesktopSettingsWriteError({ | 475 | new DesktopSettingsWriteError({ |
apps/desktop/src/settings/DesktopClientSettings.test.ts+28| ... | @@ -149,6 +149,34 @@ describe("DesktopClientSettings", () => { | ... | @@ -149,6 +149,34 @@ describe("DesktopClientSettings", () => { |
| 149 | ), | 149 | ), |
| 150 | ); | 150 | ); |
| 151 | 151 | ||
| 152 | it.effect("saves through a symlinked client settings file without replacing the link", () => | ||
| 153 | withClientSettings( | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 156 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 157 | const settings = yield* DesktopClientSettings.DesktopClientSettings; | ||
| 158 | const dotfiles = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 159 | prefix: "t3-desktop-client-settings-dotfiles-", | ||
| 160 | }); | ||
| 161 | const linkedSettingsPath = `${dotfiles}/client-settings.json`; | ||
| 162 | yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n"); | ||
| 163 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | ||
| 164 | yield* fileSystem.symlink(linkedSettingsPath, environment.clientSettingsPath); | ||
| 165 | |||
| 166 | yield* settings.set(clientSettings); | ||
| 167 | |||
| 168 | assert.equal( | ||
| 169 | yield* fileSystem.readLink(environment.clientSettingsPath), | ||
| 170 | linkedSettingsPath, | ||
| 171 | ); | ||
| 172 | assert.deepEqual( | ||
| 173 | yield* decodeClientSettingsJson(yield* fileSystem.readFileString(linkedSettingsPath)), | ||
| 174 | clientSettings, | ||
| 175 | ); | ||
| 176 | }), | ||
| 177 | ), | ||
| 178 | ); | ||
| 179 | |||
| 152 | it.effect.each([ | 180 | it.effect.each([ |
| 153 | { label: "permission", reason: "PermissionDenied" }, | 181 | { label: "permission", reason: "PermissionDenied" }, |
| 154 | { label: "I/O", reason: "Unknown" }, | 182 | { label: "I/O", reason: "Unknown" }, |
apps/desktop/src/settings/DesktopClientSettings.ts+17-3| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; | 1 | import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; |
| 2 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | 2 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 3 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | ||
| 3 | import * as Context from "effect/Context"; | 4 | import * as Context from "effect/Context"; |
| 4 | import * as Crypto from "effect/Crypto"; | 5 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| ... | @@ -41,6 +42,7 @@ export class DesktopClientSettingsReadError extends Schema.TaggedError<DesktopCl | ... | @@ -41,6 +42,7 @@ export class DesktopClientSettingsReadError extends Schema.TaggedError<DesktopCl |
| 41 | 42 | ||
| 42 | const DesktopClientSettingsWriteOperation = Schema.Literals([ | 43 | const DesktopClientSettingsWriteOperation = Schema.Literals([ |
| 43 | "create-temporary-file-name", | 44 | "create-temporary-file-name", |
| 45 | "resolve-symlink", | ||
| 44 | "encode-document", | 46 | "encode-document", |
| 45 | "create-directory", | 47 | "create-directory", |
| 46 | "write-temporary-file", | 48 | "write-temporary-file", |
| ... | @@ -126,8 +128,20 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { | ... | @@ -126,8 +128,20 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { |
| 126 | readonly settings: ClientSettings; | 128 | readonly settings: ClientSettings; |
| 127 | readonly suffix: string; | 129 | readonly suffix: string; |
| 128 | }): Effect.fn.Return<void, DesktopClientSettingsWriteError> { | 130 | }): Effect.fn.Return<void, DesktopClientSettingsWriteError> { |
| 129 | const directory = input.path.dirname(input.settingsPath); | 131 | const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe( |
| 130 | const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; | 132 | Effect.provideService(FileSystem.FileSystem, input.fileSystem), |
| 133 | Effect.provideService(Path.Path, input.path), | ||
| 134 | Effect.mapError( | ||
| 135 | (cause) => | ||
| 136 | new DesktopClientSettingsWriteError({ | ||
| 137 | operation: "resolve-symlink", | ||
| 138 | path: input.settingsPath, | ||
| 139 | cause, | ||
| 140 | }), | ||
| 141 | ), | ||
| 142 | ); | ||
| 143 | const directory = input.path.dirname(targetPath); | ||
| 144 | const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; | ||
| 131 | const encoded = yield* encodeClientSettingsJson(input.settings).pipe( | 145 | const encoded = yield* encodeClientSettingsJson(input.settings).pipe( |
| 132 | Effect.mapError( | 146 | Effect.mapError( |
| 133 | (cause) => | 147 | (cause) => |
| ... | @@ -158,7 +172,7 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { | ... | @@ -158,7 +172,7 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { |
| 158 | }), | 172 | }), |
| 159 | ), | 173 | ), |
| 160 | ); | 174 | ); |
| 161 | yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( | 175 | yield* input.fileSystem.rename(tempPath, targetPath).pipe( |
| 162 | Effect.mapError( | 176 | Effect.mapError( |
| 163 | (cause) => | 177 | (cause) => |
| 164 | new DesktopClientSettingsWriteError({ | 178 | new DesktopClientSettingsWriteError({ |
apps/server/src/atomicWrite.test.ts created+139| ... | @@ -0,0 +1,139 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as PlatformError from "effect/PlatformError"; | ||
| 9 | |||
| 10 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | ||
| 11 | |||
| 12 | it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { | ||
| 13 | it.effect("keeps a symlinked file linked and rewrites its destination", () => | ||
| 14 | Effect.gen(function* () { | ||
| 15 | const fs = yield* FileSystem.FileSystem; | ||
| 16 | const path = yield* Path.Path; | ||
| 17 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | ||
| 18 | const destination = path.join(root, "dotfiles", "settings.json"); | ||
| 19 | const link = path.join(root, "home", "settings.json"); | ||
| 20 | yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); | ||
| 21 | yield* fs.makeDirectory(path.dirname(link), { recursive: true }); | ||
| 22 | yield* fs.writeFileString(destination, "before"); | ||
| 23 | yield* fs.symlink(destination, link); | ||
| 24 | |||
| 25 | yield* writeFileStringAtomically({ filePath: link, contents: "after" }); | ||
| 26 | |||
| 27 | assert.strictEqual(yield* fs.readLink(link), destination); | ||
| 28 | assert.strictEqual(yield* fs.readFileString(destination), "after"); | ||
| 29 | }), | ||
| 30 | ); | ||
| 31 | |||
| 32 | it.effect("keeps a dangling symlink linked and creates its destination", () => | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const fs = yield* FileSystem.FileSystem; | ||
| 35 | const path = yield* Path.Path; | ||
| 36 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | ||
| 37 | const destination = path.join(root, "dotfiles", "settings.json"); | ||
| 38 | const link = path.join(root, "home", "settings.json"); | ||
| 39 | yield* fs.makeDirectory(path.dirname(link), { recursive: true }); | ||
| 40 | yield* fs.symlink(destination, link); | ||
| 41 | |||
| 42 | yield* writeFileStringAtomically({ filePath: link, contents: "fresh" }); | ||
| 43 | |||
| 44 | assert.strictEqual(yield* fs.readLink(link), destination); | ||
| 45 | assert.strictEqual(yield* fs.readFileString(destination), "fresh"); | ||
| 46 | }), | ||
| 47 | ); | ||
| 48 | |||
| 49 | it.effect("fails on a symlink cycle without replacing either link", () => | ||
| 50 | Effect.gen(function* () { | ||
| 51 | const fs = yield* FileSystem.FileSystem; | ||
| 52 | const path = yield* Path.Path; | ||
| 53 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | ||
| 54 | const first = path.join(root, "first.json"); | ||
| 55 | const second = path.join(root, "second.json"); | ||
| 56 | yield* fs.symlink(second, first); | ||
| 57 | yield* fs.symlink(first, second); | ||
| 58 | |||
| 59 | const result = yield* Effect.exit( | ||
| 60 | writeFileStringAtomically({ filePath: first, contents: "after" }), | ||
| 61 | ); | ||
| 62 | |||
| 63 | assert.isTrue(Exit.isFailure(result)); | ||
| 64 | assert.strictEqual(yield* fs.readLink(first), second); | ||
| 65 | assert.strictEqual(yield* fs.readLink(second), first); | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | |||
| 69 | it.effect("resolves a relative link through a symlinked parent directory", () => | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const fs = yield* FileSystem.FileSystem; | ||
| 72 | const path = yield* Path.Path; | ||
| 73 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | ||
| 74 | const destination = path.join(root, "dotfiles", "config", "settings.json"); | ||
| 75 | const linkedState = path.join(root, "dotfiles", "state"); | ||
| 76 | const home = path.join(root, "home"); | ||
| 77 | const link = path.join(home, "state", "settings.json"); | ||
| 78 | yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); | ||
| 79 | yield* fs.makeDirectory(linkedState, { recursive: true }); | ||
| 80 | yield* fs.makeDirectory(home, { recursive: true }); | ||
| 81 | yield* fs.symlink(linkedState, path.join(home, "state")); | ||
| 82 | yield* fs.writeFileString(destination, "before"); | ||
| 83 | yield* fs.symlink("../config/settings.json", link); | ||
| 84 | |||
| 85 | yield* writeFileStringAtomically({ filePath: link, contents: "after" }); | ||
| 86 | |||
| 87 | assert.strictEqual(yield* fs.readLink(link), "../config/settings.json"); | ||
| 88 | assert.strictEqual(yield* fs.readFileString(destination), "after"); | ||
| 89 | }), | ||
| 90 | ); | ||
| 91 | |||
| 92 | it.effect("creates a missing file and its directory", () => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const fs = yield* FileSystem.FileSystem; | ||
| 95 | const path = yield* Path.Path; | ||
| 96 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); | ||
| 97 | const filePath = path.join(root, "nested", "settings.json"); | ||
| 98 | |||
| 99 | yield* writeFileStringAtomically({ filePath, contents: "fresh" }); | ||
| 100 | |||
| 101 | assert.strictEqual(yield* fs.readFileString(filePath), "fresh"); | ||
| 102 | }), | ||
| 103 | ); | ||
| 104 | }); | ||
| 105 | |||
| 106 | it.effect("surfaces an unreadable link instead of writing over it", () => | ||
| 107 | Effect.gen(function* () { | ||
| 108 | const readLinkFailure = PlatformError.systemError({ | ||
| 109 | _tag: "Unknown", | ||
| 110 | module: "FileSystem", | ||
| 111 | method: "readLink", | ||
| 112 | pathOrDescriptor: "/home/settings.json", | ||
| 113 | }); | ||
| 114 | |||
| 115 | const result = yield* Effect.exit( | ||
| 116 | writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }), | ||
| 117 | ); | ||
| 118 | |||
| 119 | assert.deepStrictEqual(result, Exit.fail(readLinkFailure)); | ||
| 120 | }).pipe( | ||
| 121 | Effect.provide( | ||
| 122 | Layer.mergeAll( | ||
| 123 | Path.layer, | ||
| 124 | FileSystem.layerNoop({ | ||
| 125 | readLink: () => | ||
| 126 | Effect.fail( | ||
| 127 | PlatformError.systemError({ | ||
| 128 | _tag: "Unknown", | ||
| 129 | module: "FileSystem", | ||
| 130 | method: "readLink", | ||
| 131 | pathOrDescriptor: "/home/settings.json", | ||
| 132 | }), | ||
| 133 | ), | ||
| 134 | rename: () => Effect.die("an unreadable link must not be replaced"), | ||
| 135 | }), | ||
| 136 | ), | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | ); | ||
apps/server/src/atomicWrite.ts+10-3| ... | @@ -1,7 +1,13 @@ | ... | @@ -1,7 +1,13 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; | 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Path from "effect/Path"; | 3 | import * as Path from "effect/Path"; |
| 4 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | ||
| 4 | 5 | ||
| 6 | /** | ||
| 7 | * Replaces a file's contents via a sibling temp file and rename. A symlinked | ||
| 8 | * target is resolved first so the link survives and its destination is | ||
| 9 | * rewritten, since renaming over the link itself would swap it for a regular file. | ||
| 10 | */ | ||
| 5 | export const writeFileStringAtomically = (input: { | 11 | export const writeFileStringAtomically = (input: { |
| 6 | readonly filePath: string; | 12 | readonly filePath: string; |
| 7 | readonly contents: string; | 13 | readonly contents: string; |
| ... | @@ -10,16 +16,17 @@ export const writeFileStringAtomically = (input: { | ... | @@ -10,16 +16,17 @@ export const writeFileStringAtomically = (input: { |
| 10 | Effect.gen(function* () { | 16 | Effect.gen(function* () { |
| 11 | const fs = yield* FileSystem.FileSystem; | 17 | const fs = yield* FileSystem.FileSystem; |
| 12 | const path = yield* Path.Path; | 18 | const path = yield* Path.Path; |
| 13 | const targetDirectory = path.dirname(input.filePath); | 19 | const targetPath = yield* resolveSymlinkTarget(input.filePath); |
| 20 | const targetDirectory = path.dirname(targetPath); | ||
| 14 | 21 | ||
| 15 | yield* fs.makeDirectory(targetDirectory, { recursive: true }); | 22 | yield* fs.makeDirectory(targetDirectory, { recursive: true }); |
| 16 | const tempDirectory = yield* fs.makeTempDirectoryScoped({ | 23 | const tempDirectory = yield* fs.makeTempDirectoryScoped({ |
| 17 | directory: targetDirectory, | 24 | directory: targetDirectory, |
| 18 | prefix: `${path.basename(input.filePath)}.`, | 25 | prefix: `${path.basename(targetPath)}.`, |
| 19 | }); | 26 | }); |
| 20 | const tempPath = path.join(tempDirectory, "contents.tmp"); | 27 | const tempPath = path.join(tempDirectory, "contents.tmp"); |
| 21 | 28 | ||
| 22 | yield* fs.writeFileString(tempPath, input.contents); | 29 | yield* fs.writeFileString(tempPath, input.contents); |
| 23 | yield* fs.rename(tempPath, input.filePath); | 30 | yield* fs.rename(tempPath, targetPath); |
| 24 | }), | 31 | }), |
| 25 | ); | 32 | ); |
apps/server/src/serverSettings.test.ts+110| ... | @@ -23,10 +23,12 @@ import * as Path from "effect/Path"; | ... | @@ -23,10 +23,12 @@ import * as Path from "effect/Path"; |
| 23 | import * as PlatformError from "effect/PlatformError"; | 23 | import * as PlatformError from "effect/PlatformError"; |
| 24 | import * as Schema from "effect/Schema"; | 24 | import * as Schema from "effect/Schema"; |
| 25 | import * as Stream from "effect/Stream"; | 25 | import * as Stream from "effect/Stream"; |
| 26 | import * as TestClock from "effect/testing/TestClock"; | ||
| 26 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 27 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 27 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 28 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 28 | import * as ServerConfig from "./config.ts"; | 29 | import * as ServerConfig from "./config.ts"; |
| 29 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; | 30 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; |
| 31 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | ||
| 30 | import * as ServerSettingsModule from "./serverSettings.ts"; | 32 | import * as ServerSettingsModule from "./serverSettings.ts"; |
| 31 | import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; | 33 | import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; |
| 32 | 34 | ||
| ... | @@ -131,6 +133,114 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -131,6 +133,114 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 131 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 133 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 132 | ); | 134 | ); |
| 133 | 135 | ||
| 136 | it.effect("saves through a symlinked settings file without replacing the link", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | const config = yield* ServerConfig.ServerConfig; | ||
| 139 | const fs = yield* FileSystem.FileSystem; | ||
| 140 | const path = yield* Path.Path; | ||
| 141 | const service = yield* ServerSettingsModule.ServerSettingsService; | ||
| 142 | const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); | ||
| 143 | const linkedSettingsPath = path.join(dotfiles, "settings.json"); | ||
| 144 | yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); | ||
| 145 | yield* fs.remove(config.settingsPath, { force: true }); | ||
| 146 | yield* fs.symlink(linkedSettingsPath, config.settingsPath); | ||
| 147 | |||
| 148 | yield* service.updateSettings({ responseStreamingMode: "paragraph" }); | ||
| 149 | |||
| 150 | assert.equal(yield* fs.readLink(config.settingsPath), linkedSettingsPath); | ||
| 151 | const persisted = yield* decodeServerSettingsJson( | ||
| 152 | yield* fs.readFileString(linkedSettingsPath), | ||
| 153 | ); | ||
| 154 | assert.equal(persisted.responseStreamingMode, "paragraph"); | ||
| 155 | }).pipe(Effect.provide(makeServerSettingsLayer())), | ||
| 156 | ); | ||
| 157 | |||
| 158 | it.effect("reloads when the destination of a symlinked settings file changes", () => | ||
| 159 | Effect.scoped( | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const config = yield* ServerConfig.ServerConfig; | ||
| 162 | const fs = yield* FileSystem.FileSystem; | ||
| 163 | const path = yield* Path.Path; | ||
| 164 | const service = yield* ServerSettingsModule.ServerSettingsService; | ||
| 165 | const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); | ||
| 166 | const linkedSettingsPath = path.join(dotfiles, "settings.json"); | ||
| 167 | yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); | ||
| 168 | yield* fs.remove(config.settingsPath, { force: true }); | ||
| 169 | yield* fs.symlink(linkedSettingsPath, config.settingsPath); | ||
| 170 | yield* service.start; | ||
| 171 | const changes = yield* service.subscribeChanges; | ||
| 172 | |||
| 173 | yield* writeFileStringAtomically({ | ||
| 174 | filePath: linkedSettingsPath, | ||
| 175 | contents: `{ "responseStreamingMode": "paragraph" }`, | ||
| 176 | }); | ||
| 177 | |||
| 178 | const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); | ||
| 179 | assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); | ||
| 180 | }), | ||
| 181 | ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), | ||
| 182 | ); | ||
| 183 | |||
| 184 | it.effect("follows a settings link that is repointed to another directory", () => | ||
| 185 | Effect.scoped( | ||
| 186 | Effect.gen(function* () { | ||
| 187 | const config = yield* ServerConfig.ServerConfig; | ||
| 188 | const fs = yield* FileSystem.FileSystem; | ||
| 189 | const path = yield* Path.Path; | ||
| 190 | const service = yield* ServerSettingsModule.ServerSettingsService; | ||
| 191 | const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); | ||
| 192 | const firstSettingsPath = path.join(dotfiles, "first", "settings.json"); | ||
| 193 | const secondSettingsPath = path.join(dotfiles, "second", "settings.json"); | ||
| 194 | yield* fs.makeDirectory(path.dirname(firstSettingsPath), { recursive: true }); | ||
| 195 | yield* fs.makeDirectory(path.dirname(secondSettingsPath), { recursive: true }); | ||
| 196 | yield* fs.writeFileString(firstSettingsPath, `{ "responseStreamingMode": "turn" }`); | ||
| 197 | yield* fs.writeFileString(secondSettingsPath, `{ "responseStreamingMode": "paragraph" }`); | ||
| 198 | yield* fs.remove(config.settingsPath, { force: true }); | ||
| 199 | yield* fs.symlink(firstSettingsPath, config.settingsPath); | ||
| 200 | yield* service.start; | ||
| 201 | |||
| 202 | const repointChanges = yield* service.subscribeChanges; | ||
| 203 | yield* fs.remove(config.settingsPath); | ||
| 204 | yield* fs.symlink(secondSettingsPath, config.settingsPath); | ||
| 205 | const repointed = yield* repointChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); | ||
| 206 | assert.equal(Option.getOrUndefined(repointed)?.responseStreamingMode, "paragraph"); | ||
| 207 | |||
| 208 | const editChanges = yield* service.subscribeChanges; | ||
| 209 | yield* writeFileStringAtomically({ | ||
| 210 | filePath: secondSettingsPath, | ||
| 211 | contents: `{ "responseStreamingMode": "turn" }`, | ||
| 212 | }); | ||
| 213 | const edited = yield* editChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); | ||
| 214 | assert.equal(Option.getOrUndefined(edited)?.responseStreamingMode, "turn"); | ||
| 215 | }), | ||
| 216 | ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), | ||
| 217 | ); | ||
| 218 | |||
| 219 | it.effect("reloads when a dangling settings link gets its destination", () => | ||
| 220 | Effect.scoped( | ||
| 221 | Effect.gen(function* () { | ||
| 222 | const config = yield* ServerConfig.ServerConfig; | ||
| 223 | const fs = yield* FileSystem.FileSystem; | ||
| 224 | const path = yield* Path.Path; | ||
| 225 | const service = yield* ServerSettingsModule.ServerSettingsService; | ||
| 226 | const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); | ||
| 227 | const linkedSettingsPath = path.join(dotfiles, "not-yet", "settings.json"); | ||
| 228 | yield* fs.remove(config.settingsPath, { force: true }); | ||
| 229 | yield* fs.symlink(linkedSettingsPath, config.settingsPath); | ||
| 230 | yield* service.start; | ||
| 231 | const changes = yield* service.subscribeChanges; | ||
| 232 | |||
| 233 | yield* writeFileStringAtomically({ | ||
| 234 | filePath: linkedSettingsPath, | ||
| 235 | contents: `{ "responseStreamingMode": "paragraph" }`, | ||
| 236 | }); | ||
| 237 | |||
| 238 | const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); | ||
| 239 | assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); | ||
| 240 | }), | ||
| 241 | ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), | ||
| 242 | ); | ||
| 243 | |||
| 134 | it.effect("preserves context when reading a provider environment secret fails", () => { | 244 | it.effect("preserves context when reading a provider environment secret fails", () => { |
| 135 | const platformCause = PlatformError.systemError({ | 245 | const platformCause = PlatformError.systemError({ |
| 136 | _tag: "PermissionDenied", | 246 | _tag: "PermissionDenied", |
apps/server/src/serverSettings.ts+51-12| ... | @@ -52,6 +52,7 @@ import * as Scope from "effect/Scope"; | ... | @@ -52,6 +52,7 @@ import * as Scope from "effect/Scope"; |
| 52 | import * as Stream from "effect/Stream"; | 52 | import * as Stream from "effect/Stream"; |
| 53 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 53 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 54 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | 54 | import { writeFileStringAtomically } from "./atomicWrite.ts"; |
| 55 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | ||
| 55 | import * as ServerConfig from "./config.ts"; | 56 | import * as ServerConfig from "./config.ts"; |
| 56 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; | 57 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 57 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; | 58 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| ... | @@ -1181,10 +1182,24 @@ const make = Effect.gen(function* () { | ... | @@ -1181,10 +1182,24 @@ const make = Effect.gen(function* () { |
| 1181 | }), | 1182 | }), |
| 1182 | ); | 1183 | ); |
| 1183 | 1184 | ||
| 1185 | const watchFileChanges = (filePath: string) => { | ||
| 1186 | const directory = pathService.dirname(filePath); | ||
| 1187 | const fileName = pathService.basename(filePath); | ||
| 1188 | const resolvedFilePath = pathService.resolve(filePath); | ||
| 1189 | return fs | ||
| 1190 | .watch(directory) | ||
| 1191 | .pipe( | ||
| 1192 | Stream.filter( | ||
| 1193 | (event) => | ||
| 1194 | event.path === fileName || | ||
| 1195 | event.path === filePath || | ||
| 1196 | pathService.resolve(directory, event.path) === resolvedFilePath, | ||
| 1197 | ), | ||
| 1198 | ); | ||
| 1199 | }; | ||
| 1200 | |||
| 1184 | const startWatcher = Effect.gen(function* () { | 1201 | const startWatcher = Effect.gen(function* () { |
| 1185 | const settingsDir = pathService.dirname(settingsPath); | 1202 | const settingsDir = pathService.dirname(settingsPath); |
| 1186 | const settingsFile = pathService.basename(settingsPath); | ||
| 1187 | const settingsPathResolved = pathService.resolve(settingsPath); | ||
| 1188 | 1203 | ||
| 1189 | yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( | 1204 | yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( |
| 1190 | Effect.mapError( | 1205 | Effect.mapError( |
| ... | @@ -1199,19 +1214,43 @@ const make = Effect.gen(function* () { | ... | @@ -1199,19 +1214,43 @@ const make = Effect.gen(function* () { |
| 1199 | 1214 | ||
| 1200 | const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); | 1215 | const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); |
| 1201 | 1216 | ||
| 1217 | // A symlinked settings file is rewritten in its destination's directory, | ||
| 1218 | // which a watch on the link's directory never sees. The link is resolved | ||
| 1219 | // again whenever it changes, so repointing it moves the watch along. | ||
| 1220 | const watchLinkTarget = Effect.gen(function* () { | ||
| 1221 | const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( | ||
| 1222 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 1223 | Effect.provideService(Path.Path, pathService), | ||
| 1224 | ); | ||
| 1225 | if (linkTargetPath === pathService.resolve(settingsPath)) { | ||
| 1226 | return Option.none<string>(); | ||
| 1227 | } | ||
| 1228 | yield* fs | ||
| 1229 | .makeDirectory(pathService.dirname(linkTargetPath), { recursive: true }) | ||
| 1230 | .pipe(Effect.ignore({ log: true })); | ||
| 1231 | return Option.some(linkTargetPath); | ||
| 1232 | }).pipe(Effect.orElseSucceed(() => Option.none<string>())); | ||
| 1233 | |||
| 1234 | const initialLinkTarget = yield* watchLinkTarget; | ||
| 1235 | const linkTargetEvents = Stream.make(initialLinkTarget).pipe( | ||
| 1236 | Stream.concat(watchFileChanges(settingsPath).pipe(Stream.mapEffect(() => watchLinkTarget))), | ||
| 1237 | Stream.changes, | ||
| 1238 | Stream.switchMap( | ||
| 1239 | Option.match({ | ||
| 1240 | onNone: () => Stream.empty, | ||
| 1241 | onSome: (linkTargetPath) => | ||
| 1242 | watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })), | ||
| 1243 | }), | ||
| 1244 | ), | ||
| 1245 | ); | ||
| 1246 | |||
| 1202 | // Debounce watch events so the file is fully written before we read it. | 1247 | // Debounce watch events so the file is fully written before we read it. |
| 1203 | // Editors emit multiple events per save (truncate, write, rename) and | 1248 | // Editors emit multiple events per save (truncate, write, rename) and |
| 1204 | // `fs.watch` can fire before the content has been flushed to disk. | 1249 | // `fs.watch` can fire before the content has been flushed to disk. |
| 1205 | const debouncedSettingsEvents = fs.watch(settingsDir).pipe( | 1250 | const debouncedSettingsEvents = Stream.merge( |
| 1206 | Stream.filter((event) => { | 1251 | watchFileChanges(settingsPath), |
| 1207 | return ( | 1252 | linkTargetEvents, |
| 1208 | event.path === settingsFile || | 1253 | ).pipe(Stream.debounce(Duration.millis(100))); |
| 1209 | event.path === settingsPath || | ||
| 1210 | pathService.resolve(settingsDir, event.path) === settingsPathResolved | ||
| 1211 | ); | ||
| 1212 | }), | ||
| 1213 | Stream.debounce(Duration.millis(100)), | ||
| 1214 | ); | ||
| 1215 | 1254 | ||
| 1216 | yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( | 1255 | yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( |
| 1217 | Effect.ignoreCause({ log: true }), | 1256 | Effect.ignoreCause({ log: true }), |
packages/shared/package.json+4| ... | @@ -195,6 +195,10 @@ | ... | @@ -195,6 +195,10 @@ |
| 195 | "types": "./src/connectAuth.ts", | 195 | "types": "./src/connectAuth.ts", |
| 196 | "import": "./src/connectAuth.ts" | 196 | "import": "./src/connectAuth.ts" |
| 197 | }, | 197 | }, |
| 198 | "./symlink": { | ||
| 199 | "types": "./src/symlink.ts", | ||
| 200 | "import": "./src/symlink.ts" | ||
| 201 | }, | ||
| 198 | "./path": { | 202 | "./path": { |
| 199 | "types": "./src/path.ts", | 203 | "types": "./src/path.ts", |
| 200 | "import": "./src/path.ts" | 204 | "import": "./src/path.ts" |
packages/shared/src/symlink.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as FileSystem from "effect/FileSystem"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Path from "effect/Path"; | ||
| 5 | import * as PlatformError from "effect/PlatformError"; | ||
| 6 | |||
| 7 | const MAX_SYMLINK_HOPS = 40; | ||
| 8 | |||
| 9 | const isNotFound = (error: PlatformError.PlatformError) => error.reason._tag === "NotFound"; | ||
| 10 | |||
| 11 | const isNotASymlink = (error: PlatformError.PlatformError) => | ||
| 12 | isNotFound(error) || | ||
| 13 | (error.cause instanceof Error && "code" in error.cause && error.cause.code === "EINVAL"); | ||
| 14 | |||
| 15 | /** | ||
| 16 | * Follows a chain of symlinks to the file it finally names, which may not exist | ||
| 17 | * yet. Any path that is not a symlink resolves to itself. Atomic writers rename | ||
| 18 | * onto this path so a linked file keeps its link. Fails on a cycle, an overly | ||
| 19 | * long chain, or an unreadable link rather than handing back a link that a | ||
| 20 | * rename would replace. | ||
| 21 | */ | ||
| 22 | export const resolveSymlinkTarget = (filePath: string) => | ||
| 23 | Effect.gen(function* () { | ||
| 24 | const fs = yield* FileSystem.FileSystem; | ||
| 25 | const path = yield* Path.Path; | ||
| 26 | let current = path.resolve(filePath); | ||
| 27 | for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) { | ||
| 28 | const link = yield* fs.readLink(current).pipe( | ||
| 29 | Effect.map(Option.some), | ||
| 30 | Effect.catchIf(isNotASymlink, () => Effect.succeedNone), | ||
| 31 | ); | ||
| 32 | if (Option.isNone(link)) { | ||
| 33 | return current; | ||
| 34 | } | ||
| 35 | // A relative target is relative to where the link really lives, which | ||
| 36 | // differs from its lexical parent when that parent is itself a symlink. | ||
| 37 | const linkDirectory = yield* fs | ||
| 38 | .realPath(path.dirname(current)) | ||
| 39 | .pipe(Effect.catchIf(isNotFound, () => Effect.succeed(path.dirname(current)))); | ||
| 40 | current = path.resolve(linkDirectory, link.value); | ||
| 41 | } | ||
| 42 | return yield* PlatformError.systemError({ | ||
| 43 | _tag: "Unknown", | ||
| 44 | module: "FileSystem", | ||
| 45 | method: "readLink", | ||
| 46 | description: "Too many levels of symbolic links", | ||
| 47 | pathOrDescriptor: filePath, | ||
| 48 | }); | ||
| 49 | }); | ||