@plosson/agentio 0.4.4 → 0.5.2

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.
@@ -1,10 +1,8 @@
1
1
  import { join } from 'path';
2
- import { readFile, writeFile, unlink } from 'fs/promises';
3
- import { existsSync, openSync, closeSync, constants } from 'fs';
4
- import { spawn } from 'child_process';
5
- import type { ServiceName } from '../types/config';
6
- import type { GatewayConfig, DaemonState, DEFAULT_GATEWAY_CONFIG } from './types';
7
- import { CONFIG_DIR, loadConfig } from '../config/config-manager';
2
+ import { randomBytes } from 'crypto';
3
+ import type { ServiceName, Config } from '../types/config';
4
+ import type { GatewayConfig } from './types';
5
+ import { CONFIG_DIR, loadConfig, saveConfig } from '../config/config-manager';
8
6
  import { getCredentials } from '../auth/token-store';
9
7
  import { initDatabase, closeDatabase, insertInboxMessage, inboxMessageExists, getPendingOutboxMessages, updateOutboxStatus, cleanupInbox, cleanupOutbox } from './store';
10
8
  import { startApiServer, stopApiServer } from './api';
@@ -15,10 +13,8 @@ import { WhatsAppAdapter } from './adapters/whatsapp';
15
13
  import type { TelegramCredentials } from '../types/telegram';
16
14
  import type { WhatsAppCredentials } from '../types/whatsapp';
17
15
 
18
- const PID_FILE = join(CONFIG_DIR, 'gateway.pid');
19
16
  const LOG_FILE = join(CONFIG_DIR, 'gateway.log');
20
17
 
21
- let isRunning = false;
22
18
  let shutdownRequested = false;
23
19
  let adapters: Map<ServiceName, ServiceAdapter> = new Map();
24
20
  let outboxInterval: ReturnType<typeof setInterval> | null = null;
@@ -32,46 +28,6 @@ export async function getGatewayConfig(): Promise<GatewayConfig> {
32
28
  return (config as unknown as { gateway?: GatewayConfig }).gateway ?? {};
33
29
  }
34
30
 
35
- /**
36
- * Check if daemon is running
37
- */
38
- export async function isDaemonRunning(): Promise<{ running: boolean; pid?: number }> {
39
- if (!existsSync(PID_FILE)) {
40
- return { running: false };
41
- }
42
-
43
- try {
44
- const pidStr = await readFile(PID_FILE, 'utf-8');
45
- const pid = parseInt(pidStr.trim(), 10);
46
-
47
- // Check if process is still running
48
- try {
49
- process.kill(pid, 0); // Doesn't kill, just checks
50
- return { running: true, pid };
51
- } catch {
52
- // Process not running, clean up stale PID file
53
- await unlink(PID_FILE).catch(() => {});
54
- return { running: false };
55
- }
56
- } catch {
57
- return { running: false };
58
- }
59
- }
60
-
61
- /**
62
- * Write PID file
63
- */
64
- async function writePidFile(): Promise<void> {
65
- await writeFile(PID_FILE, process.pid.toString(), { mode: 0o600 });
66
- }
67
-
68
- /**
69
- * Remove PID file
70
- */
71
- async function removePidFile(): Promise<void> {
72
- await unlink(PID_FILE).catch(() => {});
73
- }
74
-
75
31
  /**
76
32
  * Handle inbound message from adapter
77
33
  */
@@ -191,16 +147,17 @@ async function initializeAdapters(): Promise<void> {
191
147
  handleInboundMessage('telegram', profile, message);
192
148
  };
193
149
 
