Compare commits

...
Author SHA1 Message Date
vorotamoroz c27dbbd572 Fix CLI settings write-back behaviour 2026-08-05 03:24:56 +00:00
vorotamorozandGitHub 83087f159e Merge pull request #1072 from vrtmrz/deps/commonlib-0.1.3
Update Commonlib to 0.1.3
2026-08-05 12:08:09 +09:00
vorotamoroz d12d0f8ef7 Update Commonlib and secure brace-expansion resolutions 2026-08-05 01:58:39 +00:00
vorotamorozandGitHub e9b2477bf3 Release Self-hosted LiveSync 1.0.3 (#1069)
Merge the reviewed 1.0.3 release commit after BRAT validation and stable promotion.
2026-08-03 22:26:35 +09:00
14 changed files with 660 additions and 74 deletions
+43 -43
View File
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.2",
"@vrtmrz/livesync-commonlib": "0.1.3",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -1887,9 +1887,9 @@
"license": "MIT"
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1968,9 +1968,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4775,9 +4775,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.2.tgz",
"integrity": "sha512-o4nHy8d4wxfUR6CcOv50vQnVc+vFZDZTI612/DPBJY3ObGm0749IobdJJ04l6iBjmVkNaB9nnaSsl7BWHuuY+A==",
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.3.tgz",
"integrity": "sha512-M6+mOlf4R60pf6mMuQdIlOoeIjOlDQn/hIRjELCkAwAcJXo5V/l8LfBCujeuvVk9FOzO3S0Q4WET9VaDHDxosw==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -4896,9 +4896,9 @@
"license": "MIT"
},
"node_modules/@wdio/config/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5264,9 +5264,9 @@
"license": "MIT"
},
"node_modules/archiver-utils/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5734,9 +5734,9 @@
"license": "MIT"
},
"node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5986,9 +5986,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -7673,9 +7673,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7770,9 +7770,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7836,9 +7836,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-n/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8013,9 +8013,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8215,9 +8215,9 @@
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9052,9 +9052,9 @@
"license": "MIT"
},
"node_modules/globby/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12668,9 +12668,9 @@
"license": "MIT"
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -177,7 +177,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.2",
"@vrtmrz/livesync-commonlib": "0.1.3",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
+5
View File
@@ -67,6 +67,10 @@ livesync-cli [database-path] [command] [args...]
- `--vault <path>` / `-V <path>`: (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 <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
--vault <path>, -V <path> (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:
+1
View File
@@ -40,6 +40,7 @@ export interface CLIOptions {
verbose?: boolean;
debug?: boolean;
force?: boolean;
writeSettings?: boolean;
command: CLICommand;
commandArgs: string[];
interval?: number;
+116 -29
View File
@@ -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 <path>, -V <path> (daemon/mirror) Path to the vault directory containing .md files
(defaults to database-path; allows separate PouchDB and vault dirs)
--interval <N>, -i <N> (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<NodeServiceContext>).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<void> => {
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);
+9
View File
@@ -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([]);
});
});
+1 -1
View File
@@ -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",
+173
View File
@@ -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<CLICommand>([
"setup",
"remote-add",
"remote-rm",
"remote-set",
"remote-activate",
]);
const REMOTE_SETTINGS_WRITE_COMMANDS = new Set<CLICommand>([
"remote-add",
"remote-rm",
"remote-set",
"remote-activate",
]);
export const CLI_RUNTIME_ONLY_SETTING_KEYS = new Set<keyof ObsidianLiveSyncSettings>([
"disableCheckingConfigMismatch",
"suspendFileWatching",
"suspendParseReplicationResult",
]);
function cloneJsonValue<T>(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<keyof ObsidianLiveSyncSettings> {
return new Set(settings.flatMap((value) => Object.keys(value) as Array<keyof ObsidianLiveSyncSettings>));
}
function copySetting(
target: ObsidianLiveSyncSettings,
source: ObsidianLiveSyncSettings,
key: keyof ObsidianLiveSyncSettings
): void {
const targetRecord = target as unknown as Record<string, unknown>;
const sourceRecord = source as unknown as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(sourceRecord, key)) {
targetRecord[key] = cloneJsonValue(sourceRecord[key]);
} else {
delete targetRecord[key];
}
}
function remoteSettingKeys(...settings: ObsidianLiveSyncSettings[]): Set<keyof ObsidianLiveSyncSettings> {
const keys = new Set<keyof ObsidianLiveSyncSettings>([
"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<keyof ObsidianLiveSyncSettings>) {
keys.add(key);
}
} catch {
// The setting service reports invalid remote configurations when loading them.
}
}
}
return keys;
}
export function changedSettingKeys(
before: ObsidianLiveSyncSettings,
after: ObsidianLiveSyncSettings
): Set<keyof ObsidianLiveSyncSettings> {
const changed = new Set<keyof ObsidianLiveSyncSettings>();
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<keyof ObsidianLiveSyncSettings>;
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<string, unknown>)[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<string, unknown>)[key];
}
} catch {
// The setting service owns validation and recovery of malformed files.
}
}
@@ -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> = {}): 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");
});
});
+1
View File
@@ -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",
+1
View File
@@ -1,4 +1,5 @@
const TASKS = [
"test:settings-writeback",
"test:setup-put-cat",
"test:mirror",
"test:daemon",
@@ -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);
});
@@ -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);
+6
View File
@@ -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