From 1167b413409ecc1a3ee040b2a65261bc292d958b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 20 May 2026 05:10:42 +0100 Subject: [PATCH] feat: add CLI commands to handle multiple remote configuration --- src/apps/cli/README.md | 14 + src/apps/cli/commands/runCommand.ts | 207 ++++++++++ src/apps/cli/commands/runCommand.unit.spec.ts | 372 +++++++++++++++++- src/apps/cli/commands/types.ts | 12 + src/apps/cli/main.ts | 19 + src/apps/cli/main.unit.spec.ts | 50 +++ 6 files changed, 673 insertions(+), 1 deletion(-) diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index d45f985..18f70b7 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -74,6 +74,12 @@ livesync-cli [database-path] [command] [args...] - `pull `: Pull a file `` from the database into local file ``. - `cat `: Read a file from the database and write to stdout. - `put `: Read from stdin and write to the database path ``. +- `remote-add `: Add a remote configuration from a connection string. +- `remote-rm `: Remove a remote configuration by ID. +- `remote-ls`: List remote configurations (`id`, `name`, `active/inactive`, redacted URI). +- `remote-export `: Export the stored connection string by remote ID. +- `remote-set `: Replace the stored connection string by remote ID. +- `remote-activate `: Activate a remote configuration by ID. - `init-settings [file]`: Create a default settings file. ### Examples @@ -252,6 +258,14 @@ livesync-cli /path/to/your-local-database --settings /path/to/settings.json rm / # Resolve conflict by keeping a specific revision livesync-cli /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef + +# Add/list/activate/remove remote configurations +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-add main "sls+https://user:pass@example.com/db" +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-ls +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-export remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-set remote-abc123 "sls+p2p://room-abc?passphrase=secret" +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-activate remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-rm remote-abc123 ``` ### Configuration diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index 9f7df02..cbcb955 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -3,6 +3,8 @@ import * as path from "path"; import { decodeSettingsFromSetupURI } from "@lib/API/processSetting"; import { configURIBase } from "@lib/common/models/shared.const"; import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@lib/common/types"; +import { ConnectionStringParser } from "@lib/common/ConnectionString"; +import { activateRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig"; import { stripAllPrefixes } from "@lib/string_and_binary/path"; import type { CLICommandContext, CLIOptions } from "./types"; import { promptForPassphrase, readStdinAsUtf8, toArrayBuffer, toDatabaseRelativePath } from "./utils"; @@ -10,6 +12,10 @@ import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./ import { performFullScan } from "@lib/serviceFeatures/offlineScanner"; import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager"; +function redactConnectionString(uri: string): string { + return uri.replace(/\/\/([^@/]+)@/u, "//***@"); +} + export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise { const { databasePath, core, settingsPath } = context; @@ -469,5 +475,206 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext return await performFullScan(core as any, log, errorManager, false, true); } + if (options.command === "remote-add") { + if (options.commandArgs.length < 2) { + throw new Error("remote-add requires two arguments: "); + } + const name = options.commandArgs[0].trim(); + const connectionString = options.commandArgs[1].trim(); + if (!name) { + throw new Error("remote-add requires a non-empty name"); + } + if (!connectionString) { + throw new Error("remote-add requires a non-empty connection string"); + } + + const parsed = ConnectionStringParser.parse(connectionString); + const canonicalUri = ConnectionStringParser.serialize(parsed); + const id = createRemoteConfigurationId(); + let activated = false; + + await core.services.setting.updateSettings((currentSettings) => { + currentSettings.remoteConfigurations ||= {}; + currentSettings.remoteConfigurations[id] = { + id, + name, + uri: canonicalUri, + isEncrypted: false, + }; + if (!currentSettings.activeConfigurationId) { + currentSettings.activeConfigurationId = id; + const applied = activateRemoteConfiguration(currentSettings, id); + activated = applied !== false; + } + return currentSettings; + }, true); + + if (activated) { + await core.services.control.applySettings(); + } + + process.stdout.write(`${id}\t${name}\t${redactConnectionString(canonicalUri)}\n`); + return true; + } + + if (options.command === "remote-rm") { + if (options.commandArgs.length < 1) { + throw new Error("remote-rm requires one argument: "); + } + const id = options.commandArgs[0].trim(); + if (!id) { + throw new Error("remote-rm requires a non-empty remote-id"); + } + + const current = core.services.setting.currentSettings(); + if (!current.remoteConfigurations?.[id]) { + process.stderr.write(`[Info] Remote configuration not found: ${id}\n`); + return false; + } + + let switchedActive = false; + await core.services.setting.updateSettings((currentSettings) => { + const configs = currentSettings.remoteConfigurations || {}; + delete configs[id]; + currentSettings.remoteConfigurations = configs; + + if (currentSettings.activeConfigurationId === id) { + const nextActiveId = Object.keys(configs)[0] || ""; + currentSettings.activeConfigurationId = nextActiveId; + switchedActive = nextActiveId !== ""; + if (nextActiveId !== "") { + activateRemoteConfiguration(currentSettings, nextActiveId); + } + } + + if (currentSettings.P2P_ActiveRemoteConfigurationId === id) { + currentSettings.P2P_ActiveRemoteConfigurationId = ""; + } + + return currentSettings; + }, true); + + if (switchedActive) { + await core.services.control.applySettings(); + } + + console.error(`[Command] remote-rm ${id}`); + return true; + } + + if (options.command === "remote-ls") { + const settings = core.services.setting.currentSettings(); + const configs = Object.values(settings.remoteConfigurations || {}); + configs.sort((a, b) => a.name.localeCompare(b.name)); + + if (configs.length === 0) { + process.stderr.write("[Info] No remote configurations found.\n"); + return true; + } + + const lines = configs.map((config) => { + const status = config.id === settings.activeConfigurationId ? "active" : "inactive"; + return `${config.id}\t${config.name}\t${status}\t${redactConnectionString(config.uri)}`; + }); + process.stdout.write(lines.join("\n") + "\n"); + return true; + } + + if (options.command === "remote-export") { + if (options.commandArgs.length < 1) { + throw new Error("remote-export requires one argument: "); + } + const id = options.commandArgs[0].trim(); + if (!id) { + throw new Error("remote-export requires a non-empty remote-id"); + } + + const config = core.services.setting.currentSettings().remoteConfigurations?.[id]; + if (!config) { + process.stderr.write(`[Info] Remote configuration not found: ${id}\n`); + return false; + } + + process.stdout.write(`${config.uri}\n`); + return true; + } + + if (options.command === "remote-set") { + if (options.commandArgs.length < 2) { + throw new Error("remote-set requires two arguments: "); + } + const id = options.commandArgs[0].trim(); + const connectionString = options.commandArgs[1].trim(); + if (!id) { + throw new Error("remote-set requires a non-empty remote-id"); + } + if (!connectionString) { + throw new Error("remote-set requires a non-empty connection string"); + } + + const parsed = ConnectionStringParser.parse(connectionString); + const canonicalUri = ConnectionStringParser.serialize(parsed); + let switchedActive = false; + + await core.services.setting.updateSettings((currentSettings) => { + const config = currentSettings.remoteConfigurations?.[id]; + if (!config) { + return currentSettings; + } + config.uri = canonicalUri; + + if (currentSettings.activeConfigurationId === id) { + const activated = activateRemoteConfiguration(currentSettings, id); + switchedActive = activated !== false; + if (activated) { + return activated; + } + } + return currentSettings; + }, true); + + const updated = core.services.setting.currentSettings().remoteConfigurations?.[id]; + if (!updated) { + process.stderr.write(`[Info] Remote configuration not found: ${id}\n`); + return false; + } + + if (switchedActive) { + await core.services.control.applySettings(); + } + + console.error(`[Command] remote-set ${id}`); + return true; + } + + if (options.command === "remote-activate") { + if (options.commandArgs.length < 1) { + throw new Error("remote-activate requires one argument: "); + } + const id = options.commandArgs[0].trim(); + if (!id) { + throw new Error("remote-activate requires a non-empty remote-id"); + } + + let switched = false; + await core.services.setting.updateSettings((currentSettings) => { + const activated = activateRemoteConfiguration(currentSettings, id); + if (activated) { + switched = true; + return activated; + } + return currentSettings; + }, true); + + if (!switched) { + process.stderr.write(`[Info] Failed to activate remote configuration: ${id}\n`); + return false; + } + + await core.services.control.applySettings(); + console.error(`[Command] remote-activate ${id}`); + return true; + } + throw new Error(`Unsupported command: ${options.command}`); } diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index 85a91b8..204394a 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -1,12 +1,19 @@ import * as processSetting from "@lib/API/processSetting"; +import { ConnectionStringParser } from "@lib/common/ConnectionString"; import { configURIBase } from "@lib/common/models/shared.const"; -import { DEFAULT_SETTINGS } from "@lib/common/types"; +import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@lib/common/types"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { runCommand } from "./runCommand"; import type { CLIOptions } from "./types"; import * as commandUtils from "./utils"; function createCoreMock() { + const liveSettings = { + ...DEFAULT_SETTINGS, + remoteConfigurations: {}, + activeConfigurationId: "", + P2P_ActiveRemoteConfigurationId: "", + } as any; return { services: { control: { @@ -16,6 +23,10 @@ function createCoreMock() { setting: { applyExternalSettings: vi.fn(async () => {}), applyPartial: vi.fn(async () => {}), + currentSettings: vi.fn(() => liveSettings), + updateSettings: vi.fn(async (updater: any) => { + updater(liveSettings); + }), }, }, serviceModules: { @@ -56,6 +67,115 @@ async function createSetupURI(passphrase: string): Promise { return await processSetting.encodeSettingsToSetupURI(settings, passphrase); } +function captureStdout() { + const writes: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => { + writes.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }); + return { + spy, + lines: () => + writes + .join("") + .split("\n") + .map((e) => e.trim()) + .filter((e) => e.length > 0), + }; +} + +function parseAddedRemoteIdFromLines(lines: string[]): string { + // remote-add prints: \t\t + const last = lines.length > 0 ? lines[lines.length - 1] : ""; + return last.split("\t")[0] || ""; +} + +type ProtocolFixture = { + protocol: string; + connectionString: string; + assertProjectedFields: (settings: any) => void; +}; + +const protocolFixtures: ProtocolFixture[] = [ + { + protocol: "couchdb", + connectionString: ConnectionStringParser.serialize({ + type: "couchdb", + settings: { + couchDB_URI: "https://db.example.com:5984", + couchDB_USER: "user1", + couchDB_PASSWORD: "pass1", + couchDB_DBNAME: "vault1", + couchDB_CustomHeaders: "", + useJWT: false, + jwtAlgorithm: "", + jwtKey: "", + jwtKid: "", + jwtSub: "", + jwtExpDuration: 5, + useRequestAPI: false, + }, + }), + assertProjectedFields: (settings) => { + expect(settings.remoteType).toBe(REMOTE_COUCHDB); + expect(settings.couchDB_URI).toBe("https://db.example.com:5984"); + expect(settings.couchDB_USER).toBe("user1"); + expect(settings.couchDB_PASSWORD).toBe("pass1"); + expect(settings.couchDB_DBNAME).toBe("vault1"); + }, + }, + { + protocol: "s3", + connectionString: ConnectionStringParser.serialize({ + type: "s3", + settings: { + accessKey: "ak", + secretKey: "sk", + endpoint: "https://s3.example.com", + bucket: "bucket-1", + region: "ap-northeast-1", + bucketPrefix: "vault/", + useCustomRequestHandler: true, + bucketCustomHeaders: "x-test:1", + forcePathStyle: false, + }, + }), + assertProjectedFields: (settings) => { + expect(settings.remoteType).toBe(REMOTE_MINIO); + expect(settings.accessKey).toBe("ak"); + expect(settings.secretKey).toBe("sk"); + expect(settings.endpoint).toBe("https://s3.example.com"); + expect(settings.bucket).toBe("bucket-1"); + expect(settings.region).toBe("ap-northeast-1"); + }, + }, + { + protocol: "p2p", + connectionString: ConnectionStringParser.serialize({ + type: "p2p", + settings: { + P2P_Enabled: false, + P2P_roomID: "room-abc", + P2P_passphrase: "pass-123", + P2P_relays: "wss://relay.example", + P2P_AppID: "self-hosted-livesync", + P2P_AutoStart: true, + P2P_AutoBroadcast: false, + P2P_turnServers: "turn:turn.example:3478", + P2P_turnUsername: "turn-user", + P2P_turnCredential: "turn-pass", + }, + }), + assertProjectedFields: (settings) => { + expect(settings.remoteType).toBe(REMOTE_P2P); + expect(settings.P2P_roomID).toBe("room-abc"); + expect(settings.P2P_passphrase).toBe("pass-123"); + expect(settings.P2P_relays).toBe("wss://relay.example"); + expect(settings.P2P_AppID).toBe("self-hosted-livesync"); + }, + }, +]; + describe("runCommand abnormal cases", () => { const context = { databasePath: "/tmp/vault", @@ -202,4 +322,254 @@ describe("runCommand abnormal cases", () => { expect(core.services.setting.applyExternalSettings).not.toHaveBeenCalled(); expect(core.services.control.applySettings).not.toHaveBeenCalled(); }); + + it("remote-add stores canonical URI and prints the created id", async () => { + const core = createCoreMock(); + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + const result = await runCommand(makeOptions("remote-add", ["my-remote", "sls+https://example.com/db"]), { + ...context, + core, + }); + + expect(result).toBe(true); + const settings = core.services.setting.currentSettings(); + const ids = Object.keys(settings.remoteConfigurations); + expect(ids.length).toBe(1); + expect(settings.remoteConfigurations[ids[0]].name).toBe("my-remote"); + expect(settings.remoteConfigurations[ids[0]].uri).toContain("sls+https://example.com/db"); + expect(settings.activeConfigurationId).toBe(ids[0]); + expect(stdout).toHaveBeenCalled(); + }); + + it("remote-activate switches active remote and applies settings", async () => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://example.com/db1", + isEncrypted: false, + }; + settings.remoteConfigurations.r2 = { + id: "r2", + name: "R2", + uri: "sls+https://example.com/db2", + isEncrypted: false, + }; + settings.activeConfigurationId = "r1"; + + const result = await runCommand(makeOptions("remote-activate", ["r2"]), { + ...context, + core, + }); + + expect(result).toBe(true); + expect(settings.activeConfigurationId).toBe("r2"); + expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); + }); + + it("remote-rm removes active remote and promotes first remaining", async () => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://example.com/db1", + isEncrypted: false, + }; + settings.remoteConfigurations.r2 = { + id: "r2", + name: "R2", + uri: "sls+https://example.com/db2", + isEncrypted: false, + }; + settings.activeConfigurationId = "r1"; + + const result = await runCommand(makeOptions("remote-rm", ["r1"]), { + ...context, + core, + }); + + expect(result).toBe(true); + expect(settings.remoteConfigurations.r1).toBeUndefined(); + expect(settings.activeConfigurationId).toBe("r2"); + expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); + }); + + it("remote-export prints the exact stored connection string", async () => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://example.com/db?db=vault", + isEncrypted: false, + }; + const stdout = captureStdout(); + + const result = await runCommand(makeOptions("remote-export", ["r1"]), { + ...context, + core, + }); + + expect(result).toBe(true); + const outLines = stdout.lines(); + expect(outLines.length > 0 ? outLines[outLines.length - 1] : "").toBe("sls+https://example.com/db?db=vault"); + expect(stdout.spy).toHaveBeenCalled(); + }); + + it("remote-set updates URI and applies settings when target is active", async () => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://old.example/db", + isEncrypted: false, + }; + settings.activeConfigurationId = "r1"; + + const result = await runCommand(makeOptions("remote-set", ["r1", "sls+https://new.example/db"]), { + ...context, + core, + }); + + expect(result).toBe(true); + expect(settings.remoteConfigurations.r1.uri).toContain("sls+https://new.example/db"); + expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); + }); + + it.each(protocolFixtures)( + "remote-activate projects effective settings for $protocol", + async ({ connectionString, assertProjectedFields }) => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://old.example/?db=old", + isEncrypted: false, + }; + settings.remoteConfigurations.r2 = { + id: "r2", + name: "R2", + uri: connectionString, + isEncrypted: false, + }; + settings.activeConfigurationId = "r1"; + + const result = await runCommand(makeOptions("remote-activate", ["r2"]), { + ...context, + core, + }); + + expect(result).toBe(true); + expect(settings.activeConfigurationId).toBe("r2"); + assertProjectedFields(settings); + } + ); + + it.each(protocolFixtures)( + "remote-set projects effective settings for active remote ($protocol)", + async ({ connectionString, assertProjectedFields }) => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://old.example/?db=old", + isEncrypted: false, + }; + settings.activeConfigurationId = "r1"; + + const result = await runCommand(makeOptions("remote-set", ["r1", connectionString]), { + ...context, + core, + }); + + expect(result).toBe(true); + assertProjectedFields(settings); + } + ); + + it.each(protocolFixtures)( + "remote-rm projects promoted active remote effective settings for $protocol", + async ({ connectionString, assertProjectedFields }) => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://old.example/?db=old", + isEncrypted: false, + }; + settings.remoteConfigurations.r2 = { + id: "r2", + name: "R2", + uri: connectionString, + isEncrypted: false, + }; + settings.activeConfigurationId = "r1"; + + const result = await runCommand(makeOptions("remote-rm", ["r1"]), { + ...context, + core, + }); + + expect(result).toBe(true); + expect(settings.activeConfigurationId).toBe("r2"); + assertProjectedFields(settings); + } + ); + + it.each([ + ["couchdb", "sls+https://user:pass@example.com:5984/?db=vault"] as const, + [ + "s3", + "sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=my-bucket®ion=ap-northeast-1", + ] as const, + [ + "p2p", + "sls+p2p://room-abc?passphrase=pass-123&relays=wss%3A%2F%2Frelay.example&appId=self-hosted-livesync", + ] as const, + ])("remote command round-trip works for %s", async (_protocol, initialConnStr) => { + const core = createCoreMock(); + + const addOut = captureStdout(); + const addResult = await runCommand(makeOptions("remote-add", ["rt", initialConnStr]), { + ...context, + core, + }); + expect(addResult).toBe(true); + const remoteId = parseAddedRemoteIdFromLines(addOut.lines()); + expect(remoteId).not.toBe(""); + + const export1Out = captureStdout(); + const export1Result = await runCommand(makeOptions("remote-export", [remoteId]), { + ...context, + core, + }); + expect(export1Result).toBe(true); + const export1Lines = export1Out.lines(); + const exported1 = export1Lines.length > 0 ? export1Lines[export1Lines.length - 1] : ""; + expect(exported1).toBe(ConnectionStringParser.serialize(ConnectionStringParser.parse(initialConnStr))); + + const roundTripInput = ConnectionStringParser.serialize(ConnectionStringParser.parse(exported1)); + const setResult = await runCommand(makeOptions("remote-set", [remoteId, roundTripInput]), { + ...context, + core, + }); + expect(setResult).toBe(true); + + const export2Out = captureStdout(); + const export2Result = await runCommand(makeOptions("remote-export", [remoteId]), { + ...context, + core, + }); + expect(export2Result).toBe(true); + const export2Lines = export2Out.lines(); + const exported2 = export2Lines.length > 0 ? export2Lines[export2Lines.length - 1] : ""; + expect(exported2).toBe(roundTripInput); + }); }); diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index d6ccee5..2f5337f 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -20,6 +20,12 @@ export type CLICommand = | "rm" | "resolve" | "mirror" + | "remote-add" + | "remote-rm" + | "remote-ls" + | "remote-export" + | "remote-set" + | "remote-activate" | "init-settings"; export interface CLIOptions { @@ -67,5 +73,11 @@ export const VALID_COMMANDS = new Set([ "rm", "resolve", "mirror", + "remote-add", + "remote-rm", + "remote-ls", + "remote-export", + "remote-set", + "remote-activate", "init-settings", ] as const); diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index b75dd34..1bd1ea5 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -62,6 +62,16 @@ Commands: rm Mark a file as deleted in local database resolve Resolve conflicts by keeping and deleting others mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path) + remote-add + Add a remote configuration from a connection string + remote-rm Remove a remote configuration by ID + remote-ls List stored remote configurations + remote-export + Export a remote connection string by ID + remote-set + Replace a stored remote connection string by ID + remote-activate + Activate a stored remote configuration by ID Options: --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed @@ -84,6 +94,12 @@ Examples: livesync-cli ./my-database info notes/hello.md livesync-cli ./my-database rm notes/hello.md livesync-cli ./my-database resolve notes/hello.md 3-abcdef + livesync-cli ./my-database remote-add my-remote "sls+https://user:pass@example.com/db" + livesync-cli ./my-database remote-ls + livesync-cli ./my-database remote-export remote-abc123 + livesync-cli ./my-database remote-set remote-abc123 "sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=mybucket" + livesync-cli ./my-database remote-activate remote-abc123 + livesync-cli ./my-database remote-rm remote-abc123 livesync-cli init-settings ./data.json livesync-cli ./my-database --verbose `); @@ -229,6 +245,9 @@ export async function main() { options.command === "cat" || options.command === "cat-rev" || options.command === "ls" || + options.command === "remote-add" || + options.command === "remote-ls" || + options.command === "remote-export" || options.command === "p2p-peers" || options.command === "info" || options.command === "rm" || diff --git a/src/apps/cli/main.unit.spec.ts b/src/apps/cli/main.unit.spec.ts index 2b70a44..63c1633 100644 --- a/src/apps/cli/main.unit.spec.ts +++ b/src/apps/cli/main.unit.spec.ts @@ -86,6 +86,56 @@ describe("CLI parseArgs", () => { expect(parsed.commandArgs).toEqual([]); }); + it("parses remote-add command", () => { + process.argv = [ + "node", + "livesync-cli", + "./databasePath", + "remote-add", + "my-remote", + "sls+https://user:pass@example.com/db", + ]; + const parsed = parseArgs(); + + expect(parsed.databasePath).toBe("./databasePath"); + expect(parsed.command).toBe("remote-add"); + expect(parsed.commandArgs).toEqual(["my-remote", "sls+https://user:pass@example.com/db"]); + }); + + it("parses remote-activate command", () => { + process.argv = ["node", "livesync-cli", "./databasePath", "remote-activate", "remote-abc"]; + const parsed = parseArgs(); + + expect(parsed.databasePath).toBe("./databasePath"); + expect(parsed.command).toBe("remote-activate"); + expect(parsed.commandArgs).toEqual(["remote-abc"]); + }); + + it("parses remote-export command", () => { + process.argv = ["node", "livesync-cli", "./databasePath", "remote-export", "remote-abc"]; + const parsed = parseArgs(); + + expect(parsed.databasePath).toBe("./databasePath"); + expect(parsed.command).toBe("remote-export"); + expect(parsed.commandArgs).toEqual(["remote-abc"]); + }); + + it("parses remote-set command", () => { + process.argv = [ + "node", + "livesync-cli", + "./databasePath", + "remote-set", + "remote-abc", + "sls+p2p://room-1?passphrase=abc", + ]; + const parsed = parseArgs(); + + expect(parsed.databasePath).toBe("./databasePath"); + expect(parsed.command).toBe("remote-set"); + expect(parsed.commandArgs).toEqual(["remote-abc", "sls+p2p://room-1?passphrase=abc"]); + }); + it("parses --interval flag with valid integer", () => { process.argv = ["node", "livesync-cli", "./vault", "--interval", "30"]; const parsed = parseArgs();