| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type * as FileSystem from "effect/FileSystem"; |
| 3 | import type { PlatformError } from "effect/PlatformError"; |
| 4 | |
| 5 | /** |
| 6 | * Write `contents` to `filePath` via a same-directory temp file + rename. |
| 7 | * |
| 8 | * A plain `writeFileString` truncates the target before writing, so a crash |
| 9 | * mid-write (or a concurrent reader — another CLI process, the RPC sidecar) |
| 10 | * can observe empty/partial JSON. rename(2) is atomic on the same |
| 11 | * filesystem, so readers see either the old contents or the new, never a |
| 12 | * torn write. When `mode` is given it is applied to the temp file *before* |
| 13 | * the rename, so a secret is never visible at its final path with default |
| 14 | * permissions. The temp file is removed on failure. |
| 15 | * |
| 16 | * Used for `~/.alchemy/profiles.json`, the per-provider credential files, |
| 17 | * and local state files. |
| 18 | */ |
| 19 | export const writeFileAtomic = ( |
| 20 | fs: FileSystem.FileSystem, |
| 21 | filePath: string, |
| 22 | contents: string, |
| 23 | mode?: number, |
| 24 | ): Effect.Effect<void, PlatformError> => |
| 25 | Effect.suspend(() => { |
| 26 | const tmp = `${filePath}.${process.pid}.${Math.random() |
| 27 | .toString(36) |
| 28 | .slice(2)}.tmp`; |
| 29 | return fs.writeFileString(tmp, contents).pipe( |
| 30 | Effect.flatMap(() => |
| 31 | mode === undefined ? Effect.void : fs.chmod(tmp, mode), |
| 32 | ), |
| 33 | Effect.flatMap(() => fs.rename(tmp, filePath)), |
| 34 | Effect.tapError(() => fs.remove(tmp).pipe(Effect.ignore)), |
| 35 | ); |
| 36 | }); |