diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index a4815f6b..8761bb08 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -48,7 +48,7 @@ CLI Main - Settings management (JSON file) - Graceful shutdown handling -## Usage +## Command overview The CLI operates on a **database directory** which contains PouchDB data and settings. @@ -151,6 +151,46 @@ npm run cli -- [database-path] [command] [args...] node src/apps/cli/dist/index.cjs [database-path] [command] [args...] ``` +### systemd installation + +The `deploy/` directory contains a systemd unit template and an install script. + +**Automated installation (user service, recommended):** + +```bash +bash src/apps/cli/deploy/install.sh --vault /path/to/vault +``` + +**With a polling interval:** + +```bash +bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60 +``` + +**System-wide installation** (requires root or `sudo` for `/etc/systemd/system/`): + +```bash +bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault +``` + +The script: + +1. Installs the repository dependencies and builds the CLI. +2. Installs the complete CLI bundle and its production dependencies under `~/.local/lib/livesync-cli` (user) or `/usr/local/lib/livesync-cli` (system), then checks that the installed CLI can start. +3. Installs the command wrapper as `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system). +4. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system). +5. Reloads systemd, enables and starts the service, and reports success only after confirming that the service remains active. + +Ensure that `~/.local/bin` for a user installation, or `/usr/local/bin` for a system-wide installation, is on the shell's `PATH` before invoking `livesync-cli` interactively. For example, add the following to the appropriate shell start-up file for a user installation when needed: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +The generated systemd unit uses the wrapper's absolute path and does not depend on the shell's `PATH`. + +**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and Vault path, then install it in the appropriate systemd directory. + ### Docker A Docker image is provided for headless / server deployments. Build from the repository root: @@ -210,7 +250,9 @@ candidate carries the host's public IP and peers can connect normally. ### Adding `livesync-cli` alias -To use the `livesync-cli` command globally, you can add an alias to your shell configuration file (e.g., `.zshrc` or `.bashrc`). +If you used the [systemd installer](#systemd-installation), no alias is required: it installs the `livesync-cli` wrapper in `~/.local/bin` or `/usr/local/bin`. If the installed command is not found, follow the `PATH` guidance in the systemd installation section. + +The aliases below are only for running the CLI from a source checkout, or from Docker without using the installer. Add the appropriate alias to your shell configuration file, such as `.zshrc` or `.bashrc`. If you are using `npm run`, add the following line: @@ -506,36 +548,6 @@ Patterns apply in both directions: the chokidar watcher will not emit events for Changes to this file require a daemon restart to take effect. -### Systemd Installation - -The `deploy/` directory contains a systemd unit template and an install script. - -**Automated install (user service, recommended):** - -```bash -bash src/apps/cli/deploy/install.sh --vault /path/to/vault -``` - -**With polling interval:** - -```bash -bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60 -``` - -**System-wide install** (requires root / sudo for `/etc/systemd/system/`): - -```bash -bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault -``` - -The script: -1. Builds the CLI (`npm install` + `npm run build`). -2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system). -3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system). -4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`. - -**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory. - ### Planned options: - `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`). diff --git a/src/apps/cli/deploy/install.sh b/src/apps/cli/deploy/install.sh index d0d3a2e9..88feff40 100755 --- a/src/apps/cli/deploy/install.sh +++ b/src/apps/cli/deploy/install.sh @@ -8,7 +8,7 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)" CLI_DIR="$REPO_ROOT/src/apps/cli" SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service" @@ -104,30 +104,70 @@ fi # ── Install binary ─────────────────────────────────────────────────────────── if [[ "$INSTALL_MODE" == "user" ]]; then BIN_DIR="$HOME/.local/bin" + LIB_DIR="$HOME/.local/lib/livesync-cli" UNIT_DIR="$HOME/.config/systemd/user" SYSTEMCTL_FLAGS="--user" else BIN_DIR="/usr/local/bin" + LIB_DIR="/usr/local/lib/livesync-cli" UNIT_DIR="/etc/systemd/system" SYSTEMCTL_FLAGS="" fi -mkdir -p "$BIN_DIR" +LIB_PARENT="$(dirname -- "$LIB_DIR")" +mkdir -p "$BIN_DIR" "$LIB_PARENT" LIVESYNC_BIN="$BIN_DIR/livesync-cli" -LIVESYNC_JS="$BIN_DIR/livesync-cli.js" +LIVESYNC_JS="$LIB_DIR/dist/index.cjs" -# Copy the CJS bundle so the wrapper is self-contained and independent of the -# build directory location. -cp "$BUILT_CJS" "$LIVESYNC_JS" +# Build a complete runtime payload before replacing any previous installation. +# The Vite output contains hashed sibling chunks, while some Node dependencies +# deliberately remain external and must be installed next to the bundle. +PAYLOAD_STAGING="$(mktemp -d "$LIB_PARENT/.livesync-cli.install.XXXXXX")" +cleanup_payload() { + if [[ -n "$PAYLOAD_STAGING" ]] && [[ -e "$PAYLOAD_STAGING" ]]; then + rm -rf -- "$PAYLOAD_STAGING" + fi +} +trap cleanup_payload EXIT -# Write a bash wrapper that invokes node on the installed bundle. +cp "$CLI_DIR/package.json" "$PAYLOAD_STAGING/package.json" +npm install --omit=dev --no-audit --no-fund --prefix "$PAYLOAD_STAGING" +cp -R "$CLI_DIR/dist" "$PAYLOAD_STAGING/dist" + +if ! node "$PAYLOAD_STAGING/dist/index.cjs" --help >/dev/null; then + echo "Error: installed CLI failed its start-up check" >&2 + exit 1 +fi + +PAYLOAD_BACKUP="" +if [[ -e "$LIB_DIR" ]] || [[ -L "$LIB_DIR" ]]; then + PAYLOAD_BACKUP="$(mktemp -d "$LIB_PARENT/.livesync-cli.backup.XXXXXX")" + rmdir "$PAYLOAD_BACKUP" + mv -- "$LIB_DIR" "$PAYLOAD_BACKUP" +fi + +if ! mv -- "$PAYLOAD_STAGING" "$LIB_DIR"; then + if [[ -n "$PAYLOAD_BACKUP" ]]; then + mv -- "$PAYLOAD_BACKUP" "$LIB_DIR" + fi + echo "Error: failed to install the CLI files at $LIB_DIR" >&2 + exit 1 +fi +PAYLOAD_STAGING="" + +if [[ -n "$PAYLOAD_BACKUP" ]]; then + rm -rf -- "$PAYLOAD_BACKUP" +fi +trap - EXIT + +# Write a bash wrapper that invokes Node.js on the installed payload. cat > "$LIVESYNC_BIN" <&2 + # shellcheck disable=SC2086 + systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true + exit 1 +fi + echo "" echo "[Done] livesync-cli service installed and started." echo "" diff --git a/src/apps/cli/deploy/install.unit.spec.ts b/src/apps/cli/deploy/install.unit.spec.ts new file mode 100644 index 00000000..3de815fc --- /dev/null +++ b/src/apps/cli/deploy/install.unit.spec.ts @@ -0,0 +1,192 @@ +import { spawnSync } from "node:child_process"; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const deploySourceDirectory = dirname(fileURLToPath(import.meta.url)); +const temporaryDirectories: string[] = []; + +type InstallerFixture = { + cliDirectory: string; + environment: NodeJS.ProcessEnv; + homeDirectory: string; + installerPath: string; + npmCallLog: string; + repositoryRoot: string; + systemctlCallLog: string; + vaultDirectory: string; +}; + +async function writeExecutable(path: string, content: string): Promise { + await writeFile(path, `${content}\n`, "utf8"); + await chmod(path, 0o755); +} + +async function createInstallerFixture(serviceActive: boolean): Promise { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "livesync-cli-installer-")); + temporaryDirectories.push(temporaryDirectory); + + const repositoryRoot = join(temporaryDirectory, "repository"); + const cliDirectory = join(repositoryRoot, "src", "apps", "cli"); + const deployDirectory = join(cliDirectory, "deploy"); + const distDirectory = join(cliDirectory, "dist"); + const fakeBinDirectory = join(temporaryDirectory, "fake-bin"); + const homeDirectory = join(temporaryDirectory, "home"); + const vaultDirectory = join(temporaryDirectory, "vault"); + const npmCallLog = join(temporaryDirectory, "npm-calls.log"); + const systemctlCallLog = join(temporaryDirectory, "systemctl-calls.log"); + + await Promise.all([ + mkdir(deployDirectory, { recursive: true }), + mkdir(distDirectory, { recursive: true }), + mkdir(fakeBinDirectory, { recursive: true }), + mkdir(homeDirectory, { recursive: true }), + mkdir(vaultDirectory, { recursive: true }), + ]); + + await Promise.all([ + copyFile(join(deploySourceDirectory, "install.sh"), join(deployDirectory, "install.sh")), + copyFile(join(deploySourceDirectory, "livesync-cli.service"), join(deployDirectory, "livesync-cli.service")), + writeFile( + join(repositoryRoot, "package.json"), + JSON.stringify({ private: true, workspaces: ["src/apps/*"] }), + "utf8" + ), + writeFile( + join(cliDirectory, "package.json"), + JSON.stringify({ + name: "self-hosted-livesync-cli", + private: true, + version: "0.0.0", + dependencies: { "fixture-runtime-dependency": "1.0.0" }, + }), + "utf8" + ), + writeFile( + join(distDirectory, "index.cjs"), + 'const chunk = require("./chunk.cjs");\n' + + 'const dependency = require("fixture-runtime-dependency");\n' + + "process.stdout.write(`${chunk}:${dependency}\\n`);\n", + "utf8" + ), + writeFile(join(distDirectory, "chunk.cjs"), 'module.exports = "chunk-ready";\n', "utf8"), + ]); + + await writeExecutable( + join(fakeBinDirectory, "npm"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'printf \'%s|%s\\n\' "$PWD" "$*" >> "$NPM_CALL_LOG"', + 'prefix=""', + "expect_prefix=0", + 'for argument in "$@"; do', + ' if [[ "$expect_prefix" -eq 1 ]]; then', + ' prefix="$argument"', + " expect_prefix=0", + ' elif [[ "$argument" == "--prefix" ]]; then', + " expect_prefix=1", + " fi", + "done", + 'if [[ -n "$prefix" ]]; then', + ' mkdir -p "$prefix/node_modules/fixture-runtime-dependency"', + " printf '%s\\n' 'module.exports = \"dependency-ready\";' > \"$prefix/node_modules/fixture-runtime-dependency/index.js\"", + "fi", + ].join("\n") + ); + await writeExecutable( + join(fakeBinDirectory, "systemctl"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'printf \'%s\\n\' "$*" >> "$SYSTEMCTL_CALL_LOG"', + 'if [[ " $* " == *" is-active "* ]]; then', + ' [[ "${FAKE_SYSTEMCTL_ACTIVE:-1}" == "1" ]]', + " exit", + "fi", + 'if [[ " $* " == *" status "* ]]; then', + " printf '%s\\n' \"fixture service status\"", + "fi", + ].join("\n") + ); + await writeExecutable(join(fakeBinDirectory, "sleep"), ["#!/usr/bin/env bash", "exit 0"].join("\n")); + + return { + cliDirectory, + environment: { + ...process.env, + FAKE_SYSTEMCTL_ACTIVE: serviceActive ? "1" : "0", + HOME: homeDirectory, + NPM_CALL_LOG: npmCallLog, + PATH: `${fakeBinDirectory}${delimiter}${process.env.PATH ?? ""}`, + SYSTEMCTL_CALL_LOG: systemctlCallLog, + }, + homeDirectory, + installerPath: join(deployDirectory, "install.sh"), + npmCallLog, + repositoryRoot, + systemctlCallLog, + vaultDirectory, + }; +} + +function runInstaller(fixture: InstallerFixture) { + return spawnSync("bash", [fixture.installerPath, "--vault", fixture.vaultDirectory], { + encoding: "utf8", + env: fixture.environment, + }); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) + ); +}); + +describe.skipIf(process.platform === "win32")("CLI systemd installer", () => { + it("installs a runnable CLI independently of the source repository", async () => { + const fixture = await createInstallerFixture(true); + + const installation = runInstaller(fixture); + + expect(installation.error).toBeUndefined(); + expect(installation.status, installation.stderr).toBe(0); + expect(installation.stdout).toContain("[Done] livesync-cli service installed and started."); + + const installedCommand = join(fixture.homeDirectory, ".local", "bin", "livesync-cli"); + const installedPayload = join(fixture.homeDirectory, ".local", "lib", "livesync-cli", "dist", "index.cjs"); + const installedUnit = join(fixture.homeDirectory, ".config", "systemd", "user", "livesync-cli.service"); + expect(await readFile(installedPayload, "utf8")).toContain('require("./chunk.cjs")'); + expect(await readFile(installedUnit, "utf8")).toContain("Type=exec"); + + await rm(fixture.repositoryRoot, { recursive: true }); + + const command = spawnSync(installedCommand, [], { encoding: "utf8", env: fixture.environment }); + expect(command.error).toBeUndefined(); + expect(command.status, command.stderr).toBe(0); + expect(command.stdout).toBe("chunk-ready:dependency-ready\n"); + + const npmCalls = await readFile(fixture.npmCallLog, "utf8"); + expect(npmCalls).toContain(`${fixture.repositoryRoot}|install --silent`); + expect(npmCalls).toContain(`${fixture.cliDirectory}|run build`); + expect(npmCalls).toMatch(/install .*--omit=dev|install --omit=dev/); + + const systemctlCalls = await readFile(fixture.systemctlCallLog, "utf8"); + expect(systemctlCalls).toContain("--user enable --now livesync-cli"); + expect(systemctlCalls).toContain("--user is-active --quiet livesync-cli"); + }); + + it("does not report success when the service fails to remain active", async () => { + const fixture = await createInstallerFixture(false); + + const installation = runInstaller(fixture); + const combinedOutput = `${installation.stdout}\n${installation.stderr}`; + + expect(installation.error).toBeUndefined(); + expect(installation.status).not.toBe(0); + expect(combinedOutput).toContain("service did not remain active after startup"); + expect(combinedOutput).not.toContain("[Done]"); + }); +}); diff --git a/src/apps/cli/deploy/livesync-cli.service b/src/apps/cli/deploy/livesync-cli.service index b76e786b..ca8f19f9 100644 --- a/src/apps/cli/deploy/livesync-cli.service +++ b/src/apps/cli/deploy/livesync-cli.service @@ -4,7 +4,7 @@ After=network-online.target Wants=network-online.target [Service] -Type=simple +Type=exec ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH Restart=on-failure RestartSec=10 diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index ee9bf01b..fb7270d7 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -12,7 +12,7 @@ "buildRun": "npm run build && npm run cli --", "build:docker": "docker build -f Dockerfile -t livesync-cli ../../..", "check": "tsc -p tsconfig.json", - "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/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: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 src/apps/cli/deploy/install.unit.spec.ts", "test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh", "test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh", "test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh", diff --git a/updates.md b/updates.md index fe31aa6c..b2f7e710 100644 --- a/updates.md +++ b/updates.md @@ -18,6 +18,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi - **Overwrite Server Data with This Device's Files** now keeps this device's synchronisation settings instead of reapplying settings from the remote database which is about to be replaced. Enabling E2EE before a rebuild therefore remains enabled and uploads encrypted data. (#1146) +### Command-line tool + +#### Fixed + +- The systemd installer now finds the repository root correctly, installs every generated bundle chunk and required production dependency, checks the installed command before activation, and reports success only when the service remains active. + ## 1.0.23 2nd September, 2026