mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-05 15:26:41 +03:00
Compare commits
4 Commits
1.0.3
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27dbbd572 | ||
|
|
83087f159e | ||
|
|
d12d0f8ef7 | ||
|
|
e9b2477bf3 |
86
package-lock.json
generated
86
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface CLIOptions {
|
||||
verbose?: boolean;
|
||||
debug?: boolean;
|
||||
force?: boolean;
|
||||
writeSettings?: boolean;
|
||||
command: CLICommand;
|
||||
commandArgs: string[];
|
||||
interval?: number;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
src/apps/cli/settingsPersistence.ts
Normal file
173
src/apps/cli/settingsPersistence.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
162
src/apps/cli/settingsPersistence.unit.spec.ts
Normal file
162
src/apps/cli/settingsPersistence.unit.spec.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,4 +1,5 @@
|
||||
const TASKS = [
|
||||
"test:settings-writeback",
|
||||
"test:setup-put-cat",
|
||||
"test:mirror",
|
||||
"test:daemon",
|
||||
|
||||
134
src/apps/cli/testdeno/test-settings-writeback.ts
Normal file
134
src/apps/cli/testdeno/test-settings-writeback.ts
Normal file
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user