From 26482e4ccd6836a9f7cbc3a0057020f4e5893f0f Mon Sep 17 00:00:00 2001 From: Andy Pai Date: Wed, 8 Apr 2026 15:20:32 -0400 Subject: [PATCH] feat: add systemd service management commands for Linux Add `wildcard systemd install/uninstall/restart` commands mirroring the existing launchd equivalents, enabling Linux users to install wildcard as a systemd user service. - Pure `generateSystemdUnit()` function producing unit file with Type=exec, Restart=on-failure, file-based logging - `systemdUnitDir()` respects $XDG_CONFIG_HOME with ~/.config fallback - Idempotent install: stop+disable before write+reload+enable - Clean uninstall: stop, disable, remove, daemon-reload, PID cleanup - Platform guard on all three commands (Linux only) - Unit file permissions set to 0o644 - Path quoting for spaces in ExecStart - 8 new tests covering generator output and XDG path resolution --- .agents/LEARNINGS.md | 3 + README.md | 15 +++ src/commands/index.ts | 1 + src/commands/systemd.test.ts | 85 +++++++++++++++++ src/commands/systemd.ts | 174 +++++++++++++++++++++++++++++++++++ src/commands/types.ts | 1 + src/wildcard.ts | 17 ++++ 7 files changed, 296 insertions(+) create mode 100644 src/commands/systemd.test.ts create mode 100644 src/commands/systemd.ts diff --git a/.agents/LEARNINGS.md b/.agents/LEARNINGS.md index 86d32e5..e39afc9 100644 --- a/.agents/LEARNINGS.md +++ b/.agents/LEARNINGS.md @@ -70,6 +70,9 @@ - When renaming CLI commands, update `README.md`, `SKILL.md`, `docs/`, relevant `tutorials/`, and any command-shape tests in the same change; hidden compatibility aliases do not keep docs/tests correct on their own. - In `cmdReport`, only auto-run enrichment when the report has an AI provider and enrichment is explicitly configured; otherwise heuristic/disabled-AI reports do unnecessary Codex work without affecting output. - For device-id backfills that also need log counts, prefer SQLite `.run(...).changes` from the `UPDATE` statements over separate pre-count queries; it preserves behavior and avoids extra table scans. +- For systemd user unit file paths, use `$XDG_CONFIG_HOME/systemd/user/` when set, falling back to `path.join(os.homedir(), '.config/systemd/user/')` — never hardcode `~/.config`. +- When adding a new platform service manager (systemd alongside launchd), mirror the existing file structure exactly: separate `.ts` handler, constant in `types.ts`, exports in `index.ts`, Commander subcommand group in `wildcard.ts`. +- For systemd unit files written by CLI install commands, set 0o644 permissions explicitly via `fs.chmodSync` — systemd silently rejects units with wrong permissions. ## Patterns That Don't Work diff --git a/README.md b/README.md index 2379f13..c37eea6 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,11 @@ wildcard launchd install --roots ~/code wildcard launchd uninstall wildcard launchd restart +# Linux: install/uninstall systemd user service +wildcard systemd install --roots ~/code +wildcard systemd uninstall +wildcard systemd restart + # Prune old local data wildcard prune --older-than 30 ``` @@ -119,6 +124,16 @@ Remote devices should set: `wildcard launchd uninstall` `wildcard launchd install --roots ~/code` +### Update systemd service (Linux) + +- Reload the running service after code/config updates: + `wildcard systemd restart` +- Rebuild service definition (for changed roots): + `wildcard systemd install --roots ~/code` +- Full clean reinstall: + `wildcard systemd uninstall` + `wildcard systemd install --roots ~/code` + Event types: - `edit`: emitted by `wildcard watch` (filesystem activity) diff --git a/src/commands/index.ts b/src/commands/index.ts index f8df1a3..1c2f1a0 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,6 +2,7 @@ export { cmdWatch } from './watch' export { cmdStatus } from './status' export { cmdShow, cmdReport, cmdSearch, cmdLog } from './activity' export { cmdInstallLaunchd, cmdUninstallLaunchd, cmdRestart } from './launchd' +export { cmdInstallSystemd, cmdUninstallSystemd, cmdRestartSystemd } from './systemd' export { cmdPrune, cmdArchive } from './retention' export { cmdSetup } from './setup' export { cmdSyncPush } from './cmd-sync' diff --git a/src/commands/systemd.test.ts b/src/commands/systemd.test.ts new file mode 100644 index 0000000..78e008c --- /dev/null +++ b/src/commands/systemd.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import os from 'node:os' +import path from 'node:path' + +import { generateSystemdUnit, systemdUnitDir } from './systemd' + +describe('systemdUnitDir', () => { + const originalXdg = process.env.XDG_CONFIG_HOME + + afterEach(() => { + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + }) + + it('uses XDG_CONFIG_HOME when set', () => { + process.env.XDG_CONFIG_HOME = '/tmp/test-xdg' + expect(systemdUnitDir()).toBe('/tmp/test-xdg/systemd/user') + }) + + it('falls back to ~/.config when XDG_CONFIG_HOME is unset', () => { + delete process.env.XDG_CONFIG_HOME + expect(systemdUnitDir()).toBe(path.join(os.homedir(), '.config/systemd/user')) + }) +}) + +describe('generateSystemdUnit', () => { + const baseOpts = { + bunPath: '/usr/local/bin/bun', + scriptPath: '/home/user/wildcard/src/wildcard.ts', + watchRoots: ['/home/user/projects', '/home/user/notes'], + storeDir: '/home/user/.local/share/wildcard', + stdoutPath: '/home/user/.local/share/wildcard/systemd.out.log', + stderrPath: '/home/user/.local/share/wildcard/systemd.err.log', + } + + it('produces a valid unit file with required directives', () => { + const unit = generateSystemdUnit(baseOpts) + + expect(unit).toContain('[Unit]') + expect(unit).toContain('[Service]') + expect(unit).toContain('[Install]') + expect(unit).toContain('Type=exec') + expect(unit).toContain('Restart=on-failure') + expect(unit).toContain('RestartSec=5') + expect(unit).toContain('WantedBy=default.target') + }) + + it('sets ExecStart with bun path, script, and watch roots', () => { + const unit = generateSystemdUnit(baseOpts) + expect(unit).toContain( + 'ExecStart=/usr/local/bin/bun /home/user/wildcard/src/wildcard.ts watch --roots /home/user/projects,/home/user/notes', + ) + }) + + it('sets Environment with WILDCARD_STORE_DIR', () => { + const unit = generateSystemdUnit(baseOpts) + expect(unit).toContain('Environment=WILDCARD_STORE_DIR=/home/user/.local/share/wildcard') + }) + + it('sets file-based logging paths', () => { + const unit = generateSystemdUnit(baseOpts) + expect(unit).toContain('StandardOutput=append:/home/user/.local/share/wildcard/systemd.out.log') + expect(unit).toContain('StandardError=append:/home/user/.local/share/wildcard/systemd.err.log') + }) + + it('handles a single watch root', () => { + const unit = generateSystemdUnit({ ...baseOpts, watchRoots: ['/home/user/code'] }) + expect(unit).toContain('watch --roots /home/user/code') + }) + + it('quotes paths containing spaces', () => { + const unit = generateSystemdUnit({ + ...baseOpts, + bunPath: '/home/user/my apps/bun', + scriptPath: '/home/user/my project/src/wildcard.ts', + watchRoots: ['/home/user/my code'], + }) + expect(unit).toContain('"/home/user/my apps/bun"') + expect(unit).toContain('"/home/user/my project/src/wildcard.ts"') + expect(unit).toContain('"/home/user/my code"') + }) +}) diff --git a/src/commands/systemd.ts b/src/commands/systemd.ts new file mode 100644 index 0000000..9544496 --- /dev/null +++ b/src/commands/systemd.ts @@ -0,0 +1,174 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawn } from 'node:child_process' + +import { ConfigStore } from '../config/index' +import { storePaths } from '../utils/paths' +import { ensureDir } from '../utils/process' + +import { normalizeRoots } from './cli-helpers' +import { SYSTEMD_UNIT_NAME } from './types' + +export function systemdUnitDir(): string { + return process.env.XDG_CONFIG_HOME + ? path.join(process.env.XDG_CONFIG_HOME, 'systemd/user') + : path.join(os.homedir(), '.config/systemd/user') +} + +export function generateSystemdUnit(opts: { + bunPath: string + scriptPath: string + watchRoots: string[] + storeDir: string + stdoutPath: string + stderrPath: string +}): string { + const quote = (s: string) => (s.includes(' ') ? `"${s}"` : s) + const execStart = [ + quote(opts.bunPath), + quote(opts.scriptPath), + 'watch', + '--roots', + quote(opts.watchRoots.join(',')), + ].join(' ') + + return `[Unit] +Description=Wildcard file activity watcher + +[Service] +Type=exec +ExecStart=${execStart} +Restart=on-failure +RestartSec=5 +Environment=WILDCARD_STORE_DIR=${opts.storeDir} +StandardOutput=append:${opts.stdoutPath} +StandardError=append:${opts.stderrPath} + +[Install] +WantedBy=default.target +` +} + +async function runSystemctl(args: string[]): Promise { + const child = spawn('systemctl', args, { stdio: 'inherit' }) + return await new Promise((resolve) => child.on('close', resolve)) +} + +export async function cmdInstallSystemd( + opts: { roots?: string[] }, + cliScriptPath: string, +): Promise { + if (process.platform !== 'linux') { + console.log('wildcard systemd install is only supported on Linux') + return + } + + const cfg = await new ConfigStore().load() + const roots = normalizeRoots(opts.roots, cfg.watch.roots) + if (!roots.length) throw new Error('No watch roots. Set watch.roots or pass --roots') + + const paths = storePaths(cfg) + ensureDir(paths.store_dir) + + const unitDir = systemdUnitDir() + ensureDir(unitDir) + + const unitPath = path.join(unitDir, `${SYSTEMD_UNIT_NAME}.service`) + const bun = process.execPath + const stdoutPath = path.join(paths.store_dir, 'systemd.out.log') + const stderrPath = path.join(paths.store_dir, 'systemd.err.log') + + const unit = generateSystemdUnit({ + bunPath: bun, + scriptPath: cliScriptPath, + watchRoots: roots, + storeDir: paths.store_dir, + stdoutPath, + stderrPath, + }) + + // Idempotent: stop and disable before writing new unit + try { + await runSystemctl(['--user', 'stop', `${SYSTEMD_UNIT_NAME}.service`]) + } catch {} + try { + await runSystemctl(['--user', 'disable', `${SYSTEMD_UNIT_NAME}.service`]) + } catch {} + + await Bun.write(unitPath, unit) + fs.chmodSync(unitPath, 0o644) + console.log(`Wrote ${unitPath}`) + + await runSystemctl(['--user', 'daemon-reload']) + + const enableCode = await runSystemctl([ + '--user', + 'enable', + '--now', + `${SYSTEMD_UNIT_NAME}.service`, + ]) + + if (enableCode === 0) { + console.log(`Service ${SYSTEMD_UNIT_NAME} enabled and started`) + } else { + console.log('Enable it manually with:') + console.log(` systemctl --user enable --now ${SYSTEMD_UNIT_NAME}.service`) + } +} + +export async function cmdUninstallSystemd(): Promise { + if (process.platform !== 'linux') { + console.log('wildcard systemd uninstall is only supported on Linux') + return + } + + const serviceName = `${SYSTEMD_UNIT_NAME}.service` + + try { + const code = await runSystemctl(['--user', 'stop', serviceName]) + if (code === 0) { + console.log(`Service ${SYSTEMD_UNIT_NAME} stopped`) + } + } catch {} + + try { + await runSystemctl(['--user', 'disable', serviceName]) + } catch {} + + const unitPath = path.join(systemdUnitDir(), serviceName) + try { + fs.unlinkSync(unitPath) + console.log(`Removed ${unitPath}`) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`Failed to remove unit file: ${err}`) + } + } + + await runSystemctl(['--user', 'daemon-reload']) + + const cfg = await new ConfigStore().load() + const paths = storePaths(cfg) + try { + fs.unlinkSync(paths.pid_path) + console.log(`Removed ${paths.pid_path}`) + } catch {} + + console.log('wildcard uninstalled') +} + +export async function cmdRestartSystemd(): Promise { + if (process.platform !== 'linux') { + console.log('wildcard systemd restart is only supported on Linux') + return + } + + const code = await runSystemctl(['--user', 'restart', `${SYSTEMD_UNIT_NAME}.service`]) + + if (code === 0) { + console.log('wildcard restarted') + } else { + console.error('Failed to restart. Is the service installed? Run: wildcard systemd install') + } +} diff --git a/src/commands/types.ts b/src/commands/types.ts index d5f0a87..0992698 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -1,4 +1,5 @@ export const LAUNCHD_LABEL = 'com.wildcard.wildcard' +export const SYSTEMD_UNIT_NAME = 'wildcard' export type Period = 'today' | 'yesterday' | 'week' diff --git a/src/wildcard.ts b/src/wildcard.ts index b444dcb..2fa3700 100755 --- a/src/wildcard.ts +++ b/src/wildcard.ts @@ -7,10 +7,12 @@ import { Command, CommanderError, Option } from 'commander' import { cmdArchive, cmdInstallLaunchd, + cmdInstallSystemd, cmdLog, cmdPrune, cmdReport, cmdRestart, + cmdRestartSystemd, cmdSearch, cmdServerDoctor, cmdServerRestart, @@ -23,6 +25,7 @@ import { cmdSyncPush, cmdEnrich, cmdUninstallLaunchd, + cmdUninstallSystemd, cmdWatch, } from './commands/index' import type { @@ -124,6 +127,20 @@ function buildProgram(): Command { launchd.command('restart').description('Restart launchd service').action(cmdRestart) + const systemd = program.command('systemd').description('Manage systemd user service (Linux)') + systemd + .command('install') + .description('Install systemd user service') + .option('--roots ', 'Watch roots (comma/space separated)') + .action(async (opts: { roots?: string[] }) => cmdInstallSystemd(opts, THIS_SCRIPT)) + + systemd + .command('uninstall') + .description('Uninstall systemd user service') + .action(cmdUninstallSystemd) + + systemd.command('restart').description('Restart systemd user service').action(cmdRestartSystemd) + program .command('install-launchd', { hidden: true }) .description('Install launchd service (deprecated: use `wildcard launchd install`)')