From 7891aaa53d566b2b9f1cb8185ae69ebff54912cd Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Wed, 6 May 2026 23:47:42 +0200 Subject: [PATCH] feat(setup): wire daemon autostart on boot/login per platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux: ~/.config/systemd/user/agentage.service + loginctl enable-linger → starts at boot, no sudo, restart-on-failure. macOS: ~/Library/LaunchAgents/io.agentage.daemon.plist + launchctl bootstrap → starts at user login (LaunchDaemon would need root and break ~/.agentage paths). Windows: schtasks /Create /SC ONLOGON /TN AgentageDaemon → starts at user logon, no admin (a Windows Service would need admin and run as LocalSystem). Default-on during fresh setup; --no-autostart opts out. setup --disconnect is symmetric: removes the unit/plist/task unless --no-autostart is also passed. Each platform module is dependency-injected (homeDir, nodeBin, entryPath, exec) so unit tests cover rendering + exec wiring on every platform without spawning real systemctl/launchctl/schtasks. --- src/commands/setup.test.ts | 97 ++++++++++++++++++++++++ src/commands/setup.ts | 50 ++++++++++++- src/daemon/autostart/darwin.test.ts | 107 +++++++++++++++++++++++++++ src/daemon/autostart/darwin.ts | 69 +++++++++++++++++ src/daemon/autostart/index.test.ts | 24 ++++++ src/daemon/autostart/index.ts | 58 +++++++++++++++ src/daemon/autostart/linux.test.ts | 110 ++++++++++++++++++++++++++++ src/daemon/autostart/linux.ts | 65 ++++++++++++++++ src/daemon/autostart/types.ts | 24 ++++++ src/daemon/autostart/win32.test.ts | 76 +++++++++++++++++++ src/daemon/autostart/win32.ts | 36 +++++++++ 11 files changed, 713 insertions(+), 3 deletions(-) create mode 100644 src/daemon/autostart/darwin.test.ts create mode 100644 src/daemon/autostart/darwin.ts create mode 100644 src/daemon/autostart/index.test.ts create mode 100644 src/daemon/autostart/index.ts create mode 100644 src/daemon/autostart/linux.test.ts create mode 100644 src/daemon/autostart/linux.ts create mode 100644 src/daemon/autostart/types.ts create mode 100644 src/daemon/autostart/win32.test.ts create mode 100644 src/daemon/autostart/win32.ts diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts index d94a74d..019b209 100644 --- a/src/commands/setup.test.ts +++ b/src/commands/setup.test.ts @@ -43,6 +43,11 @@ vi.mock('../utils/action-client.js', () => ({ invokeAction: vi.fn(), })); +vi.mock('../daemon/autostart/index.js', () => ({ + installAutostart: vi.fn(), + uninstallAutostart: vi.fn(), +})); + const mockQuestion = vi.fn(); vi.mock('node:readline/promises', () => ({ createInterface: () => ({ @@ -56,6 +61,7 @@ import { readAuth, saveAuth, deleteAuth } from '../hub/auth.js'; import { startCallbackServer, getCallbackPort } from '../hub/auth-callback.js'; import { createHubClient } from '../hub/hub-client.js'; import { invokeAction } from '../utils/action-client.js'; +import { installAutostart, uninstallAutostart } from '../daemon/autostart/index.js'; import { registerSetup } from './setup.js'; const mockLoadConfig = vi.mocked(loadConfig); @@ -68,6 +74,8 @@ const mockStartCallback = vi.mocked(startCallbackServer); const mockGetCallbackPort = vi.mocked(getCallbackPort); const mockCreateHubClient = vi.mocked(createHubClient); const mockInvokeAction = vi.mocked(invokeAction); +const mockInstallAutostart = vi.mocked(installAutostart); +const mockUninstallAutostart = vi.mocked(uninstallAutostart); const baseConfig = () => ({ machine: { id: 'machine-existing-1', name: 'test-host' }, @@ -693,6 +701,95 @@ describe('setup command', () => { errSpy.mockRestore(); }); }); + + describe('autostart wiring', () => { + it('installs autostart on fresh setup with --token (headless)', async () => { + setTty(true); + mockInstallAutostart.mockReturnValue({ + mechanism: 'systemd-user', + unitPath: '/home/u/.config/systemd/user/agentage.service', + startsAtBoot: true, + }); + + await program.parseAsync(['node', 'agentage', 'setup', '--token', 'tok']); + + expect(mockInstallAutostart).toHaveBeenCalledTimes(1); + expect(logs.some((l) => l.includes('Autostart:') && l.includes('starts at boot'))).toBe(true); + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it('skips autostart when --no-autostart is passed', async () => { + setTty(true); + await program.parseAsync(['node', 'agentage', 'setup', '--token', 'tok', '--no-autostart']); + + expect(mockInstallAutostart).not.toHaveBeenCalled(); + }); + + it('skips autostart on standalone (--no-login)', async () => { + setTty(true); + await program.parseAsync(['node', 'agentage', 'setup', '--no-login']); + + expect(mockInstallAutostart).not.toHaveBeenCalled(); + }); + + it('warns and continues when autostart install throws', async () => { + setTty(true); + mockInstallAutostart.mockImplementation(() => { + throw new Error('systemctl missing'); + }); + + await program.parseAsync(['node', 'agentage', 'setup', '--token', 'tok']); + + expect(errorLogs.some((l) => l.includes('Autostart wiring skipped'))).toBe(true); + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it('renders "starts at login" when startsAtBoot is false', async () => { + setTty(true); + mockInstallAutostart.mockReturnValue({ + mechanism: 'launchd-agent', + unitPath: '/Users/u/Library/LaunchAgents/io.agentage.daemon.plist', + startsAtBoot: false, + }); + + await program.parseAsync(['node', 'agentage', 'setup', '--token', 'tok']); + + expect(logs.some((l) => l.includes('Autostart:') && l.includes('starts at login'))).toBe( + true + ); + }); + + it('uninstalls autostart on --disconnect', async () => { + mockReadAuth.mockReturnValue({ + session: { access_token: 't', refresh_token: 'r', expires_at: 9999 }, + user: { id: 'u1', email: 'e@x.io' }, + hub: { url: 'https://hub.x', machineId: 'machine-existing-1' }, + }); + mockCreateHubClient.mockReturnValue({ + deregister: vi.fn().mockResolvedValue(undefined), + } as unknown as ReturnType); + + await program.parseAsync(['node', 'agentage', 'setup', '--disconnect']); + + expect(mockUninstallAutostart).toHaveBeenCalledTimes(1); + expect(logs.some((l) => l.includes('Autostart unit removed'))).toBe(true); + }); + + it('skips autostart removal on --disconnect --no-autostart', async () => { + mockReadAuth.mockReturnValue({ + session: { access_token: 't', refresh_token: 'r', expires_at: 9999 }, + user: { id: 'u1', email: 'e@x.io' }, + hub: { url: 'https://hub.x', machineId: 'machine-existing-1' }, + }); + mockCreateHubClient.mockReturnValue({ + deregister: vi.fn().mockResolvedValue(undefined), + } as unknown as ReturnType); + + await program.parseAsync(['node', 'agentage', 'setup', '--disconnect', '--no-autostart']); + + expect(mockUninstallAutostart).not.toHaveBeenCalled(); + }); + }); }); describe('buildAuthUrl', () => { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index e84b767..66b0b87 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -18,6 +18,11 @@ import { import type { VaultAddOutput } from '../daemon/actions/vault-add.js'; import { ensureDaemon } from '../utils/ensure-daemon.js'; import { invokeAction } from '../utils/action-client.js'; +import { + installAutostart, + uninstallAutostart, + type AutostartResult, +} from '../daemon/autostart/index.js'; import { readAuth, saveAuth, deleteAuth, type AuthState } from '../hub/auth.js'; import { startCallbackServer, getCallbackPort } from '../hub/auth-callback.js'; import { createHubClient } from '../hub/hub-client.js'; @@ -49,6 +54,7 @@ export interface SetupOptions { vault?: string | boolean; vaultSlug?: string; vaultsDir?: string; + autostart?: boolean; } type SetupMode = 'fresh' | 'reauth' | 'disconnect' | 'standalone' | 'idempotent'; @@ -235,10 +241,24 @@ const doDisconnect = async (opts: SetupOptions): Promise => { deleteAuth(); + let autostartRemoved = false; + if (opts.autostart !== false) { + try { + uninstallAutostart(); + autostartRemoved = true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!opts.json) { + console.error(chalk.dim(`Autostart removal skipped: ${msg}`)); + } + } + } + if (opts.json) { - console.log(JSON.stringify({ ok: true, mode: 'disconnect' }, null, 2)); + console.log(JSON.stringify({ ok: true, mode: 'disconnect', autostartRemoved }, null, 2)); } else { console.log(chalk.green('Disconnected from hub. Machine deregistered.')); + if (autostartRemoved) console.log(chalk.dim('Autostart unit removed.')); console.log(chalk.dim('Daemon continues running in standalone mode.')); console.log(chalk.dim('Run `agentage daemon restart` to apply.')); } @@ -377,7 +397,8 @@ const printSummary = ( userEmail: string | null, opts: SetupOptions, mcp: TargetResult[] | null, - vaultStep: VaultStepResult + vaultStep: VaultStepResult, + autostart: AutostartResult | null ): void => { if (opts.json) { console.log( @@ -402,6 +423,7 @@ const printSummary = ( fileCount: v.fileCount, })), }, + autostart, }, null, 2 @@ -423,6 +445,10 @@ const printSummary = ( .join(', '); console.log(` Vaults: ${summary}`); } + if (autostart) { + const trigger = autostart.startsAtBoot ? 'starts at boot' : 'starts at login'; + console.log(` Autostart: ${autostart.mechanism} ${chalk.dim(`(${trigger})`)}`); + } if (mcp && mcp.length > 0) { console.log(chalk.bold('\nMCP clients:')); printMcpResults(mcp); @@ -522,7 +548,21 @@ export const runSetup = async (opts: SetupOptions): Promise => { const vaultStep = await runVaultStep(opts, mode); - printSummary(config, mode, userEmail, opts, mcp, vaultStep); + // Install autostart so the daemon comes back up automatically on reboot + // (Linux: systemd --user + linger → boot; macOS/Windows → user login). + // Only on fresh setup; reauth/standalone leave existing wiring alone. + let autostart: AutostartResult | null = null; + if (mode === 'fresh' && opts.autostart !== false) { + try { + autostart = installAutostart(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.yellow(`\nAutostart wiring skipped: ${msg}`)); + console.error(chalk.dim('Daemon will not start automatically on reboot.')); + } + } + + printSummary(config, mode, userEmail, opts, mcp, vaultStep, autostart); process.exit(0); }; @@ -553,6 +593,10 @@ export const registerSetup = (program: Command): void => { 'Override the slug for the --vault explicit add (basename otherwise).' ) .option('--no-vault', 'Skip the vault step entirely (no auto-scan, no explicit add).') + .option( + '--no-autostart', + 'Skip wiring the daemon to start on boot/login (systemd-user / launchd / Task Scheduler).' + ) .option('--json', 'JSON output') .action(async (opts: SetupOptions) => { await runSetup(opts); diff --git a/src/daemon/autostart/darwin.test.ts b/src/daemon/autostart/darwin.test.ts new file mode 100644 index 0000000..db6cc0e --- /dev/null +++ b/src/daemon/autostart/darwin.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { darwin, LABEL, plistPath, renderPlist } from './darwin.js'; +import type { AutostartDeps } from './types.js'; + +describe('darwin autostart', () => { + let homeDir: string; + let exec: string[]; + let execShouldFail: Set; + let deps: AutostartDeps; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'agentage-darwin-autostart-')); + exec = []; + execShouldFail = new Set(); + deps = { + homeDir, + nodeBin: '/usr/local/bin/node', + entryPath: '/opt/agentage/dist/daemon-entry.js', + exec: (cmd: string): void => { + exec.push(cmd); + if (execShouldFail.has(cmd)) throw new Error(`exec failed: ${cmd}`); + }, + }; + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + describe('renderPlist', () => { + it('produces a plist with Label, ProgramArguments, RunAtLoad, KeepAlive', () => { + const plist = renderPlist('/usr/bin/node', '/x/daemon-entry.js', '/Users/me'); + expect(plist).toContain(`${LABEL}`); + expect(plist).toContain('/usr/bin/node'); + expect(plist).toContain('/x/daemon-entry.js'); + expect(plist).toMatch(/RunAtLoad<\/key>\s*/); + expect(plist).toMatch(/KeepAlive<\/key>\s*/); + }); + + it('escapes XML special characters in paths', () => { + const plist = renderPlist('/usr/bin/node', '/x & y/daemon.js', '/h'); + expect(plist).toContain('/x & y/daemon.js'); + }); + }); + + describe('install', () => { + it('writes plist and bootstraps the agent', () => { + // First call to bootout fails (not loaded) — that's expected & swallowed. + execShouldFail.add(`launchctl bootout gui/$(id -u)/${LABEL}`); + + const result = darwin.install(deps); + + const path = plistPath(homeDir); + expect(existsSync(path)).toBe(true); + const written = readFileSync(path, 'utf-8'); + expect(written).toContain('/usr/local/bin/node'); + expect(written).toContain('/opt/agentage/dist/daemon-entry.js'); + + expect(exec).toEqual([ + `launchctl bootout gui/$(id -u)/${LABEL}`, + `launchctl bootstrap gui/$(id -u) "${path}"`, + ]); + + expect(result.mechanism).toBe('launchd-agent'); + expect(result.unitPath).toBe(path); + expect(result.startsAtBoot).toBe(false); + }); + + it('creates LaunchAgents and logs dirs', () => { + execShouldFail.add(`launchctl bootout gui/$(id -u)/${LABEL}`); + darwin.install(deps); + expect(existsSync(join(homeDir, 'Library', 'LaunchAgents'))).toBe(true); + expect(existsSync(join(homeDir, '.agentage', 'logs'))).toBe(true); + }); + }); + + describe('uninstall', () => { + it('boots out and removes the plist', () => { + execShouldFail.add(`launchctl bootout gui/$(id -u)/${LABEL}`); + darwin.install(deps); + exec.length = 0; + execShouldFail.clear(); + + darwin.uninstall(deps); + + expect(existsSync(plistPath(homeDir))).toBe(false); + expect(exec).toEqual([`launchctl bootout gui/$(id -u)/${LABEL}`]); + }); + + it('is idempotent when nothing installed', () => { + execShouldFail.add(`launchctl bootout gui/$(id -u)/${LABEL}`); + expect(() => darwin.uninstall(deps)).not.toThrow(); + }); + }); + + describe('isInstalled', () => { + it('reflects plist presence', () => { + expect(darwin.isInstalled(deps)).toBe(false); + execShouldFail.add(`launchctl bootout gui/$(id -u)/${LABEL}`); + darwin.install(deps); + expect(darwin.isInstalled(deps)).toBe(true); + }); + }); +}); diff --git a/src/daemon/autostart/darwin.ts b/src/daemon/autostart/darwin.ts new file mode 100644 index 0000000..ccdf86d --- /dev/null +++ b/src/daemon/autostart/darwin.ts @@ -0,0 +1,69 @@ +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AutostartDeps, AutostartResult, PlatformModule } from './types.js'; + +export const LABEL = 'io.agentage.daemon'; + +export const plistDir = (homeDir: string): string => join(homeDir, 'Library', 'LaunchAgents'); + +export const plistPath = (homeDir: string): string => join(plistDir(homeDir), `${LABEL}.plist`); + +const xmlEscape = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +export const renderPlist = (nodeBin: string, entryPath: string, homeDir: string): string => + ` + + + + Label + ${LABEL} + ProgramArguments + + ${xmlEscape(nodeBin)} + ${xmlEscape(entryPath)} + + RunAtLoad + + KeepAlive + + StandardOutPath + ${xmlEscape(join(homeDir, '.agentage', 'logs', 'launchd.out.log'))} + StandardErrorPath + ${xmlEscape(join(homeDir, '.agentage', 'logs', 'launchd.err.log'))} + + +`; + +export const darwin: PlatformModule = { + install(deps: AutostartDeps): AutostartResult { + const path = plistPath(deps.homeDir); + mkdirSync(plistDir(deps.homeDir), { recursive: true }); + mkdirSync(join(deps.homeDir, '.agentage', 'logs'), { recursive: true }); + writeFileSync(path, renderPlist(deps.nodeBin, deps.entryPath, deps.homeDir), 'utf-8'); + + // launchctl bootstrap fails if the agent is already loaded; bootout first if so. + try { + deps.exec(`launchctl bootout gui/$(id -u)/${LABEL}`); + } catch { + // Not loaded — fine. + } + deps.exec(`launchctl bootstrap gui/$(id -u) "${path}"`); + + return { mechanism: 'launchd-agent', unitPath: path, startsAtBoot: false }; + }, + + uninstall(deps: AutostartDeps): void { + try { + deps.exec(`launchctl bootout gui/$(id -u)/${LABEL}`); + } catch { + // Already gone. + } + const path = plistPath(deps.homeDir); + if (existsSync(path)) unlinkSync(path); + }, + + isInstalled(deps: AutostartDeps): boolean { + return existsSync(plistPath(deps.homeDir)); + }, +}; diff --git a/src/daemon/autostart/index.test.ts b/src/daemon/autostart/index.test.ts new file mode 100644 index 0000000..3e80e1d --- /dev/null +++ b/src/daemon/autostart/index.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { platformModule } from './index.js'; +import { darwin } from './darwin.js'; +import { linux } from './linux.js'; +import { win32 } from './win32.js'; + +describe('platformModule', () => { + it('returns the linux module on linux', () => { + expect(platformModule('linux')).toBe(linux); + }); + + it('returns the darwin module on darwin', () => { + expect(platformModule('darwin')).toBe(darwin); + }); + + it('returns the win32 module on win32', () => { + expect(platformModule('win32')).toBe(win32); + }); + + it('returns null on unsupported platforms', () => { + expect(platformModule('aix')).toBeNull(); + expect(platformModule('freebsd')).toBeNull(); + }); +}); diff --git a/src/daemon/autostart/index.ts b/src/daemon/autostart/index.ts new file mode 100644 index 0000000..4ebdb53 --- /dev/null +++ b/src/daemon/autostart/index.ts @@ -0,0 +1,58 @@ +import { execSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { darwin } from './darwin.js'; +import { linux } from './linux.js'; +import { win32 } from './win32.js'; +import type { AutostartDeps, AutostartResult, PlatformModule } from './types.js'; + +export type { AutostartDeps, AutostartResult, PlatformModule } from './types.js'; + +const realExec = (cmd: string): void => { + execSync(cmd, { stdio: 'pipe' }); +}; + +const resolveDaemonEntry = (): string => { + // From dist/daemon/autostart/index.js → dist/daemon-entry.js + const here = dirname(fileURLToPath(import.meta.url)); + return join(here, '..', '..', 'daemon-entry.js'); +}; + +export const platformModule = ( + platform: NodeJS.Platform = process.platform +): PlatformModule | null => { + switch (platform) { + case 'linux': + return linux; + case 'darwin': + return darwin; + case 'win32': + return win32; + default: + return null; + } +}; + +const realDeps = (): AutostartDeps => ({ + homeDir: homedir(), + nodeBin: process.execPath, + entryPath: resolveDaemonEntry(), + exec: realExec, +}); + +export const installAutostart = (deps: AutostartDeps = realDeps()): AutostartResult | null => { + const mod = platformModule(); + if (!mod) return null; + return mod.install(deps); +}; + +export const uninstallAutostart = (deps: AutostartDeps = realDeps()): void => { + const mod = platformModule(); + if (mod) mod.uninstall(deps); +}; + +export const isAutostartInstalled = (deps: AutostartDeps = realDeps()): boolean => { + const mod = platformModule(); + return mod ? mod.isInstalled(deps) : false; +}; diff --git a/src/daemon/autostart/linux.test.ts b/src/daemon/autostart/linux.test.ts new file mode 100644 index 0000000..1624696 --- /dev/null +++ b/src/daemon/autostart/linux.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { linux, renderUnit, unitPath, UNIT_NAME } from './linux.js'; +import type { AutostartDeps } from './types.js'; + +describe('linux autostart', () => { + let homeDir: string; + let exec: string[]; + let execShouldFail: Set; + let deps: AutostartDeps; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'agentage-linux-autostart-')); + exec = []; + execShouldFail = new Set(); + deps = { + homeDir, + nodeBin: '/usr/local/bin/node', + entryPath: '/opt/agentage/dist/daemon-entry.js', + exec: (cmd: string): void => { + exec.push(cmd); + if (execShouldFail.has(cmd)) { + throw new Error(`exec failed: ${cmd}`); + } + }, + }; + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + describe('renderUnit', () => { + it('produces a valid systemd unit with ExecStart and WantedBy', () => { + const unit = renderUnit('/usr/bin/node', '/opt/agentage/daemon-entry.js'); + expect(unit).toContain('Description=Agentage daemon'); + expect(unit).toContain('Type=simple'); + expect(unit).toContain('ExecStart=/usr/bin/node /opt/agentage/daemon-entry.js'); + expect(unit).toContain('Restart=on-failure'); + expect(unit).toContain('WantedBy=default.target'); + }); + }); + + describe('install', () => { + it('writes unit file, runs systemctl reload + enable, enables linger', () => { + const result = linux.install(deps); + + expect(existsSync(unitPath(homeDir))).toBe(true); + const written = readFileSync(unitPath(homeDir), 'utf-8'); + expect(written).toContain('ExecStart=/usr/local/bin/node /opt/agentage/dist/daemon-entry.js'); + + expect(exec).toEqual([ + 'systemctl --user daemon-reload', + `systemctl --user enable --now ${UNIT_NAME}`, + 'loginctl enable-linger', + ]); + + expect(result.mechanism).toBe('systemd-user'); + expect(result.unitPath).toBe(unitPath(homeDir)); + expect(result.startsAtBoot).toBe(true); + }); + + it('falls back to startsAtBoot=false when linger fails', () => { + execShouldFail.add('loginctl enable-linger'); + + const result = linux.install(deps); + + expect(result.startsAtBoot).toBe(false); + }); + + it('creates the systemd user dir if missing', () => { + const result = linux.install(deps); + const expected = join(homeDir, '.config', 'systemd', 'user'); + expect(existsSync(expected)).toBe(true); + expect(result.unitPath.startsWith(expected)).toBe(true); + }); + }); + + describe('uninstall', () => { + it('disables, removes the unit file, reloads', () => { + // Pre-create unit + mkdirSync(join(homeDir, '.config', 'systemd', 'user'), { recursive: true }); + writeFileSync(unitPath(homeDir), 'placeholder', 'utf-8'); + + linux.uninstall(deps); + + expect(existsSync(unitPath(homeDir))).toBe(false); + expect(exec).toEqual([ + `systemctl --user disable --now ${UNIT_NAME}`, + 'systemctl --user daemon-reload', + ]); + }); + + it('is idempotent when nothing is installed', () => { + execShouldFail.add(`systemctl --user disable --now ${UNIT_NAME}`); + expect(() => linux.uninstall(deps)).not.toThrow(); + }); + }); + + describe('isInstalled', () => { + it('reflects the presence of the unit file', () => { + expect(linux.isInstalled(deps)).toBe(false); + mkdirSync(join(homeDir, '.config', 'systemd', 'user'), { recursive: true }); + writeFileSync(unitPath(homeDir), 'x', 'utf-8'); + expect(linux.isInstalled(deps)).toBe(true); + }); + }); +}); diff --git a/src/daemon/autostart/linux.ts b/src/daemon/autostart/linux.ts new file mode 100644 index 0000000..72a7165 --- /dev/null +++ b/src/daemon/autostart/linux.ts @@ -0,0 +1,65 @@ +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AutostartDeps, AutostartResult, PlatformModule } from './types.js'; + +export const UNIT_NAME = 'agentage.service'; + +export const unitDir = (homeDir: string): string => join(homeDir, '.config', 'systemd', 'user'); + +export const unitPath = (homeDir: string): string => join(unitDir(homeDir), UNIT_NAME); + +export const renderUnit = (nodeBin: string, entryPath: string): string => + `[Unit] +Description=Agentage daemon +After=default.target + +[Service] +Type=simple +ExecStart=${nodeBin} ${entryPath} +Restart=on-failure +RestartSec=5 +Environment=NODE_ENV=production + +[Install] +WantedBy=default.target +`; + +export const linux: PlatformModule = { + install(deps: AutostartDeps): AutostartResult { + const path = unitPath(deps.homeDir); + mkdirSync(unitDir(deps.homeDir), { recursive: true }); + writeFileSync(path, renderUnit(deps.nodeBin, deps.entryPath), 'utf-8'); + + deps.exec('systemctl --user daemon-reload'); + deps.exec(`systemctl --user enable --now ${UNIT_NAME}`); + + let startsAtBoot = false; + try { + deps.exec('loginctl enable-linger'); + startsAtBoot = true; + } catch { + // Polkit may refuse; daemon will start at first login instead. + } + + return { mechanism: 'systemd-user', unitPath: path, startsAtBoot }; + }, + + uninstall(deps: AutostartDeps): void { + try { + deps.exec(`systemctl --user disable --now ${UNIT_NAME}`); + } catch { + // Unit may already be gone. + } + const path = unitPath(deps.homeDir); + if (existsSync(path)) unlinkSync(path); + try { + deps.exec('systemctl --user daemon-reload'); + } catch { + // Best-effort. + } + }, + + isInstalled(deps: AutostartDeps): boolean { + return existsSync(unitPath(deps.homeDir)); + }, +}; diff --git a/src/daemon/autostart/types.ts b/src/daemon/autostart/types.ts new file mode 100644 index 0000000..487d8a8 --- /dev/null +++ b/src/daemon/autostart/types.ts @@ -0,0 +1,24 @@ +export interface AutostartDeps { + homeDir: string; + nodeBin: string; + entryPath: string; + exec: (cmd: string) => void; +} + +export interface AutostartResult { + /** systemd-user | launchd-agent | schtasks-onlogon */ + mechanism: 'systemd-user' | 'launchd-agent' | 'schtasks-onlogon'; + /** Path to the unit/plist/task — what to point the user at if they want to inspect */ + unitPath: string; + /** + * True only on Linux with linger enabled — daemon comes up before login. + * macOS LaunchAgent and Windows ONLOGON fire at user login, not boot. + */ + startsAtBoot: boolean; +} + +export interface PlatformModule { + install(deps: AutostartDeps): AutostartResult; + uninstall(deps: AutostartDeps): void; + isInstalled(deps: AutostartDeps): boolean; +} diff --git a/src/daemon/autostart/win32.test.ts b/src/daemon/autostart/win32.test.ts new file mode 100644 index 0000000..1417671 --- /dev/null +++ b/src/daemon/autostart/win32.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { buildCreateCommand, TASK_NAME, win32 } from './win32.js'; +import type { AutostartDeps } from './types.js'; + +describe('win32 autostart', () => { + let exec: string[]; + let execShouldFail: Set; + let deps: AutostartDeps; + + beforeEach(() => { + exec = []; + execShouldFail = new Set(); + deps = { + homeDir: 'C:\\Users\\me', + nodeBin: 'C:\\Program Files\\nodejs\\node.exe', + entryPath: + 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@agentage\\cli\\dist\\daemon-entry.js', + exec: (cmd: string): void => { + exec.push(cmd); + if (execShouldFail.has(cmd)) throw new Error(`exec failed: ${cmd}`); + }, + }; + }); + + describe('buildCreateCommand', () => { + it('escapes inner quotes for cmd.exe and uses ONLOGON trigger', () => { + const cmd = buildCreateCommand('C:\\node.exe', 'C:\\daemon.js'); + expect(cmd).toContain('schtasks /Create'); + expect(cmd).toContain(`/TN "${TASK_NAME}"`); + expect(cmd).toContain('/SC ONLOGON'); + expect(cmd).toContain('/RL LIMITED'); + expect(cmd).toContain('/F'); + // The /TR string contains escaped inner quotes around each path + expect(cmd).toContain('/TR "\\"C:\\node.exe\\" \\"C:\\daemon.js\\""'); + }); + }); + + describe('install', () => { + it('runs schtasks /Create with ONLOGON trigger', () => { + const result = win32.install(deps); + + expect(exec).toHaveLength(1); + expect(exec[0]).toContain('schtasks /Create'); + expect(exec[0]).toContain('/SC ONLOGON'); + expect(exec[0]).toContain(TASK_NAME); + expect(exec[0]).toContain('node.exe'); + + expect(result.mechanism).toBe('schtasks-onlogon'); + expect(result.unitPath).toBe(TASK_NAME); + expect(result.startsAtBoot).toBe(false); + }); + }); + + describe('uninstall', () => { + it('deletes the scheduled task', () => { + win32.uninstall(deps); + expect(exec).toEqual([`schtasks /Delete /TN "${TASK_NAME}" /F`]); + }); + + it('is idempotent when delete fails', () => { + execShouldFail.add(`schtasks /Delete /TN "${TASK_NAME}" /F`); + expect(() => win32.uninstall(deps)).not.toThrow(); + }); + }); + + describe('isInstalled', () => { + it('returns true when schtasks /Query succeeds', () => { + expect(win32.isInstalled(deps)).toBe(true); + }); + + it('returns false when schtasks /Query throws', () => { + execShouldFail.add(`schtasks /Query /TN "${TASK_NAME}"`); + expect(win32.isInstalled(deps)).toBe(false); + }); + }); +}); diff --git a/src/daemon/autostart/win32.ts b/src/daemon/autostart/win32.ts new file mode 100644 index 0000000..be95248 --- /dev/null +++ b/src/daemon/autostart/win32.ts @@ -0,0 +1,36 @@ +import type { AutostartDeps, AutostartResult, PlatformModule } from './types.js'; + +export const TASK_NAME = 'AgentageDaemon'; + +const buildCreateCommand = (nodeBin: string, entryPath: string): string => { + // /TR receives a single string. Inner double-quotes around each path + // (so spaces work) must be escaped with backslash for cmd.exe. + const tr = `\\"${nodeBin}\\" \\"${entryPath}\\"`; + return `schtasks /Create /TN "${TASK_NAME}" /SC ONLOGON /RL LIMITED /TR "${tr}" /F`; +}; + +export const win32: PlatformModule = { + install(deps: AutostartDeps): AutostartResult { + deps.exec(buildCreateCommand(deps.nodeBin, deps.entryPath)); + return { mechanism: 'schtasks-onlogon', unitPath: TASK_NAME, startsAtBoot: false }; + }, + + uninstall(deps: AutostartDeps): void { + try { + deps.exec(`schtasks /Delete /TN "${TASK_NAME}" /F`); + } catch { + // Task may already be gone. + } + }, + + isInstalled(deps: AutostartDeps): boolean { + try { + deps.exec(`schtasks /Query /TN "${TASK_NAME}"`); + return true; + } catch { + return false; + } + }, +}; + +export { buildCreateCommand };