@plosson/agentio 0.7.1 → 0.7.3

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,329 @@
1
+ /**
2
+ * Thin wrapper around the `siteio` CLI for `agentio mcp teleport`. Every
3
+ * siteio command we need to invoke goes through this module, which
4
+ * delegates to an injected `spawn` function. Tests inject a mock; the
5
+ * production wiring injects a tiny adapter over `Bun.spawn`.
6
+ *
7
+ * Why the indirection: `siteio` is an external dependency I can't run
8
+ * in CI, and I don't want the teleport command tests to require a real
9
+ * `siteio` binary on PATH. The runner captures the exact argv + env
10
+ * that would be invoked, the mock returns stubbed output, and the
11
+ * test asserts the argv lines up with the intended workflow.
12
+ */
13
+
14
+ export interface SpawnedProcess {
15
+ exitCode: number;
16
+ stdout: string;
17
+ stderr: string;
18
+ }
19
+
20
+ export interface SpawnOptions {
21
+ /** Full argv, including the binary name at argv[0]. */
22
+ cmd: string[];
23
+ /** Optional extra env vars (merged over process.env inside the adapter). */
24
+ env?: Record<string, string>;
25
+ }
26
+
27
+ export type SpawnFn = (opts: SpawnOptions) => Promise<SpawnedProcess>;
28
+
29
+ /**
30
+ * Default production `spawn` implementation that shells out to Bun.spawn.
31
+ * Tests pass their own mock instead.
32
+ */
33
+ export const defaultSpawn: SpawnFn = async (opts) => {
34
+ const proc = Bun.spawn(opts.cmd, {
35
+ stdout: 'pipe',
36
+ stderr: 'pipe',
37
+ env: { ...process.env, ...(opts.env ?? {}) },
38
+ });
39
+ const [stdout, stderr, exitCode] = await Promise.all([
40
+ new Response(proc.stdout).text(),
41
+ new Response(proc.stderr).text(),
42
+ proc.exited,
43
+ ]);
44
+ return { exitCode, stdout, stderr };
45
+ };
46
+
47
+ /* ------------------------------------------------------------------ */
48
+ /* SiteioRunner */
49
+ /* ------------------------------------------------------------------ */
50
+
51
+ export interface SiteioApp {
52
+ name: string;
53
+ /** URL the app is (or will be) deployed at. Present when siteio has
54
+ * assigned a subdomain. */
55
+ url?: string;
56
+ /** Free-form additional fields from siteio's JSON output — we pass
57
+ * them through without interpretation. */
58
+ [key: string]: unknown;
59
+ }
60
+
61
+ export interface SiteioRunner {
62
+ /** `siteio --version` → true on exit 0, false on any error. */
63
+ isInstalled(): Promise<boolean>;
64
+ /** `siteio status` → true on exit 0 (i.e. currently logged in). */
65
+ isLoggedIn(): Promise<boolean>;
66
+ /** `siteio apps list --json`, filtered to the named app. */
67
+ findApp(name: string): Promise<SiteioApp | null>;
68
+ /**
69
+ * `siteio apps create <name>` with either:
70
+ * - inline-Dockerfile mode (`-f <path>`), or
71
+ * - git mode (`-g <url> --branch <branch> --dockerfile <path>`).
72
+ * Exactly one of the two must be provided.
73
+ */
74
+ createApp(args: CreateAppArgs): Promise<void>;
75
+ /**
76
+ * `siteio apps set <name> [-e KEY=value]... [-v vol:path]...`.
77
+ *
78
+ * Per siteio's update semantics (see app-storage.ts:62-91 in the
79
+ * siteio repo): env vars MERGE additively (existing keys preserved
80
+ * unless overridden), but volumes REPLACE the entire mount list.
81
+ * Callers that want to preserve other volumes should read appInfo
82
+ * first and union the entries.
83
+ *
84
+ * At least one of envVars/volumes must be provided.
85
+ */
86
+ setApp(args: {
87
+ name: string;
88
+ envVars?: Record<string, string>;
89
+ /** Map of `volumeName` → `mountPath`, e.g. `{ 'agentio-data-mcp': '/data' }`. */
90
+ volumes?: Record<string, string>;
91
+ }): Promise<void>;
92
+ /** `siteio apps deploy <name> [-f <path>] [--no-cache]`. */
93
+ deploy(args: {
94
+ name: string;
95
+ dockerfilePath?: string;
96
+ noCache?: boolean;
97
+ }): Promise<void>;
98
+ /** `siteio apps restart <name>`. Required after `apps set -e` to pick up new env vars. */
99
+ restartApp(name: string): Promise<void>;
100
+ /** `siteio apps info <name> --json` — used to surface the deployed URL. */
101
+ appInfo(name: string): Promise<SiteioApp | null>;
102
+ }
103
+
104
+ /**
105
+ * Arguments for `siteio apps create`. Must be either inline-Dockerfile
106
+ * mode OR git mode; passing both (or neither) is rejected at runtime.
107
+ */
108
+ export type CreateAppArgs =
109
+ | {
110
+ name: string;
111
+ port: number;
112
+ dockerfilePath: string;
113
+ git?: undefined;
114
+ }
115
+ | {
116
+ name: string;
117
+ port: number;
118
+ dockerfilePath?: undefined;
119
+ git: {
120
+ repoUrl: string;
121
+ branch: string;
122
+ /** Path to the Dockerfile inside the repo, e.g. "docker/Dockerfile.teleport". */
123
+ dockerfilePath: string;
124
+ };
125
+ };
126
+
127
+ export interface SiteioRunnerError extends Error {
128
+ command: string[];
129
+ exitCode: number;
130
+ stderr: string;
131
+ }
132
+
133
+ function makeError(
134
+ cmd: string[],
135
+ result: SpawnedProcess,
136
+ context: string
137
+ ): SiteioRunnerError {
138
+ const err = new Error(
139
+ `siteio ${cmd.slice(1).join(' ')} failed (${context}): exit ${result.exitCode}\n${result.stderr.trim()}`
140
+ ) as SiteioRunnerError;
141
+ err.command = cmd;
142
+ err.exitCode = result.exitCode;
143
+ err.stderr = result.stderr;
144
+ return err;
145
+ }
146
+
147
+ export function createSiteioRunner(
148
+ spawn: SpawnFn = defaultSpawn
149
+ ): SiteioRunner {
150
+ return {
151
+ async isInstalled() {
152
+ try {
153
+ const r = await spawn({ cmd: ['siteio', '--version'] });
154
+ return r.exitCode === 0;
155
+ } catch {
156
+ return false;
157
+ }
158
+ },
159
+
160
+ async isLoggedIn() {
161
+ try {
162
+ const r = await spawn({ cmd: ['siteio', 'status'] });
163
+ return r.exitCode === 0;
164
+ } catch {
165
+ return false;
166
+ }
167
+ },
168
+
169
+ async findApp(name) {
170
+ const cmd = ['siteio', 'apps', 'list', '--json'];
171
+ const r = await spawn({ cmd });
172
+ if (r.exitCode !== 0) {
173
+ throw makeError(cmd, r, 'list apps');
174
+ }
175
+ // siteio's CLI mixes progress output on stdout before the JSON
176
+ // payload (e.g. "- Fetching apps\n{...}"). Find the JSON body by
177
+ // looking for the first `{` or `[` that starts a line.
178
+ const jsonStart = r.stdout.search(/^[\[{]/m);
179
+ const jsonText =
180
+ jsonStart >= 0 ? r.stdout.slice(jsonStart) : r.stdout;
181
+
182
+ let parsed: unknown;
183
+ try {
184
+ parsed = JSON.parse(jsonText);
185
+ } catch {
186
+ throw new Error(
187
+ `siteio apps list --json returned unparseable output:\n${r.stdout}`
188
+ );
189
+ }
190
+
191
+ // Normalize the shape: siteio ships {success, data: [...]} in
192
+ // recent versions, older versions may return {apps: [...]} or a
193
+ // bare [...]. Accept all three.
194
+ let apps: SiteioApp[];
195
+ if (Array.isArray(parsed)) {
196
+ apps = parsed as SiteioApp[];
197
+ } else if (
198
+ parsed &&
199
+ typeof parsed === 'object' &&
200
+ Array.isArray((parsed as { data?: unknown }).data)
201
+ ) {
202
+ apps = (parsed as { data: SiteioApp[] }).data;
203
+ } else if (
204
+ parsed &&
205
+ typeof parsed === 'object' &&
206
+ Array.isArray((parsed as { apps?: unknown }).apps)
207
+ ) {
208
+ apps = (parsed as { apps: SiteioApp[] }).apps;
209
+ } else {
210
+ throw new Error(
211
+ `siteio apps list --json: expected an array (or {data:[...]} / {apps:[...]}), got:\n${r.stdout}`
212
+ );
213
+ }
214
+
215
+ const match = apps.find(
216
+ (a) => a && typeof a === 'object' && a.name === name
217
+ );
218
+ return match ?? null;
219
+ },
220
+
221
+ async createApp(args) {
222
+ // Enforce exactly-one-of semantics at runtime too (the type system
223
+ // enforces this at the call site, but a caller coming from JS or
224
+ // forcing a cast could still break the contract).
225
+ const hasInline = typeof args.dockerfilePath === 'string';
226
+ const hasGit = args.git != null;
227
+ if (hasInline === hasGit) {
228
+ throw new Error(
229
+ `createApp: exactly one of dockerfilePath or git must be provided (got inline=${hasInline}, git=${hasGit})`
230
+ );
231
+ }
232
+
233
+ const cmd = ['siteio', 'apps', 'create', args.name];
234
+ if (hasInline) {
235
+ cmd.push('-f', args.dockerfilePath as string);
236
+ } else {
237
+ const g = args.git!;
238
+ cmd.push(
239
+ '-g',
240
+ g.repoUrl,
241
+ '--branch',
242
+ g.branch,
243
+ '--dockerfile',
244
+ g.dockerfilePath
245
+ );
246
+ }
247
+ cmd.push('-p', String(args.port));
248
+
249
+ const r = await spawn({ cmd });
250
+ if (r.exitCode !== 0) {
251
+ throw makeError(cmd, r, `create ${args.name}`);
252
+ }
253
+ },
254
+
255
+ async setApp(args) {
256
+ const envEntries = Object.entries(args.envVars ?? {});
257
+ const volEntries = Object.entries(args.volumes ?? {});
258
+ if (envEntries.length === 0 && volEntries.length === 0) {
259
+ throw new Error(
260
+ `setApp(${args.name}): no envVars or volumes provided — siteio would reject the call`
261
+ );
262
+ }
263
+ const cmd = ['siteio', 'apps', 'set', args.name];
264
+ for (const [key, value] of envEntries) {
265
+ cmd.push('-e', `${key}=${value}`);
266
+ }
267
+ for (const [name, path] of volEntries) {
268
+ cmd.push('-v', `${name}:${path}`);
269
+ }
270
+ const r = await spawn({ cmd });
271
+ if (r.exitCode !== 0) {
272
+ throw makeError(cmd, r, `set on ${args.name}`);
273
+ }
274
+ },
275
+
276
+ async deploy(args) {
277
+ const cmd = ['siteio', 'apps', 'deploy', args.name];
278
+ if (args.dockerfilePath) {
279
+ cmd.push('-f', args.dockerfilePath);
280
+ }
281
+ if (args.noCache) {
282
+ cmd.push('--no-cache');
283
+ }
284
+ const r = await spawn({ cmd });
285
+ if (r.exitCode !== 0) {
286
+ throw makeError(cmd, r, `deploy ${args.name}`);
287
+ }
288
+ },
289
+
290
+ async restartApp(name) {
291
+ const cmd = ['siteio', 'apps', 'restart', name];
292
+ const r = await spawn({ cmd });
293
+ if (r.exitCode !== 0) {
294
+ throw makeError(cmd, r, `restart ${name}`);
295
+ }
296
+ },
297
+
298
+ async appInfo(name) {
299
+ const cmd = ['siteio', 'apps', 'info', name, '--json'];
300
+ const r = await spawn({ cmd });
301
+ if (r.exitCode !== 0) {
302
+ // app doesn't exist or siteio errored — caller can treat null
303
+ // as "could not read info" rather than blow up the whole
304
+ // teleport just because we couldn't get a URL to print.
305
+ return null;
306
+ }
307
+ // Strip any progress-line prefix before the JSON body (siteio's
308
+ // CLI prints status lines on stdout before the --json payload).
309
+ const jsonStart = r.stdout.search(/^[\[{]/m);
310
+ const jsonText =
311
+ jsonStart >= 0 ? r.stdout.slice(jsonStart) : r.stdout;
312
+ try {
313
+ const parsed = JSON.parse(jsonText);
314
+ if (!parsed || typeof parsed !== 'object') return null;
315
+ // Unwrap {success, data: {...}} shape if present.
316
+ if (
317
+ 'data' in parsed &&
318
+ parsed.data &&
319
+ typeof parsed.data === 'object'
320
+ ) {
321
+ return parsed.data as SiteioApp;
322
+ }
323
+ return parsed as SiteioApp;
324
+ } catch {
325
+ return null;
326
+ }
327
+ },
328
+ };
329
+ }
@@ -0,0 +1,201 @@
1
+ import type { Subprocess } from 'bun';
2
+
3
+ /**
4
+ * Shared test helpers for subprocess-based integration tests. Not a test
5
+ * file itself — imported by `*.test.ts` files that spawn the agentio
6
+ * daemon.
7
+ *
8
+ * The main job of this module is to work around a port-allocation race
9
+ * in `findFreePort` + `Bun.spawn`:
10
+ *
11
+ * 1. We ask the OS for a free port via `Bun.serve({ port: 0 })`.
12
+ * 2. We stop the probe, freeing the port.
13
+ * 3. We spawn `agentio server start --port <that port>`.
14
+ *
15
+ * Between steps 2 and 3 another process (another parallel test, or
16
+ * anything on the host) can grab the port, and the subprocess then
17
+ * fails with `Failed to start server. Is port N in use?`.
18
+ *
19
+ * `startServerSubprocess` wraps the whole cycle in a retry loop that
20
+ * catches the "port in use" failure and re-rolls a fresh port, up to
21
+ * a few attempts.
22
+ */
23
+
24
+ export interface StartSubprocessOptions {
25
+ home: string;
26
+ extraArgs?: string[];
27
+ extraEnv?: Record<string, string>;
28
+ /** Max retries on port allocation failure. Default 4. */
29
+ maxRetries?: number;
30
+ }
31
+
32
+ export interface StartedSubprocess {
33
+ proc: Subprocess<'ignore', 'pipe', 'pipe'>;
34
+ port: number;
35
+ apiKey: string;
36
+ startupLog: string;
37
+ }
38
+
39
+ export async function findFreePort(): Promise<number> {
40
+ const probe = Bun.serve({ port: 0, fetch: () => new Response('') });
41
+ const port = probe.port;
42
+ probe.stop(true);
43
+ if (typeof port !== 'number') {
44
+ throw new Error('Bun.serve did not return a numeric port');
45
+ }
46
+ return port;
47
+ }
48
+
49
+ export async function startServerSubprocess(
50
+ opts: StartSubprocessOptions
51
+ ): Promise<StartedSubprocess> {
52
+ const maxRetries = opts.maxRetries ?? 4;
53
+ let lastError: Error | null = null;
54
+
55
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
56
+ const port = await findFreePort();
57
+
58
+ const env: Record<string, string> = {
59
+ ...process.env,
60
+ HOME: opts.home,
61
+ AGENTIO_SERVER_PORT: '',
62
+ AGENTIO_SERVER_HOST: '',
63
+ AGENTIO_SERVER_API_KEY: '',
64
+ ...(opts.extraEnv ?? {}),
65
+ };
66
+ for (const k of [
67
+ 'AGENTIO_SERVER_PORT',
68
+ 'AGENTIO_SERVER_HOST',
69
+ 'AGENTIO_SERVER_API_KEY',
70
+ ]) {
71
+ if (!opts.extraEnv?.[k]) delete env[k];
72
+ }
73
+
74
+ const proc = Bun.spawn(
75
+ [
76
+ 'bun',
77
+ 'run',
78
+ 'src/index.ts',
79
+ 'server',
80
+ 'start',
81
+ '--foreground',
82
+ '--port',
83
+ String(port),
84
+ ...(opts.extraArgs ?? []),
85
+ ],
86
+ { stdout: 'pipe', stderr: 'pipe', env }
87
+ );
88
+
89
+ // Race stdout reads against proc.exited + a hard timeout so we can
90
+ // cleanly distinguish "starting up" from "already died".
91
+ const decoder = new TextDecoder();
92
+ let buffer = '';
93
+ const reader = proc.stdout.getReader();
94
+ const deadline = Date.now() + 10_000;
95
+ let ready = false;
96
+ let died = false;
97
+
98
+ try {
99
+ while (!ready) {
100
+ if (Date.now() > deadline) {
101
+ proc.kill('SIGKILL');
102
+ throw new Error(`startup timeout at attempt ${attempt + 1}:\n${buffer}`);
103
+ }
104
+ const { done, value } = await Promise.race([
105
+ reader.read(),
106
+ new Promise<{ done: true; value: undefined }>((resolve) =>
107
+ setTimeout(
108
+ () => resolve({ done: true, value: undefined }),
109
+ Math.max(100, deadline - Date.now())
110
+ )
111
+ ),
112
+ ]);
113
+ if (done) {
114
+ died = true;
115
+ break;
116
+ }
117
+ buffer += decoder.decode(value);
118
+ if (buffer.includes('Server ready')) {
119
+ ready = true;
120
+ break;
121
+ }
122
+ }
123
+ } finally {
124
+ reader.releaseLock();
125
+ }
126
+
127
+ if (!ready) {
128
+ const stderr = await new Response(proc.stderr).text().catch(() => '');
129
+ const combined = `${buffer}\n${stderr}`;
130
+ // If this was a port collision, retry with a fresh port.
131
+ if (
132
+ died &&
133
+ (combined.includes('Is port') ||
134
+ combined.includes('EADDRINUSE') ||
135
+ combined.includes('address already in use'))
136
+ ) {
137
+ lastError = new Error(
138
+ `port ${port} collided on attempt ${attempt + 1}: ${combined.trim()}`
139
+ );
140
+ // Make sure the process is gone before looping.
141
+ try {
142
+ proc.kill('SIGKILL');
143
+ await proc.exited;
144
+ } catch {
145
+ /* ignore */
146
+ }
147
+ continue;
148
+ }
149
+ // Any other startup failure: throw immediately.
150
+ try {
151
+ proc.kill('SIGKILL');
152
+ await proc.exited;
153
+ } catch {
154
+ /* ignore */
155
+ }
156
+ throw new Error(
157
+ `server failed to start (attempt ${attempt + 1}):\n${combined}`
158
+ );
159
+ }
160
+
161
+ const apiKey = buffer.match(/API Key: (\S+)/)?.[1] ?? '';
162
+ if (!apiKey) {
163
+ proc.kill('SIGKILL');
164
+ await proc.exited;
165
+ throw new Error(
166
+ `could not parse API key from startup log:\n${buffer}`
167
+ );
168
+ }
169
+
170
+ return {
171
+ proc: proc as Subprocess<'ignore', 'pipe', 'pipe'>,
172
+ port,
173
+ apiKey,
174
+ startupLog: buffer,
175
+ };
176
+ }
177
+
178
+ throw new Error(
179
+ `startServerSubprocess: exhausted ${maxRetries} retries. Last error: ${lastError?.message ?? 'unknown'}`
180
+ );
181
+ }
182
+
183
+ export async function shutdown(
184
+ proc: Subprocess<'ignore', 'pipe', 'pipe'>,
185
+ signal: 'SIGTERM' | 'SIGINT' = 'SIGTERM',
186
+ timeoutMs = 5000
187
+ ): Promise<number> {
188
+ proc.kill(signal);
189
+ const result = await Promise.race([
190
+ proc.exited.then((code) => ({ ok: true as const, code })),
191
+ new Promise<{ ok: false }>((resolve) =>
192
+ setTimeout(() => resolve({ ok: false as const }), timeoutMs)
193
+ ),
194
+ ]);
195
+ if (!result.ok) {
196
+ proc.kill('SIGKILL');
197
+ await proc.exited;
198
+ throw new Error(`process did not exit on ${signal} within ${timeoutMs}ms`);
199
+ }
200
+ return result.code;
201
+ }
@@ -1,3 +1,5 @@
1
+ import type { ServerConfig } from './server';
2
+
1
3
  export interface GatewayServerConfig {
2
4
  // Server binding (for running a gateway daemon)
3
5
  port?: number; // Port to bind (default: 7890)
@@ -57,6 +59,7 @@ export interface Config {
57
59
  };
58
60
  env?: Record<string, string>;
59
61
  gateway?: GatewayConfig;
62
+ server?: ServerConfig;
60
63
  }
61
64
 
62
65
  export type ServiceName = 'gdocs' | 'gdrive' | 'gmail' | 'gcal' | 'gtasks' | 'gchat' | 'gsheets' | 'github' | 'jira' | 'slack' | 'telegram' | 'whatsapp' | 'discourse' | 'sql';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Configuration for the agentio HTTP MCP server (`agentio server start`).
3
+ *
4
+ * Stored under `config.server` in `~/.config/agentio/config.json`, mirroring
5
+ * how the gateway daemon stores its own config under `config.gateway`.
6
+ */
7
+ export interface ServerConfig {
8
+ /** Operator API key. Auto-generated on first boot if missing. */
9
+ apiKey?: string;
10
+ /** Port to bind (default: 9999). */
11
+ port?: number;
12
+ /** Host to bind (default: '0.0.0.0'). */
13
+ host?: string;
14
+ /** Dynamically registered OAuth clients (RFC 7591). */
15
+ clients?: OAuthClient[];
16
+ /** Issued bearer tokens (RFC 6749). */
17
+ tokens?: ServerToken[];
18
+ }
19
+
20
+ /**
21
+ * An OAuth client registered via Dynamic Client Registration. Public clients
22
+ * only — no client_secret. Identified by an opaque client_id.
23
+ */
24
+ export interface OAuthClient {
25
+ clientId: string;
26
+ clientName?: string;
27
+ redirectUris: string[];
28
+ /** Unix epoch milliseconds. */
29
+ createdAt: number;
30
+ }
31
+
32
+ /**
33
+ * An issued bearer token. Opaque, 32 random bytes base64url. Persists across
34
+ * server restarts; expires after 30 days unless revoked.
35
+ */
36
+ export interface ServerToken {
37
+ token: string;
38
+ clientId: string;
39
+ /** The `?services=...` query the client supplied at /authorize. */
40
+ scope: string;
41
+ /** Unix epoch milliseconds. */
42
+ issuedAt: number;
43
+ /** Unix epoch milliseconds. */
44
+ expiresAt: number;
45
+ }
46
+
47
+ /**
48
+ * A short-lived authorization code. Stored in-memory only — never persisted
49
+ * to disk. The full flow takes seconds, so a process restart between
50
+ * /authorize and /token is acceptable cause for re-auth.
51
+ */
52
+ export interface AuthCode {
53
+ code: string;
54
+ clientId: string;
55
+ redirectUri: string;
56
+ /** PKCE S256 challenge from /authorize. */
57
+ codeChallenge: string;
58
+ scope: string;
59
+ /** Unix epoch milliseconds. */
60
+ expiresAt: number;
61
+ }