From c27dbbd572a621bc4533bcf639b0fcbc44349da4 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 4 Aug 2026 12:59:36 +0000 Subject: [PATCH] Fix CLI settings write-back behaviour --- src/apps/cli/README.md | 5 + src/apps/cli/commands/types.ts | 1 + src/apps/cli/main.ts | 145 ++++++++++++--- src/apps/cli/main.unit.spec.ts | 9 + src/apps/cli/package.json | 2 +- src/apps/cli/settingsPersistence.ts | 173 ++++++++++++++++++ src/apps/cli/settingsPersistence.unit.spec.ts | 162 ++++++++++++++++ src/apps/cli/testdeno/deno.json | 1 + src/apps/cli/testdeno/run-ci-suite.ts | 1 + .../cli/testdeno/test-settings-writeback.ts | 134 ++++++++++++++ src/apps/cli/testdeno/test-setup-put-cat.ts | 7 + updates.md | 6 + 12 files changed, 616 insertions(+), 30 deletions(-) create mode 100644 src/apps/cli/settingsPersistence.ts create mode 100644 src/apps/cli/settingsPersistence.unit.spec.ts create mode 100644 src/apps/cli/testdeno/test-settings-writeback.ts diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 270b3823..09e9bb9b 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -67,6 +67,10 @@ livesync-cli [database-path] [command] [args...] - `--vault ` / `-V `: (daemon/mirror only) Path to the vault directory containing `.md` files. - Allows the PouchDB database directory and the actual vault directory to be different locations. - For `mirror` command, the positional `[vault-path]` argument takes precedence over `--vault`. +- `--write-settings`: Write setting migrations and other lasting changes after the command succeeds. + - `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option. + - All remaining commands leave the settings file unchanged by default. + - Temporary values used to suspend synchronisation or select a remote for one command are never written. ### Commands @@ -333,6 +337,7 @@ Options: --debug, -d Enable debug logging (includes verbose) --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed --vault , -V (daemon/mirror) Path to vault directory, decoupled from database-path + --write-settings Write setting changes after a successful command --help, -h Show this help message Commands: diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index 7a7b13c5..7dfa8d80 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -40,6 +40,7 @@ export interface CLIOptions { verbose?: boolean; debug?: boolean; force?: boolean; + writeSettings?: boolean; command: CLICommand; commandArgs: string[]; interval?: number; diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index d0602c76..38b2b033 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -25,10 +25,20 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b import { IgnoreRules } from "./serviceModules/IgnoreRules"; import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; -import { createNodeStandardIo, fsPromises as fs, path, fs as fsSync } from "@vrtmrz/livesync-commonlib/node"; +import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; import type { StandardIo } from "@vrtmrz/livesync-commonlib/context"; import { writeStderrLine, writeStdoutLine } from "./cliOutput"; import { createDefaultCliSettings } from "./cliSettingsDefaults"; +import { + applyStoredSetting, + changedSettingKeys, + CLI_RUNTIME_ONLY_SETTING_KEYS, + cloneSettings, + isSettingsWriteCommand, + preserveStoredSetting, + reconcileDurableSettings, + settingsEqual, +} from "./settingsPersistence"; const SETTINGS_FILE = ".livesync/settings.json"; ensureGlobalNodeLocalStorage(); @@ -92,6 +102,7 @@ Options: --vault , -V (daemon/mirror) Path to the vault directory containing .md files (defaults to database-path; allows separate PouchDB and vault dirs) --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed + --write-settings Write setting changes after a successful command Examples: livesync-cli ./my-database Run daemon (LiveSync mode) @@ -141,6 +152,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO let verbose = false; let debug = false; let force = false; + let writeSettings = false; let interval: number | undefined; let command: CLICommand = "daemon"; const commandArgs: string[] = []; @@ -197,6 +209,9 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO case "-f": force = true; break; + case "--write-settings": + writeSettings = true; + break; default: { if (!databasePath) { if (command === "daemon" && isCLICommand(token)) { @@ -237,6 +252,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO verbose, debug, force, + writeSettings, command, commandArgs, interval, @@ -411,16 +427,26 @@ export async function main( // Setup settings handlers const settingService = serviceHubInstance.setting; + const originalSettingsText = await fs.readFile(settingsPath, "utf-8").catch(() => undefined); + let latestPreparedSettingsText: string | undefined; + let preparedSettingsRevision = 0; + let commandIsRunning = false; + let commandPreparedSettingsTexts: string[] = []; (settingService as InjectableSettingService).saveData.setHandler( async (data: ObsidianLiveSyncSettings) => { try { - await fs.writeFile(settingsPath, JSON.stringify(data, null, 2), "utf-8"); + latestPreparedSettingsText = JSON.stringify(data, null, 2); + preparedSettingsRevision++; + if (commandIsRunning) { + commandPreparedSettingsTexts.push(latestPreparedSettingsText); + } if (options.verbose) { - writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`); + writeStderrLine(standardIo, `[Settings] Prepared an update for ${settingsPath}`); } } catch (error) { - writeStderrLine(standardIo, `[Settings] Failed to save:`, error); + writeStderrLine(standardIo, `[Settings] Failed to prepare an update:`, error); + throw error; } } ); @@ -503,24 +529,21 @@ export async function main( process.on("SIGINT", () => void shutdown("SIGINT")); process.on("SIGTERM", () => void shutdown("SIGTERM")); - // Save the settings file before any lifecycle events can mutate and persist them. - // suspendAllSync and other lifecycle hooks clobber sync settings in memory, and - // various code paths persist the clobbered state to disk. We restore on shutdown. - const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!); - - // Restore settings file on any exit to undo lifecycle mutations. - // Write to a temp path first so a crash mid-write doesn't leave a truncated file. - process.on("exit", () => { - if (settingsBackup) { - const tmpPath = settingsPath + ".tmp"; - try { - fsSync.writeFileSync(tmpPath, settingsBackup, "utf-8"); - fsSync.renameSync(tmpPath, settingsPath); - } catch (err) { - writeStderrLine(standardIo, "[Settings] Failed to restore settings on exit:", err); + const writeSettingsAtomically = async (content: string | undefined): Promise => { + if (content === undefined || content === originalSettingsText) return; + const temporaryPath = `${settingsPath}.${process.pid}.tmp`; + await fs.mkdir(path.dirname(settingsPath), { recursive: true }); + try { + await fs.writeFile(temporaryPath, content, "utf-8"); + await fs.rename(temporaryPath, settingsPath); + if (options.verbose) { + writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`); } + } catch (error) { + await fs.unlink(temporaryPath).catch(() => {}); + throw error; } - }); + }; // Start the core try { @@ -531,9 +554,15 @@ export async function main( writeStderrLine(standardIo, `[Error] Failed to initialize LiveSync`); process.exit(1); } + const settingsAfterLoadText = latestPreparedSettingsText + ? preserveStoredSetting(latestPreparedSettingsText, originalSettingsText, "useIndexedDBAdapter") + : originalSettingsText; + // Capture sync settings before suspendAllSync() clobbers them. // Used by daemon mode to restore the correct sync behaviour after the mirror scan. - const settingsBeforeSuspend = core.services.setting.currentSettings(); + const settingsBeforeSuspend = cloneSettings(core.services.setting.currentSettings()); + const durableSettingsBeforeSuspend = cloneSettings(settingsBeforeSuspend); + applyStoredSetting(durableSettingsBeforeSuspend, settingsAfterLoadText, "useIndexedDBAdapter"); const originalSyncSettings = { liveSync: settingsBeforeSuspend.liveSync, syncOnStart: settingsBeforeSuspend.syncOnStart, @@ -544,7 +573,19 @@ export async function main( syncAfterMerge: settingsBeforeSuspend.syncAfterMerge, }; await core.services.setting.suspendAllSync(); + const settingsAfterSuspend = cloneSettings(core.services.setting.currentSettings()); await core.services.control.onReady(); + const settingsBeforeCommand = cloneSettings(core.services.setting.currentSettings()); + const transientSettingKeys = changedSettingKeys(settingsBeforeSuspend, settingsAfterSuspend); + for (const key of CLI_RUNTIME_ONLY_SETTING_KEYS) { + transientSettingKeys.add(key); + } + const durableSettingsBeforeCommand = reconcileDurableSettings({ + durableBase: durableSettingsBeforeSuspend, + runtimeBaseline: settingsAfterSuspend, + runtimeCurrent: settingsBeforeCommand, + preserveKeys: transientSettingKeys, + }); infoLog(`[Ready] LiveSync is running`); infoLog(`[Ready] Press Ctrl+C to stop`); @@ -568,14 +609,58 @@ export async function main( infoLog(""); } - const result = await commandRunner(options, { - databasePath, - vaultPath, - core, - p2pReplicator, - settingsPath, - originalSyncSettings, - }); + commandPreparedSettingsTexts = []; + let result: boolean; + try { + commandIsRunning = true; + result = await commandRunner(options, { + databasePath, + vaultPath, + core, + p2pReplicator, + settingsPath, + originalSyncSettings, + }); + } finally { + commandIsRunning = false; + } + + let settingsTextToCommit: string | undefined; + if (result && options.command === "setup") { + settingsTextToCommit = commandPreparedSettingsTexts[0]; + if (settingsTextToCommit === undefined) { + throw new Error("The setup command completed without preparing its settings update."); + } + } else if (result && (isSettingsWriteCommand(options.command) || options.writeSettings)) { + const runtimeSettingsAfterCommand = cloneSettings(core.services.setting.currentSettings()); + const durableSettingsAfterCommand = reconcileDurableSettings({ + durableBase: durableSettingsBeforeCommand, + runtimeBaseline: settingsBeforeCommand, + runtimeCurrent: runtimeSettingsAfterCommand, + preserveKeys: transientSettingKeys, + command: options.command, + }); + + if ( + isSettingsWriteCommand(options.command) || + !settingsEqual(durableSettingsAfterCommand, durableSettingsBeforeSuspend) + ) { + const runtimeSettings = cloneSettings(core.services.setting.currentSettings()); + const revisionBeforeSave = preparedSettingsRevision; + try { + await core.services.setting.updateSettings(() => cloneSettings(durableSettingsAfterCommand), true); + if (preparedSettingsRevision === revisionBeforeSave || latestPreparedSettingsText === undefined) { + throw new Error("The setting service did not prepare the requested settings update."); + } + settingsTextToCommit = latestPreparedSettingsText; + } finally { + await core.services.setting.updateSettings(() => runtimeSettings, false); + } + } else { + settingsTextToCommit = settingsAfterLoadText; + } + } + if (!result) { writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`); process.exitCode = 1; @@ -584,10 +669,12 @@ export async function main( } if (options.command === "daemon" && result) { + await writeSettingsAtomically(settingsTextToCommit); // Keep the process running await new Promise(() => {}); } else { await core.services.control.onUnload(); + await writeSettingsAtomically(settingsTextToCommit); } } catch (error) { writeStderrLine(standardIo, `[Error] Failed to start:`, error); diff --git a/src/apps/cli/main.unit.spec.ts b/src/apps/cli/main.unit.spec.ts index 20f32fb5..0b8dc45f 100644 --- a/src/apps/cli/main.unit.spec.ts +++ b/src/apps/cli/main.unit.spec.ts @@ -206,4 +206,13 @@ describe("CLI parseArgs", () => { expect(parsed.command).toBe("daemon"); expect(parsed.interval).toBe(30); }); + + it("parses --write-settings as a global option", () => { + process.argv = ["node", "livesync-cli", "./vault", "--write-settings", "ls"]; + const parsed = parseArgs(); + + expect(parsed.command).toBe("ls"); + expect(parsed.writeSettings).toBe(true); + expect(parsed.commandArgs).toEqual([]); + }); }); diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index a4a7dd0d..7833db8a 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -12,7 +12,7 @@ "buildRun": "npm run build && npm run cli --", "build:docker": "docker build -f Dockerfile -t livesync-cli ../../..", "check": "tsc -p tsconfig.json", - "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts", + "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts", "test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh", "test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh", "test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh", diff --git a/src/apps/cli/settingsPersistence.ts b/src/apps/cli/settingsPersistence.ts new file mode 100644 index 00000000..84e7d47f --- /dev/null +++ b/src/apps/cli/settingsPersistence.ts @@ -0,0 +1,173 @@ +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; +import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; +import type { CLICommand } from "./commands/types"; + +const SETTINGS_WRITE_COMMANDS = new Set([ + "setup", + "remote-add", + "remote-rm", + "remote-set", + "remote-activate", +]); + +const REMOTE_SETTINGS_WRITE_COMMANDS = new Set([ + "remote-add", + "remote-rm", + "remote-set", + "remote-activate", +]); + +export const CLI_RUNTIME_ONLY_SETTING_KEYS = new Set([ + "disableCheckingConfigMismatch", + "suspendFileWatching", + "suspendParseReplicationResult", +]); + +function cloneJsonValue(value: T): T { + if (value === undefined) return value; + return JSON.parse(JSON.stringify(value)) as T; +} + +export function cloneSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings { + return cloneJsonValue(settings); +} + +function settingValuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function settingsEqual(left: ObsidianLiveSyncSettings, right: ObsidianLiveSyncSettings): boolean { + return settingValuesEqual(left, right); +} + +function settingsKeys(...settings: ObsidianLiveSyncSettings[]): Set { + return new Set(settings.flatMap((value) => Object.keys(value) as Array)); +} + +function copySetting( + target: ObsidianLiveSyncSettings, + source: ObsidianLiveSyncSettings, + key: keyof ObsidianLiveSyncSettings +): void { + const targetRecord = target as unknown as Record; + const sourceRecord = source as unknown as Record; + if (Object.prototype.hasOwnProperty.call(sourceRecord, key)) { + targetRecord[key] = cloneJsonValue(sourceRecord[key]); + } else { + delete targetRecord[key]; + } +} + +function remoteSettingKeys(...settings: ObsidianLiveSyncSettings[]): Set { + const keys = new Set([ + "remoteConfigurations", + "activeConfigurationId", + "P2P_ActiveRemoteConfigurationId", + "remoteType", + ]); + for (const current of settings) { + for (const configuration of Object.values(current.remoteConfigurations ?? {})) { + try { + const parsed = ConnectionStringParser.parse(configuration.uri); + for (const key of Object.keys(parsed.settings) as Array) { + keys.add(key); + } + } catch { + // The setting service reports invalid remote configurations when loading them. + } + } + } + return keys; +} + +export function changedSettingKeys( + before: ObsidianLiveSyncSettings, + after: ObsidianLiveSyncSettings +): Set { + const changed = new Set(); + for (const key of settingsKeys(before, after)) { + if (!settingValuesEqual(before[key], after[key])) { + changed.add(key); + } + } + return changed; +} + +export function isSettingsWriteCommand(command: CLICommand): boolean { + return SETTINGS_WRITE_COMMANDS.has(command); +} + +export function reconcileDurableSettings(options: { + durableBase: ObsidianLiveSyncSettings; + runtimeBaseline: ObsidianLiveSyncSettings; + runtimeCurrent: ObsidianLiveSyncSettings; + preserveKeys: ReadonlySet; + command?: CLICommand; +}): ObsidianLiveSyncSettings { + const durable = cloneSettings(options.durableBase); + for (const key of settingsKeys(options.runtimeBaseline, options.runtimeCurrent)) { + if (options.preserveKeys.has(key)) continue; + if (!settingValuesEqual(options.runtimeBaseline[key], options.runtimeCurrent[key])) { + copySetting(durable, options.runtimeCurrent, key); + } + } + + if (options.command) { + if (REMOTE_SETTINGS_WRITE_COMMANDS.has(options.command)) { + copySetting(durable, options.runtimeCurrent, "remoteConfigurations"); + copySetting(durable, options.runtimeCurrent, "activeConfigurationId"); + copySetting(durable, options.runtimeCurrent, "P2P_ActiveRemoteConfigurationId"); + + if (durable.activeConfigurationId) { + activateRemoteConfiguration(durable, durable.activeConfigurationId); + } + } else { + // Commands such as remote-status may activate a profile temporarily. Only + // the dedicated remote settings commands are allowed to retain that switch. + for (const key of remoteSettingKeys(options.durableBase, options.runtimeBaseline, options.runtimeCurrent)) { + copySetting(durable, options.durableBase, key); + } + } + } + + return durable; +} + +export function preserveStoredSetting( + candidateText: string, + originalText: string | undefined, + key: keyof ObsidianLiveSyncSettings +): string { + if (originalText === undefined) return candidateText; + try { + const candidate = JSON.parse(candidateText) as ObsidianLiveSyncSettings; + const original = JSON.parse(originalText) as ObsidianLiveSyncSettings; + if (Object.prototype.hasOwnProperty.call(original, key)) { + copySetting(candidate, original, key); + } else { + delete (candidate as unknown as Record)[key]; + } + return JSON.stringify(candidate, null, 2); + } catch { + return candidateText; + } +} + +export function applyStoredSetting( + target: ObsidianLiveSyncSettings, + storedText: string | undefined, + key: keyof ObsidianLiveSyncSettings +): void { + if (storedText === undefined) return; + try { + const stored = JSON.parse(storedText) as ObsidianLiveSyncSettings; + if (Object.prototype.hasOwnProperty.call(stored, key)) { + copySetting(target, stored, key); + } else { + delete (target as unknown as Record)[key]; + } + } catch { + // The setting service owns validation and recovery of malformed files. + } +} diff --git a/src/apps/cli/settingsPersistence.unit.spec.ts b/src/apps/cli/settingsPersistence.unit.spec.ts new file mode 100644 index 00000000..2115c2d0 --- /dev/null +++ b/src/apps/cli/settingsPersistence.unit.spec.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; +import { + applyStoredSetting, + changedSettingKeys, + cloneSettings, + isSettingsWriteCommand, + preserveStoredSetting, + reconcileDurableSettings, +} from "./settingsPersistence"; + +function settings(overrides: Partial = {}): ObsidianLiveSyncSettings { + return Object.assign(cloneSettings(DEFAULT_SETTINGS), overrides); +} + +describe("CLI settings persistence", () => { + it("identifies commands which change the settings file automatically", () => { + expect(isSettingsWriteCommand("setup")).toBe(true); + expect(isSettingsWriteCommand("remote-add")).toBe(true); + expect(isSettingsWriteCommand("remote-rm")).toBe(true); + expect(isSettingsWriteCommand("remote-set")).toBe(true); + expect(isSettingsWriteCommand("remote-activate")).toBe(true); + expect(isSettingsWriteCommand("ls")).toBe(false); + expect(isSettingsWriteCommand("remote-status")).toBe(false); + }); + + it("retains lasting changes without retaining CLI suspension values", () => { + const durableBase = settings({ + liveSync: true, + syncOnStart: true, + periodicReplication: true, + P2P_AutoStart: true, + settingVersion: 9, + customChunkSize: 40, + }); + const runtimeBaseline = settings({ + ...durableBase, + liveSync: false, + syncOnStart: false, + periodicReplication: false, + P2P_AutoStart: false, + }); + const runtimeCurrent = settings({ + ...runtimeBaseline, + settingVersion: 10, + customChunkSize: 60, + }); + + const reconciled = reconcileDurableSettings({ + durableBase, + runtimeBaseline, + runtimeCurrent, + preserveKeys: changedSettingKeys(durableBase, runtimeBaseline), + command: "ls", + }); + + expect(reconciled.liveSync).toBe(true); + expect(reconciled.syncOnStart).toBe(true); + expect(reconciled.periodicReplication).toBe(true); + expect(reconciled.P2P_AutoStart).toBe(true); + expect(reconciled.settingVersion).toBe(10); + expect(reconciled.customChunkSize).toBe(60); + }); + + it("retains a remote profile change and restores the durable sync values", () => { + const durableBase = settings({ + liveSync: true, + remoteConfigurations: {}, + activeConfigurationId: "", + }); + const runtimeBaseline = settings({ ...durableBase, liveSync: false }); + const runtimeCurrent = settings({ + ...runtimeBaseline, + remoteConfigurations: { + main: { + id: "main", + name: "Main", + uri: "sls+https://user:pass@example.com/?db=notes", + isEncrypted: false, + }, + }, + activeConfigurationId: "main", + }); + activateRemoteConfiguration(runtimeCurrent, "main"); + + const reconciled = reconcileDurableSettings({ + durableBase, + runtimeBaseline, + runtimeCurrent, + preserveKeys: changedSettingKeys(durableBase, runtimeBaseline), + command: "remote-add", + }); + + expect(reconciled.liveSync).toBe(true); + expect(reconciled.activeConfigurationId).toBe("main"); + expect(reconciled.remoteConfigurations.main?.name).toBe("Main"); + expect(reconciled.couchDB_URI).toBe("https://example.com"); + expect(reconciled.couchDB_DBNAME).toBe("notes"); + }); + + it("does not retain a remote profile selected temporarily by an operational command", () => { + const durableBase = settings({ + remoteConfigurations: { + first: { + id: "first", + name: "First", + uri: "sls+https://first:pass@example.com/?db=first", + isEncrypted: false, + }, + second: { + id: "second", + name: "Second", + uri: "sls+https://second:pass@example.net/?db=second", + isEncrypted: false, + }, + }, + activeConfigurationId: "first", + }); + activateRemoteConfiguration(durableBase, "first"); + const runtimeBaseline = cloneSettings(durableBase); + const runtimeCurrent = cloneSettings(durableBase); + activateRemoteConfiguration(runtimeCurrent, "second"); + + const reconciled = reconcileDurableSettings({ + durableBase, + runtimeBaseline, + runtimeCurrent, + preserveKeys: new Set(), + command: "remote-status", + }); + + expect(reconciled.activeConfigurationId).toBe("first"); + expect(reconciled.couchDB_URI).toBe("https://example.com"); + expect(reconciled.couchDB_USER).toBe("first"); + expect(reconciled.couchDB_DBNAME).toBe("first"); + }); + + it("preserves the stored adapter choice while the CLI uses its Node.js adapter", () => { + const original = JSON.stringify({ useIndexedDBAdapter: true, settingVersion: 9 }); + const prepared = JSON.stringify({ useIndexedDBAdapter: false, settingVersion: 10 }); + const preserved = preserveStoredSetting(prepared, original, "useIndexedDBAdapter"); + const target = settings({ useIndexedDBAdapter: false }); + + applyStoredSetting(target, preserved, "useIndexedDBAdapter"); + + expect(JSON.parse(preserved).useIndexedDBAdapter).toBe(true); + expect(target.useIndexedDBAdapter).toBe(true); + }); + + it("does not add the CLI adapter override to an older settings file", () => { + const original = JSON.stringify({ settingVersion: 9 }); + const prepared = JSON.stringify({ useIndexedDBAdapter: false, settingVersion: 10 }); + const preserved = preserveStoredSetting(prepared, original, "useIndexedDBAdapter"); + const target = settings({ useIndexedDBAdapter: false }); + + applyStoredSetting(target, preserved, "useIndexedDBAdapter"); + + expect(JSON.parse(preserved)).not.toHaveProperty("useIndexedDBAdapter"); + expect(target).not.toHaveProperty("useIndexedDBAdapter"); + }); +}); diff --git a/src/apps/cli/testdeno/deno.json b/src/apps/cli/testdeno/deno.json index 7cb8a80d..c6de3cd5 100644 --- a/src/apps/cli/testdeno/deno.json +++ b/src/apps/cli/testdeno/deno.json @@ -7,6 +7,7 @@ "test:daemon": "deno test --env-file=.test.env -A --no-check test-daemon.ts", "test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts", "test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts", + "test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts", "test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts", "test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts", "test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts", diff --git a/src/apps/cli/testdeno/run-ci-suite.ts b/src/apps/cli/testdeno/run-ci-suite.ts index 7b5824ca..b7614135 100644 --- a/src/apps/cli/testdeno/run-ci-suite.ts +++ b/src/apps/cli/testdeno/run-ci-suite.ts @@ -1,4 +1,5 @@ const TASKS = [ + "test:settings-writeback", "test:setup-put-cat", "test:mirror", "test:daemon", diff --git a/src/apps/cli/testdeno/test-settings-writeback.ts b/src/apps/cli/testdeno/test-settings-writeback.ts new file mode 100644 index 00000000..4e3c4adf --- /dev/null +++ b/src/apps/cli/testdeno/test-settings-writeback.ts @@ -0,0 +1,134 @@ +import { assert, assertEquals } from "@std/assert"; +import { TempDir } from "./helpers/temp.ts"; +import { runCli } from "./helpers/cli.ts"; +import { initSettingsFile } from "./helpers/settings.ts"; + +async function prepareSettingsFixture(prefix: string) { + const workDir = await TempDir.create(prefix); + const settingsFile = workDir.join("settings.json"); + const databaseDir = workDir.join("database"); + await Deno.mkdir(databaseDir, { recursive: true }); + await initSettingsFile(settingsFile); + return { workDir, settingsFile, databaseDir }; +} + +Deno.test("settings-changing commands persist durable settings without CLI runtime suspension", async () => { + const fixture = await prepareSettingsFixture("livesync-cli-settings-command"); + await using workDir = fixture.workDir; + const { settingsFile, databaseDir } = fixture; + + const settings = JSON.parse(await Deno.readTextFile(settingsFile)); + settings.liveSync = true; + settings.syncOnStart = true; + settings.periodicReplication = true; + settings.P2P_Enabled = true; + settings.P2P_AutoStart = true; + settings.P2P_AutoBroadcast = true; + await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2)); + + const result = await runCli( + databaseDir, + "--settings", + settingsFile, + "remote-add", + "test-remote", + "sls+https://user:pass@example.com/database" + ); + assertEquals(result.code, 0, result.combined); + const firstRemoteId = result.stdout.trim().split("\t")[0]; + assert(firstRemoteId, `remote-add did not return an ID: ${result.combined}`); + + let persisted = JSON.parse(await Deno.readTextFile(settingsFile)); + let remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ name?: string }>; + assert( + remotes.some((remote) => remote.name === "test-remote"), + "remote-add did not persist the new profile" + ); + assertEquals(persisted.liveSync, true); + assertEquals(persisted.syncOnStart, true); + assertEquals(persisted.periodicReplication, true); + assertEquals(persisted.P2P_Enabled, true); + assertEquals(persisted.P2P_AutoStart, true); + assertEquals(persisted.P2P_AutoBroadcast, true); + + const secondAdd = await runCli( + databaseDir, + "--settings", + settingsFile, + "remote-add", + "second-remote", + "sls+https://other:secret@example.net/second" + ); + assertEquals(secondAdd.code, 0, secondAdd.combined); + const secondRemoteId = secondAdd.stdout.trim().split("\t")[0]; + assert(secondRemoteId, `second remote-add did not return an ID: ${secondAdd.combined}`); + + const activate = await runCli(databaseDir, "--settings", settingsFile, "remote-activate", secondRemoteId); + assertEquals(activate.code, 0, activate.combined); + persisted = JSON.parse(await Deno.readTextFile(settingsFile)); + assertEquals(persisted.activeConfigurationId, secondRemoteId); + + const set = await runCli( + databaseDir, + "--settings", + settingsFile, + "remote-set", + secondRemoteId, + "sls+https://replacement:secret@example.org/replaced" + ); + assertEquals(set.code, 0, set.combined); + const exported = await runCli(databaseDir, "--settings", settingsFile, "remote-export", secondRemoteId); + assertEquals(exported.code, 0, exported.combined); + assert(exported.stdout.includes("replacement"), "remote-set did not persist the replacement URI"); + + const remove = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", secondRemoteId); + assertEquals(remove.code, 0, remove.combined); + persisted = JSON.parse(await Deno.readTextFile(settingsFile)); + remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ id?: string }>; + assert(!remotes.some((remote) => remote.id === secondRemoteId), "remote-rm did not persist the removal"); + assertEquals(persisted.activeConfigurationId, firstRemoteId); +}); + +Deno.test("ordinary commands keep the settings file unchanged by default", async () => { + const fixture = await prepareSettingsFixture("livesync-cli-settings-readonly"); + await using workDir = fixture.workDir; + const { settingsFile, databaseDir } = fixture; + + const settings = JSON.parse(await Deno.readTextFile(settingsFile)); + settings.settingVersion = 9; + const original = JSON.stringify(settings, null, 2); + await Deno.writeTextFile(settingsFile, original); + + const result = await runCli(databaseDir, "--settings", settingsFile, "ls"); + assertEquals(result.code, 0, result.combined); + assertEquals(await Deno.readTextFile(settingsFile), original); +}); + +Deno.test("--write-settings persists durable start-up setting changes", async () => { + const fixture = await prepareSettingsFixture("livesync-cli-settings-explicit"); + await using workDir = fixture.workDir; + const { settingsFile, databaseDir } = fixture; + + const settings = JSON.parse(await Deno.readTextFile(settingsFile)); + settings.settingVersion = 9; + delete settings.useIndexedDBAdapter; + await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2)); + + const result = await runCli(databaseDir, "--settings", settingsFile, "--write-settings", "ls"); + assertEquals(result.code, 0, result.combined); + + const persisted = JSON.parse(await Deno.readTextFile(settingsFile)); + assertEquals(persisted.settingVersion, 10); + assert(!("useIndexedDBAdapter" in persisted), "the CLI-only adapter override was written to the settings file"); +}); + +Deno.test("failed settings-changing commands leave the settings file unchanged", async () => { + const fixture = await prepareSettingsFixture("livesync-cli-settings-failure"); + await using workDir = fixture.workDir; + const { settingsFile, databaseDir } = fixture; + + const original = await Deno.readTextFile(settingsFile); + const result = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", "missing-remote"); + assert(result.code !== 0, "remote-rm unexpectedly succeeded"); + assertEquals(await Deno.readTextFile(settingsFile), original); +}); diff --git a/src/apps/cli/testdeno/test-setup-put-cat.ts b/src/apps/cli/testdeno/test-setup-put-cat.ts index 4494a1fc..1df861e4 100644 --- a/src/apps/cli/testdeno/test-setup-put-cat.ts +++ b/src/apps/cli/testdeno/test-setup-put-cat.ts @@ -41,6 +41,13 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev setupResult.combined.includes("[Command] setup ->"), `setup command did not execute expected code path\n${setupResult.combined}` ); + const persistedSetup = JSON.parse(await Deno.readTextFile(settingsFile)); + assertEquals(persistedSetup.isConfigured, true, "setup did not persist the configured state"); + assert( + typeof persistedSetup.encryptedCouchDBConnection === "string" && + persistedSetup.encryptedCouchDBConnection.length > 0, + "setup did not persist the encrypted connection settings" + ); const run = (...args: string[]) => runCliOrFail(vaultDir, "--settings", settingsFile, ...args); diff --git a/updates.md b/updates.md index cacd6a16..3fa7f43a 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ## Unreleased +### Command-line tool + +#### Fixed + +- Successful setup and remote-configuration commands now retain their settings changes. Other commands leave the settings file unchanged unless `--write-settings` is supplied, and temporary CLI suspension values are never written (#1070). + ## 1.0.3 3rd August, 2026