diff --git a/apps/app/src/features/connections/connection-switcher.tsx b/apps/app/src/features/connections/connection-switcher.tsx index e301ed149..ada4fd63e 100644 --- a/apps/app/src/features/connections/connection-switcher.tsx +++ b/apps/app/src/features/connections/connection-switcher.tsx @@ -104,7 +104,12 @@ export function ConnectionSwitcher(): ReactElement | null { {environments.remotes.length > 0 || environments.connecting.length > 0 ? ( ) : null} - {launch.available ? ( + {environments.hostsError === undefined ? null : ( + + {environments.hostsError} + + )} + {environments.hostsError !== undefined ? null : launch.available ? ( { diff --git a/apps/app/src/platform.ts b/apps/app/src/platform.ts index 9fd0f9b53..c36fdac99 100644 --- a/apps/app/src/platform.ts +++ b/apps/app/src/platform.ts @@ -22,6 +22,7 @@ export type EnvironmentSnapshot = { readonly revision: number; readonly connecting: readonly ConnectingSshHost[]; readonly remotes: readonly SshRemoteEnvironment[]; + readonly hostsError?: string; }; export type DiscoveredSshHost = { diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index bb1137c60..e80b97372 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -70,6 +70,7 @@ export default defineConfig({ exclude: [ "@getpie/contract", "@getpie/core", + "@getpie/effect-json-store", "@getpie/server", "@getpie/ssh", "@getpie/tailscale", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 12aeb62a3..5ce49c2f4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,6 +27,7 @@ "@getpie/cli": "workspace:*", "@getpie/contract": "workspace:*", "@getpie/core": "workspace:*", + "@getpie/effect-json-store": "workspace:*", "@getpie/server": "workspace:*", "@getpie/ssh": "workspace:*", "@getpie/tailscale": "workspace:*", diff --git a/apps/desktop/src/main/application/desktop-application.test.ts b/apps/desktop/src/main/application/desktop-application.test.ts index 76aed7928..29c67fe00 100644 --- a/apps/desktop/src/main/application/desktop-application.test.ts +++ b/apps/desktop/src/main/application/desktop-application.test.ts @@ -1,7 +1,11 @@ -import { Deferred, Effect, Option, Scope, Stream, SubscriptionRef } from "effect"; +import { Deferred, Effect, Option, Schema, Scope, Stream, SubscriptionRef } from "effect"; import { describe, expect, it } from "vitest"; -import type { ServerConnection, ServerStatusSnapshot } from "../../shared/desktop-rpc"; +import { + EnvironmentSnapshotSchema, + type ServerConnection, + type ServerStatusSnapshot, +} from "../../shared/desktop-rpc"; import { disabledDesktopCli } from "../cli/desktop-cli"; import type { LocalServer } from "../server/local-server"; import { disabledDesktopSsh, SshHostDiscoveryError } from "../ssh/desktop-ssh"; @@ -165,7 +169,8 @@ describe("DesktopApplication", () => { await expect(Effect.runPromise(h.application.serverConnection)).resolves.toEqual( localConnection, ); - await expect(Effect.runPromise(h.application.environmentSnapshot)).resolves.toMatchObject({ + const snapshot = await Effect.runPromise(h.application.environmentSnapshot); + expect(snapshot).toMatchObject({ connecting: [], remotes: [ { @@ -175,6 +180,7 @@ describe("DesktopApplication", () => { }, ], }); + expect(Schema.encodeSync(EnvironmentSnapshotSchema)(snapshot)).not.toHaveProperty("hostsError"); }); it("reports a missing OpenSSH client on bootstrap", async () => { diff --git a/apps/desktop/src/main/application/desktop-application.ts b/apps/desktop/src/main/application/desktop-application.ts index a403d0b49..897e671fc 100644 --- a/apps/desktop/src/main/application/desktop-application.ts +++ b/apps/desktop/src/main/application/desktop-application.ts @@ -75,12 +75,14 @@ export type DesktopApplicationDependencies = { readonly scope: Scope.Scope; }; -function emptySnapshot(): EnvironmentSnapshot { - return { +function emptySnapshot(hostsError?: string): EnvironmentSnapshot { + const snapshot = { revision: 0, connecting: [], remotes: [], }; + if (hostsError === undefined) return snapshot; + return { ...snapshot, hostsError }; } export function makeDesktopApplication({ @@ -92,17 +94,21 @@ export function makeDesktopApplication({ scope, }: DesktopApplicationDependencies): DesktopApplication["Service"] { const environmentsRef = Effect.runSync( - SubscriptionRef.make(emptySnapshot()), + SubscriptionRef.make(emptySnapshot(ssh.savedHostsMessage)), ); const visible = Effect.runSync(SubscriptionRef.make(false)); const updateEnvironments = ( updater: (current: EnvironmentSnapshot) => Omit, ): Effect.Effect => - SubscriptionRef.updateAndGet(environmentsRef, (current) => ({ - ...updater(current), - revision: current.revision + 1, - })); + SubscriptionRef.updateAndGet(environmentsRef, (current) => { + const next = { + ...updater(current), + revision: current.revision + 1, + }; + if (current.hostsError === undefined) return next; + return { ...next, hostsError: current.hostsError }; + }); const dropRemoteIfCurrent = (remote: SshRemoteEnvironment) => SubscriptionRef.updateAndGet(environmentsRef, (current) => { @@ -110,11 +116,13 @@ export function makeDesktopApplication({ if (existing === undefined || existing.connection !== remote.connection) { return current; } - return { + const next = { connecting: current.connecting, remotes: current.remotes.filter((entry) => entry.id !== remote.id), revision: current.revision + 1, }; + if (current.hostsError === undefined) return next; + return { ...next, hostsError: current.hostsError }; }); const connectSsh = (target: string, options?: { readonly background?: boolean }) => diff --git a/apps/desktop/src/main/ssh/desktop-ssh.test.ts b/apps/desktop/src/main/ssh/desktop-ssh.test.ts index 0e173890e..7ec4729ff 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.test.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.test.ts @@ -9,7 +9,7 @@ import { Effect, FileSystem, Layer, Scope } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { describe, expect, it } from "vitest"; -import { makeDesktopSsh } from "./desktop-ssh"; +import { makeDesktopSsh, SAVED_HOSTS_UNAVAILABLE } from "./desktop-ssh"; function fakeConnected(target: SshTarget): SshConnectedEnvironment { return { @@ -57,94 +57,81 @@ describe("DesktopSsh saved hosts", () => { it("returns no hosts when the persist file is missing", async () => { const saved = await withSsh((dir) => Effect.gen(function* () { - const ssh = yield* makeDesktopSsh({ persistPath: path.join(dir, "ssh-environments.json") }); - return yield* ssh.listSaved; + const file = path.join(dir, "ssh-environments.json"); + const ssh = yield* makeDesktopSsh({ persistPath: file }); + const fs = yield* FileSystem.FileSystem; + return { saved: yield* ssh.listSaved, exists: yield* fs.exists(file) }; }), ); - expect(saved).toEqual([]); + expect(saved.saved).toEqual([]); + expect(saved.exists).toBe(false); }); - it("reads version-1 hosts and skips malformed entries", async () => { - const saved = await withSsh((dir) => + it("starts when a version-1 file is invalid and leaves it untouched", async () => { + const fileText = JSON.stringify({ + version: 1, + data: { + environments: [ + { + id: "abc123abc123abcd", + alias: "myserver", + hostname: "example.com", + username: "alice", + port: 2222, + }, + { id: "", alias: "bad" }, + ], + }, + }); + const error = await withSsh((dir) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString( - path.join(dir, "ssh-environments.json"), - JSON.stringify({ - version: 1, - data: { - environments: [ - { - id: "abc123abc123abcd", - alias: "myserver", - hostname: "example.com", - username: "alice", - port: 2222, - }, - { id: "", alias: "bad" }, - { - id: "deadbeefdeadbeef", - alias: "other", - hostname: "other.example", - username: null, - port: null, - }, - ], - }, - }), - ); - const ssh = yield* makeDesktopSsh({ persistPath: path.join(dir, "ssh-environments.json") }); - return yield* ssh.listSaved; + const file = path.join(dir, "ssh-environments.json"); + yield* fs.writeFileString(file, fileText); + const ssh = yield* makeDesktopSsh({ persistPath: file }); + const listed = yield* ssh.listSaved; + const connect = yield* ssh.connect("alice@example.com").pipe(Effect.flip); + return { + listed, + connect, + raw: yield* fs.readFileString(file), + message: ssh.savedHostsMessage, + }; }), ); - expect(saved).toEqual([ - { - id: "abc123abc123abcd", - target: { + expect(error.listed).toEqual([]); + expect(error.connect.message).toBe(SAVED_HOSTS_UNAVAILABLE); + expect(error.message).toBe(SAVED_HOSTS_UNAVAILABLE); + expect(error.raw).toBe(fileText); + }); + + it("adopts a pre-envelope file without dropping valid hosts", async () => { + const fileText = JSON.stringify({ + version: 1, + activeId: "abc123abc123abcd", + environments: [ + { + id: "abc123abc123abcd", alias: "myserver", hostname: "example.com", username: "alice", - port: 2222, - }, - }, - { - id: "deadbeefdeadbeef", - target: { - alias: "other", - hostname: "other.example", - username: null, - port: null, + port: 22, }, - }, - ]); - }); - - it("still reads a pre-envelope version-1 file", async () => { - const saved = await withSsh((dir) => + ], + }); + const error = await withSsh((dir) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString( - path.join(dir, "ssh-environments.json"), - JSON.stringify({ - version: 1, - activeId: "abc123abc123abcd", - environments: [ - { - id: "abc123abc123abcd", - alias: "myserver", - hostname: "example.com", - username: "alice", - port: 22, - }, - ], - }), - ); - const ssh = yield* makeDesktopSsh({ persistPath: path.join(dir, "ssh-environments.json") }); - return yield* ssh.listSaved; + const file = path.join(dir, "ssh-environments.json"); + yield* fs.writeFileString(file, fileText); + const ssh = yield* makeDesktopSsh({ persistPath: file }); + const listed = yield* ssh.listSaved; + const info = yield* fs.stat(file); + return { listed, raw: yield* fs.readFileString(file), mode: (info.mode ?? 0) & 0o777 }; }), ); - expect(saved).toEqual([ + expect(error.listed).toEqual([ { id: "abc123abc123abcd", target: { @@ -155,6 +142,60 @@ describe("DesktopSsh saved hosts", () => { }, }, ]); + const parsed: unknown = JSON.parse(error.raw); + expect(parsed).toEqual({ + version: 1, + data: { + environments: [ + { + id: "abc123abc123abcd", + alias: "myserver", + hostname: "example.com", + username: "alice", + port: 22, + }, + ], + }, + }); + expect(error.mode).toBe(0o600); + }); + + it("does not adopt a pre-envelope file when any entry is invalid", async () => { + const fileText = JSON.stringify({ + version: 1, + activeId: "abc123abc123abcd", + environments: [ + { + id: "abc123abc123abcd", + alias: "myserver", + hostname: "example.com", + username: "alice", + port: 22, + }, + { id: "", alias: "bad" }, + ], + }); + const result = await withSsh((dir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = path.join(dir, "ssh-environments.json"); + yield* fs.writeFileString(file, fileText); + const ssh = yield* makeDesktopSsh({ persistPath: file }); + const listed = yield* ssh.listSaved; + const connect = yield* ssh.connect("alice@example.com").pipe(Effect.flip); + return { + listed, + connect, + raw: yield* fs.readFileString(file), + message: ssh.savedHostsMessage, + }; + }), + ); + + expect(result.listed).toEqual([]); + expect(result.connect.message).toBe(SAVED_HOSTS_UNAVAILABLE); + expect(result.message).toBe(SAVED_HOSTS_UNAVAILABLE); + expect(result.raw).toBe(fileText); }); it("remove deletes a saved host from disk", async () => { diff --git a/apps/desktop/src/main/ssh/desktop-ssh.ts b/apps/desktop/src/main/ssh/desktop-ssh.ts index 262ce6607..e6efb3ee3 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.ts @@ -1,5 +1,4 @@ -import path from "node:path"; - +import { makeJsonDocument } from "@getpie/effect-json-store"; import { resolvePieHome, sshEnvironmentsFile } from "@getpie/server/daemon"; import { connectSshEnvironment, @@ -23,12 +22,13 @@ import { Context, Data, Effect, + Exit, FileSystem, Layer, Ref, + Schema, Scope, Semaphore, - type PlatformError, } from "effect"; import { ChildProcessSpawner } from "effect/process"; @@ -50,6 +50,9 @@ export type { const SAVED_FILE_MODE = 0o600; const ENVIRONMENT_ID_TIMEOUT_MS = 8_000; +export const SAVED_HOSTS_UNAVAILABLE = + "Saved SSH hosts could not be read. The file was left unchanged."; + export class SshPersistError extends Data.TaggedError("SshPersistError")<{ readonly message: string; readonly cause?: unknown; @@ -81,30 +84,13 @@ type LiveSshSession = { readonly connected: SshConnectedEnvironment; }; -type SavedFileData = { - readonly environments: ReadonlyArray<{ - readonly id: string; - readonly alias: string; - readonly hostname: string; - readonly username: string | null; - readonly port: number | null; - }>; -}; - -type SavedFile = { - readonly version: 1; - readonly data: SavedFileData; -}; - -type SavedState = { - readonly environments: readonly SavedSshEnvironment[]; -}; - type SshConnection = DesktopSshConnectResult["connection"]; export type DesktopSshShape = { readonly client: SshClientAvailability; readonly listSaved: Effect.Effect; + /** Set when the hosts file cannot be read. Desktop still starts. */ + readonly savedHostsMessage?: string; readonly connect: ( raw: string, ) => Effect.Effect; @@ -159,97 +145,71 @@ const fetchEnvironmentId = (connection: SshConnection): Effect.Effect { + const environments: Array = []; + for (const raw of file.environments) { + const decoded = Schema.decodeUnknownExit(LegacyHost)(raw); + if (Exit.isFailure(decoded)) { + throw new Error("legacy SSH host entry failed validation"); } - if ( - !("hostname" in entry) || - typeof entry.hostname !== "string" || - entry.hostname.length === 0 - ) { - continue; - } - const username = "username" in entry ? entry.username : null; - const port = "port" in entry ? entry.port : null; - if (username !== null && typeof username !== "string") continue; - if (port !== null && (typeof port !== "number" || !Number.isInteger(port))) continue; environments.push({ - id: entry.id, - target: { - alias: entry.alias, - hostname: entry.hostname, - username, - port, - }, + id: decoded.value.id, + alias: decoded.value.alias, + hostname: decoded.value.hostname, + username: decoded.value.username ?? null, + port: decoded.value.port ?? null, }); } - return environments; -} - -function parseSavedData(value: unknown): SavedState { - if (typeof value !== "object" || value === null) { - return { environments: [] }; - } - const record = value as { environments?: unknown }; - return { environments: parseEnvironments(record.environments) }; -} - -function parseSavedFile(raw: string): SavedState { - try { - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null) { - return { environments: [] }; - } - const record = parsed as { version?: unknown; data?: unknown; environments?: unknown }; - if (record.version !== 1) { - return { environments: [] }; - } - if ("data" in record) return parseSavedData(record.data); - return parseSavedData(record); - } catch { - return { environments: [] }; - } -} - -function serializeSavedFile(state: SavedState): string { - const file: SavedFile = { - version: 1, - data: { - environments: state.environments.map((entry) => ({ - id: entry.id, - alias: entry.target.alias, - hostname: entry.target.hostname, - username: entry.target.username, - port: entry.target.port, - })), - }, - }; - return `${JSON.stringify(file, null, 2)}\n`; -} - -const readSaved = (filePath: string): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); - return raw.length === 0 ? { environments: [] } : parseSavedFile(raw); - }); + return { environments }; +}; -const writeSaved = ( - filePath: string, - state: SavedState, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, serializeSavedFile(state)); - yield* fs.chmod(filePath, SAVED_FILE_MODE); +const unavailableError = () => new SshPersistError({ message: SAVED_HOSTS_UNAVAILABLE }); + +const toSaved = (record: typeof SavedEnvironmentRecord.Type): SavedSshEnvironment => ({ + id: record.id, + target: { + alias: record.alias, + hostname: record.hostname, + username: record.username, + port: record.port, + }, +}); + +const fromSaved = (entry: SavedSshEnvironment): typeof SavedEnvironmentRecord.Type => ({ + id: entry.id, + alias: entry.target.alias, + hostname: entry.target.hostname, + username: entry.target.username, + port: entry.target.port, +}); + +const persistError = (filePath: string, cause: unknown) => + new SshPersistError({ + message: `Failed to persist SSH environments to ${filePath}.`, + cause, }); export function makeDesktopSsh(input: { @@ -273,33 +233,55 @@ export function makeDesktopSsh(input: { FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner >(); const filePath = input.persistPath; + const fs = yield* FileSystem.FileSystem; + const documentOptions = { + path: filePath, + schema: SavedEnvironmentsSchema, + defaults: { environments: [] }, + seedMissing: false, + mode: SAVED_FILE_MODE, + legacy: { schema: LegacySavedFile, migrate: adoptLegacyHosts }, + } as const; + const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); + const opened = exists ? yield* Effect.exit(makeJsonDocument(documentOptions)) : undefined; + let saved = opened !== undefined && opened._tag === "Success" ? opened.value : undefined; + const unavailable = opened !== undefined && opened._tag === "Failure"; const liveRef = yield* Ref.make(new Map()); const persistGate = yield* Semaphore.make(1); const cli = { env: input.env }; const client = yield* probeSshClient(cli); const loadEnvironmentId = input.loadEnvironmentId ?? fetchEnvironmentId; + const hosts = saved === undefined ? [] : (yield* saved.get).environments.map(toSaved); + + const requireSaved = Effect.gen(function* () { + if (unavailable) return yield* unavailableError(); + if (saved !== undefined) return saved; + const created = yield* makeJsonDocument(documentOptions).pipe( + Effect.mapError((cause) => persistError(filePath, cause)), + ); + saved = created; + return created; + }); const modifySaved = ( mutate: (environments: readonly SavedSshEnvironment[]) => readonly SavedSshEnvironment[], ) => persistGate.withPermit( Effect.gen(function* () { - const saved = yield* readSaved(filePath); - yield* writeSaved(filePath, { environments: mutate(saved.environments) }).pipe( - Effect.tapError(() => - Effect.logError("ssh.environments.persist.failed").pipe( - Effect.annotateLogs({ path: filePath }), + const document = yield* requireSaved; + yield* document + .update((current) => ({ + environments: mutate(current.environments.map(toSaved)).map(fromSaved), + })) + .pipe( + Effect.tapError(() => + Effect.logError("ssh.environments.persist.failed").pipe( + Effect.annotateLogs({ path: filePath }), + ), ), - ), - Effect.mapError( - (error) => - new SshPersistError({ - message: `Failed to persist SSH environments to ${filePath}.`, - cause: error, - }), - ), - ); - }).pipe(Effect.provide(platform)), + Effect.mapError((cause) => persistError(filePath, cause)), + ); + }), ); const resultFromLive = (live: LiveSshSession): DesktopSshConnectResult => ({ @@ -344,12 +326,14 @@ export function makeDesktopSsh(input: { return DesktopSsh.of({ client, - listSaved: readSaved(filePath).pipe( - Effect.provide(platform), - Effect.map((state) => state.environments), - ), + savedHostsMessage: unavailable ? SAVED_HOSTS_UNAVAILABLE : undefined, + listSaved: Effect.gen(function* () { + if (saved === undefined) return hosts; + return (yield* saved.get).environments.map(toSaved); + }), connect: (raw) => Effect.gen(function* () { + if (unavailable) return yield* unavailableError(); const missingMessage = client.available ? undefined : client.message; if (missingMessage !== undefined && input.connectEnvironment === undefined) { return yield* new SshClientMissingError({ @@ -392,25 +376,22 @@ export function makeDesktopSsh(input: { ); return resultFromLive(live); } - yield* writeSaved(filePath, { - environments: [ - ...(yield* readSaved(filePath)).environments.filter((entry) => entry.id !== id), - { id, target }, - ], - }).pipe( - Effect.tapError(() => - Effect.logError("ssh.environments.persist.failed").pipe( - Effect.annotateLogs({ path: filePath }), + const document = yield* requireSaved; + yield* document + .update((current) => ({ + environments: [ + ...current.environments.filter((entry) => entry.id !== id), + fromSaved({ id, target }), + ], + })) + .pipe( + Effect.tapError(() => + Effect.logError("ssh.environments.persist.failed").pipe( + Effect.annotateLogs({ path: filePath }), + ), ), - ), - Effect.mapError( - (error) => - new SshPersistError({ - message: `Failed to persist SSH environments to ${filePath}.`, - cause: error, - }), - ), - ); + Effect.mapError((cause) => persistError(filePath, cause)), + ); yield* adoptLive({ id, target, environmentId, connected }); yield* Effect.log("ssh.environment.connected").pipe( Effect.annotateLogs({ diff --git a/apps/desktop/src/shared/desktop-rpc.ts b/apps/desktop/src/shared/desktop-rpc.ts index 6e8574cac..ab0f76314 100644 --- a/apps/desktop/src/shared/desktop-rpc.ts +++ b/apps/desktop/src/shared/desktop-rpc.ts @@ -45,6 +45,7 @@ export const EnvironmentSnapshotSchema = Schema.Struct({ revision: Schema.Natural, connecting: Schema.Array(ConnectingSshHostSchema), remotes: Schema.Array(SshRemoteEnvironmentSchema), + hostsError: Schema.optionalKey(Schema.String), }); export type EnvironmentSnapshot = typeof EnvironmentSnapshotSchema.Type; diff --git a/docs/host-persistence.md b/docs/host-persistence.md index 3c214487e..c12d559e3 100644 --- a/docs/host-persistence.md +++ b/docs/host-persistence.md @@ -50,7 +50,8 @@ $PIE_HOME/ ├── storage/ │ ├── projects.json │ ├── sessions//.json -│ └── schedules/.json +│ ├── schedules/.json +│ └── ssh-environments.json ├── worktrees/// ├── logs/ │ ├── pie.log @@ -118,6 +119,20 @@ Renderer `localStorage pie:theme` remains a FOUC cache of `appearance.theme` (se `path` is an absolute registered workspace path and is the only persisted `projectId -> path` mapping. +### Saved SSH hosts + +| Property | Current contract | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Path | `$PIE_HOME/storage/ssh-environments.json` | +| Owner | Desktop `DesktopSsh`. The path is declared in `paths.ts`; the CLI does not read or write it. | +| Data | Version 1 `{ environments: [{ id, alias, hostname, username, port }] }`. Saved targets only. No token, no live tunnel. | +| Write points | Missing file is not created at startup. Connect and remove write the array. A fully valid pre-envelope file is rewritten as version 1 on first read. | +| Compatibility | A pre-envelope file is adopted only when every entry validates. `activeId` is dropped. One invalid entry aborts adoption and leaves the file byte-for-byte unchanged. Corrupt JSON, a version-1 file that fails the schema, a newer version, or an aborted adoption is left untouched. Desktop still starts; SSH hosts are unavailable and show a fixed error. | +| Extension | Stay on version 1 until a field change requires an explicit migration. | +| Retention | Retained with `$PIE_HOME`. Remove deletes one array entry. No uninstall cleanup. | +| Permissions | `0600` after a write (adoption or mutation). A read of the current envelope does not change the file. | +| Atomicity | `makeJsonDocument`: sibling temp file, then rename. Process-local write lock. Missing files are not seeded. | + ### Allocated project folders A draft send with no selected Project calls `project.allocateChatProjectDir`, which creates diff --git a/packages/effect-json-store/src/codec.ts b/packages/effect-json-store/src/codec.ts index 3d034a5c9..a1cce5d2b 100644 --- a/packages/effect-json-store/src/codec.ts +++ b/packages/effect-json-store/src/codec.ts @@ -63,6 +63,7 @@ export const makeFileCodec = ( schema: AnySchema, migrations: ReadonlyArray>, legacy?: MigrationStep, + mode?: number, ): FileCodec => { const latestVersion = migrations.length + 1; @@ -77,7 +78,7 @@ export const makeFileCodec = ( try: () => `${JSON.stringify(envelope, null, 2)}\n`, catch: (cause) => cause, }); - yield* writeFileAtomic(fs, file, text); + yield* writeFileAtomic(fs, file, text, mode === undefined ? undefined : { mode }); }, (effect, file) => effect.pipe(Effect.mapError((cause) => new JsonStoreWriteError({ file, cause }))), diff --git a/packages/effect-json-store/src/document.ts b/packages/effect-json-store/src/document.ts index d467f8181..22c8134b1 100644 --- a/packages/effect-json-store/src/document.ts +++ b/packages/effect-json-store/src/document.ts @@ -60,15 +60,22 @@ export interface JsonDocumentOptions< readonly legacy?: MigrationStep; /** Seed value written when the file does not exist yet. Treated as immutable. */ readonly defaults: Latest["Type"]; + /** + * When false, a missing file stays missing and `defaults` live only in memory + * until `set` or `update`. Defaults to true. + */ + readonly seedMissing?: boolean; + /** Pinned after each atomic write. Omitted files keep the process umask. */ + readonly mode?: number; } /** * Open a single versioned JSON document — a standalone file with a seed value, - * loaded eagerly: a missing file is seeded with `defaults` immediately, an - * outdated file is migrated step by step in memory (each step's output is - * validated against the next version's schema) and written back once, and a - * file from a newer version fails with {@link JsonStoreVersionTooNewError} - * without ever being touched. Corrupt files fail loudly and are never reset. + * loaded eagerly. A missing file is seeded with `defaults` unless + * `seedMissing` is false, in which case the defaults stay in memory until the + * first `set` or `update`. An outdated file is migrated in memory and written + * back once. A newer file fails with {@link JsonStoreVersionTooNewError} + * without being touched. Corrupt files fail loudly and are never reset. * * For dynamic sets of keyed records, use `makeJsonCollection` instead. * @@ -98,14 +105,16 @@ export const makeJsonDocument = < const { defaults, path: file, schema } = options; const migrations = options.migrations ?? []; const fs = yield* FileSystem.FileSystem; - const codec = makeFileCodec(fs, schema, migrations, options.legacy); + const codec = makeFileCodec(fs, schema, migrations, options.legacy, options.mode); const loadFromDisk: Effect.Effect = codec .load(file) .pipe( Effect.flatMap((value) => value === undefined - ? codec.save(file, defaults).pipe(Effect.as(defaults)) + ? options.seedMissing === false + ? Effect.succeed(defaults) + : codec.save(file, defaults).pipe(Effect.as(defaults)) : isDocumentValue(value) ? Effect.succeed(value) : Effect.die(new TypeError("json document codec returned an invalid value")), diff --git a/packages/effect-json-store/test/document.test.ts b/packages/effect-json-store/test/document.test.ts index fa3aaf10c..d984bb94f 100644 --- a/packages/effect-json-store/test/document.test.ts +++ b/packages/effect-json-store/test/document.test.ts @@ -23,6 +23,25 @@ it.effect("seeds a missing file with defaults immediately, creating parent direc ), ); +it.effect("does not create a missing file when seeding is disabled", () => + withTmp((dir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = path.join(dir, "config.json"); + const store = yield* makeJsonDocument({ + path: file, + schema, + defaults, + seedMissing: false, + }); + assert.deepEqual(yield* store.get, defaults); + assert.equal(yield* fs.exists(file), false); + yield* store.set({ theme: "dark", count: 1 }); + assert.equal(yield* fs.exists(file), true); + }), + ), +); + it.effect("set persists atomically and a reopened store reads the value back", () => withTmp((dir) => Effect.gen(function* () { diff --git a/packages/server/src/config/paths.ts b/packages/server/src/config/paths.ts index 30f4a2d65..726ae5186 100644 --- a/packages/server/src/config/paths.ts +++ b/packages/server/src/config/paths.ts @@ -28,7 +28,7 @@ export class Paths extends Context.Service< readonly logsDir: string; /** `~/Pie` — parent for `project.allocateChatProjectDir` chat folders (`type: "chat"`). */ readonly chatProjectsDir: string; - /** `storage/ssh-environments.json` — Desktop and CLI saved SSH hosts. */ + /** `storage/ssh-environments.json` — Desktop saved SSH hosts. */ readonly sshEnvironmentsFile: string; } >()("Paths") {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c748bdaf..e2d29034f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -562,6 +562,9 @@ importers: '@getpie/core': specifier: workspace:* version: link:../../packages/core + '@getpie/effect-json-store': + specifier: workspace:* + version: link:../../packages/effect-json-store '@getpie/server': specifier: workspace:* version: link:../../packages/server