194
- for (const profile of telegramProfiles) {
150
+ for (const entry of telegramProfiles) {
151
+ const profileName = typeof entry === 'string' ? entry : entry.name;
195
152
  try {
196
- const credentials = await getCredentials<TelegramCredentials>('telegram', profile);
153
+ const credentials = await getCredentials<TelegramCredentials>('telegram', profileName);
197
154
  if (credentials) {
198
- await telegramAdapter.connect(profile, credentials);
155
+ await telegramAdapter.connect(profileName, credentials);
199
156
  } else {
200
- console.error(`[telegram] No credentials for profile: ${profile}`);
157
+ console.error(`[telegram] No credentials for profile: ${profileName}`);
201
158
  }
202
159
  } catch (error) {
203
- console.error(`[telegram] Failed to connect ${profile}:`, error instanceof Error ? error.message : error);
160
+ console.error(`[telegram] Failed to connect ${profileName}:`, error instanceof Error ? error.message : error);
204
161
  }
205
162
  }
206
163
 
@@ -215,16 +172,17 @@ async function initializeAdapters(): Promise<void> {
215
172
  handleInboundMessage('whatsapp', profile, message);
216
173
  };
217
174
 
218
- for (const profile of whatsappProfiles) {
175
+ for (const entry of whatsappProfiles) {
176
+ const profileName = typeof entry === 'string' ? entry : entry.name;
219
177
  try {
220
- const credentials = await getCredentials<WhatsAppCredentials>('whatsapp', profile);
178
+ const credentials = await getCredentials<WhatsAppCredentials>('whatsapp', profileName);
221
179
  if (credentials) {
222
- await whatsappAdapter.connect(profile, credentials);
180
+ await whatsappAdapter.connect(profileName, credentials);
223
181
  } else {
224
- console.error(`[whatsapp] No credentials for profile: ${profile}`);
182
+ console.error(`[whatsapp] No credentials for profile: ${profileName}`);
225
183
  }
226
184
  } catch (error) {
227
- console.error(`[whatsapp] Failed to connect ${profile}:`, error instanceof Error ? error.message : error);
185
+ console.error(`[whatsapp] Failed to connect ${profileName}:`, error instanceof Error ? error.message : error);
228
186
  }
229
187
  }
230
188
 
@@ -248,52 +206,10 @@ async function shutdownAdapters(): Promise<void> {
248
206
  }
249
207
 
250
208
  /**
251
- * Start the gateway daemon
209
+ * Start the gateway server (runs in foreground, managed by systemd)
252
210
  */
253
- export async function startDaemon(options: { foreground?: boolean } = {}): Promise<void> {
254
- // Check if already running
255
- const status = await isDaemonRunning();
256
- if (status.running) {
257
- throw new Error(`Gateway already running (PID ${status.pid})`);
258
- }
259
-
260
- if (!options.foreground) {
261
- // Fork to background
262
- // Find the script path from argv or use import.meta to get current file location
263
- let scriptPath: string;
264
-
265
- // import.meta.path gives us the current file path, navigate to index.ts
266
- const currentFile = import.meta.path || import.meta.url.replace('file://', '');
267
- const srcDir = join(currentFile, '..', '..');
268
- scriptPath = join(srcDir, 'index.ts');
269
-
270
- // Verify the path exists, fallback to cwd-based path
271
- if (!existsSync(scriptPath)) {
272
- scriptPath = join(process.cwd(), 'src', 'index.ts');
273
- }
274
-
275
- // Open log file for appending - child writes directly to file
276
- const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND, 0o644);
277
-
278
- const child = spawn(process.execPath, [scriptPath, 'gateway', 'start', '--foreground'], {
279
- detached: true,
280
- stdio: ['ignore', logFd, logFd],
281
- env: process.env,
282
- });
283
-
284
- child.unref();
285
-
286
- // Close the fd in parent - child has its own copy
287
- closeSync(logFd);
288
-
289
- console.log(`Gateway started in background (PID ${child.pid})`);
290
- console.log(`Logs: ${LOG_FILE}`);
291
- return;
292
- }
293
-
294
- // Running in foreground
295
- isRunning = true;
296
- console.log(`Gateway starting (PID ${process.pid})`);
211
+ export async function startGateway(): Promise<void> {
212
+ console.log(`agentio-gateway starting (PID ${process.pid})`);
297
213
 
298
214
  // Handle shutdown signals
299
215
  const shutdown = async (signal: string) => {
@@ -319,9 +235,6 @@ export async function startDaemon(options: { foreground?: boolean } = {}): Promi
319
235
  // Close database
320
236
  closeDatabase();
321
237
 
322
- // Remove PID file
323
- await removePidFile();
324
-
325
238
  console.log('Gateway stopped');
326
239
  process.exit(0);
327
240
  };
@@ -330,11 +243,20 @@ export async function startDaemon(options: { foreground?: boolean } = {}): Promi
330
243
  process.on('SIGTERM', () => shutdown('SIGTERM'));
331
244
 
332
245
  try {
333
- // Write PID file
334
- await writePidFile();
335
-
336
- // Load config
337
- const gatewayConfig = await getGatewayConfig();
246
+ // Load config and auto-generate API key on first run
247
+ const config = await loadConfig() as Config;
248
+ let gatewayConfig = config.gateway ?? {};
249
+
250
+ if (!gatewayConfig.apiKey) {
251
+ const generatedKey = `gw_${randomBytes(24).toString('base64url')}`;
252
+ gatewayConfig = {
253
+ ...gatewayConfig,
254
+ apiKey: generatedKey,
255
+ };
256
+ config.gateway = gatewayConfig;
257
+ await saveConfig(config);
258
+ console.log(`First run - generated API key: ${generatedKey}`);
259
+ }
338
260
 
339
261
  // Initialize database
340
262
  await initDatabase();
@@ -350,7 +272,7 @@ export async function startDaemon(options: { foreground?: boolean } = {}): Promi
350
272
  await initializeAdapters();
351
273
 
352
274
  // Start API server
353
- startApiServer(gatewayConfig.api, adapters);
275
+ startApiServer(gatewayConfig, adapters);
354
276
 
355
277
  // Start outbox processor (every 2 seconds)
356
278
  outboxInterval = setInterval(processOutbox, 2000);
@@ -364,98 +286,8 @@ export async function startDaemon(options: { foreground?: boolean } = {}): Promi
364
286
  await new Promise(() => {}); // Wait forever
365
287
  } catch (error) {
366
288
  console.error('Gateway error:', error instanceof Error ? error.message : error);
367
- await removePidFile();
368
289
  process.exit(1);
369
290
  }
370
291
  }
371
292
 
372
- /**
373
- * Stop the gateway daemon
374
- */
375
- export async function stopDaemon(): Promise<void> {
376
- const status = await isDaemonRunning();
377
- if (!status.running || !status.pid) {
378
- console.log('Gateway is not running');
379
- return;
380
- }
381
-
382
- try {
383
- process.kill(status.pid, 'SIGTERM');
384
- console.log(`Sent SIGTERM to gateway (PID ${status.pid})`);
385
-
386
- // Wait for process to stop
387
- for (let i = 0; i < 30; i++) {
388
- await new Promise((resolve) => setTimeout(resolve, 100));
389
- try {
390
- process.kill(status.pid, 0);
391
- } catch {
392
- console.log('Gateway stopped');
393
- return;
394
- }
395
- }
396
-
397
- // Force kill if still running
398
- try {
399
- process.kill(status.pid, 'SIGKILL');
400
- console.log('Gateway force killed');
401
- } catch {
402
- console.log('Gateway stopped');
403
- }
404
- } catch (error) {
405
- console.error('Failed to stop gateway:', error instanceof Error ? error.message : error);
406
- }
407
- }
408
-
409
- /**
410
- * Get daemon status
411
- */
412
- export async function getDaemonStatus(): Promise<{
413
- running: boolean;
414
- pid?: number;
415
- adapters?: { service: string; profile: string; connected: boolean }[];
416
- }> {
417
- const status = await isDaemonRunning();
418
- if (!status.running) {
419
- return { running: false };
420
- }
421
-
422
- // Try to get status from API
423
- const gatewayConfig = await getGatewayConfig();
424
- const port = gatewayConfig.api?.port ?? 7890;
425
- const host = gatewayConfig.api?.host ?? '127.0.0.1';
426
-
427
- try {
428
- const response = await fetch(`http://${host}:${port}/status`, {
429
- headers: gatewayConfig.api?.secret ? { Authorization: `Bearer ${gatewayConfig.api.secret}` } : {},
430
- });
431
-
432
- if (response.ok) {
433
- const data = await response.json() as { adapters: { service: string; profile: string; connected: boolean }[] };
434
- return {
435
- running: true,
436
- pid: status.pid,
437
- adapters: data.adapters,
438
- };
439
- }
440
- } catch {
441
- // API not responding, but process exists
442
- }
443
-
444
- return { running: true, pid: status.pid };
445
- }
446
-
447
- /**
448
- * Reload daemon configuration
449
- */
450
- export async function reloadDaemon(): Promise<void> {
451
- const status = await isDaemonRunning();
452
- if (!status.running || !status.pid) {
453
- throw new Error('Gateway is not running');
454
- }
455
-
456
- // Send SIGHUP to trigger reload
457
- process.kill(status.pid, 'SIGHUP');
458
- console.log(`Sent reload signal to gateway (PID ${status.pid})`);
459
- }
460
-
461
- export { PID_FILE, LOG_FILE };
293
+ export { LOG_FILE };
@@ -59,11 +59,11 @@ export type OutboxStatus = 'pending' | 'sending' | 'sent' | 'failed';
59
59
 
60
60
  export const DEFAULT_GATEWAY_CONFIG: Required<GatewayConfig> = {
61
61
  name: '',
62
- secret: '',
63
- api: {
62
+ apiUrl: '',
63
+ apiKey: '',
64
+ server: {
64
65
  port: 7890,
65
- host: '127.0.0.1',
66
- secret: '',
66
+ host: '0.0.0.0', // Bind to all interfaces by default for server
67
67
  },
68
68
  webhook: {
69
69
  url: '',
@@ -132,15 +132,25 @@ export class GDriveClient implements ServiceClient {
132
132
  }
133
133
 
134
134
  const q = queryParts.join(' and ') || undefined;
135
-
136
- const response = await this.drive.files.list({
137
- pageSize: Math.min(limit, 100),
138
- q,
139
- fields: 'files(id,name,mimeType,size,createdTime,modifiedTime,owners,parents,webViewLink,webContentLink,starred,trashed,shared,description)',
140
- orderBy,
141
- });
142
-
143
- return (response.data.files || []).map(this.parseFile);
135
+ const allFiles: GDriveFile[] = [];
136
+ let pageToken: string | undefined;
137
+
138
+ // Paginate through results until we have enough or no more pages
139
+ do {
140
+ const response = await this.drive.files.list({
141
+ pageSize: Math.min(limit - allFiles.length, 100),
142
+ pageToken,
143
+ q,
144
+ fields: 'nextPageToken,files(id,name,mimeType,size,createdTime,modifiedTime,owners,parents,webViewLink,webContentLink,starred,trashed,shared,description)',
145
+ orderBy,
146
+ });
147
+
148
+ const files = (response.data.files || []).map(this.parseFile);
149
+ allFiles.push(...files);
150
+ pageToken = response.data.nextPageToken || undefined;
151
+ } while (pageToken && allFiles.length < limit);
152
+
153
+ return allFiles.slice(0, limit);
144
154
  } catch (err) {
145
155
  this.throwApiError(err, 'list files');
146
156
  }
@@ -1,7 +1,7 @@
1
- export interface GatewayApiConfig {
2
- port?: number;
3
- host?: string;
4
- secret?: string;
1
+ export interface GatewayServerConfig {
2
+ // Server binding (for running a gateway daemon)
3
+ port?: number; // Port to bind (default: 7890)
4
+ host?: string; // Host to bind (default: 0.0.0.0 for server)
5
5
  }
6
6
 
7
7
  export interface GatewayWebhookConfig {
@@ -21,30 +21,39 @@ export interface GatewayRetentionConfig {
21
21
  }
22
22
 
23
23
  export interface GatewayConfig {
24
- name?: string; // Gateway identity name
25
- secret?: string; // Shared secret for API auth and teleport
26
- api?: GatewayApiConfig;
24
+ name?: string; // Gateway identity name (for local server identification)
25
+ apiUrl?: string; // Gateway URL (e.g., "https://gateway.example.com:7890")
26
+ apiKey?: string; // API key for authentication
27
+ server?: GatewayServerConfig; // Server binding settings (for running daemon)
27
28
  webhook?: GatewayWebhookConfig;
28
29
  media?: GatewayMediaConfig;
29
30
  retention?: GatewayRetentionConfig;
30
31
  }
31
32
 
33
+ export interface ProfileEntry {
34
+ name: string;
35
+ readOnly?: boolean;
36
+ }
37
+
38
+ // Helper type for backward compatibility during migration
39
+ export type ProfileValue = string | ProfileEntry;
40
+
32
41
  export interface Config {
33
42
  profiles: {
34
- gdocs?: string[];
35
- gdrive?: string[];
36
- gmail?: string[];
37
- gcal?: string[];
38
- gtasks?: string[];
39
- gchat?: string[];
40
- gsheets?: string[];
41
- github?: string[];
42
- jira?: string[];
43
- slack?: string[];
44
- telegram?: string[];
45
- whatsapp?: string[];
46
- discourse?: string[];
47
- sql?: string[];
43
+ gdocs?: ProfileValue[];
44
+ gdrive?: ProfileValue[];
45
+ gmail?: ProfileValue[];
46
+ gcal?: ProfileValue[];
47
+ gtasks?: ProfileValue[];
48
+ gchat?: ProfileValue[];
49
+ gsheets?: ProfileValue[];
50
+ github?: ProfileValue[];
51
+ jira?: ProfileValue[];
52
+ slack?: ProfileValue[];
53
+ telegram?: ProfileValue[];
54
+ whatsapp?: ProfileValue[];
55
+ discourse?: ProfileValue[];
56
+ sql?: ProfileValue[];
48
57
  };
49
58
  env?: Record<string, string>;
50
59
  gateway?: GatewayConfig;
@@ -515,22 +515,15 @@ export function printGDriveFileList(files: GDriveFile[], title: string = 'Files'
515
515
 
516
516
  console.log(`${title} (${files.length})\n`);
517
517
 
518
- for (let i = 0; i < files.length; i++) {
519
- const file = files[i];
518
+ for (const file of files) {
520
519
  const isFolder = file.mimeType === 'application/vnd.google-apps.folder';
521
- const typeIndicator = isFolder ? '[folder]' : `[${getShortMimeType(file.mimeType)}]`;
522
- const flags: string[] = [];
523
- if (file.starred) flags.push('*');
524
- if (file.shared) flags.push('shared');
525
- const flagStr = flags.length > 0 ? ` (${flags.join(', ')})` : '';
526
-
527
- console.log(`[${i + 1}] ${file.name} ${typeIndicator}${flagStr}`);
528
- console.log(` ID: ${file.id}`);
529
- if (file.size) console.log(` Size: ${formatBytes(file.size)}`);
530
- if (file.owners?.length) console.log(` Owner: ${file.owners[0]}`);
531
- if (file.modifiedTime) console.log(` Modified: ${file.modifiedTime}`);
532
- if (file.webViewLink) console.log(` Link: ${file.webViewLink}`);
533
- console.log('');
520
+ const type = getShortMimeType(file.mimeType).padEnd(7);
521
+ const size = file.size ? formatBytes(file.size).padStart(8) : ' -';
522
+ const date = file.modifiedTime ? file.modifiedTime.slice(0, 10) : ' ';
523
+ const flags = (file.starred ? '*' : '') + (file.shared ? '⇄' : '');
524
+ const name = isFolder ? `${file.name}/` : file.name;
525
+ console.log(`${type} ${size} ${date} ${flags.padEnd(2)} ${name}`);
526
+ console.log(` ${file.id}`);
534
527
  }
535
528
  }
536
529
 
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
- import { listProfiles, removeProfile } from '../config/config-manager';
2
+ import { listProfiles, removeProfile, setProfileReadOnly } from '../config/config-manager';
3
3
  import { removeCredentials, getCredentials } from '../auth/token-store';
4
- import { handleError } from './errors';
4
+ import { handleError, CliError } from './errors';
5
5
  import type { ServiceName } from '../types/config';
6
6
 
7
7
  export interface ProfileCommandsOptions<T> {
@@ -31,10 +31,11 @@ export function createProfileCommands<T>(
31
31
  if (profiles.length === 0) {
32
32
  console.log('No profiles configured');
33
33
  } else {
34
- for (const name of profiles) {
35
- const credentials = await getCredentials<T>(service, name);
34
+ for (const entry of profiles) {
35
+ const credentials = await getCredentials<T>(service, entry.name);
36
36
  const extraInfo = getExtraInfo ? getExtraInfo(credentials) : '';
37
- console.log(`${name}${extraInfo}`);
37
+ const readOnlyIndicator = entry.readOnly ? ' [read-only]' : '';
38
+ console.log(`${entry.name}${readOnlyIndicator}${extraInfo}`);
38
39
  }
39
40
  }
40
41
  } catch (error) {
@@ -42,6 +43,36 @@ export function createProfileCommands<T>(
42
43
  }
43
44
  });
44
45
 
46
+ profile
47
+ .command('update')
48
+ .description(`Update a ${displayName} profile`)
49
+ .requiredOption('--profile <name>', 'Profile name')
50
+ .option('--read-only', 'Set profile as read-only')
51
+ .option('--no-read-only', 'Remove read-only restriction')
52
+ .action(async (opts) => {
53
+ try {
54
+ const profileName = opts.profile;
55
+
56
+ // Check if read-only flag is explicitly set or unset
57
+ if (opts.readOnly === undefined) {
58
+ throw new CliError('INVALID_PARAMS', 'No update specified', 'Use --read-only or --no-read-only');
59
+ }
60
+
61
+ const updated = await setProfileReadOnly(service, profileName, opts.readOnly);
62
+ if (!updated) {
63
+ throw new CliError('PROFILE_NOT_FOUND', `Profile "${profileName}" not found`);
64
+ }
65
+
66
+ if (opts.readOnly) {
67
+ console.log(`Profile "${profileName}" is now read-only`);
68
+ } else {
69
+ console.log(`Profile "${profileName}" read-only restriction removed`);
70
+ }
71
+ } catch (error) {
72
+ handleError(error);
73
+ }
74
+ });
75
+
45
76
  profile
46
77
  .command('remove')
47
78
  .description(`Remove a ${displayName} profile`)
@@ -0,0 +1,22 @@
1
+ import { CliError } from './errors';
2
+ import { isProfileReadOnly } from '../config/config-manager';
3
+ import type { ServiceName } from '../types/config';
4
+
5
+ /**
6
+ * Enforce that a profile has write access for a given operation.
7
+ * Throws a CliError if the profile is read-only.
8
+ */
9
+ export async function enforceWriteAccess(
10
+ service: ServiceName,
11
+ profile: string,
12
+ operation: string
13
+ ): Promise<void> {
14
+ const readOnly = await isProfileReadOnly(service, profile);
15
+ if (readOnly) {
16
+ throw new CliError(
17
+ 'PERMISSION_DENIED',
18
+ `Cannot ${operation}: profile "${profile}" is read-only`,
19
+ `To modify this profile's access: agentio ${service} profile update --profile ${profile} --no-read-only`
20
+ );
21
+ }
22
+ }