Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .agents/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<platform>.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

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
85 changes: 85 additions & 0 deletions src/commands/systemd.test.ts
Original file line number Diff line number Diff line change
@@ -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"')
})
})
174 changes: 174 additions & 0 deletions src/commands/systemd.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
const child = spawn('systemctl', args, { stdio: 'inherit' })
return await new Promise<number>((resolve) => child.on('close', resolve))
}

export async function cmdInstallSystemd(
opts: { roots?: string[] },
cliScriptPath: string,
): Promise<void> {
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<void> {
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<void> {
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')
}
}
1 change: 1 addition & 0 deletions src/commands/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const LAUNCHD_LABEL = 'com.wildcard.wildcard'
export const SYSTEMD_UNIT_NAME = 'wildcard'

export type Period = 'today' | 'yesterday' | 'week'

Expand Down
17 changes: 17 additions & 0 deletions src/wildcard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { Command, CommanderError, Option } from 'commander'
import {
cmdArchive,
cmdInstallLaunchd,
cmdInstallSystemd,
cmdLog,
cmdPrune,
cmdReport,
cmdRestart,
cmdRestartSystemd,
cmdSearch,
cmdServerDoctor,
cmdServerRestart,
Expand All @@ -23,6 +25,7 @@ import {
cmdSyncPush,
cmdEnrich,
cmdUninstallLaunchd,
cmdUninstallSystemd,
cmdWatch,
} from './commands/index'
import type {
Expand Down Expand Up @@ -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 <paths...>', '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`)')
Expand Down
Loading