@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,882 @@
1
+ import { Command } from 'commander';
2
+ import { randomBytes } from 'crypto';
3
+ import { writeFile, unlink, mkdtemp } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+
7
+ import { generateExportData } from './config';
8
+ import { loadConfig } from '../config/config-manager';
9
+ import { generateTeleportDockerfile } from '../server/dockerfile-gen';
10
+ import {
11
+ createSiteioRunner,
12
+ type SiteioRunner,
13
+ } from '../server/siteio-runner';
14
+ import { handleError, CliError } from '../utils/errors';
15
+ import type { Config } from '../types/config';
16
+
17
+ /**
18
+ * `agentio mcp teleport <name>` — one-command deploy of the local agentio
19
+ * HTTP MCP server to a siteio-managed remote.
20
+ *
21
+ * Flow:
22
+ * 1. Preflight: siteio installed, siteio logged in, at least one local
23
+ * profile configured.
24
+ * 2. Refuse if an app with the same name already exists on siteio
25
+ * (warn + exit; user has to `siteio apps rm <name>` to reuse it).
26
+ * 3. Generate a fresh AGENTIO_SERVER_API_KEY (we do NOT reuse the
27
+ * local one — each deployment is independent).
28
+ * 4. Export the local profiles + credentials via `generateExportData()`
29
+ * (same encrypted blob `agentio config export --all` produces).
30
+ * 5. Generate an inline Dockerfile via generateTeleportDockerfile().
31
+ * 6. Write the Dockerfile to a temp file.
32
+ * 7. `siteio apps create <name> -f <tempfile> -p 9999`
33
+ * 8. `siteio apps set <name> -e AGENTIO_KEY=... -e AGENTIO_CONFIG=... -e AGENTIO_SERVER_API_KEY=...`
34
+ * 9. `siteio apps deploy <name>`
35
+ * 10. Print the deployed URL (from `siteio apps info`), the API key,
36
+ * and the `claude mcp add` command the user should run locally.
37
+ * 11. Delete the temp Dockerfile in a finally (always).
38
+ *
39
+ * Dry-run and dockerfile-only modes skip parts of the flow for
40
+ * inspection + offline development.
41
+ */
42
+
43
+ /** Path inside the repo to the teleport Dockerfile (relative to repo root). */
44
+ export const TELEPORT_DOCKERFILE_PATH = 'docker/Dockerfile.teleport';
45
+
46
+ /** Container path where agentio writes config + tokens.enc. */
47
+ export const DATA_VOLUME_PATH = '/data';
48
+
49
+ /**
50
+ * Compute the named-volume identifier for an app. Per-app suffix avoids
51
+ * collisions when a user deploys multiple agentio instances on the same
52
+ * siteio agent (e.g. mcp-prod + mcp-staging).
53
+ */
54
+ export function volumeNameFor(appName: string): string {
55
+ return `agentio-data-${appName}`;
56
+ }
57
+
58
+ /**
59
+ * Inspect an app's `volumes` field (as returned by `siteio apps info`)
60
+ * and decide whether `/data` is already mounted. Tolerates the various
61
+ * shapes siteio might return (array of strings, array of objects with
62
+ * `path` or `mountPath`, or absent).
63
+ */
64
+ export function hasDataVolumeMount(appInfo: unknown): boolean {
65
+ if (!appInfo || typeof appInfo !== 'object') return false;
66
+ const vols = (appInfo as { volumes?: unknown }).volumes;
67
+ if (!Array.isArray(vols)) return false;
68
+ return vols.some((v) => {
69
+ if (typeof v === 'string') return v.endsWith(`:${DATA_VOLUME_PATH}`);
70
+ if (v && typeof v === 'object') {
71
+ const obj = v as Record<string, unknown>;
72
+ return obj.path === DATA_VOLUME_PATH || obj.mountPath === DATA_VOLUME_PATH;
73
+ }
74
+ return false;
75
+ });
76
+ }
77
+
78
+ /** Siteio app names must match this pattern. Mirrors Docker image name rules. */
79
+ const VALID_NAME = /^[a-z][a-z0-9-]{0,62}$/;
80
+
81
+ /**
82
+ * Normalize common git URL shapes to an HTTPS URL that siteio's agent
83
+ * can clone without credentials. SSH URLs (`git@github.com:owner/repo.git`)
84
+ * are converted; HTTPS URLs pass through unchanged.
85
+ */
86
+ export function normalizeGitUrl(url: string): string {
87
+ const trimmed = url.trim();
88
+ // git@github.com:owner/repo.git → https://github.com/owner/repo.git
89
+ // git@gitlab.example.com:group/sub/repo.git → https://gitlab.example.com/group/sub/repo.git
90
+ const sshMatch = trimmed.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
91
+ if (sshMatch) {
92
+ const host = sshMatch[1];
93
+ const path = sshMatch[2];
94
+ return `https://${host}/${path}.git`;
95
+ }
96
+ // Already HTTPS / HTTP — leave alone.
97
+ if (/^https?:\/\//.test(trimmed)) {
98
+ return trimmed;
99
+ }
100
+ // Unknown shape — return as-is and let siteio report the error.
101
+ return trimmed;
102
+ }
103
+
104
+ export function validateAppName(name: string): void {
105
+ if (!VALID_NAME.test(name)) {
106
+ throw new CliError(
107
+ 'INVALID_PARAMS',
108
+ `Invalid app name "${name}"`,
109
+ 'Use lowercase letters, digits, and hyphens only (must start with a letter, max 63 chars).'
110
+ );
111
+ }
112
+ }
113
+
114
+ export function generateServerApiKey(): string {
115
+ return `srv_${randomBytes(24).toString('base64url')}`;
116
+ }
117
+
118
+ /**
119
+ * Dependency-injected internals so the command can be unit-tested
120
+ * without touching disk, spawning siteio, or hitting the real config.
121
+ */
122
+ export interface TeleportDeps {
123
+ runner: SiteioRunner;
124
+ loadConfig: () => Promise<Config>;
125
+ generateExportData: () => Promise<{ key: string; config: string }>;
126
+ generateServerApiKey: () => string;
127
+ generateDockerfile: () => string;
128
+ writeTempFile: (content: string) => Promise<string>;
129
+ removeTempFile: (path: string) => Promise<void>;
130
+ /**
131
+ * Return the git origin URL of the current project, or null if the
132
+ * cwd isn't a git repo / has no origin remote. Used to compute the
133
+ * siteio `--git` argument in git-mode.
134
+ */
135
+ detectGitOriginUrl: () => Promise<string | null>;
136
+ /**
137
+ * HTTP probe used by `waitForHealth`. Returns the status code (200 on
138
+ * a healthy server). Network errors are surfaced as `null` so the
139
+ * poller can treat them the same as a not-yet-ready container.
140
+ */
141
+ probeHealth: (url: string) => Promise<number | null>;
142
+ /** Resolved after `ms` milliseconds. Injected for testability. */
143
+ sleep: (ms: number) => Promise<void>;
144
+ log: (msg: string) => void;
145
+ warn: (msg: string) => void;
146
+ }
147
+
148
+ /* ------------------------------------------------------------------ */
149
+ /* health polling */
150
+ /* ------------------------------------------------------------------ */
151
+
152
+ /** How long to wait for /health to return 200 before giving up. */
153
+ export const HEALTH_TIMEOUT_MS = 90_000;
154
+ /** Spacing between consecutive /health probes. */
155
+ export const HEALTH_INTERVAL_MS = 2_000;
156
+ /** Number of log lines to surface when the health check times out. */
157
+ export const HEALTH_FAILURE_LOG_TAIL = 50;
158
+
159
+ /**
160
+ * Poll `${url}/health` until it returns 200 or we exhaust the attempt
161
+ * budget (ceil(timeoutMs / intervalMs)). Returns true on success; false
162
+ * otherwise. Uses an attempt-count loop (not wall clock) so tests that
163
+ * stub `deps.sleep` to a no-op can exercise the timeout path without
164
+ * actually waiting 90 real seconds.
165
+ */
166
+ export async function waitForHealth(
167
+ url: string,
168
+ deps: Pick<TeleportDeps, 'probeHealth' | 'sleep' | 'log'>,
169
+ opts: { timeoutMs?: number; intervalMs?: number } = {}
170
+ ): Promise<boolean> {
171
+ const timeoutMs = opts.timeoutMs ?? HEALTH_TIMEOUT_MS;
172
+ const intervalMs = opts.intervalMs ?? HEALTH_INTERVAL_MS;
173
+ const maxAttempts = Math.max(1, Math.ceil(timeoutMs / intervalMs));
174
+ const healthUrl = `${url.replace(/\/+$/, '')}/health`;
175
+ deps.log(`Waiting for ${healthUrl} (up to ${Math.round(timeoutMs / 1000)}s)…`);
176
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
177
+ const status = await deps.probeHealth(healthUrl);
178
+ if (status === 200) {
179
+ deps.log(` /health responded 200 after ${attempt} attempt(s).`);
180
+ return true;
181
+ }
182
+ if (attempt < maxAttempts) {
183
+ await deps.sleep(intervalMs);
184
+ }
185
+ }
186
+ return false;
187
+ }
188
+
189
+ export interface TeleportOptions {
190
+ name: string;
191
+ dockerfileOnly?: boolean;
192
+ output?: string;
193
+ dryRun?: boolean;
194
+ noCache?: boolean;
195
+ /**
196
+ * When set, switches to git-mode: siteio clones the repo on the agent
197
+ * and builds docker/Dockerfile.teleport with the repo as the build
198
+ * context. Required to deploy unreleased code — the default inline
199
+ * mode fetches the latest GitHub release binary, which won't contain
200
+ * commits that haven't shipped yet.
201
+ */
202
+ gitBranch?: string;
203
+ /**
204
+ * Override the git URL siteio clones from. Default: detected via
205
+ * `git remote get-url origin` in the current working directory,
206
+ * normalized from SSH (`git@github.com:owner/repo.git`) to HTTPS.
207
+ */
208
+ gitUrl?: string;
209
+ /**
210
+ * Sync mode: re-export the local config + push it to an EXISTING
211
+ * siteio app via `apps set -e AGENTIO_KEY=… -e AGENTIO_CONFIG=…`,
212
+ * then `apps restart`. Does NOT touch AGENTIO_SERVER_API_KEY (so
213
+ * the operator key on the remote stays the same and Claude clients
214
+ * keep using the same /authorize PIN). Does NOT rebuild the Docker
215
+ * image. Use this when you've added or changed profiles locally and
216
+ * want the remote to pick them up.
217
+ */
218
+ sync?: boolean;
219
+ }
220
+
221
+ export interface TeleportResult {
222
+ name: string;
223
+ /** Deployed URL from siteio, if we could resolve it. */
224
+ url?: string;
225
+ serverApiKey: string;
226
+ /** `claude mcp add ...` line to hand to the user. null if URL unknown. */
227
+ claudeMcpAddCommand: string | null;
228
+ }
229
+
230
+ /**
231
+ * Sync mode: re-export local config and push it to an existing siteio
232
+ * app, then restart. Same dependency-injection model as runTeleport
233
+ * for testability.
234
+ */
235
+ async function runSync(
236
+ opts: TeleportOptions,
237
+ deps: TeleportDeps
238
+ ): Promise<TeleportResult> {
239
+ // Preflight: same as full teleport.
240
+ deps.log('Checking siteio…');
241
+ if (!(await deps.runner.isInstalled())) {
242
+ throw new CliError(
243
+ 'CONFIG_ERROR',
244
+ 'siteio is not installed or not on PATH',
245
+ 'Install siteio first: https://github.com/plosson/siteio'
246
+ );
247
+ }
248
+ if (!(await deps.runner.isLoggedIn())) {
249
+ throw new CliError(
250
+ 'AUTH_FAILED',
251
+ 'Not logged into siteio',
252
+ 'Run: siteio login --api-url <url> --api-key <key>'
253
+ );
254
+ }
255
+ const config = await deps.loadConfig();
256
+ const profileCount = Object.values(config.profiles ?? {}).reduce(
257
+ (acc, list) => acc + (list?.length ?? 0),
258
+ 0
259
+ );
260
+ if (profileCount === 0) {
261
+ throw new CliError(
262
+ 'NOT_FOUND',
263
+ 'No agentio profiles configured locally',
264
+ 'Add at least one profile first with `agentio <service> profile add`.'
265
+ );
266
+ }
267
+ deps.log(`Found ${profileCount} local profile(s).`);
268
+
269
+ // Sync requires the app to ALREADY EXIST. This is the inverse of the
270
+ // normal teleport check.
271
+ deps.log(`Checking that siteio app "${opts.name}" exists…`);
272
+ const existing = await deps.runner.findApp(opts.name);
273
+ if (!existing) {
274
+ throw new CliError(
275
+ 'NOT_FOUND',
276
+ `No siteio app named "${opts.name}" to sync to`,
277
+ `Run \`agentio mcp teleport ${opts.name}\` (without --sync) first to create it.`
278
+ );
279
+ }
280
+
281
+ // Re-export local config — generates a fresh AGENTIO_KEY each call.
282
+ deps.log('Re-exporting local configuration…');
283
+ const exported = await deps.generateExportData();
284
+
285
+ // Detect whether /data is already mounted as a persistent volume.
286
+ // If not, attach it as part of this sync (one-time backfill for apps
287
+ // teleported before the volume was a default).
288
+ const detail = await deps.runner.appInfo(opts.name);
289
+ const needsVolumeBackfill = !hasDataVolumeMount(detail);
290
+ if (needsVolumeBackfill) {
291
+ deps.log(
292
+ `No persistent volume mounted at ${DATA_VOLUME_PATH} — will attach ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH} as part of this sync.`
293
+ );
294
+ }
295
+
296
+ // Dry-run: report what would happen and exit.
297
+ if (opts.dryRun) {
298
+ deps.log('--- Dry run: the following commands would be executed ---');
299
+ const dryParts = [
300
+ `siteio apps set ${opts.name}`,
301
+ '-e AGENTIO_KEY=<redacted>',
302
+ `-e AGENTIO_CONFIG=<${exported.config.length} chars>`,
303
+ ];
304
+ if (needsVolumeBackfill) {
305
+ dryParts.push(
306
+ `-v ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH}`
307
+ );
308
+ }
309
+ deps.log(dryParts.join(' '));
310
+ deps.log(`siteio apps restart ${opts.name}`);
311
+ deps.log(
312
+ '(AGENTIO_SERVER_API_KEY is intentionally NOT touched — operator key on the remote stays the same.)'
313
+ );
314
+ return {
315
+ name: opts.name,
316
+ serverApiKey: '',
317
+ claudeMcpAddCommand: null,
318
+ };
319
+ }
320
+
321
+ deps.log(
322
+ needsVolumeBackfill
323
+ ? 'Updating env vars + attaching persistent volume on siteio…'
324
+ : 'Updating environment variables on siteio…'
325
+ );
326
+ // Critical: only AGENTIO_KEY + AGENTIO_CONFIG in env. Do NOT pass
327
+ // AGENTIO_SERVER_API_KEY — siteio's `apps set -e` only updates the
328
+ // vars you name, leaving others intact, which is exactly what we
329
+ // want: the operator key stays the same so Claude /authorize keeps
330
+ // accepting the existing PIN.
331
+ //
332
+ // For volumes: only attach /data if it isn't already mounted. Siteio
333
+ // REPLACES the volumes list on update (env merges; volumes don't),
334
+ // so attaching when something else is mounted would clobber it.
335
+ await deps.runner.setApp({
336
+ name: opts.name,
337
+ envVars: {
338
+ AGENTIO_KEY: exported.key,
339
+ AGENTIO_CONFIG: exported.config,
340
+ },
341
+ ...(needsVolumeBackfill
342
+ ? {
343
+ volumes: { [volumeNameFor(opts.name)]: DATA_VOLUME_PATH },
344
+ }
345
+ : {}),
346
+ });
347
+
348
+ deps.log('Restarting container so the new env vars take effect…');
349
+ await deps.runner.restartApp(opts.name);
350
+
351
+ // We already fetched appInfo earlier for volume detection; reuse
352
+ // its URL field rather than calling again. Same fallback as the
353
+ // full-teleport path: siteio's `apps info --json` omits the
354
+ // generated subdomain URL, so fall back to findApp if it's missing.
355
+ let url = typeof detail?.url === 'string' ? detail.url : undefined;
356
+ if (!url) {
357
+ const listed = await deps.runner.findApp(opts.name);
358
+ if (typeof listed?.url === 'string') url = listed.url;
359
+ }
360
+
361
+ // Same health-check / log-surface pattern as the full teleport path.
362
+ // A sync that breaks the container (bad env, corrupted config blob,
363
+ // volume backfill surprise) should fail loudly instead of silently
364
+ // leaving a crash-looping remote.
365
+ if (url) {
366
+ const healthy = await waitForHealth(url, deps);
367
+ if (!healthy) {
368
+ deps.warn(
369
+ `Container failed to report healthy after ${Math.round(HEALTH_TIMEOUT_MS / 1000)}s. Fetching logs…`
370
+ );
371
+ const logs = await deps.runner.logsApp(opts.name, {
372
+ tail: HEALTH_FAILURE_LOG_TAIL,
373
+ });
374
+ deps.warn('--- container logs (tail) ---');
375
+ deps.warn(logs.trim() || '(no logs returned by siteio)');
376
+ deps.warn('--- end logs ---');
377
+ throw new CliError(
378
+ 'API_ERROR',
379
+ `Sync to "${opts.name}" restarted the container but /health never returned 200`,
380
+ 'Inspect the logs above. The previous config is gone — the next sync (or a manual `siteio apps restart`) will still see the broken state until you fix the root cause.'
381
+ );
382
+ }
383
+ }
384
+
385
+ deps.log('');
386
+ deps.log('Sync complete!');
387
+ if (url) {
388
+ deps.log(` URL: ${url}`);
389
+ deps.log(` Health: ${url}/health`);
390
+ }
391
+ if (needsVolumeBackfill) {
392
+ deps.log(
393
+ ' First sync after volume backfill: previous /data state is gone, so'
394
+ );
395
+ deps.log(
396
+ ' any bearer Claude had cached is now invalid. Re-paste the'
397
+ );
398
+ deps.log(
399
+ ' operator API key when prompted. From here on, bearers persist.'
400
+ );
401
+ } else {
402
+ deps.log(
403
+ ' Note: container restarted. With the persistent volume on /data,'
404
+ );
405
+ deps.log(
406
+ ' connected clients should keep their existing bearer.'
407
+ );
408
+ }
409
+
410
+ return {
411
+ name: opts.name,
412
+ url,
413
+ // We did not generate a new server key in sync mode.
414
+ serverApiKey: '',
415
+ claudeMcpAddCommand: null,
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Core orchestration. Pure function of its dependencies — used by both
421
+ * the real command and the unit tests.
422
+ */
423
+ export async function runTeleport(
424
+ opts: TeleportOptions,
425
+ deps: TeleportDeps
426
+ ): Promise<TeleportResult> {
427
+ validateAppName(opts.name);
428
+
429
+ // Sync mode short-circuits — different command shape, different
430
+ // preflight (app must EXIST, not absent), no Dockerfile work, no
431
+ // create. Mutual exclusion with the "create new app" flags.
432
+ if (opts.sync) {
433
+ if (opts.dockerfileOnly) {
434
+ throw new CliError(
435
+ 'INVALID_PARAMS',
436
+ '--sync cannot be combined with --dockerfile-only',
437
+ '--dockerfile-only emits a Dockerfile for a fresh deploy; --sync just pushes new env to an existing app.'
438
+ );
439
+ }
440
+ if (opts.gitBranch) {
441
+ throw new CliError(
442
+ 'INVALID_PARAMS',
443
+ '--sync cannot be combined with --git-branch',
444
+ '--git-branch triggers a fresh build; --sync only pushes config. Use one or the other.'
445
+ );
446
+ }
447
+ if (opts.noCache) {
448
+ throw new CliError(
449
+ 'INVALID_PARAMS',
450
+ '--sync cannot be combined with --no-cache',
451
+ '--no-cache is a build flag; --sync does not rebuild.'
452
+ );
453
+ }
454
+ if (opts.output) {
455
+ throw new CliError(
456
+ 'INVALID_PARAMS',
457
+ '--sync cannot be combined with --output',
458
+ '--output is for --dockerfile-only.'
459
+ );
460
+ }
461
+ return runSync(opts, deps);
462
+ }
463
+
464
+ // dockerfile-only: skip every siteio interaction, just emit the
465
+ // Dockerfile to stdout or a file and return.
466
+ if (opts.dockerfileOnly) {
467
+ const content = deps.generateDockerfile();
468
+ if (opts.output) {
469
+ const path = opts.output.startsWith('/')
470
+ ? opts.output
471
+ : `${process.cwd()}/${opts.output}`;
472
+ await writeFile(path, content, { mode: 0o600 });
473
+ deps.log(`Wrote Dockerfile to ${path}`);
474
+ } else {
475
+ // stdout directly — no log prefix, caller redirects as needed.
476
+ process.stdout.write(content);
477
+ }
478
+ return {
479
+ name: opts.name,
480
+ serverApiKey: '',
481
+ claudeMcpAddCommand: null,
482
+ };
483
+ }
484
+
485
+ // Preflight.
486
+ deps.log('Checking siteio…');
487
+ if (!(await deps.runner.isInstalled())) {
488
+ throw new CliError(
489
+ 'CONFIG_ERROR',
490
+ 'siteio is not installed or not on PATH',
491
+ 'Install siteio first: https://github.com/plosson/siteio'
492
+ );
493
+ }
494
+ if (!(await deps.runner.isLoggedIn())) {
495
+ throw new CliError(
496
+ 'AUTH_FAILED',
497
+ 'Not logged into siteio',
498
+ 'Run: siteio login --api-url <url> --api-key <key>'
499
+ );
500
+ }
501
+
502
+ const config = await deps.loadConfig();
503
+ const profileCount = Object.values(config.profiles ?? {}).reduce(
504
+ (acc, list) => acc + (list?.length ?? 0),
505
+ 0
506
+ );
507
+ if (profileCount === 0) {
508
+ throw new CliError(
509
+ 'NOT_FOUND',
510
+ 'No agentio profiles configured locally',
511
+ 'Add at least one profile first with `agentio <service> profile add`.'
512
+ );
513
+ }
514
+ deps.log(`Found ${profileCount} local profile(s).`);
515
+
516
+ // App must not already exist.
517
+ deps.log(`Checking if siteio app "${opts.name}" already exists…`);
518
+ const existing = await deps.runner.findApp(opts.name);
519
+ if (existing) {
520
+ deps.warn(
521
+ `A siteio app named "${opts.name}" already exists. ` +
522
+ `Run \`siteio apps rm ${opts.name}\` if you want to redeploy from scratch.`
523
+ );
524
+ throw new CliError(
525
+ 'INVALID_PARAMS',
526
+ `App "${opts.name}" already exists on siteio`
527
+ );
528
+ }
529
+
530
+ // Generate a fresh server API key for the remote.
531
+ const serverApiKey = deps.generateServerApiKey();
532
+
533
+ // Export the local config.
534
+ deps.log('Exporting local configuration…');
535
+ const exported = await deps.generateExportData();
536
+
537
+ // Resolve git mode settings up front so dry-run can show the same
538
+ // command shape the real run would use.
539
+ const isGitMode = Boolean(opts.gitBranch);
540
+ let gitSettings: { repoUrl: string; branch: string } | null = null;
541
+ if (isGitMode) {
542
+ let repoUrl = opts.gitUrl;
543
+ if (!repoUrl) {
544
+ const detected = await deps.detectGitOriginUrl();
545
+ if (!detected) {
546
+ throw new CliError(
547
+ 'CONFIG_ERROR',
548
+ 'Could not detect git origin URL for --git-branch mode',
549
+ 'Run from inside a git repo with an "origin" remote, or pass --git-url <url> explicitly.'
550
+ );
551
+ }
552
+ repoUrl = normalizeGitUrl(detected);
553
+ } else {
554
+ repoUrl = normalizeGitUrl(repoUrl);
555
+ }
556
+ gitSettings = { repoUrl, branch: opts.gitBranch! };
557
+ deps.log(
558
+ `Git mode: will clone ${gitSettings.repoUrl} @ ${gitSettings.branch}`
559
+ );
560
+ }
561
+
562
+ // Dry-run: report what would happen and exit.
563
+ if (opts.dryRun) {
564
+ deps.log('--- Dry run: the following commands would be executed ---');
565
+ if (gitSettings) {
566
+ deps.log(
567
+ `siteio apps create ${opts.name} -g ${gitSettings.repoUrl} --branch ${gitSettings.branch} --dockerfile ${TELEPORT_DOCKERFILE_PATH} -p 9999`
568
+ );
569
+ } else {
570
+ deps.log(
571
+ `siteio apps create ${opts.name} -f <tempfile> -p 9999`
572
+ );
573
+ }
574
+ deps.log(
575
+ `siteio apps set ${opts.name} -e AGENTIO_KEY=<redacted> -e AGENTIO_CONFIG=<${exported.config.length} chars> -e AGENTIO_SERVER_API_KEY=${serverApiKey}`
576
+ );
577
+ deps.log(
578
+ `siteio apps deploy ${opts.name}${opts.noCache ? ' --no-cache' : ''}`
579
+ );
580
+ if (!gitSettings) {
581
+ const dockerfile = deps.generateDockerfile();
582
+ deps.log('--- Dockerfile that would be uploaded ---');
583
+ deps.log(dockerfile);
584
+ } else {
585
+ deps.log(
586
+ `--- siteio will build ${TELEPORT_DOCKERFILE_PATH} from the cloned repo (no inline Dockerfile) ---`
587
+ );
588
+ }
589
+ return {
590
+ name: opts.name,
591
+ serverApiKey,
592
+ claudeMcpAddCommand: null,
593
+ };
594
+ }
595
+
596
+ // In inline mode, write the generated Dockerfile to a temp file so
597
+ // siteio can read it with -f. In git mode, no temp file is needed —
598
+ // the Dockerfile already lives in the repo siteio is cloning.
599
+ const tempPath = gitSettings
600
+ ? null
601
+ : await deps.writeTempFile(deps.generateDockerfile());
602
+
603
+ try {
604
+ deps.log(`Creating siteio app "${opts.name}"…`);
605
+ if (gitSettings) {
606
+ await deps.runner.createApp({
607
+ name: opts.name,
608
+ port: 9999,
609
+ git: {
610
+ repoUrl: gitSettings.repoUrl,
611
+ branch: gitSettings.branch,
612
+ dockerfilePath: TELEPORT_DOCKERFILE_PATH,
613
+ },
614
+ });
615
+ } else {
616
+ await deps.runner.createApp({
617
+ name: opts.name,
618
+ dockerfilePath: tempPath!,
619
+ port: 9999,
620
+ });
621
+ }
622
+
623
+ deps.log('Setting environment variables and persistent volume…');
624
+ await deps.runner.setApp({
625
+ name: opts.name,
626
+ envVars: {
627
+ AGENTIO_KEY: exported.key,
628
+ AGENTIO_CONFIG: exported.config,
629
+ AGENTIO_SERVER_API_KEY: serverApiKey,
630
+ },
631
+ // Persistent named volume mounted at /data so config.server.tokens
632
+ // (issued OAuth bearers) survive container restarts. Without this
633
+ // mount, every restart wipes the bearer and connected clients
634
+ // would re-run the OAuth flow.
635
+ volumes: { [volumeNameFor(opts.name)]: DATA_VOLUME_PATH },
636
+ });
637
+
638
+ deps.log('Deploying (this may take a minute — Docker is building your image)…');
639
+ await deps.runner.deploy({
640
+ name: opts.name,
641
+ // In git mode, there's no -f to re-pass on deploy — siteio uses
642
+ // the stored git settings from create.
643
+ ...(tempPath ? { dockerfilePath: tempPath } : {}),
644
+ noCache: opts.noCache,
645
+ });
646
+
647
+ // Try to surface the deployed URL. Non-fatal if siteio doesn't
648
+ // give us one back.
649
+ const info = await deps.runner.appInfo(opts.name);
650
+ let url = typeof info?.url === 'string' ? info.url : undefined;
651
+ // siteio's `apps info --json` output omits the generated subdomain
652
+ // URL (domains: [] in the payload) even though the app is reachable
653
+ // at it. `apps list --json` DOES include the url field at the top
654
+ // level. Fall back to findApp so the post-deploy health check can
655
+ // still run even when siteio doesn't surface url in info.
656
+ if (!url) {
657
+ const listed = await deps.runner.findApp(opts.name);
658
+ if (typeof listed?.url === 'string') url = listed.url;
659
+ }
660
+
661
+ // Poll /health to CONFIRM the container actually came up. siteio's
662
+ // deploy returns success as soon as Docker starts the container, so
663
+ // a crash-loop (bad volume permissions, bad config, missing binary,
664
+ // etc.) looks like a successful deploy until the user probes it
665
+ // themselves. Surfacing logs on timeout is the fix.
666
+ if (url) {
667
+ const healthy = await waitForHealth(url, deps);
668
+ if (!healthy) {
669
+ deps.warn(
670
+ `Container failed to report healthy after ${Math.round(HEALTH_TIMEOUT_MS / 1000)}s. Fetching logs…`
671
+ );
672
+ const logs = await deps.runner.logsApp(opts.name, {
673
+ tail: HEALTH_FAILURE_LOG_TAIL,
674
+ });
675
+ deps.warn('--- container logs (tail) ---');
676
+ deps.warn(logs.trim() || '(no logs returned by siteio)');
677
+ deps.warn('--- end logs ---');
678
+ throw new CliError(
679
+ 'API_ERROR',
680
+ `Deploy "${opts.name}" started but /health never returned 200`,
681
+ 'Inspect the logs above. Common causes: permission errors on mounted volumes, missing env vars, binary not found for the container arch.'
682
+ );
683
+ }
684
+ } else {
685
+ deps.warn(
686
+ 'Skipping health check: siteio did not return a URL for this app. ' +
687
+ `Run \`siteio apps info ${opts.name}\` and curl <url>/health manually to verify.`
688
+ );
689
+ }
690
+
691
+ const claudeCmd = url
692
+ ? `claude mcp add --scope local --transport http agentio "${url}/mcp?services=rss"`
693
+ : null;
694
+
695
+ deps.log('');
696
+ deps.log('Teleport complete!');
697
+ if (url) {
698
+ deps.log(` URL: ${url}`);
699
+ deps.log(` Health: ${url}/health`);
700
+ deps.log(` MCP: ${url}/mcp`);
701
+ } else {
702
+ deps.log(
703
+ ` URL: (siteio did not return a URL — run \`siteio apps info ${opts.name}\` to look it up)`
704
+ );
705
+ }
706
+ deps.log(` API key: ${serverApiKey}`);
707
+ deps.log(' (you will type this into the Authorize page when Claude Code first connects)');
708
+ deps.log('');
709
+ if (claudeCmd) {
710
+ deps.log('To add to Claude Code:');
711
+ deps.log(` ${claudeCmd}`);
712
+ deps.log(
713
+ ' (swap `services=rss` for the profiles you want exposed)'
714
+ );
715
+ }
716
+
717
+ return {
718
+ name: opts.name,
719
+ url,
720
+ serverApiKey,
721
+ claudeMcpAddCommand: claudeCmd,
722
+ };
723
+ } finally {
724
+ // In inline mode, always remove the temp Dockerfile on both success
725
+ // and failure. In git mode there is nothing to clean up.
726
+ if (tempPath) {
727
+ await deps.removeTempFile(tempPath).catch(() => {
728
+ /* ignore — not worth throwing over */
729
+ });
730
+ }
731
+ }
732
+ }
733
+
734
+ /* ------------------------------------------------------------------ */
735
+ /* production wiring */
736
+ /* ------------------------------------------------------------------ */
737
+
738
+ async function defaultWriteTempFile(content: string): Promise<string> {
739
+ const dir = await mkdtemp(join(tmpdir(), 'agentio-teleport-'));
740
+ const path = join(dir, 'Dockerfile');
741
+ await writeFile(path, content, { mode: 0o600 });
742
+ return path;
743
+ }
744
+
745
+ async function defaultRemoveTempFile(path: string): Promise<void> {
746
+ await unlink(path).catch(() => {});
747
+ // The mkdtemp dir is left behind intentionally — it's in /tmp and
748
+ // only contains the one empty file, so the OS will reap it.
749
+ }
750
+
751
+ /**
752
+ * Default git-origin-URL detector: shell out to `git remote get-url origin`.
753
+ * Returns null if the cwd isn't a git repo, has no origin remote, or if
754
+ * the git binary isn't on PATH.
755
+ */
756
+ /**
757
+ * Default health probe: HEAD-equivalent GET on the given URL. Returns
758
+ * the HTTP status code, or null if the request couldn't be made (DNS,
759
+ * connection refused, TLS error, etc). We treat connection errors the
760
+ * same as "not ready yet" so the poller keeps retrying.
761
+ */
762
+ async function defaultProbeHealth(url: string): Promise<number | null> {
763
+ try {
764
+ // Short timeout per attempt so a hung connection can't eat the
765
+ // whole polling budget. AbortSignal.timeout is natively supported
766
+ // by Bun's fetch.
767
+ const res = await fetch(url, {
768
+ method: 'GET',
769
+ signal: AbortSignal.timeout(5_000),
770
+ });
771
+ // Drain the body so the socket is released promptly; we only care
772
+ // about the status code here.
773
+ await res.text().catch(() => {});
774
+ return res.status;
775
+ } catch {
776
+ return null;
777
+ }
778
+ }
779
+
780
+ async function defaultSleep(ms: number): Promise<void> {
781
+ return new Promise((resolve) => setTimeout(resolve, ms));
782
+ }
783
+
784
+ async function defaultDetectGitOriginUrl(): Promise<string | null> {
785
+ try {
786
+ const proc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
787
+ stdout: 'pipe',
788
+ stderr: 'pipe',
789
+ });
790
+ const [stdout, exitCode] = await Promise.all([
791
+ new Response(proc.stdout).text(),
792
+ proc.exited,
793
+ ]);
794
+ if (exitCode !== 0) return null;
795
+ const url = stdout.trim();
796
+ return url.length > 0 ? url : null;
797
+ } catch {
798
+ return null;
799
+ }
800
+ }
801
+
802
+ /**
803
+ * Register the `teleport` subcommand under a parent Commander command
804
+ * (typically `mcp`). Invoked from `registerMcpCommands` so the user
805
+ * types `agentio mcp teleport <name>` rather than `agentio teleport`.
806
+ */
807
+ export function registerTeleportCommand(parent: Command): void {
808
+ parent
809
+ .command('teleport')
810
+ .description(
811
+ 'Deploy the agentio HTTP MCP server to a siteio-managed remote in one command'
812
+ )
813
+ .argument(
814
+ '<name>',
815
+ 'Siteio app name (becomes the subdomain: e.g. "mcp" → mcp.<your-siteio-domain>)'
816
+ )
817
+ .option(
818
+ '--dockerfile-only',
819
+ 'Print (or write) the Dockerfile without calling siteio'
820
+ )
821
+ .option(
822
+ '--output <path>',
823
+ 'Used with --dockerfile-only to write the Dockerfile to a file instead of stdout'
824
+ )
825
+ .option(
826
+ '--dry-run',
827
+ 'Run preflight + config export but do not invoke siteio; print the commands that would run'
828
+ )
829
+ .option(
830
+ '--no-cache',
831
+ 'Pass --no-cache to `siteio apps deploy` to force a fresh Docker build'
832
+ )
833
+ .option(
834
+ '--git-branch <branch>',
835
+ 'Deploy unreleased code by telling siteio to clone this repo and build docker/Dockerfile.teleport from the given branch (instead of fetching the latest release binary)'
836
+ )
837
+ .option(
838
+ '--git-url <url>',
839
+ 'Override the git URL siteio clones from. Default: detected via `git remote get-url origin`, normalized to HTTPS'
840
+ )
841
+ .option(
842
+ '--sync',
843
+ 'Push the latest local config (profiles + credentials) to an EXISTING siteio app and restart it. Use after adding/changing a profile. Does not rebuild the image; does not change the operator API key.'
844
+ )
845
+ .action(async (name: string, options) => {
846
+ try {
847
+ const runner = createSiteioRunner();
848
+ await runTeleport(
849
+ {
850
+ name,
851
+ dockerfileOnly: Boolean(options.dockerfileOnly),
852
+ output: options.output,
853
+ dryRun: Boolean(options.dryRun),
854
+ // Commander's --no-cache sets options.cache=false when
855
+ // declared as --no-cache; but we declared it as --no-cache
856
+ // directly, so it comes through as options.noCache=true.
857
+ // Handle both just in case.
858
+ noCache: Boolean(options.noCache ?? options.cache === false),
859
+ gitBranch: options.gitBranch,
860
+ gitUrl: options.gitUrl,
861
+ sync: Boolean(options.sync),
862
+ },
863
+ {
864
+ runner,
865
+ loadConfig: () => loadConfig() as Promise<Config>,
866
+ generateExportData,
867
+ generateServerApiKey,
868
+ generateDockerfile: () => generateTeleportDockerfile(),
869
+ writeTempFile: defaultWriteTempFile,
870
+ removeTempFile: defaultRemoveTempFile,
871
+ detectGitOriginUrl: defaultDetectGitOriginUrl,
872
+ probeHealth: defaultProbeHealth,
873
+ sleep: defaultSleep,
874
+ log: (msg) => console.log(msg),
875
+ warn: (msg) => console.error(msg),
876
+ }
877
+ );
878
+ } catch (error) {
879
+ handleError(error);
880
+ }
881
+ });
882
+ }