mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-03 01:07:07 +00:00
Merge pull request #1150 from vrtmrz/fix/1142-cli-systemd-installer
Fix systemd CLI installation and start-up checks
This commit is contained in:
+44
-32
@@ -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`).
|
||||
|
||||
@@ -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" <<WRAPPER
|
||||
#!/usr/bin/env bash
|
||||
exec node "$LIVESYNC_JS" "\$@"
|
||||
WRAPPER
|
||||
chmod +x "$LIVESYNC_BIN"
|
||||
echo "[INFO] Installed bundle: $LIVESYNC_JS"
|
||||
echo "[INFO] Installed CLI files: $LIB_DIR"
|
||||
echo "[INFO] Installed binary: $LIVESYNC_BIN"
|
||||
|
||||
# ── Write systemd unit ───────────────────────────────────────────────────────
|
||||
@@ -180,6 +220,15 @@ systemctl $SYSTEMCTL_FLAGS daemon-reload
|
||||
# shellcheck disable=SC2086
|
||||
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
|
||||
|
||||
sleep 1
|
||||
# shellcheck disable=SC2086
|
||||
if ! systemctl $SYSTEMCTL_FLAGS is-active --quiet livesync-cli; then
|
||||
echo "Error: livesync-cli service did not remain active after startup." >&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 ""
|
||||
|
||||
@@ -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<void> {
|
||||
await writeFile(path, `${content}\n`, "utf8");
|
||||
await chmod(path, 0o755);
|
||||
}
|
||||
|
||||
async function createInstallerFixture(serviceActive: boolean): Promise<InstallerFixture> {
|
||||
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]");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user