From 4b47ebbd4dd01b7d770791a1a189d75aa5a8155d Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 30 Aug 2026 09:38:34 +0000 Subject: [PATCH] fix(cli): install complete systemd runtime --- src/apps/cli/README.md | 10 +- src/apps/cli/deploy/install.sh | 65 +++++++- src/apps/cli/deploy/install.unit.spec.ts | 192 +++++++++++++++++++++++ src/apps/cli/deploy/livesync-cli.service | 2 +- src/apps/cli/package.json | 2 +- updates.md | 6 + 6 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 src/apps/cli/deploy/install.unit.spec.ts diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 09e9bb9b..6109ea44 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -522,10 +522,12 @@ 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`. + +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. **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. 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 dc59969f..c0dc38e8 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 ffdf139e..37f9f9f0 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### 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.21 26th August, 2026