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
97 changes: 97 additions & 0 deletions src/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({
Expand All @@ -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);
Expand All @@ -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' },
Expand Down Expand Up @@ -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<typeof createHubClient>);

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<typeof createHubClient>);

await program.parseAsync(['node', 'agentage', 'setup', '--disconnect', '--no-autostart']);

expect(mockUninstallAutostart).not.toHaveBeenCalled();
});
});
});

describe('buildAuthUrl', () => {
Expand Down
50 changes: 47 additions & 3 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -49,6 +54,7 @@ export interface SetupOptions {
vault?: string | boolean;
vaultSlug?: string;
vaultsDir?: string;
autostart?: boolean;
}

type SetupMode = 'fresh' | 'reauth' | 'disconnect' | 'standalone' | 'idempotent';
Expand Down Expand Up @@ -235,10 +241,24 @@ const doDisconnect = async (opts: SetupOptions): Promise<void> => {

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.'));
}
Expand Down Expand Up @@ -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(
Expand All @@ -402,6 +423,7 @@ const printSummary = (
fileCount: v.fileCount,
})),
},
autostart,
},
null,
2
Expand All @@ -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);
Expand Down Expand Up @@ -522,7 +548,21 @@ export const runSetup = async (opts: SetupOptions): Promise<void> => {

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);
};

Expand Down Expand Up @@ -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);
Expand Down
107 changes: 107 additions & 0 deletions src/daemon/autostart/darwin.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
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(`<string>${LABEL}</string>`);
expect(plist).toContain('<string>/usr/bin/node</string>');
expect(plist).toContain('<string>/x/daemon-entry.js</string>');
expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/);
expect(plist).toMatch(/<key>KeepAlive<\/key>\s*<true\/>/);
});

it('escapes XML special characters in paths', () => {
const plist = renderPlist('/usr/bin/node', '/x & y/daemon.js', '/h');
expect(plist).toContain('/x &amp; 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);
});
});
});
Loading