@plosson/agentio 0.7.2 → 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,733 @@
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
+ log: (msg: string) => void;
137
+ warn: (msg: string) => void;
138
+ }
139
+
140
+ export interface TeleportOptions {
141
+ name: string;
142
+ dockerfileOnly?: boolean;
143
+ output?: string;
144
+ dryRun?: boolean;
145
+ noCache?: boolean;
146
+ /**
147
+ * When set, switches to git-mode: siteio clones the repo on the agent
148
+ * and builds docker/Dockerfile.teleport with the repo as the build
149
+ * context. Required to deploy unreleased code — the default inline
150
+ * mode fetches the latest GitHub release binary, which won't contain
151
+ * commits that haven't shipped yet.
152
+ */
153
+ gitBranch?: string;
154
+ /**
155
+ * Override the git URL siteio clones from. Default: detected via
156
+ * `git remote get-url origin` in the current working directory,
157
+ * normalized from SSH (`git@github.com:owner/repo.git`) to HTTPS.
158
+ */
159
+ gitUrl?: string;
160
+ /**
161
+ * Sync mode: re-export the local config + push it to an EXISTING
162
+ * siteio app via `apps set -e AGENTIO_KEY=… -e AGENTIO_CONFIG=…`,
163
+ * then `apps restart`. Does NOT touch AGENTIO_SERVER_API_KEY (so
164
+ * the operator key on the remote stays the same and Claude clients
165
+ * keep using the same /authorize PIN). Does NOT rebuild the Docker
166
+ * image. Use this when you've added or changed profiles locally and
167
+ * want the remote to pick them up.
168
+ */
169
+ sync?: boolean;
170
+ }
171
+
172
+ export interface TeleportResult {
173
+ name: string;
174
+ /** Deployed URL from siteio, if we could resolve it. */
175
+ url?: string;
176
+ serverApiKey: string;
177
+ /** `claude mcp add ...` line to hand to the user. null if URL unknown. */
178
+ claudeMcpAddCommand: string | null;
179
+ }
180
+
181
+ /**
182
+ * Sync mode: re-export local config and push it to an existing siteio
183
+ * app, then restart. Same dependency-injection model as runTeleport
184
+ * for testability.
185
+ */
186
+ async function runSync(
187
+ opts: TeleportOptions,
188
+ deps: TeleportDeps
189
+ ): Promise<TeleportResult> {
190
+ // Preflight: same as full teleport.
191
+ deps.log('Checking siteio…');
192
+ if (!(await deps.runner.isInstalled())) {
193
+ throw new CliError(
194
+ 'CONFIG_ERROR',
195
+ 'siteio is not installed or not on PATH',
196
+ 'Install siteio first: https://github.com/plosson/siteio'
197
+ );
198
+ }
199
+ if (!(await deps.runner.isLoggedIn())) {
200
+ throw new CliError(
201
+ 'AUTH_FAILED',
202
+ 'Not logged into siteio',
203
+ 'Run: siteio login --api-url <url> --api-key <key>'
204
+ );
205
+ }
206
+ const config = await deps.loadConfig();
207
+ const profileCount = Object.values(config.profiles ?? {}).reduce(
208
+ (acc, list) => acc + (list?.length ?? 0),
209
+ 0
210
+ );
211
+ if (profileCount === 0) {
212
+ throw new CliError(
213
+ 'NOT_FOUND',
214
+ 'No agentio profiles configured locally',
215
+ 'Add at least one profile first with `agentio <service> profile add`.'
216
+ );
217
+ }
218
+ deps.log(`Found ${profileCount} local profile(s).`);
219
+
220
+ // Sync requires the app to ALREADY EXIST. This is the inverse of the
221
+ // normal teleport check.
222
+ deps.log(`Checking that siteio app "${opts.name}" exists…`);
223
+ const existing = await deps.runner.findApp(opts.name);
224
+ if (!existing) {
225
+ throw new CliError(
226
+ 'NOT_FOUND',
227
+ `No siteio app named "${opts.name}" to sync to`,
228
+ `Run \`agentio mcp teleport ${opts.name}\` (without --sync) first to create it.`
229
+ );
230
+ }
231
+
232
+ // Re-export local config — generates a fresh AGENTIO_KEY each call.
233
+ deps.log('Re-exporting local configuration…');
234
+ const exported = await deps.generateExportData();
235
+
236
+ // Detect whether /data is already mounted as a persistent volume.
237
+ // If not, attach it as part of this sync (one-time backfill for apps
238
+ // teleported before the volume was a default).
239
+ const detail = await deps.runner.appInfo(opts.name);
240
+ const needsVolumeBackfill = !hasDataVolumeMount(detail);
241
+ if (needsVolumeBackfill) {
242
+ deps.log(
243
+ `No persistent volume mounted at ${DATA_VOLUME_PATH} — will attach ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH} as part of this sync.`
244
+ );
245
+ }
246
+
247
+ // Dry-run: report what would happen and exit.
248
+ if (opts.dryRun) {
249
+ deps.log('--- Dry run: the following commands would be executed ---');
250
+ const dryParts = [
251
+ `siteio apps set ${opts.name}`,
252
+ '-e AGENTIO_KEY=<redacted>',
253
+ `-e AGENTIO_CONFIG=<${exported.config.length} chars>`,
254
+ ];
255
+ if (needsVolumeBackfill) {
256
+ dryParts.push(
257
+ `-v ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH}`
258
+ );
259
+ }
260
+ deps.log(dryParts.join(' '));
261
+ deps.log(`siteio apps restart ${opts.name}`);
262
+ deps.log(
263
+ '(AGENTIO_SERVER_API_KEY is intentionally NOT touched — operator key on the remote stays the same.)'
264
+ );
265
+ return {
266
+ name: opts.name,
267
+ serverApiKey: '',
268
+ claudeMcpAddCommand: null,
269
+ };
270
+ }
271
+
272
+ deps.log(
273
+ needsVolumeBackfill
274
+ ? 'Updating env vars + attaching persistent volume on siteio…'
275
+ : 'Updating environment variables on siteio…'
276
+ );
277
+ // Critical: only AGENTIO_KEY + AGENTIO_CONFIG in env. Do NOT pass
278
+ // AGENTIO_SERVER_API_KEY — siteio's `apps set -e` only updates the
279
+ // vars you name, leaving others intact, which is exactly what we
280
+ // want: the operator key stays the same so Claude /authorize keeps
281
+ // accepting the existing PIN.
282
+ //
283
+ // For volumes: only attach /data if it isn't already mounted. Siteio
284
+ // REPLACES the volumes list on update (env merges; volumes don't),
285
+ // so attaching when something else is mounted would clobber it.
286
+ await deps.runner.setApp({
287
+ name: opts.name,
288
+ envVars: {
289
+ AGENTIO_KEY: exported.key,
290
+ AGENTIO_CONFIG: exported.config,
291
+ },
292
+ ...(needsVolumeBackfill
293
+ ? {
294
+ volumes: { [volumeNameFor(opts.name)]: DATA_VOLUME_PATH },
295
+ }
296
+ : {}),
297
+ });
298
+
299
+ deps.log('Restarting container so the new env vars take effect…');
300
+ await deps.runner.restartApp(opts.name);
301
+
302
+ // We already fetched appInfo earlier for volume detection; reuse
303
+ // its URL field rather than calling again.
304
+ const url = typeof detail?.url === 'string' ? detail.url : undefined;
305
+
306
+ deps.log('');
307
+ deps.log('Sync complete!');
308
+ if (url) {
309
+ deps.log(` URL: ${url}`);
310
+ deps.log(` Health: ${url}/health`);
311
+ }
312
+ if (needsVolumeBackfill) {
313
+ deps.log(
314
+ ' First sync after volume backfill: previous /data state is gone, so'
315
+ );
316
+ deps.log(
317
+ ' any bearer Claude had cached is now invalid. Re-paste the'
318
+ );
319
+ deps.log(
320
+ ' operator API key when prompted. From here on, bearers persist.'
321
+ );
322
+ } else {
323
+ deps.log(
324
+ ' Note: container restarted. With the persistent volume on /data,'
325
+ );
326
+ deps.log(
327
+ ' connected clients should keep their existing bearer.'
328
+ );
329
+ }
330
+
331
+ return {
332
+ name: opts.name,
333
+ url,
334
+ // We did not generate a new server key in sync mode.
335
+ serverApiKey: '',
336
+ claudeMcpAddCommand: null,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Core orchestration. Pure function of its dependencies — used by both
342
+ * the real command and the unit tests.
343
+ */
344
+ export async function runTeleport(
345
+ opts: TeleportOptions,
346
+ deps: TeleportDeps
347
+ ): Promise<TeleportResult> {
348
+ validateAppName(opts.name);
349
+
350
+ // Sync mode short-circuits — different command shape, different
351
+ // preflight (app must EXIST, not absent), no Dockerfile work, no
352
+ // create. Mutual exclusion with the "create new app" flags.
353
+ if (opts.sync) {
354
+ if (opts.dockerfileOnly) {
355
+ throw new CliError(
356
+ 'INVALID_PARAMS',
357
+ '--sync cannot be combined with --dockerfile-only',
358
+ '--dockerfile-only emits a Dockerfile for a fresh deploy; --sync just pushes new env to an existing app.'
359
+ );
360
+ }
361
+ if (opts.gitBranch) {
362
+ throw new CliError(
363
+ 'INVALID_PARAMS',
364
+ '--sync cannot be combined with --git-branch',
365
+ '--git-branch triggers a fresh build; --sync only pushes config. Use one or the other.'
366
+ );
367
+ }
368
+ if (opts.noCache) {
369
+ throw new CliError(
370
+ 'INVALID_PARAMS',
371
+ '--sync cannot be combined with --no-cache',
372
+ '--no-cache is a build flag; --sync does not rebuild.'
373
+ );
374
+ }
375
+ if (opts.output) {
376
+ throw new CliError(
377
+ 'INVALID_PARAMS',
378
+ '--sync cannot be combined with --output',
379
+ '--output is for --dockerfile-only.'
380
+ );
381
+ }
382
+ return runSync(opts, deps);
383
+ }
384
+
385
+ // dockerfile-only: skip every siteio interaction, just emit the
386
+ // Dockerfile to stdout or a file and return.
387
+ if (opts.dockerfileOnly) {
388
+ const content = deps.generateDockerfile();
389
+ if (opts.output) {
390
+ const path = opts.output.startsWith('/')
391
+ ? opts.output
392
+ : `${process.cwd()}/${opts.output}`;
393
+ await writeFile(path, content, { mode: 0o600 });
394
+ deps.log(`Wrote Dockerfile to ${path}`);
395
+ } else {
396
+ // stdout directly — no log prefix, caller redirects as needed.
397
+ process.stdout.write(content);
398
+ }
399
+ return {
400
+ name: opts.name,
401
+ serverApiKey: '',
402
+ claudeMcpAddCommand: null,
403
+ };
404
+ }
405
+
406
+ // Preflight.
407
+ deps.log('Checking siteio…');
408
+ if (!(await deps.runner.isInstalled())) {
409
+ throw new CliError(
410
+ 'CONFIG_ERROR',
411
+ 'siteio is not installed or not on PATH',
412
+ 'Install siteio first: https://github.com/plosson/siteio'
413
+ );
414
+ }
415
+ if (!(await deps.runner.isLoggedIn())) {
416
+ throw new CliError(
417
+ 'AUTH_FAILED',
418
+ 'Not logged into siteio',
419
+ 'Run: siteio login --api-url <url> --api-key <key>'
420
+ );
421
+ }
422
+
423
+ const config = await deps.loadConfig();
424
+ const profileCount = Object.values(config.profiles ?? {}).reduce(
425
+ (acc, list) => acc + (list?.length ?? 0),
426
+ 0
427
+ );
428
+ if (profileCount === 0) {
429
+ throw new CliError(
430
+ 'NOT_FOUND',
431
+ 'No agentio profiles configured locally',
432
+ 'Add at least one profile first with `agentio <service> profile add`.'
433
+ );
434
+ }
435
+ deps.log(`Found ${profileCount} local profile(s).`);
436
+
437
+ // App must not already exist.
438
+ deps.log(`Checking if siteio app "${opts.name}" already exists…`);
439
+ const existing = await deps.runner.findApp(opts.name);
440
+ if (existing) {
441
+ deps.warn(
442
+ `A siteio app named "${opts.name}" already exists. ` +
443
+ `Run \`siteio apps rm ${opts.name}\` if you want to redeploy from scratch.`
444
+ );
445
+ throw new CliError(
446
+ 'INVALID_PARAMS',
447
+ `App "${opts.name}" already exists on siteio`
448
+ );
449
+ }
450
+
451
+ // Generate a fresh server API key for the remote.
452
+ const serverApiKey = deps.generateServerApiKey();
453
+
454
+ // Export the local config.
455
+ deps.log('Exporting local configuration…');
456
+ const exported = await deps.generateExportData();
457
+
458
+ // Resolve git mode settings up front so dry-run can show the same
459
+ // command shape the real run would use.
460
+ const isGitMode = Boolean(opts.gitBranch);
461
+ let gitSettings: { repoUrl: string; branch: string } | null = null;
462
+ if (isGitMode) {
463
+ let repoUrl = opts.gitUrl;
464
+ if (!repoUrl) {
465
+ const detected = await deps.detectGitOriginUrl();
466
+ if (!detected) {
467
+ throw new CliError(
468
+ 'CONFIG_ERROR',
469
+ 'Could not detect git origin URL for --git-branch mode',
470
+ 'Run from inside a git repo with an "origin" remote, or pass --git-url <url> explicitly.'
471
+ );
472
+ }
473
+ repoUrl = normalizeGitUrl(detected);
474
+ } else {
475
+ repoUrl = normalizeGitUrl(repoUrl);
476
+ }
477
+ gitSettings = { repoUrl, branch: opts.gitBranch! };
478
+ deps.log(
479
+ `Git mode: will clone ${gitSettings.repoUrl} @ ${gitSettings.branch}`
480
+ );
481
+ }
482
+
483
+ // Dry-run: report what would happen and exit.
484
+ if (opts.dryRun) {
485
+ deps.log('--- Dry run: the following commands would be executed ---');
486
+ if (gitSettings) {
487
+ deps.log(
488
+ `siteio apps create ${opts.name} -g ${gitSettings.repoUrl} --branch ${gitSettings.branch} --dockerfile ${TELEPORT_DOCKERFILE_PATH} -p 9999`
489
+ );
490
+ } else {
491
+ deps.log(
492
+ `siteio apps create ${opts.name} -f <tempfile> -p 9999`
493
+ );
494
+ }
495
+ deps.log(
496
+ `siteio apps set ${opts.name} -e AGENTIO_KEY=<redacted> -e AGENTIO_CONFIG=<${exported.config.length} chars> -e AGENTIO_SERVER_API_KEY=${serverApiKey}`
497
+ );
498
+ deps.log(
499
+ `siteio apps deploy ${opts.name}${opts.noCache ? ' --no-cache' : ''}`
500
+ );
501
+ if (!gitSettings) {
502
+ const dockerfile = deps.generateDockerfile();
503
+ deps.log('--- Dockerfile that would be uploaded ---');
504
+ deps.log(dockerfile);
505
+ } else {
506
+ deps.log(
507
+ `--- siteio will build ${TELEPORT_DOCKERFILE_PATH} from the cloned repo (no inline Dockerfile) ---`
508
+ );
509
+ }
510
+ return {
511
+ name: opts.name,
512
+ serverApiKey,
513
+ claudeMcpAddCommand: null,
514
+ };
515
+ }
516
+
517
+ // In inline mode, write the generated Dockerfile to a temp file so
518
+ // siteio can read it with -f. In git mode, no temp file is needed —
519
+ // the Dockerfile already lives in the repo siteio is cloning.
520
+ const tempPath = gitSettings
521
+ ? null
522
+ : await deps.writeTempFile(deps.generateDockerfile());
523
+
524
+ try {
525
+ deps.log(`Creating siteio app "${opts.name}"…`);
526
+ if (gitSettings) {
527
+ await deps.runner.createApp({
528
+ name: opts.name,
529
+ port: 9999,
530
+ git: {
531
+ repoUrl: gitSettings.repoUrl,
532
+ branch: gitSettings.branch,
533
+ dockerfilePath: TELEPORT_DOCKERFILE_PATH,
534
+ },
535
+ });
536
+ } else {
537
+ await deps.runner.createApp({
538
+ name: opts.name,
539
+ dockerfilePath: tempPath!,
540
+ port: 9999,
541
+ });
542
+ }
543
+
544
+ deps.log('Setting environment variables and persistent volume…');
545
+ await deps.runner.setApp({
546
+ name: opts.name,
547
+ envVars: {
548
+ AGENTIO_KEY: exported.key,
549
+ AGENTIO_CONFIG: exported.config,
550
+ AGENTIO_SERVER_API_KEY: serverApiKey,
551
+ },
552
+ // Persistent named volume mounted at /data so config.server.tokens
553
+ // (issued OAuth bearers) survive container restarts. Without this
554
+ // mount, every restart wipes the bearer and connected clients
555
+ // would re-run the OAuth flow.
556
+ volumes: { [volumeNameFor(opts.name)]: DATA_VOLUME_PATH },
557
+ });
558
+
559
+ deps.log('Deploying (this may take a minute — Docker is building your image)…');
560
+ await deps.runner.deploy({
561
+ name: opts.name,
562
+ // In git mode, there's no -f to re-pass on deploy — siteio uses
563
+ // the stored git settings from create.
564
+ ...(tempPath ? { dockerfilePath: tempPath } : {}),
565
+ noCache: opts.noCache,
566
+ });
567
+
568
+ // Try to surface the deployed URL. Non-fatal if siteio doesn't
569
+ // give us one back.
570
+ const info = await deps.runner.appInfo(opts.name);
571
+ const url = typeof info?.url === 'string' ? info.url : undefined;
572
+ const claudeCmd = url
573
+ ? `claude mcp add --scope local --transport http agentio "${url}/mcp?services=rss"`
574
+ : null;
575
+
576
+ deps.log('');
577
+ deps.log('Teleport complete!');
578
+ if (url) {
579
+ deps.log(` URL: ${url}`);
580
+ deps.log(` Health: ${url}/health`);
581
+ deps.log(` MCP: ${url}/mcp`);
582
+ } else {
583
+ deps.log(
584
+ ` URL: (siteio did not return a URL — run \`siteio apps info ${opts.name}\` to look it up)`
585
+ );
586
+ }
587
+ deps.log(` API key: ${serverApiKey}`);
588
+ deps.log(' (you will type this into the Authorize page when Claude Code first connects)');
589
+ deps.log('');
590
+ if (claudeCmd) {
591
+ deps.log('To add to Claude Code:');
592
+ deps.log(` ${claudeCmd}`);
593
+ deps.log(
594
+ ' (swap `services=rss` for the profiles you want exposed)'
595
+ );
596
+ }
597
+
598
+ return {
599
+ name: opts.name,
600
+ url,
601
+ serverApiKey,
602
+ claudeMcpAddCommand: claudeCmd,
603
+ };
604
+ } finally {
605
+ // In inline mode, always remove the temp Dockerfile on both success
606
+ // and failure. In git mode there is nothing to clean up.
607
+ if (tempPath) {
608
+ await deps.removeTempFile(tempPath).catch(() => {
609
+ /* ignore — not worth throwing over */
610
+ });
611
+ }
612
+ }
613
+ }
614
+
615
+ /* ------------------------------------------------------------------ */
616
+ /* production wiring */
617
+ /* ------------------------------------------------------------------ */
618
+
619
+ async function defaultWriteTempFile(content: string): Promise<string> {
620
+ const dir = await mkdtemp(join(tmpdir(), 'agentio-teleport-'));
621
+ const path = join(dir, 'Dockerfile');
622
+ await writeFile(path, content, { mode: 0o600 });
623
+ return path;
624
+ }
625
+
626
+ async function defaultRemoveTempFile(path: string): Promise<void> {
627
+ await unlink(path).catch(() => {});
628
+ // The mkdtemp dir is left behind intentionally — it's in /tmp and
629
+ // only contains the one empty file, so the OS will reap it.
630
+ }
631
+
632
+ /**
633
+ * Default git-origin-URL detector: shell out to `git remote get-url origin`.
634
+ * Returns null if the cwd isn't a git repo, has no origin remote, or if
635
+ * the git binary isn't on PATH.
636
+ */
637
+ async function defaultDetectGitOriginUrl(): Promise<string | null> {
638
+ try {
639
+ const proc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
640
+ stdout: 'pipe',
641
+ stderr: 'pipe',
642
+ });
643
+ const [stdout, exitCode] = await Promise.all([
644
+ new Response(proc.stdout).text(),
645
+ proc.exited,
646
+ ]);
647
+ if (exitCode !== 0) return null;
648
+ const url = stdout.trim();
649
+ return url.length > 0 ? url : null;
650
+ } catch {
651
+ return null;
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Register the `teleport` subcommand under a parent Commander command
657
+ * (typically `mcp`). Invoked from `registerMcpCommands` so the user
658
+ * types `agentio mcp teleport <name>` rather than `agentio teleport`.
659
+ */
660
+ export function registerTeleportCommand(parent: Command): void {
661
+ parent
662
+ .command('teleport')
663
+ .description(
664
+ 'Deploy the agentio HTTP MCP server to a siteio-managed remote in one command'
665
+ )
666
+ .argument(
667
+ '<name>',
668
+ 'Siteio app name (becomes the subdomain: e.g. "mcp" → mcp.<your-siteio-domain>)'
669
+ )
670
+ .option(
671
+ '--dockerfile-only',
672
+ 'Print (or write) the Dockerfile without calling siteio'
673
+ )
674
+ .option(
675
+ '--output <path>',
676
+ 'Used with --dockerfile-only to write the Dockerfile to a file instead of stdout'
677
+ )
678
+ .option(
679
+ '--dry-run',
680
+ 'Run preflight + config export but do not invoke siteio; print the commands that would run'
681
+ )
682
+ .option(
683
+ '--no-cache',
684
+ 'Pass --no-cache to `siteio apps deploy` to force a fresh Docker build'
685
+ )
686
+ .option(
687
+ '--git-branch <branch>',
688
+ '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)'
689
+ )
690
+ .option(
691
+ '--git-url <url>',
692
+ 'Override the git URL siteio clones from. Default: detected via `git remote get-url origin`, normalized to HTTPS'
693
+ )
694
+ .option(
695
+ '--sync',
696
+ '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.'
697
+ )
698
+ .action(async (name: string, options) => {
699
+ try {
700
+ const runner = createSiteioRunner();
701
+ await runTeleport(
702
+ {
703
+ name,
704
+ dockerfileOnly: Boolean(options.dockerfileOnly),
705
+ output: options.output,
706
+ dryRun: Boolean(options.dryRun),
707
+ // Commander's --no-cache sets options.cache=false when
708
+ // declared as --no-cache; but we declared it as --no-cache
709
+ // directly, so it comes through as options.noCache=true.
710
+ // Handle both just in case.
711
+ noCache: Boolean(options.noCache ?? options.cache === false),
712
+ gitBranch: options.gitBranch,
713
+ gitUrl: options.gitUrl,
714
+ sync: Boolean(options.sync),
715
+ },
716
+ {
717
+ runner,
718
+ loadConfig: () => loadConfig() as Promise<Config>,
719
+ generateExportData,
720
+ generateServerApiKey,
721
+ generateDockerfile: () => generateTeleportDockerfile(),
722
+ writeTempFile: defaultWriteTempFile,
723
+ removeTempFile: defaultRemoveTempFile,
724
+ detectGitOriginUrl: defaultDetectGitOriginUrl,
725
+ log: (msg) => console.log(msg),
726
+ warn: (msg) => console.error(msg),
727
+ }
728
+ );
729
+ } catch (error) {
730
+ handleError(error);
731
+ }
732
+ });
733
+ }