From 9f0f01f6faed334b4642d115b4e0000c93e61851 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Wed, 22 Apr 2026 01:14:01 +0200 Subject: [PATCH] feat(setup)!: replace init/login/logout with unified agentage setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single command replaces `agentage init`, `agentage login`, and `agentage logout`. Interactive when invoked from a TTY (one confirmation prompt) and fully headless with `--yes`, `--token`, `--machine-id`, `--reauth`, or `--disconnect` flags. Cloud-init / scripted installs target: agentage setup --machine-id $ID --token $TK --hub $URL --yes --no-interactive The daemon's existing `resolveMachine` precedence (machine.json first) means `--machine-id` writes the file before the daemon boots and is adopted automatically — no daemon code change needed. BREAKING CHANGE: `agentage init`, `agentage login`, and `agentage logout` are removed. Commander now responds with `unknown command` for any straggler invocation. Replace with the corresponding `agentage setup [--reauth|--disconnect]` form. --- CHANGELOG.md | 6 + README.md | 11 +- src/cli.ts | 8 +- src/commands/agents.ts | 2 +- src/commands/init.test.ts | 105 --------- src/commands/init.ts | 60 ----- src/commands/login.test.ts | 171 -------------- src/commands/login.ts | 103 -------- src/commands/logout.test.ts | 84 ------- src/commands/logout.ts | 34 --- src/commands/machines.ts | 2 +- src/commands/run.ts | 2 +- src/commands/runs.ts | 2 +- src/commands/setup.test.ts | 452 ++++++++++++++++++++++++++++++++++++ src/commands/setup.ts | 387 ++++++++++++++++++++++++++++++ 15 files changed, 856 insertions(+), 573 deletions(-) delete mode 100644 src/commands/init.test.ts delete mode 100644 src/commands/init.ts delete mode 100644 src/commands/login.test.ts delete mode 100644 src/commands/login.ts delete mode 100644 src/commands/logout.test.ts delete mode 100644 src/commands/logout.ts create mode 100644 src/commands/setup.test.ts create mode 100644 src/commands/setup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8857b6f..3245862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [Unreleased] + +### BREAKING + +- Replace `agentage init`, `agentage login`, `agentage logout` with unified `agentage setup` command. Interactive by default (one confirmation prompt), fully headless with `--yes` / `--token` / `--machine-id` flags. `--disconnect` replaces `logout`; `--reauth` re-runs OAuth. Callers invoking the old commands will receive `unknown command` from Commander. + ## [0.19.0] - 2026-04-19 ### New Features diff --git a/README.md b/README.md index cc88b9b..6b27e6e 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Two built-in factories: **markdown** (`.agent.md` files with YAML frontmatter) a ### Hub Sync -When authenticated (`agentage login`), the daemon connects to the hub via WebSocket — registering the machine, syncing agents, and relaying run events. +When authenticated (`agentage setup`), the daemon connects to the hub via WebSocket — registering the machine, syncing agents, and relaying run events. | Method | Endpoint | Description | | ------ | ------------------- | ------------------------------------- | @@ -141,7 +141,7 @@ The daemon is designed to keep its hub connection alive across transient network **Auth handling.** Token refresh is attempted both proactively (before each heartbeat, if within 5 min of expiry) and reactively (on any hub API 401 response). Refresh uses the Supabase refresh token fetched from `/api/health`. A failed refresh surfaces as a warning but does not kill the daemon — the next reconnect cycle will retry. -**Offline mode.** If no auth is present at startup, the daemon runs in standalone mode: local agent execution still works via the REST API and local WebSocket (`/ws`), but no hub sync is attempted. Running `agentage login` after the fact requires a daemon restart to pick up the new auth. +**Offline mode.** If no auth is present at startup, the daemon runs in standalone mode: local agent execution still works via the REST API and local WebSocket (`/ws`), but no hub sync is attempted. Running `agentage setup` after the fact requires a daemon restart to pick up the new auth. ## CLI Commands @@ -154,8 +154,8 @@ The daemon is designed to keep its hub connection alive across transient network | `agentage machines` | List hub-connected machines | | `agentage status` | Show daemon and hub status | | `agentage logs` | View daemon logs | -| `agentage login` | Authenticate with the hub | -| `agentage logout` | Log out | +| `agentage setup` | Configure machine + hub + auth (interactive or headless) | +| `agentage setup --disconnect` | Deregister and remove credentials | ## Project Structure @@ -171,8 +171,7 @@ src/ │ ├── machines.ts # agentage machines │ ├── status.ts # agentage status │ ├── logs.ts # agentage logs -│ ├── login.ts # agentage login -│ └── logout.ts # agentage logout +│ └── setup.ts # agentage setup ├── daemon/ # Daemon server │ ├── server.ts # Express + HTTP server setup │ ├── routes.ts # REST API routes diff --git a/src/cli.ts b/src/cli.ts index 1b8180f..85d1b50 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,14 +7,12 @@ import { registerAgents } from './commands/agents.js'; import { registerRuns } from './commands/runs.js'; import { registerMachines } from './commands/machines.js'; import { registerStatus } from './commands/status.js'; -import { registerLogin } from './commands/login.js'; -import { registerLogout } from './commands/logout.js'; import { registerLogs } from './commands/logs.js'; import { registerDaemon } from './commands/daemon-cmd.js'; import { registerWhoami } from './commands/whoami.js'; import { registerCompletions } from './commands/completions.js'; import { registerConfig } from './commands/config-cmd.js'; -import { registerInit } from './commands/init.js'; +import { registerSetup } from './commands/setup.js'; import { createCreateCommand } from './commands/create.js'; import { registerUpdate } from './commands/update.js'; import { registerProjects } from './commands/projects.js'; @@ -33,14 +31,12 @@ registerAgents(program); registerRuns(program); registerMachines(program); registerStatus(program); -registerLogin(program); -registerLogout(program); registerLogs(program); registerDaemon(program); registerWhoami(program); registerCompletions(program); registerConfig(program); -registerInit(program); +registerSetup(program); program.addCommand(createCreateCommand()); registerUpdate(program); registerProjects(program); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index a21ac53..1347121 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -80,7 +80,7 @@ const listHubAgents = async (jsonMode: boolean): Promise => { try { agents = await get('/api/hub/agents'); } catch { - console.error(chalk.red("Not connected to hub. Run 'agentage login' first.")); + console.error(chalk.red("Not connected to hub. Run 'agentage setup' first.")); process.exitCode = 1; return; } diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts deleted file mode 100644 index 1598c60..0000000 --- a/src/commands/init.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Command } from 'commander'; - -vi.mock('../daemon/config.js', () => ({ - loadConfig: vi.fn(), - saveConfig: vi.fn(), -})); - -vi.mock('../utils/ensure-daemon.js', () => ({ - ensureDaemon: vi.fn(), -})); - -import { loadConfig, saveConfig } from '../daemon/config.js'; -import { registerInit } from './init.js'; - -const mockLoadConfig = vi.mocked(loadConfig); -const mockSaveConfig = vi.mocked(saveConfig); - -describe('init command', () => { - let program: Command; - let logs: string[]; - - const defaultConfig = { - machine: { id: 'machine-123', name: 'test-host' }, - daemon: { port: 4243 }, - agents: { default: '/home/user/agents', additional: [] }, - projects: { default: '/home/user/projects', additional: [] }, - sync: { - events: { - state: true, - result: true, - error: true, - input_required: true, - 'output.llm.delta': true, - 'output.llm.tool_call': true, - 'output.llm.usage': true, - 'output.progress': true, - }, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - logs = []; - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - logs.push(args.map(String).join(' ')); - }); - - mockLoadConfig.mockReturnValue(structuredClone(defaultConfig)); - - program = new Command(); - program.exitOverride(); - registerInit(program); - }); - - it('sets machine name with --name', async () => { - await program.parseAsync(['node', 'agentage', 'init', '--name', 'my-pc']); - - const saved = mockSaveConfig.mock.calls[0]![0]; - expect(saved.machine.name).toBe('my-pc'); - expect(logs.some((l) => l.includes('Agentage initialized'))).toBe(true); - expect(logs.some((l) => l.includes('Machine name: my-pc'))).toBe(true); - }); - - it('adds agents dir with --dir', async () => { - await program.parseAsync(['node', 'agentage', 'init', '--dir', '/tmp/agents']); - - const saved = mockSaveConfig.mock.calls[0]![0]; - expect(saved.agents.additional).toContain('/tmp/agents'); - expect(logs.some((l) => l.includes('Agents dir:'))).toBe(true); - }); - - it('sets hub URL with --hub', async () => { - await program.parseAsync(['node', 'agentage', 'init', '--hub', 'https://my.hub']); - - const saved = mockSaveConfig.mock.calls[0]![0]; - expect(saved.hub).toEqual({ url: 'https://my.hub' }); - expect(logs.some((l) => l.includes('Hub URL: https://my.hub'))).toBe(true); - }); - - it('auto-prepends https for hub URL without protocol', async () => { - await program.parseAsync(['node', 'agentage', 'init', '--hub', 'my.hub']); - - const saved = mockSaveConfig.mock.calls[0]![0]; - expect(saved.hub).toEqual({ url: 'https://my.hub' }); - }); - - it('shows login hint when hub provided without --no-login', async () => { - await program.parseAsync(['node', 'agentage', 'init', '--hub', 'https://my.hub']); - - expect(logs.some((l) => l.includes('agentage login'))).toBe(true); - }); - - it('skips login hint with --no-login', async () => { - await program.parseAsync(['node', 'agentage', 'init', '--hub', 'https://my.hub', '--no-login']); - - expect(logs.some((l) => l.includes('agentage login'))).toBe(false); - }); - - it('shows daemon started in summary', async () => { - await program.parseAsync(['node', 'agentage', 'init']); - - expect(logs.some((l) => l.includes('Daemon: started'))).toBe(true); - }); -}); diff --git a/src/commands/init.ts b/src/commands/init.ts deleted file mode 100644 index c14f3a1..0000000 --- a/src/commands/init.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { type Command } from 'commander'; -import { resolve } from 'node:path'; -import chalk from 'chalk'; -import { loadConfig, saveConfig } from '../daemon/config.js'; -import { ensureDaemon } from '../utils/ensure-daemon.js'; - -export const registerInit = (program: Command): void => { - program - .command('init') - .description('Initialize agentage setup') - .option('--hub ', 'Set hub URL') - .option('--name ', 'Set machine name') - .option('--dir ', 'Add to agents.additional') - .option('--no-login', 'Skip login step') - .action(async (opts: { hub?: string; name?: string; dir?: string; login: boolean }) => { - const config = loadConfig(); - const steps: string[] = []; - - // Step 1: Machine name - if (opts.name) { - config.machine.name = opts.name; - steps.push(`Machine name: ${opts.name}`); - } - - // Step 2: Additional agents dir - if (opts.dir) { - const absolute = resolve(opts.dir); - if (config.agents.default !== absolute && !config.agents.additional.includes(absolute)) { - config.agents.additional.push(absolute); - } - steps.push(`Agents dir: ${absolute}`); - } - - // Step 3: Hub URL - if (opts.hub) { - const hubUrl = opts.hub.startsWith('http') ? opts.hub : `https://${opts.hub}`; - config.hub = { url: hubUrl }; - steps.push(`Hub URL: ${hubUrl}`); - } - - saveConfig(config); - - // Step 4: Start daemon - await ensureDaemon(); - steps.push('Daemon: started'); - - // Step 5: Login hint - if (opts.hub && opts.login) { - steps.push(`Login: run 'agentage login --hub ${opts.hub}' to authenticate`); - } - - // Summary - console.log(chalk.green('Agentage initialized:')); - for (const step of steps) { - console.log(` ${step}`); - } - - console.log(chalk.dim(`\nConfig: machine=${config.machine.name}, id=${config.machine.id}`)); - }); -}; diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts deleted file mode 100644 index fddd352..0000000 --- a/src/commands/login.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { Command } from 'commander'; - -vi.mock('../utils/ensure-daemon.js', () => ({ - ensureDaemon: vi.fn(), -})); - -vi.mock('../hub/auth.js', () => ({ - saveAuth: vi.fn(), - readAuth: vi.fn(), - deleteAuth: vi.fn(), -})); - -vi.mock('../hub/auth-callback.js', () => ({ - startCallbackServer: vi.fn(), - getCallbackPort: vi.fn(), -})); - -vi.mock('../daemon/config.js', () => ({ - loadConfig: vi.fn(), - saveConfig: vi.fn(), -})); - -vi.mock('open', () => ({ - default: vi.fn(), -})); - -import { saveAuth } from '../hub/auth.js'; -import { startCallbackServer, getCallbackPort } from '../hub/auth-callback.js'; -import { loadConfig, saveConfig } from '../daemon/config.js'; -import { registerLogin } from './login.js'; - -const mockSaveAuth = vi.mocked(saveAuth); -const mockStartCallback = vi.mocked(startCallbackServer); -const mockGetCallbackPort = vi.mocked(getCallbackPort); -const mockLoadConfig = vi.mocked(loadConfig); -const mockSaveConfig = vi.mocked(saveConfig); - -describe('login command', () => { - let program: Command; - let logs: string[]; - let errorLogs: string[]; - let tempDir: string; - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - - beforeEach(() => { - vi.clearAllMocks(); - tempDir = mkdtempSync(join(tmpdir(), 'agentage-login-test-')); - logs = []; - errorLogs = []; - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - logs.push(args.map(String).join(' ')); - }); - vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { - errorLogs.push(args.map(String).join(' ')); - }); - - mockLoadConfig.mockReturnValue({ - machine: { id: 'machine-1', name: 'test-pc' }, - daemon: { port: 4243 }, - agents: { default: '/tmp/agents', additional: [] }, - projects: { default: '/tmp/projects', additional: [] }, - sync: { events: {} }, - } as unknown as ReturnType); - - program = new Command(); - program.exitOverride(); - registerLogin(program); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('token mode', () => { - it('saves auth with direct token', async () => { - await program.parseAsync(['node', 'agentage', 'login', '--token', 'my-token']); - - expect(mockSaveAuth).toHaveBeenCalledWith( - expect.objectContaining({ - session: expect.objectContaining({ access_token: 'my-token' }), - hub: expect.objectContaining({ url: 'https://agentage.io', machineId: 'machine-1' }), - }) - ); - expect(mockSaveConfig).toHaveBeenCalled(); - expect(logs.some((l) => l.includes('Logged in with token'))).toBe(true); - expect(mockExit).toHaveBeenCalledWith(0); - }); - - it('uses custom hub URL with --hub', async () => { - await program.parseAsync([ - 'node', - 'agentage', - 'login', - '--hub', - 'https://custom.hub', - '--token', - 'tk', - ]); - - expect(mockSaveAuth).toHaveBeenCalledWith( - expect.objectContaining({ - hub: expect.objectContaining({ url: 'https://custom.hub' }), - }) - ); - }); - - it('auto-prepends https:// when protocol is missing', async () => { - await program.parseAsync([ - 'node', - 'agentage', - 'login', - '--hub', - 'dev.agentage.io', - '--token', - 'tk', - ]); - - expect(mockSaveAuth).toHaveBeenCalledWith( - expect.objectContaining({ - hub: expect.objectContaining({ url: 'https://dev.agentage.io' }), - }) - ); - }); - }); - - describe('browser mode', () => { - it('starts callback server and opens browser', async () => { - mockStartCallback.mockResolvedValue({ - session: { access_token: 'at', refresh_token: 'rt', expires_at: 9999 }, - user: { id: 'u1', email: 'v@test.com' }, - hub: { url: '', machineId: '' }, - }); - mockGetCallbackPort.mockReturnValue(54321); - - await program.parseAsync(['node', 'agentage', 'login']); - - expect(mockStartCallback).toHaveBeenCalled(); - expect(mockSaveAuth).toHaveBeenCalled(); - expect(logs.some((l) => l.includes('v@test.com'))).toBe(true); - expect(mockExit).toHaveBeenCalledWith(0); - }); - - it('fails when callback server does not start', async () => { - mockStartCallback.mockImplementation(() => new Promise(() => {})); // never resolves - mockGetCallbackPort.mockReturnValue(0); - - await program.parseAsync(['node', 'agentage', 'login']); - - expect(errorLogs.some((l) => l.includes('Failed to start callback server'))).toBe(true); - expect(process.exitCode).toBe(1); - process.exitCode = undefined; - }); - - it('handles login failure', async () => { - mockStartCallback.mockRejectedValue(new Error('Timed out')); - mockGetCallbackPort.mockReturnValue(9999); - - await program.parseAsync(['node', 'agentage', 'login']); - - expect(errorLogs.some((l) => l.includes('Login failed') && l.includes('Timed out'))).toBe( - true - ); - expect(process.exitCode).toBe(1); - process.exitCode = undefined; - }); - }); -}); diff --git a/src/commands/login.ts b/src/commands/login.ts deleted file mode 100644 index cfed46f..0000000 --- a/src/commands/login.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { type Command } from 'commander'; -import chalk from 'chalk'; -import open from 'open'; -import { ensureDaemon } from '../utils/ensure-daemon.js'; -import { saveAuth, type AuthState } from '../hub/auth.js'; -import { startCallbackServer, getCallbackPort } from '../hub/auth-callback.js'; -import { loadConfig, saveConfig } from '../daemon/config.js'; - -const DEFAULT_HUB_URL = 'https://agentage.io'; - -export const registerLogin = (program: Command): void => { - program - .command('login') - .description('Authenticate with hub') - .option('--hub ', 'Hub URL', DEFAULT_HUB_URL) - .option('--token ', 'Use access token directly (headless/CI)') - .action(async (opts: { hub: string; token?: string }) => { - await ensureDaemon(); - - const hubUrl = opts.hub.startsWith('http') ? opts.hub : `https://${opts.hub}`; - - if (opts.token) { - // Direct token mode — for headless/CI - console.log(chalk.yellow('Direct token login — skipping browser flow')); - console.log( - chalk.yellow( - 'Note: refresh tokens are not available in direct mode. Session will expire.' - ) - ); - - const config = loadConfig(); - const authState: AuthState = { - session: { - access_token: opts.token, - refresh_token: '', - expires_at: 0, - }, - user: { id: '', email: '' }, - hub: { url: hubUrl, machineId: config.machine.id }, - }; - - saveAuth(authState); - - // Save hub URL to config - config.hub = { url: hubUrl }; - saveConfig(config); - - console.log(chalk.green('Logged in with token.')); - process.exit(0); - } - - // Start callback server, then open browser to hub login page - console.log('Opening browser for authentication...'); - - const authPromise = startCallbackServer(hubUrl); - - // Wait a tick for the server to start, then get the port - await new Promise((r) => setTimeout(r, 100)); - const port = getCallbackPort(); - - if (!port) { - console.error(chalk.red('Failed to start callback server')); - process.exitCode = 1; - return; - } - - const authUrl = `${hubUrl}/login?cli_port=${port}`; - - try { - await open(authUrl); - } catch { - // Browser didn't open — print URL manually - console.log(chalk.yellow('Could not open browser. Open this URL manually:')); - console.log(authUrl); - } - - console.log('Waiting for login...'); - - try { - const authState = await authPromise; - - // Set hub info - authState.hub.url = hubUrl; - const config = loadConfig(); - authState.hub.machineId = config.machine.id; - - saveAuth(authState); - - // Save hub URL to config - config.hub = { url: hubUrl }; - saveConfig(config); - - console.log(chalk.green(`✓ Logged in as ${authState.user.email}`)); - console.log(`Machine "${config.machine.name}" will connect to hub automatically.`); - process.exit(0); - } catch (err) { - console.error( - chalk.red(`Login failed: ${err instanceof Error ? err.message : String(err)}`) - ); - process.exitCode = 1; - } - }); -}; diff --git a/src/commands/logout.test.ts b/src/commands/logout.test.ts deleted file mode 100644 index b48925f..0000000 --- a/src/commands/logout.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Command } from 'commander'; - -vi.mock('../utils/ensure-daemon.js', () => ({ - ensureDaemon: vi.fn(), -})); - -vi.mock('../hub/auth.js', () => ({ - readAuth: vi.fn(), - deleteAuth: vi.fn(), -})); - -vi.mock('../hub/hub-client.js', () => ({ - createHubClient: vi.fn(), -})); - -import { readAuth, deleteAuth } from '../hub/auth.js'; -import { createHubClient } from '../hub/hub-client.js'; -import { registerLogout } from './logout.js'; - -const mockReadAuth = vi.mocked(readAuth); -const mockDeleteAuth = vi.mocked(deleteAuth); -const mockCreateHubClient = vi.mocked(createHubClient); - -describe('logout command', () => { - let program: Command; - let logs: string[]; - - beforeEach(() => { - vi.clearAllMocks(); - logs = []; - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - logs.push(args.map(String).join(' ')); - }); - - program = new Command(); - program.exitOverride(); - registerLogout(program); - }); - - it('prints not logged in when no auth', async () => { - mockReadAuth.mockReturnValue(null); - - await program.parseAsync(['node', 'agentage', 'logout']); - - expect(logs.some((l) => l.includes('Not logged in'))).toBe(true); - expect(mockDeleteAuth).not.toHaveBeenCalled(); - }); - - it('deregisters and deletes auth on logout', async () => { - const mockDeregister = vi.fn().mockResolvedValue(undefined); - mockReadAuth.mockReturnValue({ - session: { access_token: 'tk', refresh_token: 'rt', expires_at: 9999 }, - user: { id: 'u1', email: 'v@test.com' }, - hub: { url: 'https://hub.test', machineId: 'machine-1' }, - }); - mockCreateHubClient.mockReturnValue({ deregister: mockDeregister } as unknown as ReturnType< - typeof createHubClient - >); - - await program.parseAsync(['node', 'agentage', 'logout']); - - expect(mockDeregister).toHaveBeenCalledWith('machine-1'); - expect(mockDeleteAuth).toHaveBeenCalled(); - expect(logs.some((l) => l.includes('Disconnected from hub'))).toBe(true); - expect(logs.some((l) => l.includes('standalone mode'))).toBe(true); - }); - - it('still deletes auth when deregister fails', async () => { - mockReadAuth.mockReturnValue({ - session: { access_token: 'tk', refresh_token: '', expires_at: 0 }, - user: { id: 'u1', email: '' }, - hub: { url: 'https://hub.test', machineId: 'machine-1' }, - }); - mockCreateHubClient.mockReturnValue({ - deregister: vi.fn().mockRejectedValue(new Error('Network error')), - } as unknown as ReturnType); - - await program.parseAsync(['node', 'agentage', 'logout']); - - expect(mockDeleteAuth).toHaveBeenCalled(); - expect(logs.some((l) => l.includes('Disconnected from hub'))).toBe(true); - }); -}); diff --git a/src/commands/logout.ts b/src/commands/logout.ts deleted file mode 100644 index f74496c..0000000 --- a/src/commands/logout.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type Command } from 'commander'; -import chalk from 'chalk'; -import { ensureDaemon } from '../utils/ensure-daemon.js'; -import { readAuth, deleteAuth } from '../hub/auth.js'; -import { createHubClient } from '../hub/hub-client.js'; - -export const registerLogout = (program: Command): void => { - program - .command('logout') - .description('Disconnect from hub') - .action(async () => { - await ensureDaemon(); - - const auth = readAuth(); - if (!auth) { - console.log(chalk.yellow('Not logged in.')); - return; - } - - // Best-effort deregister from hub - try { - const client = createHubClient(auth.hub.url, auth); - await client.deregister(auth.hub.machineId); - } catch { - // Hub may be unreachable — that's fine - } - - deleteAuth(); - - console.log(chalk.green('Disconnected from hub. Machine deregistered.')); - console.log(chalk.dim('Daemon continues running in standalone mode.')); - console.log(chalk.dim('Run `agentage daemon restart` to apply.')); - }); -}; diff --git a/src/commands/machines.ts b/src/commands/machines.ts index d71f396..dab86ef 100644 --- a/src/commands/machines.ts +++ b/src/commands/machines.ts @@ -24,7 +24,7 @@ export const registerMachines = (program: Command): void => { try { machines = await get('/api/hub/machines'); } catch { - console.error(chalk.red("Not connected to hub. Run 'agentage login' first.")); + console.error(chalk.red("Not connected to hub. Run 'agentage setup' first.")); process.exitCode = 1; return; } diff --git a/src/commands/run.ts b/src/commands/run.ts index aa82660..4286740 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -199,7 +199,7 @@ const runRemote = async ( try { machines = await get>('/api/hub/machines'); } catch { - console.error(chalk.red("Not connected to hub. Run 'agentage login' first.")); + console.error(chalk.red("Not connected to hub. Run 'agentage setup' first.")); process.exitCode = 1; return; } diff --git a/src/commands/runs.ts b/src/commands/runs.ts index 0cd43fc..6baf39e 100644 --- a/src/commands/runs.ts +++ b/src/commands/runs.ts @@ -23,7 +23,7 @@ export const registerRuns = (program: Command): void => { await ensureDaemon(); if (opts.all) { - console.error(chalk.red("Not connected to hub. Run 'agentage login' first.")); + console.error(chalk.red("Not connected to hub. Run 'agentage setup' first.")); process.exitCode = 1; return; } diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts new file mode 100644 index 0000000..ae57c5c --- /dev/null +++ b/src/commands/setup.test.ts @@ -0,0 +1,452 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Command } from 'commander'; + +vi.mock('../daemon/config.js', () => ({ + loadConfig: vi.fn(), + saveConfig: vi.fn(), + getConfigDir: vi.fn(), +})); + +vi.mock('../utils/ensure-daemon.js', () => ({ + ensureDaemon: vi.fn(), +})); + +vi.mock('../hub/auth.js', () => ({ + readAuth: vi.fn(), + saveAuth: vi.fn(), + deleteAuth: vi.fn(), +})); + +vi.mock('../hub/auth-callback.js', () => ({ + startCallbackServer: vi.fn(), + getCallbackPort: vi.fn(), +})); + +vi.mock('../hub/hub-client.js', () => ({ + createHubClient: vi.fn(), +})); + +vi.mock('open', () => ({ + default: vi.fn(), +})); + +const mockQuestion = vi.fn(); +vi.mock('node:readline/promises', () => ({ + createInterface: () => ({ + question: mockQuestion, + close: vi.fn(), + }), +})); + +import { loadConfig, saveConfig, getConfigDir } from '../daemon/config.js'; +import { readAuth, saveAuth, deleteAuth } from '../hub/auth.js'; +import { startCallbackServer, getCallbackPort } from '../hub/auth-callback.js'; +import { createHubClient } from '../hub/hub-client.js'; +import { registerSetup } from './setup.js'; + +const mockLoadConfig = vi.mocked(loadConfig); +const mockSaveConfig = vi.mocked(saveConfig); +const mockGetConfigDir = vi.mocked(getConfigDir); +const mockReadAuth = vi.mocked(readAuth); +const mockSaveAuth = vi.mocked(saveAuth); +const mockDeleteAuth = vi.mocked(deleteAuth); +const mockStartCallback = vi.mocked(startCallbackServer); +const mockGetCallbackPort = vi.mocked(getCallbackPort); +const mockCreateHubClient = vi.mocked(createHubClient); + +const baseConfig = () => ({ + machine: { id: 'machine-existing-1', name: 'test-host' }, + daemon: { port: 4243 }, + agents: { default: '/tmp/agents', additional: [] as string[] }, + projects: { default: '/tmp/projects', additional: [] as string[] }, + sync: { + events: { + state: true, + result: true, + error: true, + input_required: true, + 'output.llm.delta': true, + 'output.llm.tool_call': true, + 'output.llm.usage': true, + 'output.progress': true, + }, + }, +}); + +describe('setup command', () => { + let program: Command; + let tempDir: string; + let logs: string[]; + let errorLogs: string[]; + let originalIsTTY: boolean | undefined; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + const setTty = (isTty: boolean): void => { + Object.defineProperty(process.stdout, 'isTTY', { value: isTty, configurable: true }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + tempDir = mkdtempSync(join(tmpdir(), 'agentage-setup-test-')); + logs = []; + errorLogs = []; + originalIsTTY = process.stdout.isTTY; + + mockGetConfigDir.mockReturnValue(tempDir); + mockLoadConfig.mockReturnValue( + structuredClone(baseConfig()) as unknown as ReturnType + ); + mockReadAuth.mockReturnValue(null); + mockQuestion.mockReset(); + mockQuestion.mockResolvedValue('y'); + + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errorLogs.push(args.map(String).join(' ')); + }); + + program = new Command(); + program.exitOverride(); + registerSetup(program); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + }); + + describe('flag validation', () => { + it('exits 3 on --reauth + --disconnect', async () => { + await program.parseAsync(['node', 'agentage', 'setup', '--reauth', '--disconnect']); + expect(errorLogs.some((l) => l.includes('cannot be combined'))).toBe(true); + expect(mockExit).toHaveBeenCalledWith(3); + }); + }); + + describe('disconnect mode', () => { + it('prints not-logged-in when no auth', async () => { + await program.parseAsync(['node', 'agentage', 'setup', '--disconnect']); + expect(logs.some((l) => l.includes('Not logged in'))).toBe(true); + expect(mockDeleteAuth).not.toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it('deregisters and deletes auth when logged in', async () => { + const dereg = vi.fn().mockResolvedValue(undefined); + 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: dereg } as unknown as ReturnType< + typeof createHubClient + >); + + await program.parseAsync(['node', 'agentage', 'setup', '--disconnect']); + + expect(dereg).toHaveBeenCalledWith('machine-existing-1'); + expect(mockDeleteAuth).toHaveBeenCalled(); + expect(logs.some((l) => l.includes('Disconnected from hub'))).toBe(true); + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it('still deletes auth when deregister fails', async () => { + mockReadAuth.mockReturnValue({ + session: { access_token: 't', refresh_token: '', expires_at: 0 }, + user: { id: 'u1', email: '' }, + hub: { url: 'https://hub.x', machineId: 'machine-existing-1' }, + }); + mockCreateHubClient.mockReturnValue({ + deregister: vi.fn().mockRejectedValue(new Error('Network')), + } as unknown as ReturnType); + + await program.parseAsync(['node', 'agentage', 'setup', '--disconnect']); + + expect(mockDeleteAuth).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(0); + }); + }); + + describe('idempotent mode', () => { + it('shows summary when auth exists and no explicit changes', async () => { + setTty(true); + mockReadAuth.mockReturnValue({ + session: { access_token: 't', refresh_token: 'r', expires_at: 9999 }, + user: { id: 'u1', email: 'me@x.io' }, + hub: { url: 'https://hub.x', machineId: 'machine-existing-1' }, + }); + + await program.parseAsync(['node', 'agentage', 'setup']); + + expect(logs.some((l) => l.includes('Already configured'))).toBe(true); + expect(logs.some((l) => l.includes('me@x.io'))).toBe(true); + expect(mockSaveAuth).not.toHaveBeenCalled(); + expect(mockSaveConfig).not.toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(0); + }); + }); + + describe('non-interactive guard', () => { + it('exits 2 when no TTY, no --token, no --no-login', async () => { + setTty(false); + await program.parseAsync(['node', 'agentage', 'setup']); + expect(errorLogs.some((l) => l.includes('cannot prompt'))).toBe(true); + expect(mockExit).toHaveBeenCalledWith(2); + }); + + it('exits 2 with --no-interactive even when TTY is present', async () => { + setTty(true); + await program.parseAsync(['node', 'agentage', 'setup', '--no-interactive']); + expect(errorLogs.some((l) => l.includes('cannot prompt'))).toBe(true); + expect(mockExit).toHaveBeenCalledWith(2); + }); + }); + + describe('fresh setup with TTY confirmation', () => { + it('proceeds on Enter and opens browser', async () => { + setTty(true); + mockQuestion.mockResolvedValue(''); + mockStartCallback.mockResolvedValue({ + session: { access_token: 'at', refresh_token: 'rt', expires_at: 9999 }, + user: { id: 'u1', email: 'v@x.io' }, + hub: { url: '', machineId: '' }, + }); + mockGetCallbackPort.mockReturnValue(54321); + + await program.parseAsync(['node', 'agentage', 'setup']); + + expect(mockSaveConfig).toHaveBeenCalled(); + expect(mockStartCallback).toHaveBeenCalled(); + expect(mockSaveAuth).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it('aborts on `n`', async () => { + setTty(true); + mockQuestion.mockResolvedValue('n'); + + await program.parseAsync(['node', 'agentage', 'setup']); + + expect(logs.some((l) => l.includes('Aborted'))).toBe(true); + expect(mockSaveAuth).not.toHaveBeenCalled(); + expect(mockStartCallback).not.toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + + describe('--token (headless)', () => { + it('saves auth without browser', async () => { + setTty(true); + await program.parseAsync(['node', 'agentage', 'setup', '--token', 'my-token']); + + expect(mockSaveAuth).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ access_token: 'my-token' }), + hub: expect.objectContaining({ url: 'https://agentage.io' }), + }) + ); + expect(mockStartCallback).not.toHaveBeenCalled(); + expect(mockQuestion).not.toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(0); + }); + }); + + describe('--machine-id (cloud-init path)', () => { + it('writes machine.json with supplied id and name before daemon starts', async () => { + setTty(false); + const id = '11111111-2222-3333-4444-555555555555'; + await program.parseAsync([ + 'node', + 'agentage', + 'setup', + '--machine-id', + id, + '--name', + 'cloud-vm', + '--token', + 'tk', + '--hub', + 'https://my.hub', + ]); + + const path = join(tempDir, 'machine.json'); + expect(existsSync(path)).toBe(true); + const written = JSON.parse(readFileSync(path, 'utf-8')) as { id: string; name: string }; + expect(written).toEqual({ id, name: 'cloud-vm' }); + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it('errors on rename without --force', async () => { + setTty(true); + writeFileSync( + join(tempDir, 'machine.json'), + JSON.stringify({ id: 'existing-id', name: 'old-name' }) + ); + + await program.parseAsync([ + 'node', + 'agentage', + 'setup', + '--name', + 'new-name', + '--token', + 'tk', + ]); + + expect(errorLogs.some((l) => l.includes('--force'))).toBe(true); + expect(mockExit).toHaveBeenCalledWith(7); + }); + + it('allows rename with --force', async () => { + setTty(true); + writeFileSync( + join(tempDir, 'machine.json'), + JSON.stringify({ id: 'existing-id', name: 'old-name' }) + ); + + await program.parseAsync([ + 'node', + 'agentage', + 'setup', + '--name', + 'new-name', + '--token', + 'tk', + '--force', + ]); + + const written = JSON.parse(readFileSync(join(tempDir, 'machine.json'), 'utf-8')) as { + id: string; + name: string; + }; + expect(written.name).toBe('new-name'); + expect(written.id).toBe('existing-id'); + expect(mockExit).toHaveBeenCalledWith(0); + }); + }); + + describe('--reauth', () => { + it('skips confirmation and re-runs OAuth keeping existing config', async () => { + setTty(true); + mockReadAuth.mockReturnValue({ + session: { access_token: 'old', refresh_token: 'r', expires_at: 0 }, + user: { id: 'u1', email: 'me@x.io' }, + hub: { url: 'https://hub.x', machineId: 'machine-existing-1' }, + }); + mockStartCallback.mockResolvedValue({ + session: { access_token: 'new', refresh_token: 'r2', expires_at: 9999 }, + user: { id: 'u1', email: 'me@x.io' }, + hub: { url: '', machineId: '' }, + }); + mockGetCallbackPort.mockReturnValue(54321); + + await program.parseAsync(['node', 'agentage', 'setup', '--reauth']); + + expect(mockQuestion).not.toHaveBeenCalled(); + expect(mockStartCallback).toHaveBeenCalled(); + expect(mockSaveAuth).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(0); + }); + }); + + describe('--no-login (standalone)', () => { + it('saves config but does not authenticate', async () => { + setTty(true); + await program.parseAsync(['node', 'agentage', 'setup', '--no-login', '--yes']); + + expect(mockSaveConfig).toHaveBeenCalled(); + expect(mockSaveAuth).not.toHaveBeenCalled(); + expect(mockStartCallback).not.toHaveBeenCalled(); + expect(logs.some((l) => l.includes('Standalone'))).toBe(true); + expect(mockExit).toHaveBeenCalledWith(0); + }); + }); + + describe('--json', () => { + it('emits JSON summary on fresh setup with --token', async () => { + setTty(false); + await program.parseAsync([ + 'node', + 'agentage', + 'setup', + '--token', + 'tk', + '--name', + 'my-pc', + '--json', + ]); + + const jsonLine = logs.find((l) => l.trim().startsWith('{')); + expect(jsonLine).toBeDefined(); + const parsed = JSON.parse(jsonLine!) as { + ok: boolean; + mode: string; + machine: { name: string }; + }; + expect(parsed.ok).toBe(true); + expect(parsed.mode).toBe('fresh'); + expect(parsed.machine.name).toBe('my-pc'); + }); + }); + + describe('hub URL normalization', () => { + it('auto-prepends https:// when scheme is missing', async () => { + setTty(false); + await program.parseAsync(['node', 'agentage', 'setup', '--hub', 'my.hub', '--token', 'tk']); + + const savedConfig = mockSaveConfig.mock.calls[0]![0]; + expect(savedConfig.hub).toEqual({ url: 'https://my.hub' }); + }); + + it('preserves http:// for local URLs', async () => { + setTty(false); + await program.parseAsync([ + 'node', + 'agentage', + 'setup', + '--hub', + 'http://localhost:3001', + '--token', + 'tk', + ]); + + const savedConfig = mockSaveConfig.mock.calls[0]![0]; + expect(savedConfig.hub).toEqual({ url: 'http://localhost:3001' }); + }); + }); + + describe('removal of init/login/logout', () => { + it('agentage init exits with unknown command', async () => { + const cleanProgram = new Command(); + cleanProgram.exitOverride(); + registerSetup(cleanProgram); + + await expect(cleanProgram.parseAsync(['node', 'agentage', 'init'])).rejects.toThrow(); + }); + + it('agentage login exits with unknown command', async () => { + const cleanProgram = new Command(); + cleanProgram.exitOverride(); + registerSetup(cleanProgram); + + await expect(cleanProgram.parseAsync(['node', 'agentage', 'login'])).rejects.toThrow(); + }); + + it('agentage logout exits with unknown command', async () => { + const cleanProgram = new Command(); + cleanProgram.exitOverride(); + registerSetup(cleanProgram); + + await expect(cleanProgram.parseAsync(['node', 'agentage', 'logout'])).rejects.toThrow(); + }); + }); +}); diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..b18678d --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,387 @@ +import { type Command } from 'commander'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { hostname } from 'node:os'; +import { join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { createInterface } from 'node:readline/promises'; +import { stdin, stdout } from 'node:process'; +import chalk from 'chalk'; +import open from 'open'; +import { + loadConfig, + saveConfig, + getConfigDir, + type DaemonConfig, + type MachineIdentity, +} from '../daemon/config.js'; +import { ensureDaemon } from '../utils/ensure-daemon.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'; + +const DEFAULT_HUB_URL = 'https://agentage.io'; + +export interface SetupOptions { + hub?: string; + name?: string; + dir?: string; + machineId?: string; + token?: string; + reauth?: boolean; + disconnect?: boolean; + login?: boolean; + yes?: boolean; + interactive?: boolean; + force?: boolean; + json?: boolean; +} + +type SetupMode = 'fresh' | 'reauth' | 'disconnect' | 'standalone' | 'idempotent'; + +const normalizeHubUrl = (url: string): string => + url.startsWith('http://') || url.startsWith('https://') ? url : `https://${url}`; + +const machineJsonPath = (): string => join(getConfigDir(), 'machine.json'); + +const readMachineJson = (): MachineIdentity | undefined => { + const path = machineJsonPath(); + if (!existsSync(path)) return undefined; + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial; + if (typeof parsed.id === 'string' && typeof parsed.name === 'string') { + return { id: parsed.id, name: parsed.name }; + } + } catch { + return undefined; + } + return undefined; +}; + +const writeMachineJson = (identity: MachineIdentity): void => { + const dir = getConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(machineJsonPath(), JSON.stringify(identity, null, 2) + '\n', 'utf-8'); +}; + +const ensureMachineIdentity = (opts: SetupOptions): void => { + const existing = readMachineJson(); + + if (existing && opts.name && existing.name !== opts.name && !opts.force) { + console.error( + chalk.red(`Error: machine.json already has name "${existing.name}". Pass --force to rename.`) + ); + process.exit(7); + return; + } + + if (existing && opts.machineId && existing.id !== opts.machineId && !opts.force) { + console.error( + chalk.red(`Error: machine.json already has id "${existing.id}". Pass --force to overwrite.`) + ); + process.exit(7); + return; + } + + const id = opts.machineId ?? existing?.id ?? randomUUID(); + const name = opts.name ?? existing?.name ?? hostname(); + + if (!existing || existing.id !== id || existing.name !== name) { + writeMachineJson({ id, name }); + } +}; + +const mergeConfig = (opts: SetupOptions): DaemonConfig => { + const config = loadConfig(); + + if (opts.hub) { + config.hub = { url: normalizeHubUrl(opts.hub) }; + } else if (!config.hub) { + config.hub = { url: DEFAULT_HUB_URL }; + } + + if (opts.dir) { + const absolute = resolve(opts.dir); + if (config.agents.default !== absolute && !config.agents.additional.includes(absolute)) { + config.agents.additional.push(absolute); + } + } + + if (opts.name) { + config.machine.name = opts.name; + } + + return config; +}; + +const confirmConnect = async (config: DaemonConfig): Promise => { + const rl = createInterface({ input: stdin, output: stdout }); + try { + const hub = config.hub?.url ?? DEFAULT_HUB_URL; + const ans = (await rl.question(`Connect machine "${config.machine.name}" to ${hub} ? [Y/n] `)) + .trim() + .toLowerCase(); + if (ans === '' || ans === 'y' || ans === 'yes') return true; + if (ans === 'n' || ans === 'no') return false; + const ans2 = (await rl.question(`Please answer 'y' or 'n': `)).trim().toLowerCase(); + return ans2 === '' || ans2 === 'y' || ans2 === 'yes'; + } finally { + rl.close(); + } +}; + +const doAuthBrowser = async (hubUrl: string, machineId: string): Promise => { + console.log('Opening browser for authentication...'); + + const authPromise = startCallbackServer(hubUrl); + await new Promise((r) => setTimeout(r, 100)); + const port = getCallbackPort(); + + if (!port) { + console.error(chalk.red('Failed to start callback server')); + process.exit(4); + return; + } + + const authUrl = `${hubUrl}/login?cli_port=${port}`; + + try { + await open(authUrl); + } catch { + console.log(chalk.yellow('Could not open browser. Open this URL manually:')); + console.log(authUrl); + } + + console.log('Waiting for login...'); + + try { + const authState = await authPromise; + authState.hub.url = hubUrl; + authState.hub.machineId = machineId; + saveAuth(authState); + } catch (err) { + console.error(chalk.red(`Login failed: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(4); + } +}; + +const doAuthToken = (token: string, hubUrl: string, machineId: string): void => { + console.log(chalk.yellow('Direct token login — skipping browser flow')); + console.log( + chalk.yellow('Note: refresh tokens are not available in direct mode. Session will expire.') + ); + + const authState: AuthState = { + session: { + access_token: token, + refresh_token: '', + expires_at: 0, + }, + user: { id: '', email: '' }, + hub: { url: hubUrl, machineId }, + }; + saveAuth(authState); +}; + +const doDisconnect = async (opts: SetupOptions): Promise => { + await ensureDaemon(); + const auth = readAuth(); + + if (!auth) { + if (opts.json) { + console.log( + JSON.stringify({ ok: true, mode: 'disconnect', alreadyDisconnected: true }, null, 2) + ); + } else { + console.log(chalk.yellow('Not logged in.')); + } + return; + } + + try { + const client = createHubClient(auth.hub.url, auth); + await client.deregister(auth.hub.machineId); + } catch { + // Hub unreachable — continue with local cleanup + } + + deleteAuth(); + + if (opts.json) { + console.log(JSON.stringify({ ok: true, mode: 'disconnect' }, null, 2)); + } else { + console.log(chalk.green('Disconnected from hub. Machine deregistered.')); + console.log(chalk.dim('Daemon continues running in standalone mode.')); + console.log(chalk.dim('Run `agentage daemon restart` to apply.')); + } +}; + +const printIdempotent = ( + config: DaemonConfig, + auth: AuthState | null, + opts: SetupOptions +): void => { + if (opts.json) { + console.log( + JSON.stringify( + { + ok: true, + mode: 'idempotent', + machine: { id: config.machine.id, name: config.machine.name }, + hub: { + url: auth?.hub?.url ?? config.hub?.url ?? null, + connected: !!auth, + userEmail: auth?.user?.email ?? null, + }, + agentsDir: config.agents.default, + }, + null, + 2 + ) + ); + return; + } + console.log(chalk.yellow('Already configured:')); + console.log(` Machine: ${config.machine.name} (${config.machine.id.slice(0, 8)})`); + console.log(` Hub: ${auth?.hub?.url ?? config.hub?.url ?? '(none)'}`); + console.log(` User: ${auth?.user?.email ?? '(none)'}`); + console.log( + chalk.dim( + '\nRun `agentage setup --reauth` to re-login or `agentage setup --disconnect` to remove.' + ) + ); +}; + +const printSummary = ( + config: DaemonConfig, + mode: SetupMode, + userEmail: string | null, + opts: SetupOptions +): void => { + if (opts.json) { + console.log( + JSON.stringify( + { + ok: true, + mode, + machine: { id: config.machine.id, name: config.machine.name }, + hub: { + url: config.hub?.url ?? null, + connected: mode !== 'standalone' && userEmail !== null, + userEmail, + }, + agentsDir: config.agents.default, + }, + null, + 2 + ) + ); + return; + } + console.log(chalk.green('Agentage configured:')); + console.log(` Machine: ${config.machine.name} (${config.machine.id.slice(0, 8)})`); + console.log(` Hub: ${config.hub?.url ?? '(none)'}`); + console.log(` Agents dir: ${config.agents.default}`); + if (userEmail) console.log(` User: ${userEmail}`); + if (mode === 'standalone') { + console.log(chalk.dim('\nStandalone mode — run `agentage setup --reauth` to connect to hub.')); + } else { + console.log(chalk.dim('\nRun `agentage status` to see daemon and hub state.')); + } +}; + +export const runSetup = async (opts: SetupOptions): Promise => { + if (opts.reauth && opts.disconnect) { + console.error(chalk.red('Error: --reauth and --disconnect cannot be combined.')); + process.exit(3); + return; + } + + if (opts.disconnect) { + await doDisconnect(opts); + process.exit(0); + return; + } + + const wantsAuth = opts.login !== false; + const interactive = opts.interactive !== false; + const isTty = !!process.stdout.isTTY; + + // Idempotent: existing auth and no explicit re-setup intent + const existingAuth = readAuth(); + const explicitChange = + opts.reauth || opts.token || opts.machineId || opts.name || opts.hub || opts.dir; + if (existingAuth && !explicitChange && wantsAuth) { + printIdempotent(loadConfig(), existingAuth, opts); + process.exit(0); + return; + } + + // Non-interactive guard for browser auth + if (wantsAuth && !opts.token && (!interactive || !isTty)) { + console.error( + chalk.red('Error: cannot prompt for login (not a TTY). Pass --token or --no-login.') + ); + process.exit(2); + return; + } + + ensureMachineIdentity(opts); + const config = mergeConfig(opts); + saveConfig(config); + + const skipConfirm = !!( + opts.yes || + opts.token || + opts.reauth || + !isTty || + opts.interactive === false + ); + + if (!skipConfirm) { + const ok = await confirmConnect(config); + if (!ok) { + console.log(chalk.yellow('Aborted.')); + process.exit(1); + return; + } + } + + await ensureDaemon(); + + let userEmail: string | null = null; + const mode: SetupMode = !wantsAuth ? 'standalone' : opts.reauth ? 'reauth' : 'fresh'; + + if (wantsAuth) { + const hubUrl = config.hub!.url; + if (opts.token) { + doAuthToken(opts.token, hubUrl, config.machine.id); + } else { + await doAuthBrowser(hubUrl, config.machine.id); + } + userEmail = readAuth()?.user?.email ?? null; + } + + printSummary(config, mode, userEmail, opts); + process.exit(0); +}; + +export const registerSetup = (program: Command): void => { + program + .command('setup') + .description('Configure machine, hub, and authentication') + .option('--name ', 'Machine name (default: hostname)') + .option('--machine-id ', 'Pre-assign machine identity (cloud-init)') + .option('--hub ', 'Hub URL (default: https://agentage.io)') + .option('--dir ', 'Agents directory') + .option('--token ', 'Headless: use access token directly, skip browser') + .option('--reauth', 'Re-run OAuth keeping existing config') + .option('--disconnect', 'Deregister + delete auth.json') + .option('--no-login', 'Configure but do not authenticate (standalone mode)') + .option('-y, --yes', 'Skip confirmation') + .option('--no-interactive', 'Refuse to prompt; error if input would be needed') + .option('--force', 'Overwrite existing machine identity on rename') + .option('--json', 'JSON output') + .action(async (opts: SetupOptions) => { + await runSetup(opts); + }); +};