@plosson/agentio 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,514 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, writeFileSync } from 'fs';
3
+ import { spawnSync } from 'bun';
4
+
5
+ import { handleError, CliError } from '../utils/errors';
6
+ import { loadConfig, saveConfig } from '../config/config-manager';
7
+ import { startServer } from '../server/daemon';
8
+ import type { Config } from '../types/config';
9
+ import type { ServerToken } from '../types/server';
10
+
11
+ /**
12
+ * Validate a CLI-supplied port string. Must parse to an integer in [1, 65535].
13
+ * Bun.serve happily accepts NaN / out-of-range / negative numbers and falls
14
+ * back to a default port, which is a footgun — bail loudly instead.
15
+ */
16
+ function parsePort(value: string): number {
17
+ const n = Number(value);
18
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
19
+ throw new CliError(
20
+ 'INVALID_PARAMS',
21
+ `Invalid --port: must be an integer in [1, 65535], got "${value}"`
22
+ );
23
+ }
24
+ return n;
25
+ }
26
+
27
+ const SERVICE_NAME = 'agentio-server';
28
+ const SERVICE_FILE = `/etc/systemd/system/${SERVICE_NAME}.service`;
29
+
30
+ /**
31
+ * Find the agentio binary path. Mirrors src/commands/gateway.ts.
32
+ */
33
+ function findBinaryPath(): string {
34
+ const candidates = [
35
+ '/usr/local/bin/agentio',
36
+ `${process.env.HOME || ''}/.local/bin/agentio`,
37
+ process.argv[1],
38
+ ];
39
+
40
+ for (const path of candidates) {
41
+ if (path && existsSync(path)) {
42
+ const result = spawnSync({
43
+ cmd: ['realpath', path],
44
+ stdout: 'pipe',
45
+ stderr: 'pipe',
46
+ });
47
+ if (result.exitCode === 0) {
48
+ return result.stdout.toString().trim();
49
+ }
50
+ return path;
51
+ }
52
+ }
53
+
54
+ throw new Error('Could not find agentio binary');
55
+ }
56
+
57
+ function isServiceInstalled(): boolean {
58
+ return existsSync(SERVICE_FILE);
59
+ }
60
+
61
+ function checkRoot(): { isRoot: boolean; canSudo: boolean } {
62
+ const whoami = spawnSync({ cmd: ['whoami'], stdout: 'pipe' });
63
+ const isRoot = whoami.stdout.toString().trim() === 'root';
64
+ if (isRoot) return { isRoot: true, canSudo: true };
65
+
66
+ const sudoCheck = spawnSync({
67
+ cmd: ['sudo', '-n', 'true'],
68
+ stdout: 'pipe',
69
+ stderr: 'pipe',
70
+ });
71
+ return { isRoot: false, canSudo: sudoCheck.exitCode === 0 };
72
+ }
73
+
74
+ function runCommand(
75
+ cmd: string[],
76
+ useSudo: boolean
77
+ ): { success: boolean; output: string; error: string } {
78
+ const fullCmd = useSudo ? ['sudo', ...cmd] : cmd;
79
+ const result = spawnSync({ cmd: fullCmd, stdout: 'pipe', stderr: 'pipe' });
80
+ return {
81
+ success: result.exitCode === 0,
82
+ output: result.stdout.toString(),
83
+ error: result.stderr.toString(),
84
+ };
85
+ }
86
+
87
+ function generateServiceFile(binaryPath: string): string {
88
+ return `[Unit]
89
+ Description=agentio HTTP MCP server
90
+ After=network.target
91
+
92
+ [Service]
93
+ Type=simple
94
+ ExecStart=${binaryPath} server start --foreground
95
+ Restart=always
96
+ RestartSec=5
97
+ Environment=HOME=${process.env.HOME}
98
+
99
+ [Install]
100
+ WantedBy=multi-user.target
101
+ `;
102
+ }
103
+
104
+ export function registerServerCommands(program: Command): void {
105
+ const server = program
106
+ .command('server')
107
+ .description('HTTP MCP server daemon management');
108
+
109
+ // install — systemd integration
110
+ server
111
+ .command('install')
112
+ .description('Install agentio server as a systemd service')
113
+ .action(async () => {
114
+ try {
115
+ console.log('Installing agentio-server service...\n');
116
+
117
+ const { isRoot, canSudo } = checkRoot();
118
+ if (!isRoot && !canSudo) {
119
+ console.log('This command requires sudo privileges.');
120
+ console.log('Run with: sudo agentio server install');
121
+ process.exit(1);
122
+ }
123
+ const useSudo = !isRoot;
124
+
125
+ let binaryPath: string;
126
+ try {
127
+ binaryPath = findBinaryPath();
128
+ } catch {
129
+ throw new CliError('CONFIG_ERROR', 'Could not find agentio binary');
130
+ }
131
+
132
+ console.log(`Binary: ${binaryPath}`);
133
+
134
+ console.log('\nCreating systemd service...');
135
+ const serviceContent = generateServiceFile(binaryPath);
136
+ const tempFile = `/tmp/${SERVICE_NAME}.service`;
137
+ writeFileSync(tempFile, serviceContent);
138
+
139
+ const copyResult = runCommand(
140
+ ['cp', tempFile, SERVICE_FILE],
141
+ useSudo
142
+ );
143
+ if (!copyResult.success) {
144
+ throw new CliError(
145
+ 'CONFIG_ERROR',
146
+ `Failed to create service file: ${copyResult.error}`
147
+ );
148
+ }
149
+
150
+ console.log('Enabling service...');
151
+ const commands = [
152
+ ['systemctl', 'daemon-reload'],
153
+ ['systemctl', 'enable', SERVICE_NAME],
154
+ ];
155
+ for (const cmd of commands) {
156
+ const result = runCommand(cmd, useSudo);
157
+ if (!result.success) {
158
+ throw new CliError(
159
+ 'CONFIG_ERROR',
160
+ `Command failed: ${cmd.join(' ')}`
161
+ );
162
+ }
163
+ }
164
+
165
+ console.log('Starting agentio server...');
166
+ const startResult = runCommand(
167
+ ['systemctl', 'start', SERVICE_NAME],
168
+ useSudo
169
+ );
170
+ if (!startResult.success) {
171
+ throw new CliError(
172
+ 'CONFIG_ERROR',
173
+ `Failed to start service: ${startResult.error}`
174
+ );
175
+ }
176
+
177
+ await new Promise((resolve) => setTimeout(resolve, 2000));
178
+ const statusResult = spawnSync({
179
+ cmd: ['systemctl', 'is-active', SERVICE_NAME],
180
+ stdout: 'pipe',
181
+ });
182
+
183
+ if (statusResult.stdout.toString().trim() !== 'active') {
184
+ console.log('\nService failed to start. Check logs with:');
185
+ console.log(' journalctl -u agentio-server -f');
186
+ process.exit(1);
187
+ }
188
+
189
+ console.log('\nagentio-server installed and running!');
190
+ console.log('\nManage with:');
191
+ console.log(' agentio server status');
192
+ console.log(' agentio server stop');
193
+ console.log(' agentio server restart');
194
+ console.log(' agentio server logs');
195
+ } catch (error) {
196
+ handleError(error);
197
+ }
198
+ });
199
+
200
+ // start — foreground or via systemd
201
+ server
202
+ .command('start')
203
+ .description('Start the agentio HTTP MCP server')
204
+ .option('--foreground', 'Run in foreground (used by systemd or for dev)')
205
+ .option('--port <n>', 'Port to bind (default: 9999)')
206
+ .option('--host <host>', 'Host to bind (default: 0.0.0.0)')
207
+ .option('--api-key <key>', 'Override the stored API key for this run only')
208
+ .action(async (options) => {
209
+ try {
210
+ const port = options.port ? parsePort(options.port) : undefined;
211
+
212
+ if (options.foreground) {
213
+ await startServer({
214
+ port,
215
+ host: options.host,
216
+ apiKey: options.apiKey,
217
+ });
218
+ } else if (isServiceInstalled()) {
219
+ const { isRoot } = checkRoot();
220
+ const result = runCommand(
221
+ ['systemctl', 'start', SERVICE_NAME],
222
+ !isRoot
223
+ );
224
+ if (!result.success) {
225
+ throw new CliError(
226
+ 'CONFIG_ERROR',
227
+ `Failed to start: ${result.error}`
228
+ );
229
+ }
230
+ console.log('Server started');
231
+ } else {
232
+ await startServer({
233
+ port,
234
+ host: options.host,
235
+ apiKey: options.apiKey,
236
+ });
237
+ }
238
+ } catch (error) {
239
+ handleError(error);
240
+ }
241
+ });
242
+
243
+ // stop
244
+ server
245
+ .command('stop')
246
+ .description('Stop the agentio server (systemd only)')
247
+ .action(async () => {
248
+ try {
249
+ if (!isServiceInstalled()) {
250
+ console.log('agentio-server service not installed');
251
+ console.log('Run: agentio server install');
252
+ return;
253
+ }
254
+ const { isRoot } = checkRoot();
255
+ const result = runCommand(
256
+ ['systemctl', 'stop', SERVICE_NAME],
257
+ !isRoot
258
+ );
259
+ if (!result.success) {
260
+ throw new CliError('CONFIG_ERROR', `Failed to stop: ${result.error}`);
261
+ }
262
+ console.log('Server stopped');
263
+ } catch (error) {
264
+ handleError(error);
265
+ }
266
+ });
267
+
268
+ // restart
269
+ server
270
+ .command('restart')
271
+ .description('Restart the agentio server (systemd only)')
272
+ .action(async () => {
273
+ try {
274
+ if (!isServiceInstalled()) {
275
+ console.log('agentio-server service not installed');
276
+ console.log('Run: agentio server install');
277
+ return;
278
+ }
279
+ const { isRoot } = checkRoot();
280
+ const result = runCommand(
281
+ ['systemctl', 'restart', SERVICE_NAME],
282
+ !isRoot
283
+ );
284
+ if (!result.success) {
285
+ throw new CliError(
286
+ 'CONFIG_ERROR',
287
+ `Failed to restart: ${result.error}`
288
+ );
289
+ }
290
+ console.log('Server restarted');
291
+ } catch (error) {
292
+ handleError(error);
293
+ }
294
+ });
295
+
296
+ // status
297
+ server
298
+ .command('status')
299
+ .description('Show agentio server status')
300
+ .action(async () => {
301
+ try {
302
+ const config = (await loadConfig()) as Config;
303
+ const port = config.server?.port ?? 9999;
304
+
305
+ if (isServiceInstalled()) {
306
+ const statusResult = spawnSync({
307
+ cmd: ['systemctl', 'is-active', SERVICE_NAME],
308
+ stdout: 'pipe',
309
+ });
310
+ const isActive =
311
+ statusResult.stdout.toString().trim() === 'active';
312
+ console.log(`Server: ${isActive ? 'running' : 'stopped'} (systemd)`);
313
+ } else {
314
+ // Try to probe /health on the configured port.
315
+ try {
316
+ const response = await fetch(`http://127.0.0.1:${port}/health`);
317
+ if (response.ok) {
318
+ console.log(`Server: running (foreground, port ${port})`);
319
+ } else {
320
+ console.log(`Server: not running`);
321
+ }
322
+ } catch {
323
+ console.log('Server: not running');
324
+ }
325
+ }
326
+
327
+ console.log(`API Key: ${
328
+ config.server?.apiKey
329
+ ? config.server.apiKey.slice(0, 12) + '...'
330
+ : '(not set)'
331
+ }`);
332
+ } catch (error) {
333
+ handleError(error);
334
+ }
335
+ });
336
+
337
+ // logs
338
+ server
339
+ .command('logs')
340
+ .description('View agentio server logs (systemd / journalctl)')
341
+ .option('-f, --follow', 'Follow log output')
342
+ .option('-n, --lines <n>', 'Number of lines to show', '50')
343
+ .action(async (options) => {
344
+ try {
345
+ if (!isServiceInstalled()) {
346
+ console.log('agentio-server service not installed');
347
+ console.log(
348
+ 'When running in --foreground, logs go to the terminal directly.'
349
+ );
350
+ return;
351
+ }
352
+
353
+ const args = ['journalctl', '-u', SERVICE_NAME, '--no-pager'];
354
+ if (options.follow) {
355
+ args.push('-f');
356
+ } else {
357
+ args.push('-n', options.lines);
358
+ }
359
+
360
+ const proc = Bun.spawn(args, {
361
+ stdout: 'inherit',
362
+ stderr: 'inherit',
363
+ });
364
+
365
+ if (options.follow) {
366
+ process.on('SIGINT', () => {
367
+ proc.kill();
368
+ process.exit(0);
369
+ });
370
+ }
371
+
372
+ await proc.exited;
373
+ } catch (error) {
374
+ handleError(error);
375
+ }
376
+ });
377
+
378
+ // tokens — manage issued OAuth bearer tokens
379
+ const tokens = server
380
+ .command('tokens')
381
+ .description('Manage issued OAuth bearer tokens');
382
+
383
+ tokens
384
+ .command('list')
385
+ .description('List all issued bearer tokens')
386
+ .action(async () => {
387
+ try {
388
+ const config = (await loadConfig()) as Config;
389
+ const list = config.server?.tokens ?? [];
390
+ if (list.length === 0) {
391
+ console.log('No tokens issued yet.');
392
+ return;
393
+ }
394
+ const fmt = (t: ServerToken) => {
395
+ const issued = new Date(t.issuedAt).toISOString();
396
+ const expires = new Date(t.expiresAt).toISOString();
397
+ const expired = t.expiresAt < Date.now() ? ' (EXPIRED)' : '';
398
+ const id = t.token.slice(0, 12);
399
+ return ` ${id}… client=${t.clientId} scope=${t.scope || '(none)'} issued=${issued} expires=${expires}${expired}`;
400
+ };
401
+ console.log(`${list.length} token(s) issued:`);
402
+ for (const t of list) {
403
+ console.log(fmt(t));
404
+ }
405
+ } catch (error) {
406
+ handleError(error);
407
+ }
408
+ });
409
+
410
+ tokens
411
+ .command('revoke')
412
+ .description(
413
+ 'Revoke a token by its 12-character prefix or full opaque value'
414
+ )
415
+ .argument('<id>', 'Token id (first 12 chars) or full token value')
416
+ .action(async (id: string) => {
417
+ try {
418
+ const config = (await loadConfig()) as Config;
419
+ const list = config.server?.tokens ?? [];
420
+
421
+ // Match either the full token value or any token whose value
422
+ // starts with the given prefix.
423
+ const matches = list.filter(
424
+ (t) => t.token === id || t.token.startsWith(id)
425
+ );
426
+
427
+ if (matches.length === 0) {
428
+ throw new CliError(
429
+ 'NOT_FOUND',
430
+ `No token found matching "${id}"`,
431
+ 'Run `agentio server tokens list` to see issued tokens.'
432
+ );
433
+ }
434
+ if (matches.length > 1) {
435
+ throw new CliError(
436
+ 'INVALID_PARAMS',
437
+ `Ambiguous prefix "${id}" matches ${matches.length} tokens`,
438
+ 'Use a longer prefix or the full token value.'
439
+ );
440
+ }
441
+
442
+ const target = matches[0].token;
443
+ const remaining = list.filter((t) => t.token !== target);
444
+ config.server = {
445
+ ...config.server,
446
+ tokens: remaining,
447
+ };
448
+ await saveConfig(config);
449
+ console.log(`Revoked token ${target.slice(0, 12)}…`);
450
+ console.log(
451
+ 'Note: a running daemon caches tokens in memory and will continue to honor this one until restart.'
452
+ );
453
+ } catch (error) {
454
+ handleError(error);
455
+ }
456
+ });
457
+
458
+ tokens
459
+ .command('clear')
460
+ .description('Revoke ALL issued tokens (forces every client to re-auth)')
461
+ .action(async () => {
462
+ try {
463
+ const config = (await loadConfig()) as Config;
464
+ const count = config.server?.tokens?.length ?? 0;
465
+ config.server = {
466
+ ...config.server,
467
+ tokens: [],
468
+ };
469
+ await saveConfig(config);
470
+ console.log(`Cleared ${count} token(s).`);
471
+ if (count > 0) {
472
+ console.log(
473
+ 'Note: a running daemon caches tokens in memory and will continue to honor them until restart.'
474
+ );
475
+ }
476
+ } catch (error) {
477
+ handleError(error);
478
+ }
479
+ });
480
+
481
+ // uninstall
482
+ server
483
+ .command('uninstall')
484
+ .description('Remove agentio-server systemd service')
485
+ .action(async () => {
486
+ try {
487
+ if (!isServiceInstalled()) {
488
+ console.log('agentio-server service not installed');
489
+ return;
490
+ }
491
+
492
+ const { isRoot } = checkRoot();
493
+ const useSudo = !isRoot;
494
+
495
+ console.log('Stopping and removing agentio-server service...');
496
+ const commands = [
497
+ ['systemctl', 'stop', SERVICE_NAME],
498
+ ['systemctl', 'disable', SERVICE_NAME],
499
+ ['rm', SERVICE_FILE],
500
+ ['systemctl', 'daemon-reload'],
501
+ ];
502
+ for (const cmd of commands) {
503
+ runCommand(cmd, useSudo);
504
+ }
505
+
506
+ console.log('agentio-server service removed');
507
+ console.log(
508
+ '\nNote: Configuration is preserved in ~/.config/agentio/config.json'
509
+ );
510
+ } catch (error) {
511
+ handleError(error);
512
+ }
513
+ });
514
+ }