@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,1462 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ DATA_VOLUME_PATH,
5
+ generateServerApiKey,
6
+ hasDataVolumeMount,
7
+ normalizeGitUrl,
8
+ runTeleport,
9
+ TELEPORT_DOCKERFILE_PATH,
10
+ validateAppName,
11
+ volumeNameFor,
12
+ type TeleportDeps,
13
+ } from './teleport';
14
+ import type { SiteioRunner, SiteioApp } from '../server/siteio-runner';
15
+ import type { Config } from '../types/config';
16
+
17
+ /**
18
+ * Unit tests for `agentio teleport`. Every test injects a fake
19
+ * SiteioRunner + fake config/export/temp-file functions, so siteio is
20
+ * never actually invoked and no files are written anywhere.
21
+ *
22
+ * The tests exercise the orchestration logic: preflight checks, the
23
+ * exact sequence of runner calls, env var assembly, dry-run behavior,
24
+ * dockerfile-only behavior, and cleanup of the temp file on both
25
+ * success and failure.
26
+ */
27
+
28
+ /* ------------------------------------------------------------------ */
29
+ /* fake runner */
30
+ /* ------------------------------------------------------------------ */
31
+
32
+ interface RunnerCall {
33
+ method: string;
34
+ args: unknown;
35
+ }
36
+
37
+ interface FakeRunnerOptions {
38
+ installed?: boolean;
39
+ loggedIn?: boolean;
40
+ existingApp?: SiteioApp | null;
41
+ deployedApp?: SiteioApp | null;
42
+ /** Stdout returned by logsApp. Default: empty string. */
43
+ logsStdout?: string;
44
+ failOn?:
45
+ | 'isInstalled'
46
+ | 'isLoggedIn'
47
+ | 'findApp'
48
+ | 'createApp'
49
+ | 'setApp'
50
+ | 'deploy'
51
+ | 'restartApp'
52
+ | 'appInfo';
53
+ }
54
+
55
+ function makeFakeRunner(opts: FakeRunnerOptions = {}): {
56
+ runner: SiteioRunner;
57
+ calls: RunnerCall[];
58
+ } {
59
+ const calls: RunnerCall[] = [];
60
+
61
+ const shouldFail = (method: FakeRunnerOptions['failOn']): boolean =>
62
+ opts.failOn === method;
63
+
64
+ const runner: SiteioRunner = {
65
+ async isInstalled() {
66
+ calls.push({ method: 'isInstalled', args: null });
67
+ if (shouldFail('isInstalled')) throw new Error('spawn failure');
68
+ return opts.installed ?? true;
69
+ },
70
+ async isLoggedIn() {
71
+ calls.push({ method: 'isLoggedIn', args: null });
72
+ if (shouldFail('isLoggedIn')) throw new Error('spawn failure');
73
+ return opts.loggedIn ?? true;
74
+ },
75
+ async findApp(name) {
76
+ calls.push({ method: 'findApp', args: { name } });
77
+ if (shouldFail('findApp')) throw new Error('list failed');
78
+ return opts.existingApp ?? null;
79
+ },
80
+ async createApp(args) {
81
+ calls.push({ method: 'createApp', args });
82
+ if (shouldFail('createApp')) throw new Error('create failed');
83
+ },
84
+ async setApp(args) {
85
+ calls.push({ method: 'setApp', args });
86
+ if (shouldFail('setApp')) throw new Error('set failed');
87
+ },
88
+ async deploy(args) {
89
+ calls.push({ method: 'deploy', args });
90
+ if (shouldFail('deploy')) throw new Error('deploy failed');
91
+ },
92
+ async restartApp(name) {
93
+ calls.push({ method: 'restartApp', args: { name } });
94
+ if (shouldFail('restartApp')) throw new Error('restart failed');
95
+ },
96
+ async appInfo(name) {
97
+ calls.push({ method: 'appInfo', args: { name } });
98
+ if (shouldFail('appInfo')) return null;
99
+ // Use `in` check so an explicit null passes through as null
100
+ // instead of being coalesced into the default.
101
+ if ('deployedApp' in opts) return opts.deployedApp ?? null;
102
+ return { name, url: `https://${name}.siteio.example.com` };
103
+ },
104
+ async logsApp(name, logOpts) {
105
+ calls.push({ method: 'logsApp', args: { name, opts: logOpts ?? null } });
106
+ return opts.logsStdout ?? '';
107
+ },
108
+ };
109
+
110
+ return { runner, calls };
111
+ }
112
+
113
+ /* ------------------------------------------------------------------ */
114
+ /* fake deps */
115
+ /* ------------------------------------------------------------------ */
116
+
117
+ interface FakeDepsOptions extends FakeRunnerOptions {
118
+ profiles?: Config['profiles'];
119
+ exportKey?: string;
120
+ exportConfig?: string;
121
+ fixedServerApiKey?: string;
122
+ dockerfile?: string;
123
+ /** Value returned by detectGitOriginUrl. Default: null. */
124
+ gitOriginUrl?: string | null;
125
+ /**
126
+ * Sequence of HTTP status codes (or nulls) `probeHealth` should return
127
+ * across successive polls. When exhausted, falls back to the default
128
+ * (a healthy 200). Pass [] to simulate an unreachable container.
129
+ */
130
+ healthProbeResponses?: Array<number | null>;
131
+ }
132
+
133
+ interface FakeDeps extends TeleportDeps {
134
+ calls: RunnerCall[];
135
+ log: (msg: string) => void;
136
+ warn: (msg: string) => void;
137
+ logLines: string[];
138
+ warnLines: string[];
139
+ tempFileWrites: { path: string; content: string }[];
140
+ tempFileDeletes: string[];
141
+ healthProbeUrls: string[];
142
+ sleepCalls: number[];
143
+ }
144
+
145
+ function makeDeps(opts: FakeDepsOptions = {}): FakeDeps {
146
+ const { runner, calls } = makeFakeRunner(opts);
147
+ const logLines: string[] = [];
148
+ const warnLines: string[] = [];
149
+ const tempFileWrites: { path: string; content: string }[] = [];
150
+ const tempFileDeletes: string[] = [];
151
+ const healthProbeUrls: string[] = [];
152
+ const sleepCalls: number[] = [];
153
+
154
+ let tempCounter = 0;
155
+ let healthProbeIdx = 0;
156
+
157
+ const deps: FakeDeps = {
158
+ calls,
159
+ logLines,
160
+ warnLines,
161
+ tempFileWrites,
162
+ tempFileDeletes,
163
+ healthProbeUrls,
164
+ sleepCalls,
165
+ runner,
166
+ loadConfig: async () =>
167
+ ({
168
+ profiles:
169
+ opts.profiles ?? ({ rss: [{ name: 'default' }] } as Config['profiles']),
170
+ }) as Config,
171
+ generateExportData: async () => ({
172
+ key: opts.exportKey ?? 'f'.repeat(64),
173
+ config: opts.exportConfig ?? 'fake_encrypted_blob==',
174
+ }),
175
+ generateServerApiKey: () =>
176
+ opts.fixedServerApiKey ?? 'srv_testkey123456789012345678901234',
177
+ generateDockerfile: () =>
178
+ opts.dockerfile ?? '# fake dockerfile\nFROM scratch\n',
179
+ writeTempFile: async (content) => {
180
+ const path = `/tmp/fake-teleport-${tempCounter++}/Dockerfile`;
181
+ tempFileWrites.push({ path, content });
182
+ return path;
183
+ },
184
+ removeTempFile: async (path) => {
185
+ tempFileDeletes.push(path);
186
+ },
187
+ detectGitOriginUrl: async () =>
188
+ 'gitOriginUrl' in opts ? (opts.gitOriginUrl ?? null) : null,
189
+ probeHealth: async (url) => {
190
+ healthProbeUrls.push(url);
191
+ // Default behavior: 200 on the first probe so happy-path tests
192
+ // don't have to configure anything. Callers exercising timeouts
193
+ // pass `healthProbeResponses: []` or an explicit list.
194
+ if (opts.healthProbeResponses == null) return 200;
195
+ const list = opts.healthProbeResponses;
196
+ if (healthProbeIdx < list.length) {
197
+ return list[healthProbeIdx++] ?? null;
198
+ }
199
+ return null;
200
+ },
201
+ // Sleep is a no-op in tests — we don't want real time to pass.
202
+ // The loop inside waitForHealth is bounded by Date.now() ≥ deadline,
203
+ // so we also need the deadline to be reachable; see the test that
204
+ // exercises a timeout, which shrinks the timeoutMs explicitly.
205
+ sleep: async (ms) => {
206
+ sleepCalls.push(ms);
207
+ },
208
+ log: (msg) => logLines.push(msg),
209
+ warn: (msg) => warnLines.push(msg),
210
+ };
211
+
212
+ return deps;
213
+ }
214
+
215
+ /* ------------------------------------------------------------------ */
216
+ /* validateAppName */
217
+ /* ------------------------------------------------------------------ */
218
+
219
+ describe('validateAppName', () => {
220
+ test.each([
221
+ ['mcp'],
222
+ ['m'],
223
+ ['my-app'],
224
+ ['app-1'],
225
+ ['abc-def-ghi'],
226
+ ['a0123456789'],
227
+ ])('accepts valid name "%s"', (name) => {
228
+ expect(() => validateAppName(name)).not.toThrow();
229
+ });
230
+
231
+ test.each([
232
+ ['UPPERCASE'],
233
+ ['CamelCase'],
234
+ ['1starts-with-digit'],
235
+ ['-starts-with-hyphen'],
236
+ ['has_underscore'],
237
+ ['has spaces'],
238
+ ['has.dot'],
239
+ ['has/slash'],
240
+ [''],
241
+ ['a'.repeat(64)], // one over max
242
+ ])('rejects invalid name "%s"', (name) => {
243
+ expect(() => validateAppName(name)).toThrow();
244
+ });
245
+
246
+ test('accepts name at max length (63)', () => {
247
+ expect(() => validateAppName('a' + '1'.repeat(62))).not.toThrow();
248
+ });
249
+ });
250
+
251
+ /* ------------------------------------------------------------------ */
252
+ /* generateServerApiKey */
253
+ /* ------------------------------------------------------------------ */
254
+
255
+ describe('generateServerApiKey', () => {
256
+ test('produces srv_ prefix + 32-char base64url body', () => {
257
+ const k = generateServerApiKey();
258
+ expect(k).toMatch(/^srv_[A-Za-z0-9_-]+$/);
259
+ expect(k.length).toBe(4 + 32); // 24 bytes base64url = 32 chars
260
+ });
261
+
262
+ test('two calls produce distinct keys', () => {
263
+ expect(generateServerApiKey()).not.toBe(generateServerApiKey());
264
+ });
265
+ });
266
+
267
+ /* ------------------------------------------------------------------ */
268
+ /* runTeleport — happy path */
269
+ /* ------------------------------------------------------------------ */
270
+
271
+ describe('runTeleport — happy path', () => {
272
+ test('executes preflight → create → set → deploy → info in order', async () => {
273
+ const deps = makeDeps();
274
+ const result = await runTeleport({ name: 'mcp' }, deps);
275
+
276
+ const methods = deps.calls.map((c) => c.method);
277
+ expect(methods).toEqual([
278
+ 'isInstalled',
279
+ 'isLoggedIn',
280
+ 'findApp',
281
+ 'createApp',
282
+ 'setApp',
283
+ 'deploy',
284
+ 'appInfo',
285
+ ]);
286
+
287
+ expect(result.name).toBe('mcp');
288
+ expect(result.url).toBe('https://mcp.siteio.example.com');
289
+ expect(result.serverApiKey).toMatch(/^srv_/);
290
+ expect(result.claudeMcpAddCommand).toContain('claude mcp add');
291
+ expect(result.claudeMcpAddCommand).toContain('mcp.siteio.example.com/mcp');
292
+ });
293
+
294
+ test('createApp is called with the temp Dockerfile path + port 9999', async () => {
295
+ const deps = makeDeps();
296
+ await runTeleport({ name: 'mcp' }, deps);
297
+ const createCall = deps.calls.find((c) => c.method === 'createApp');
298
+ expect(createCall).toBeDefined();
299
+ const args = createCall!.args as {
300
+ name: string;
301
+ dockerfilePath: string;
302
+ port: number;
303
+ };
304
+ expect(args.name).toBe('mcp');
305
+ expect(args.dockerfilePath).toMatch(/Dockerfile$/);
306
+ expect(args.port).toBe(9999);
307
+ });
308
+
309
+ test('setApp is called with AGENTIO_KEY + AGENTIO_CONFIG + AGENTIO_SERVER_API_KEY', async () => {
310
+ const deps = makeDeps({
311
+ exportKey: 'key_value_from_export',
312
+ exportConfig: 'config_blob_from_export==',
313
+ fixedServerApiKey: 'srv_fixed_test_key',
314
+ });
315
+ await runTeleport({ name: 'mcp' }, deps);
316
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
317
+ const args = setCall!.args as {
318
+ name: string;
319
+ envVars: Record<string, string>;
320
+ };
321
+ expect(args.envVars.AGENTIO_KEY).toBe('key_value_from_export');
322
+ expect(args.envVars.AGENTIO_CONFIG).toBe('config_blob_from_export==');
323
+ expect(args.envVars.AGENTIO_SERVER_API_KEY).toBe('srv_fixed_test_key');
324
+ });
325
+
326
+ test('deploy is called with the Dockerfile path (so siteio rebuilds)', async () => {
327
+ const deps = makeDeps();
328
+ await runTeleport({ name: 'mcp' }, deps);
329
+ const deployCall = deps.calls.find((c) => c.method === 'deploy');
330
+ const args = deployCall!.args as {
331
+ name: string;
332
+ dockerfilePath?: string;
333
+ noCache?: boolean;
334
+ };
335
+ expect(args.name).toBe('mcp');
336
+ expect(args.dockerfilePath).toMatch(/Dockerfile$/);
337
+ expect(args.noCache).toBeUndefined();
338
+ });
339
+
340
+ test('--no-cache flag propagates to siteio deploy', async () => {
341
+ const deps = makeDeps();
342
+ await runTeleport({ name: 'mcp', noCache: true }, deps);
343
+ const deployCall = deps.calls.find((c) => c.method === 'deploy');
344
+ const args = deployCall!.args as { noCache?: boolean };
345
+ expect(args.noCache).toBe(true);
346
+ });
347
+
348
+ test('writes Dockerfile to temp file AND deletes it on success', async () => {
349
+ const deps = makeDeps();
350
+ await runTeleport({ name: 'mcp' }, deps);
351
+ expect(deps.tempFileWrites).toHaveLength(1);
352
+ expect(deps.tempFileWrites[0].content).toContain('FROM scratch');
353
+ expect(deps.tempFileDeletes).toHaveLength(1);
354
+ expect(deps.tempFileDeletes[0]).toBe(deps.tempFileWrites[0].path);
355
+ });
356
+
357
+ test('log output mentions the API key and the claude mcp add command', async () => {
358
+ const deps = makeDeps({ fixedServerApiKey: 'srv_abcDEF123456789012345678901234' });
359
+ await runTeleport({ name: 'mcp' }, deps);
360
+ const joined = deps.logLines.join('\n');
361
+ expect(joined).toContain('srv_abcDEF123456789012345678901234');
362
+ expect(joined).toContain('claude mcp add');
363
+ });
364
+ });
365
+
366
+ /* ------------------------------------------------------------------ */
367
+ /* runTeleport — preflight failures */
368
+ /* ------------------------------------------------------------------ */
369
+
370
+ describe('runTeleport — preflight failures', () => {
371
+ test('siteio not installed → CliError', async () => {
372
+ const deps = makeDeps({ installed: false });
373
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow(
374
+ /not installed/
375
+ );
376
+ // Must NOT have tried to run any subsequent siteio operations.
377
+ const methods = deps.calls.map((c) => c.method);
378
+ expect(methods).toEqual(['isInstalled']);
379
+ });
380
+
381
+ test('siteio not logged in → CliError', async () => {
382
+ const deps = makeDeps({ loggedIn: false });
383
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow(
384
+ /logged into siteio/
385
+ );
386
+ const methods = deps.calls.map((c) => c.method);
387
+ expect(methods).toEqual(['isInstalled', 'isLoggedIn']);
388
+ });
389
+
390
+ test('no local profiles → CliError', async () => {
391
+ const deps = makeDeps({ profiles: {} });
392
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow(
393
+ /No agentio profiles/
394
+ );
395
+ });
396
+
397
+ test('app already exists → CliError + warning', async () => {
398
+ const deps = makeDeps({
399
+ existingApp: { name: 'mcp', url: 'https://mcp.existing.com' },
400
+ });
401
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow(
402
+ /already exists/
403
+ );
404
+ // A warning was emitted before the throw so the user has context.
405
+ expect(deps.warnLines.some((l) => l.includes('siteio apps rm'))).toBe(
406
+ true
407
+ );
408
+ // Must not have attempted to create/set/deploy.
409
+ const methods = deps.calls.map((c) => c.method);
410
+ expect(methods).not.toContain('createApp');
411
+ expect(methods).not.toContain('deploy');
412
+ });
413
+ });
414
+
415
+ /* ------------------------------------------------------------------ */
416
+ /* runTeleport — temp file lifecycle */
417
+ /* ------------------------------------------------------------------ */
418
+
419
+ describe('runTeleport — temp file lifecycle', () => {
420
+ test('deletes the temp file even when createApp fails', async () => {
421
+ const deps = makeDeps({ failOn: 'createApp' });
422
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow();
423
+ expect(deps.tempFileDeletes).toHaveLength(1);
424
+ });
425
+
426
+ test('deletes the temp file even when setApp fails', async () => {
427
+ const deps = makeDeps({ failOn: 'setApp' });
428
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow();
429
+ expect(deps.tempFileDeletes).toHaveLength(1);
430
+ });
431
+
432
+ test('deletes the temp file even when deploy fails', async () => {
433
+ const deps = makeDeps({ failOn: 'deploy' });
434
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow();
435
+ expect(deps.tempFileDeletes).toHaveLength(1);
436
+ });
437
+
438
+ test('appInfo returning null still completes successfully with a warning', async () => {
439
+ const deps = makeDeps({ deployedApp: null });
440
+ const result = await runTeleport({ name: 'mcp' }, deps);
441
+ expect(result.url).toBeUndefined();
442
+ expect(result.claudeMcpAddCommand).toBeNull();
443
+ const joined = deps.logLines.join('\n');
444
+ expect(joined).toContain('siteio did not return a URL');
445
+ });
446
+ });
447
+
448
+ /* ------------------------------------------------------------------ */
449
+ /* runTeleport — dockerfile-only */
450
+ /* ------------------------------------------------------------------ */
451
+
452
+ describe('runTeleport — dockerfile-only', () => {
453
+ let originalWrite: typeof process.stdout.write;
454
+ let captured: string;
455
+
456
+ beforeEach(() => {
457
+ captured = '';
458
+ originalWrite = process.stdout.write.bind(process.stdout);
459
+ process.stdout.write = ((chunk: string | Uint8Array): boolean => {
460
+ captured +=
461
+ typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString();
462
+ return true;
463
+ }) as typeof process.stdout.write;
464
+ });
465
+
466
+ afterEach(() => {
467
+ process.stdout.write = originalWrite;
468
+ });
469
+
470
+ test('writes Dockerfile to stdout with no siteio interaction', async () => {
471
+ const deps = makeDeps({ dockerfile: '# generated dockerfile\n' });
472
+ const result = await runTeleport(
473
+ { name: 'mcp', dockerfileOnly: true },
474
+ deps
475
+ );
476
+ expect(captured).toContain('# generated dockerfile');
477
+ // Zero siteio calls.
478
+ expect(deps.calls).toHaveLength(0);
479
+ expect(result.url).toBeUndefined();
480
+ });
481
+
482
+ test('--output writes to the given path instead of stdout', async () => {
483
+ const deps = makeDeps({ dockerfile: '# out file\n' });
484
+ // Use the injected writeTempFile as a side-channel? No — the code
485
+ // uses the real fs.writeFile for --output. We test this by pointing
486
+ // at a temp directory.
487
+ const { mkdtemp } = await import('fs/promises');
488
+ const { tmpdir } = await import('os');
489
+ const { join } = await import('path');
490
+ const dir = await mkdtemp(join(tmpdir(), 'agentio-test-output-'));
491
+ const outPath = join(dir, 'Dockerfile.out');
492
+
493
+ await runTeleport(
494
+ { name: 'mcp', dockerfileOnly: true, output: outPath },
495
+ deps
496
+ );
497
+
498
+ const { readFile, rm } = await import('fs/promises');
499
+ const written = await readFile(outPath, 'utf8');
500
+ expect(written).toContain('# out file');
501
+ expect(deps.calls).toHaveLength(0);
502
+ await rm(dir, { recursive: true, force: true });
503
+ });
504
+ });
505
+
506
+ /* ------------------------------------------------------------------ */
507
+ /* runTeleport — dry-run */
508
+ /* ------------------------------------------------------------------ */
509
+
510
+ describe('runTeleport — dry-run', () => {
511
+ test('runs preflight but skips create/set/deploy', async () => {
512
+ const deps = makeDeps();
513
+ await runTeleport({ name: 'mcp', dryRun: true }, deps);
514
+ const methods = deps.calls.map((c) => c.method);
515
+ expect(methods).toContain('isInstalled');
516
+ expect(methods).toContain('isLoggedIn');
517
+ expect(methods).toContain('findApp');
518
+ expect(methods).not.toContain('createApp');
519
+ expect(methods).not.toContain('setApp');
520
+ expect(methods).not.toContain('deploy');
521
+ });
522
+
523
+ test('prints the Dockerfile and the siteio commands that would run', async () => {
524
+ const deps = makeDeps({ dockerfile: '# dry-run dockerfile\n' });
525
+ await runTeleport({ name: 'mcp', dryRun: true }, deps);
526
+ const joined = deps.logLines.join('\n');
527
+ expect(joined).toContain('# dry-run dockerfile');
528
+ expect(joined).toContain('siteio apps create mcp');
529
+ expect(joined).toContain('siteio apps set mcp');
530
+ expect(joined).toContain('siteio apps deploy mcp');
531
+ });
532
+
533
+ test('dry-run does not write a temp file OR call siteio', async () => {
534
+ const deps = makeDeps();
535
+ await runTeleport({ name: 'mcp', dryRun: true }, deps);
536
+ expect(deps.tempFileWrites).toHaveLength(0);
537
+ expect(deps.tempFileDeletes).toHaveLength(0);
538
+ });
539
+
540
+ test('dry-run with --no-cache shows the flag in the reported command', async () => {
541
+ const deps = makeDeps();
542
+ await runTeleport(
543
+ { name: 'mcp', dryRun: true, noCache: true },
544
+ deps
545
+ );
546
+ const joined = deps.logLines.join('\n');
547
+ expect(joined).toContain('deploy mcp --no-cache');
548
+ });
549
+
550
+ test('dry-run redacts the AGENTIO_KEY value in printed output', async () => {
551
+ const deps = makeDeps({
552
+ exportKey: 'REAL_SECRET_KEY_SHOULD_NOT_APPEAR_ANYWHERE',
553
+ });
554
+ await runTeleport({ name: 'mcp', dryRun: true }, deps);
555
+ const joined = deps.logLines.join('\n');
556
+ expect(joined).not.toContain('REAL_SECRET_KEY_SHOULD_NOT_APPEAR_ANYWHERE');
557
+ expect(joined).toContain('redacted');
558
+ });
559
+ });
560
+
561
+ /* ------------------------------------------------------------------ */
562
+ /* runTeleport — name validation */
563
+ /* ------------------------------------------------------------------ */
564
+
565
+ describe('runTeleport — name validation', () => {
566
+ test('invalid name → throws before any siteio call', async () => {
567
+ const deps = makeDeps();
568
+ await expect(
569
+ runTeleport({ name: 'MCP_INVALID' }, deps)
570
+ ).rejects.toThrow(/Invalid app name/);
571
+ expect(deps.calls).toHaveLength(0);
572
+ });
573
+ });
574
+
575
+ /* ------------------------------------------------------------------ */
576
+ /* normalizeGitUrl */
577
+ /* ------------------------------------------------------------------ */
578
+
579
+ describe('normalizeGitUrl', () => {
580
+ test('SSH → HTTPS (github)', () => {
581
+ expect(normalizeGitUrl('git@github.com:plosson/agentio.git')).toBe(
582
+ 'https://github.com/plosson/agentio.git'
583
+ );
584
+ });
585
+
586
+ test('SSH without .git suffix still emits .git', () => {
587
+ expect(normalizeGitUrl('git@github.com:plosson/agentio')).toBe(
588
+ 'https://github.com/plosson/agentio.git'
589
+ );
590
+ });
591
+
592
+ test('SSH with multi-level path (gitlab-style)', () => {
593
+ expect(
594
+ normalizeGitUrl('git@gitlab.example.com:group/sub/repo.git')
595
+ ).toBe('https://gitlab.example.com/group/sub/repo.git');
596
+ });
597
+
598
+ test('HTTPS URL passes through unchanged', () => {
599
+ expect(
600
+ normalizeGitUrl('https://github.com/plosson/agentio.git')
601
+ ).toBe('https://github.com/plosson/agentio.git');
602
+ });
603
+
604
+ test('HTTP URL passes through unchanged', () => {
605
+ expect(normalizeGitUrl('http://gitea.local/x/y.git')).toBe(
606
+ 'http://gitea.local/x/y.git'
607
+ );
608
+ });
609
+
610
+ test('trims surrounding whitespace', () => {
611
+ expect(
612
+ normalizeGitUrl(' git@github.com:plosson/agentio.git\n')
613
+ ).toBe('https://github.com/plosson/agentio.git');
614
+ });
615
+
616
+ test('unknown shape passes through unchanged (siteio will error)', () => {
617
+ expect(normalizeGitUrl('not-a-url')).toBe('not-a-url');
618
+ });
619
+ });
620
+
621
+ /* ------------------------------------------------------------------ */
622
+ /* runTeleport — git mode */
623
+ /* ------------------------------------------------------------------ */
624
+
625
+ describe('runTeleport — git mode', () => {
626
+ test('happy path: calls createApp with git args, never writes a temp file', async () => {
627
+ const deps = makeDeps({
628
+ gitOriginUrl: 'https://github.com/plosson/agentio.git',
629
+ });
630
+ const result = await runTeleport(
631
+ {
632
+ name: 'mcp',
633
+ gitBranch: 'http-mcp-server-phase-1',
634
+ },
635
+ deps
636
+ );
637
+
638
+ expect(result.url).toBe('https://mcp.siteio.example.com');
639
+ expect(result.serverApiKey).toMatch(/^srv_/);
640
+
641
+ const createCall = deps.calls.find((c) => c.method === 'createApp');
642
+ expect(createCall).toBeDefined();
643
+ const args = createCall!.args as {
644
+ name: string;
645
+ port: number;
646
+ dockerfilePath?: string;
647
+ git?: { repoUrl: string; branch: string; dockerfilePath: string };
648
+ };
649
+ expect(args.name).toBe('mcp');
650
+ expect(args.port).toBe(9999);
651
+ expect(args.dockerfilePath).toBeUndefined();
652
+ expect(args.git).toBeDefined();
653
+ expect(args.git!.repoUrl).toBe(
654
+ 'https://github.com/plosson/agentio.git'
655
+ );
656
+ expect(args.git!.branch).toBe('http-mcp-server-phase-1');
657
+ expect(args.git!.dockerfilePath).toBe(TELEPORT_DOCKERFILE_PATH);
658
+
659
+ // No temp file written OR deleted.
660
+ expect(deps.tempFileWrites).toHaveLength(0);
661
+ expect(deps.tempFileDeletes).toHaveLength(0);
662
+ });
663
+
664
+ test('SSH origin URL is auto-normalized to HTTPS', async () => {
665
+ const deps = makeDeps({
666
+ gitOriginUrl: 'git@github.com:plosson/agentio.git',
667
+ });
668
+ await runTeleport(
669
+ { name: 'mcp', gitBranch: 'main' },
670
+ deps
671
+ );
672
+ const createCall = deps.calls.find((c) => c.method === 'createApp');
673
+ const args = createCall!.args as {
674
+ git?: { repoUrl: string };
675
+ };
676
+ expect(args.git!.repoUrl).toBe(
677
+ 'https://github.com/plosson/agentio.git'
678
+ );
679
+ });
680
+
681
+ test('--git-url overrides detection', async () => {
682
+ const deps = makeDeps({
683
+ gitOriginUrl: 'https://github.com/plosson/agentio.git',
684
+ });
685
+ await runTeleport(
686
+ {
687
+ name: 'mcp',
688
+ gitBranch: 'main',
689
+ gitUrl: 'https://gitea.example.com/owner/fork.git',
690
+ },
691
+ deps
692
+ );
693
+ const createCall = deps.calls.find((c) => c.method === 'createApp');
694
+ const args = createCall!.args as { git?: { repoUrl: string } };
695
+ expect(args.git!.repoUrl).toBe(
696
+ 'https://gitea.example.com/owner/fork.git'
697
+ );
698
+ });
699
+
700
+ test('--git-url SSH value is normalized to HTTPS', async () => {
701
+ const deps = makeDeps();
702
+ await runTeleport(
703
+ {
704
+ name: 'mcp',
705
+ gitBranch: 'main',
706
+ gitUrl: 'git@gitea.example.com:owner/fork.git',
707
+ },
708
+ deps
709
+ );
710
+ const createCall = deps.calls.find((c) => c.method === 'createApp');
711
+ const args = createCall!.args as { git?: { repoUrl: string } };
712
+ expect(args.git!.repoUrl).toBe(
713
+ 'https://gitea.example.com/owner/fork.git'
714
+ );
715
+ });
716
+
717
+ test('no detectable origin and no --git-url → CliError, zero siteio calls past preflight', async () => {
718
+ const deps = makeDeps({ gitOriginUrl: null });
719
+ await expect(
720
+ runTeleport({ name: 'mcp', gitBranch: 'main' }, deps)
721
+ ).rejects.toThrow(/Could not detect git origin URL/);
722
+ // Preflight ran (isInstalled/isLoggedIn/findApp) but create should
723
+ // NOT have been attempted.
724
+ const methods = deps.calls.map((c) => c.method);
725
+ expect(methods).not.toContain('createApp');
726
+ });
727
+
728
+ test('deploy is called WITHOUT dockerfilePath in git mode', async () => {
729
+ const deps = makeDeps({
730
+ gitOriginUrl: 'https://github.com/x/y.git',
731
+ });
732
+ await runTeleport(
733
+ { name: 'mcp', gitBranch: 'main' },
734
+ deps
735
+ );
736
+ const deployCall = deps.calls.find((c) => c.method === 'deploy');
737
+ const args = deployCall!.args as {
738
+ name: string;
739
+ dockerfilePath?: string;
740
+ };
741
+ expect(args.name).toBe('mcp');
742
+ expect(args.dockerfilePath).toBeUndefined();
743
+ });
744
+
745
+ test('--no-cache still propagates in git mode', async () => {
746
+ const deps = makeDeps({
747
+ gitOriginUrl: 'https://github.com/x/y.git',
748
+ });
749
+ await runTeleport(
750
+ { name: 'mcp', gitBranch: 'main', noCache: true },
751
+ deps
752
+ );
753
+ const deployCall = deps.calls.find((c) => c.method === 'deploy');
754
+ const args = deployCall!.args as { noCache?: boolean };
755
+ expect(args.noCache).toBe(true);
756
+ });
757
+
758
+ test('startup log announces git mode with resolved URL + branch', async () => {
759
+ const deps = makeDeps({
760
+ gitOriginUrl: 'git@github.com:plosson/agentio.git',
761
+ });
762
+ await runTeleport(
763
+ { name: 'mcp', gitBranch: 'feat-x' },
764
+ deps
765
+ );
766
+ const joined = deps.logLines.join('\n');
767
+ expect(joined).toContain('Git mode');
768
+ expect(joined).toContain('https://github.com/plosson/agentio.git');
769
+ expect(joined).toContain('feat-x');
770
+ });
771
+
772
+ test('dry-run in git mode shows siteio create -g command, no Dockerfile body', async () => {
773
+ const deps = makeDeps({
774
+ gitOriginUrl: 'https://github.com/plosson/agentio.git',
775
+ dockerfile: '# should not appear in git-mode dry-run\n',
776
+ });
777
+ await runTeleport(
778
+ { name: 'mcp', gitBranch: 'main', dryRun: true },
779
+ deps
780
+ );
781
+ const joined = deps.logLines.join('\n');
782
+ expect(joined).toContain('siteio apps create mcp -g');
783
+ expect(joined).toContain('--branch main');
784
+ expect(joined).toContain(`--dockerfile ${TELEPORT_DOCKERFILE_PATH}`);
785
+ // Must NOT dump the inline Dockerfile body — it's not relevant.
786
+ expect(joined).not.toContain('should not appear in git-mode dry-run');
787
+ // Must not hit siteio for real.
788
+ const methods = deps.calls.map((c) => c.method);
789
+ expect(methods).not.toContain('createApp');
790
+ expect(methods).not.toContain('deploy');
791
+ });
792
+
793
+ test('failed createApp in git mode: no temp file cleanup needed', async () => {
794
+ const deps = makeDeps({
795
+ gitOriginUrl: 'https://github.com/x/y.git',
796
+ failOn: 'createApp',
797
+ });
798
+ await expect(
799
+ runTeleport({ name: 'mcp', gitBranch: 'main' }, deps)
800
+ ).rejects.toThrow();
801
+ expect(deps.tempFileWrites).toHaveLength(0);
802
+ expect(deps.tempFileDeletes).toHaveLength(0);
803
+ });
804
+ });
805
+
806
+ /* ------------------------------------------------------------------ */
807
+ /* runTeleport — sync mode */
808
+ /* ------------------------------------------------------------------ */
809
+
810
+ describe('runTeleport — sync mode (--sync)', () => {
811
+ test('happy path: preflight → findApp → appInfo → setApp → restartApp', async () => {
812
+ const deps = makeDeps({
813
+ existingApp: { name: 'mcp' },
814
+ deployedApp: {
815
+ name: 'mcp',
816
+ url: 'https://mcp.x.com',
817
+ // Existing /data volume — no backfill needed.
818
+ volumes: [{ name: 'agentio-data-mcp', mountPath: '/data' }],
819
+ },
820
+ });
821
+ const result = await runTeleport(
822
+ { name: 'mcp', sync: true },
823
+ deps
824
+ );
825
+
826
+ const methods = deps.calls.map((c) => c.method);
827
+ expect(methods).toEqual([
828
+ 'isInstalled',
829
+ 'isLoggedIn',
830
+ 'findApp',
831
+ 'appInfo',
832
+ 'setApp',
833
+ 'restartApp',
834
+ ]);
835
+ expect(result.url).toBe('https://mcp.x.com');
836
+ });
837
+
838
+ test('refuses to sync against a non-existent app', async () => {
839
+ const deps = makeDeps({ existingApp: null });
840
+ await expect(
841
+ runTeleport({ name: 'mcp', sync: true }, deps)
842
+ ).rejects.toThrow(/No siteio app named "mcp" to sync to/);
843
+ // Did not attempt to set env or restart.
844
+ const methods = deps.calls.map((c) => c.method);
845
+ expect(methods).not.toContain('setApp');
846
+ expect(methods).not.toContain('restartApp');
847
+ });
848
+
849
+ test('setApp passes ONLY AGENTIO_KEY + AGENTIO_CONFIG (NOT AGENTIO_SERVER_API_KEY)', async () => {
850
+ const deps = makeDeps({
851
+ existingApp: { name: 'mcp' },
852
+ exportKey: 'rotated_key',
853
+ exportConfig: 'rotated_config==',
854
+ });
855
+ await runTeleport({ name: 'mcp', sync: true }, deps);
856
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
857
+ const args = setCall!.args as {
858
+ name: string;
859
+ envVars: Record<string, string>;
860
+ };
861
+ expect(Object.keys(args.envVars).sort()).toEqual([
862
+ 'AGENTIO_CONFIG',
863
+ 'AGENTIO_KEY',
864
+ ]);
865
+ expect(args.envVars.AGENTIO_KEY).toBe('rotated_key');
866
+ expect(args.envVars.AGENTIO_CONFIG).toBe('rotated_config==');
867
+ expect(args.envVars.AGENTIO_SERVER_API_KEY).toBeUndefined();
868
+ });
869
+
870
+ test('restartApp is called with the app name', async () => {
871
+ const deps = makeDeps({ existingApp: { name: 'mcp' } });
872
+ await runTeleport({ name: 'mcp', sync: true }, deps);
873
+ const restartCall = deps.calls.find((c) => c.method === 'restartApp');
874
+ expect(restartCall).toBeDefined();
875
+ expect(restartCall!.args).toEqual({ name: 'mcp' });
876
+ });
877
+
878
+ test('does NOT generate a Dockerfile or write a temp file', async () => {
879
+ const deps = makeDeps({ existingApp: { name: 'mcp' } });
880
+ await runTeleport({ name: 'mcp', sync: true }, deps);
881
+ expect(deps.tempFileWrites).toHaveLength(0);
882
+ expect(deps.tempFileDeletes).toHaveLength(0);
883
+ });
884
+
885
+ test('does NOT call createApp or deploy', async () => {
886
+ const deps = makeDeps({ existingApp: { name: 'mcp' } });
887
+ await runTeleport({ name: 'mcp', sync: true }, deps);
888
+ const methods = deps.calls.map((c) => c.method);
889
+ expect(methods).not.toContain('createApp');
890
+ expect(methods).not.toContain('deploy');
891
+ });
892
+
893
+ test('output for backfill case mentions one-time bearer invalidation', async () => {
894
+ const deps = makeDeps({
895
+ existingApp: { name: 'mcp' },
896
+ // No volumes on the deployedApp → backfill triggers.
897
+ deployedApp: { name: 'mcp', url: 'https://mcp.x.com', volumes: [] },
898
+ });
899
+ await runTeleport({ name: 'mcp', sync: true }, deps);
900
+ const joined = deps.logLines.join('\n');
901
+ expect(joined).toContain('Sync complete');
902
+ expect(joined).toContain('volume backfill');
903
+ expect(joined.toLowerCase()).toContain('re-paste');
904
+ expect(joined).toContain('persist');
905
+ });
906
+
907
+ test('output for already-mounted case promises bearer survival', async () => {
908
+ const deps = makeDeps({
909
+ existingApp: { name: 'mcp' },
910
+ deployedApp: {
911
+ name: 'mcp',
912
+ url: 'https://mcp.x.com',
913
+ volumes: [{ name: 'agentio-data-mcp', mountPath: '/data' }],
914
+ },
915
+ });
916
+ await runTeleport({ name: 'mcp', sync: true }, deps);
917
+ const joined = deps.logLines.join('\n');
918
+ expect(joined).toContain('Sync complete');
919
+ expect(joined).toContain('persistent volume');
920
+ expect(joined).toContain('keep their existing bearer');
921
+ });
922
+
923
+ test('preserves the same operator API key (no fresh generation in sync)', async () => {
924
+ const deps = makeDeps({
925
+ existingApp: { name: 'mcp' },
926
+ fixedServerApiKey: 'srv_should_not_appear',
927
+ });
928
+ const result = await runTeleport(
929
+ { name: 'mcp', sync: true },
930
+ deps
931
+ );
932
+ // Sync result has no serverApiKey since we didn't generate one.
933
+ expect(result.serverApiKey).toBe('');
934
+ });
935
+ });
936
+
937
+ /* ------------------------------------------------------------------ */
938
+ /* runTeleport — sync mode dry-run */
939
+ /* ------------------------------------------------------------------ */
940
+
941
+ describe('runTeleport — sync dry-run', () => {
942
+ test('shows the apps set + apps restart commands without executing', async () => {
943
+ const deps = makeDeps({
944
+ existingApp: { name: 'mcp' },
945
+ exportConfig: 'X'.repeat(1000),
946
+ });
947
+ await runTeleport(
948
+ { name: 'mcp', sync: true, dryRun: true },
949
+ deps
950
+ );
951
+ const joined = deps.logLines.join('\n');
952
+ expect(joined).toContain('siteio apps set mcp');
953
+ expect(joined).toContain('siteio apps restart mcp');
954
+ expect(joined).toContain('AGENTIO_KEY=<redacted>');
955
+ expect(joined).toContain('AGENTIO_CONFIG=<1000 chars>');
956
+ // Did NOT actually call setApp / restartApp.
957
+ const methods = deps.calls.map((c) => c.method);
958
+ expect(methods).not.toContain('setApp');
959
+ expect(methods).not.toContain('restartApp');
960
+ });
961
+
962
+ test('preflight + findApp still run in dry-run', async () => {
963
+ const deps = makeDeps({ existingApp: { name: 'mcp' } });
964
+ await runTeleport(
965
+ { name: 'mcp', sync: true, dryRun: true },
966
+ deps
967
+ );
968
+ const methods = deps.calls.map((c) => c.method);
969
+ expect(methods).toContain('isInstalled');
970
+ expect(methods).toContain('isLoggedIn');
971
+ expect(methods).toContain('findApp');
972
+ });
973
+
974
+ test('dry-run still bails if app does not exist', async () => {
975
+ const deps = makeDeps({ existingApp: null });
976
+ await expect(
977
+ runTeleport({ name: 'mcp', sync: true, dryRun: true }, deps)
978
+ ).rejects.toThrow(/No siteio app named/);
979
+ });
980
+ });
981
+
982
+ /* ------------------------------------------------------------------ */
983
+ /* runTeleport — sync mutual exclusion */
984
+ /* ------------------------------------------------------------------ */
985
+
986
+ describe('runTeleport — --sync mutual exclusion', () => {
987
+ test('--sync + --dockerfile-only → CliError', async () => {
988
+ const deps = makeDeps({ existingApp: { name: 'mcp' } });
989
+ await expect(
990
+ runTeleport(
991
+ { name: 'mcp', sync: true, dockerfileOnly: true },
992
+ deps
993
+ )
994
+ ).rejects.toThrow(/--sync cannot be combined with --dockerfile-only/);
995
+ // Did not even hit preflight.
996
+ expect(deps.calls).toHaveLength(0);
997
+ });
998
+
999
+ test('--sync + --git-branch → CliError', async () => {
1000
+ const deps = makeDeps({ existingApp: { name: 'mcp' } });
1001
+ await expect(
1002
+ runTeleport(
1003
+ { name: 'mcp', sync: true, gitBranch: 'main' },
1004
+ deps
1005
+ )
1006
+ ).rejects.toThrow(/--sync cannot be combined with --git-branch/);
1007
+ });
1008
+
1009
+ test('--sync + --no-cache → CliError', async () => {
1010
+ const deps = makeDeps({ existingApp: { name: 'mcp' } });
1011
+ await expect(
1012
+ runTeleport({ name: 'mcp', sync: true, noCache: true }, deps)
1013
+ ).rejects.toThrow(/--sync cannot be combined with --no-cache/);
1014
+ });
1015
+
1016
+ test('--sync + --output → CliError', async () => {
1017
+ const deps = makeDeps({ existingApp: { name: 'mcp' } });
1018
+ await expect(
1019
+ runTeleport(
1020
+ { name: 'mcp', sync: true, output: '/tmp/x' },
1021
+ deps
1022
+ )
1023
+ ).rejects.toThrow(/--sync cannot be combined with --output/);
1024
+ });
1025
+ });
1026
+
1027
+ /* ------------------------------------------------------------------ */
1028
+ /* volume helpers */
1029
+ /* ------------------------------------------------------------------ */
1030
+
1031
+ describe('volumeNameFor', () => {
1032
+ test('namespaces by app name', () => {
1033
+ expect(volumeNameFor('mcp')).toBe('agentio-data-mcp');
1034
+ expect(volumeNameFor('mcp-prod')).toBe('agentio-data-mcp-prod');
1035
+ });
1036
+ });
1037
+
1038
+ describe('hasDataVolumeMount', () => {
1039
+ test('null / non-object → false', () => {
1040
+ expect(hasDataVolumeMount(null)).toBe(false);
1041
+ expect(hasDataVolumeMount(undefined)).toBe(false);
1042
+ expect(hasDataVolumeMount('string')).toBe(false);
1043
+ });
1044
+
1045
+ test('no volumes field → false', () => {
1046
+ expect(hasDataVolumeMount({ name: 'x' })).toBe(false);
1047
+ });
1048
+
1049
+ test('empty volumes array → false', () => {
1050
+ expect(hasDataVolumeMount({ volumes: [] })).toBe(false);
1051
+ });
1052
+
1053
+ test('object form with mountPath /data → true', () => {
1054
+ expect(
1055
+ hasDataVolumeMount({
1056
+ volumes: [{ name: 'agentio-data-mcp', mountPath: '/data' }],
1057
+ })
1058
+ ).toBe(true);
1059
+ });
1060
+
1061
+ test('object form with path /data → true (alternate field name)', () => {
1062
+ expect(
1063
+ hasDataVolumeMount({
1064
+ volumes: [{ name: 'agentio-data-mcp', path: '/data' }],
1065
+ })
1066
+ ).toBe(true);
1067
+ });
1068
+
1069
+ test('object form with /other mount → false', () => {
1070
+ expect(
1071
+ hasDataVolumeMount({
1072
+ volumes: [{ name: 'cache', mountPath: '/cache' }],
1073
+ })
1074
+ ).toBe(false);
1075
+ });
1076
+
1077
+ test('string form name:/data → true', () => {
1078
+ expect(
1079
+ hasDataVolumeMount({ volumes: ['agentio-data-mcp:/data'] })
1080
+ ).toBe(true);
1081
+ });
1082
+
1083
+ test('mixed array with at least one /data → true', () => {
1084
+ expect(
1085
+ hasDataVolumeMount({
1086
+ volumes: [
1087
+ { name: 'cache', mountPath: '/cache' },
1088
+ { name: 'data', mountPath: '/data' },
1089
+ ],
1090
+ })
1091
+ ).toBe(true);
1092
+ });
1093
+ });
1094
+
1095
+ /* ------------------------------------------------------------------ */
1096
+ /* persistent volume on initial teleport (inline + git mode) */
1097
+ /* ------------------------------------------------------------------ */
1098
+
1099
+ describe('runTeleport — initial deploy attaches persistent volume', () => {
1100
+ test('inline mode: setApp call includes volume agentio-data-<name>:/data', async () => {
1101
+ const deps = makeDeps();
1102
+ await runTeleport({ name: 'mcp' }, deps);
1103
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
1104
+ const args = setCall!.args as {
1105
+ envVars?: Record<string, string>;
1106
+ volumes?: Record<string, string>;
1107
+ };
1108
+ expect(args.volumes).toBeDefined();
1109
+ expect(args.volumes!['agentio-data-mcp']).toBe(DATA_VOLUME_PATH);
1110
+ });
1111
+
1112
+ test('git mode: setApp call includes the volume too', async () => {
1113
+ const deps = makeDeps({
1114
+ gitOriginUrl: 'https://github.com/x/y.git',
1115
+ });
1116
+ await runTeleport(
1117
+ { name: 'mcp', gitBranch: 'main' },
1118
+ deps
1119
+ );
1120
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
1121
+ const args = setCall!.args as { volumes?: Record<string, string> };
1122
+ expect(args.volumes!['agentio-data-mcp']).toBe(DATA_VOLUME_PATH);
1123
+ });
1124
+
1125
+ test('volume name follows the per-app convention', async () => {
1126
+ const deps = makeDeps();
1127
+ await runTeleport({ name: 'my-prod-deploy' }, deps);
1128
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
1129
+ const args = setCall!.args as { volumes?: Record<string, string> };
1130
+ expect(Object.keys(args.volumes!)).toEqual(['agentio-data-my-prod-deploy']);
1131
+ });
1132
+ });
1133
+
1134
+ /* ------------------------------------------------------------------ */
1135
+ /* sync backfills the volume only when missing */
1136
+ /* ------------------------------------------------------------------ */
1137
+
1138
+ describe('runTeleport — sync volume backfill', () => {
1139
+ test('app already has /data mount → setApp omits volumes', async () => {
1140
+ const deps = makeDeps({
1141
+ existingApp: { name: 'mcp' },
1142
+ deployedApp: {
1143
+ name: 'mcp',
1144
+ url: 'https://mcp.x.com',
1145
+ volumes: [{ name: 'agentio-data-mcp', mountPath: '/data' }],
1146
+ },
1147
+ });
1148
+ await runTeleport({ name: 'mcp', sync: true }, deps);
1149
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
1150
+ const args = setCall!.args as {
1151
+ envVars?: Record<string, string>;
1152
+ volumes?: Record<string, string>;
1153
+ };
1154
+ expect(args.volumes).toBeUndefined();
1155
+ expect(args.envVars).toBeDefined();
1156
+ });
1157
+
1158
+ test('app has NO /data mount → setApp includes volumes (backfill)', async () => {
1159
+ const deps = makeDeps({
1160
+ existingApp: { name: 'mcp' },
1161
+ deployedApp: { name: 'mcp', url: 'https://mcp.x.com', volumes: [] },
1162
+ });
1163
+ await runTeleport({ name: 'mcp', sync: true }, deps);
1164
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
1165
+ const args = setCall!.args as { volumes?: Record<string, string> };
1166
+ expect(args.volumes!['agentio-data-mcp']).toBe(DATA_VOLUME_PATH);
1167
+ });
1168
+
1169
+ test('app has /other mount but no /data → setApp includes /data volume', async () => {
1170
+ const deps = makeDeps({
1171
+ existingApp: { name: 'mcp' },
1172
+ deployedApp: {
1173
+ name: 'mcp',
1174
+ url: 'https://mcp.x.com',
1175
+ volumes: [{ name: 'something-else', mountPath: '/cache' }],
1176
+ },
1177
+ });
1178
+ await runTeleport({ name: 'mcp', sync: true }, deps);
1179
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
1180
+ const args = setCall!.args as { volumes?: Record<string, string> };
1181
+ expect(args.volumes!['agentio-data-mcp']).toBe(DATA_VOLUME_PATH);
1182
+ });
1183
+
1184
+ test('appInfo returns null → treated as needs-backfill (defensive)', async () => {
1185
+ const deps = makeDeps({
1186
+ existingApp: { name: 'mcp' },
1187
+ deployedApp: null,
1188
+ });
1189
+ await runTeleport({ name: 'mcp', sync: true }, deps);
1190
+ const setCall = deps.calls.find((c) => c.method === 'setApp');
1191
+ const args = setCall!.args as { volumes?: Record<string, string> };
1192
+ expect(args.volumes!['agentio-data-mcp']).toBe(DATA_VOLUME_PATH);
1193
+ });
1194
+
1195
+ test('dry-run shows the -v flag when backfill is needed', async () => {
1196
+ const deps = makeDeps({
1197
+ existingApp: { name: 'mcp' },
1198
+ deployedApp: { name: 'mcp', volumes: [] },
1199
+ });
1200
+ await runTeleport(
1201
+ { name: 'mcp', sync: true, dryRun: true },
1202
+ deps
1203
+ );
1204
+ const joined = deps.logLines.join('\n');
1205
+ expect(joined).toContain('-v agentio-data-mcp:/data');
1206
+ });
1207
+
1208
+ test('dry-run does NOT show -v when volume already present', async () => {
1209
+ const deps = makeDeps({
1210
+ existingApp: { name: 'mcp' },
1211
+ deployedApp: {
1212
+ name: 'mcp',
1213
+ volumes: [{ name: 'agentio-data-mcp', mountPath: '/data' }],
1214
+ },
1215
+ });
1216
+ await runTeleport(
1217
+ { name: 'mcp', sync: true, dryRun: true },
1218
+ deps
1219
+ );
1220
+ const joined = deps.logLines.join('\n');
1221
+ expect(joined).not.toContain('-v agentio-data-mcp');
1222
+ });
1223
+ });
1224
+
1225
+ /* ------------------------------------------------------------------ */
1226
+ /* runTeleport — sync failure cleanup */
1227
+ /* ------------------------------------------------------------------ */
1228
+
1229
+ describe('runTeleport — sync failure paths', () => {
1230
+ test('setApp fails → restartApp NOT called', async () => {
1231
+ const deps = makeDeps({
1232
+ existingApp: { name: 'mcp' },
1233
+ failOn: 'setApp',
1234
+ });
1235
+ await expect(
1236
+ runTeleport({ name: 'mcp', sync: true }, deps)
1237
+ ).rejects.toThrow();
1238
+ const methods = deps.calls.map((c) => c.method);
1239
+ expect(methods).not.toContain('restartApp');
1240
+ });
1241
+
1242
+ test('restartApp fails → error propagates', async () => {
1243
+ const deps = makeDeps({
1244
+ existingApp: { name: 'mcp' },
1245
+ failOn: 'restartApp',
1246
+ });
1247
+ await expect(
1248
+ runTeleport({ name: 'mcp', sync: true }, deps)
1249
+ ).rejects.toThrow(/restart failed/);
1250
+ // setApp ran (we got past it before restartApp blew up).
1251
+ const methods = deps.calls.map((c) => c.method);
1252
+ expect(methods).toContain('setApp');
1253
+ });
1254
+ });
1255
+
1256
+ /* ------------------------------------------------------------------ */
1257
+ /* waitForHealth (direct) */
1258
+ /* ------------------------------------------------------------------ */
1259
+
1260
+ describe('waitForHealth', () => {
1261
+ test('returns true on first 200 without sleeping', async () => {
1262
+ const { waitForHealth } = await import('./teleport');
1263
+ const probed: string[] = [];
1264
+ const sleeps: number[] = [];
1265
+ const logs: string[] = [];
1266
+ const ok = await waitForHealth(
1267
+ 'https://mcp.example.com',
1268
+ {
1269
+ probeHealth: async (u) => {
1270
+ probed.push(u);
1271
+ return 200;
1272
+ },
1273
+ sleep: async (ms) => {
1274
+ sleeps.push(ms);
1275
+ },
1276
+ log: (m) => logs.push(m),
1277
+ },
1278
+ { timeoutMs: 1000, intervalMs: 100 }
1279
+ );
1280
+ expect(ok).toBe(true);
1281
+ expect(probed).toEqual(['https://mcp.example.com/health']);
1282
+ expect(sleeps).toEqual([]); // no sleep after a first-attempt success
1283
+ expect(logs.join('\n')).toMatch(/responded 200 after 1 attempt/);
1284
+ });
1285
+
1286
+ test('returns true when 200 arrives after a few not-ready probes', async () => {
1287
+ const { waitForHealth } = await import('./teleport');
1288
+ const sequence: Array<number | null> = [null, 503, null, 200];
1289
+ let idx = 0;
1290
+ const sleeps: number[] = [];
1291
+ const ok = await waitForHealth(
1292
+ 'https://mcp.example.com/',
1293
+ {
1294
+ probeHealth: async () => sequence[idx++] ?? null,
1295
+ sleep: async (ms) => {
1296
+ sleeps.push(ms);
1297
+ },
1298
+ log: () => {},
1299
+ },
1300
+ { timeoutMs: 10_000, intervalMs: 100 }
1301
+ );
1302
+ expect(ok).toBe(true);
1303
+ expect(sleeps).toEqual([100, 100, 100]); // 3 sleeps before the 4th probe hit 200
1304
+ });
1305
+
1306
+ test('returns false when probe never hits 200 within the budget', async () => {
1307
+ const { waitForHealth } = await import('./teleport');
1308
+ let probeCount = 0;
1309
+ const sleeps: number[] = [];
1310
+ const ok = await waitForHealth(
1311
+ 'https://mcp.example.com',
1312
+ {
1313
+ probeHealth: async () => {
1314
+ probeCount++;
1315
+ return null;
1316
+ },
1317
+ sleep: async (ms) => {
1318
+ sleeps.push(ms);
1319
+ },
1320
+ log: () => {},
1321
+ },
1322
+ { timeoutMs: 500, intervalMs: 100 }
1323
+ );
1324
+ expect(ok).toBe(false);
1325
+ // timeout/interval = 5 attempts, 4 sleeps between them.
1326
+ expect(probeCount).toBe(5);
1327
+ expect(sleeps.length).toBe(4);
1328
+ });
1329
+
1330
+ test('strips trailing slash(es) from url before appending /health', async () => {
1331
+ const { waitForHealth } = await import('./teleport');
1332
+ const probed: string[] = [];
1333
+ await waitForHealth(
1334
+ 'https://mcp.example.com///',
1335
+ {
1336
+ probeHealth: async (u) => {
1337
+ probed.push(u);
1338
+ return 200;
1339
+ },
1340
+ sleep: async () => {},
1341
+ log: () => {},
1342
+ },
1343
+ { timeoutMs: 1000, intervalMs: 100 }
1344
+ );
1345
+ expect(probed[0]).toBe('https://mcp.example.com/health');
1346
+ });
1347
+ });
1348
+
1349
+ /* ------------------------------------------------------------------ */
1350
+ /* runTeleport — health-check surfacing */
1351
+ /* ------------------------------------------------------------------ */
1352
+
1353
+ describe('runTeleport — health check on deploy', () => {
1354
+ test('happy path probes /health at the deployed URL and does not fetch logs', async () => {
1355
+ const deps = makeDeps();
1356
+ await runTeleport({ name: 'mcp' }, deps);
1357
+ expect(deps.healthProbeUrls[0]).toBe('https://mcp.siteio.example.com/health');
1358
+ expect(deps.calls.map((c) => c.method)).not.toContain('logsApp');
1359
+ });
1360
+
1361
+ test('health never returns 200 → fetches logs, surfaces them via warn, throws CliError', async () => {
1362
+ const deps = makeDeps({
1363
+ healthProbeResponses: [], // always null → timeout path
1364
+ logsStdout: 'Error: EACCES: permission denied, mkdir /data/.config\n',
1365
+ });
1366
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow(
1367
+ /\/health never returned 200/
1368
+ );
1369
+ // logs were fetched with the expected tail size
1370
+ const logsCall = deps.calls.find((c) => c.method === 'logsApp');
1371
+ expect(logsCall).toBeDefined();
1372
+ expect((logsCall!.args as { opts: { tail: number } }).opts.tail).toBeGreaterThan(0);
1373
+ // The log tail was surfaced to the user on stderr (deps.warn)
1374
+ expect(deps.warnLines.join('\n')).toContain('EACCES: permission denied');
1375
+ });
1376
+
1377
+ test('empty log stdout still produces a clear warning (no "undefined" output)', async () => {
1378
+ const deps = makeDeps({
1379
+ healthProbeResponses: [],
1380
+ logsStdout: '',
1381
+ });
1382
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow();
1383
+ expect(deps.warnLines.join('\n')).toContain('(no logs returned by siteio)');
1384
+ });
1385
+
1386
+ test('siteio did not return a URL → health check is skipped with a warning, no throw', async () => {
1387
+ const deps = makeDeps({ deployedApp: { name: 'mcp' } }); // no url field
1388
+ const result = await runTeleport({ name: 'mcp' }, deps);
1389
+ expect(result.url).toBeUndefined();
1390
+ expect(deps.healthProbeUrls).toEqual([]);
1391
+ expect(deps.warnLines.join('\n')).toContain('Skipping health check');
1392
+ });
1393
+
1394
+ test('appInfo lacks url but findApp has it → falls back, still runs health check', async () => {
1395
+ // Mirrors real siteio behavior: `apps info --json` omits the
1396
+ // generated subdomain URL even though `apps list --json` surfaces
1397
+ // it. We fall back to findApp (which wraps `apps list`) so the
1398
+ // health check can still run.
1399
+ const deps = makeDeps({
1400
+ deployedApp: { name: 'mcp' }, // info: no url
1401
+ // existingApp is read by findApp on re-call — setting it supplies
1402
+ // the fallback URL.
1403
+ existingApp: { name: 'mcp', url: 'https://mcp.siteio.example.com' },
1404
+ });
1405
+ // But runTeleport's "create" path REFUSES if existingApp is found,
1406
+ // so we need to bypass that. Trick: set existingApp to null at call
1407
+ // time; we can't really do that here without extending the fixture.
1408
+ // Instead, emulate via a custom runner.
1409
+ let findAppCalls = 0;
1410
+ const deployInfo: SiteioApp = { name: 'mcp' }; // info returns no url
1411
+ const fallbackInfo: SiteioApp = {
1412
+ name: 'mcp',
1413
+ url: 'https://mcp.siteio.example.com',
1414
+ };
1415
+ deps.runner.findApp = async () => {
1416
+ findAppCalls++;
1417
+ // First call (preflight — "does app already exist?") must return null
1418
+ // so runTeleport proceeds with create. Second call (post-deploy URL
1419
+ // fallback) returns the populated URL.
1420
+ return findAppCalls === 1 ? null : fallbackInfo;
1421
+ };
1422
+ deps.runner.appInfo = async () => deployInfo;
1423
+ await runTeleport({ name: 'mcp' }, deps);
1424
+ expect(findAppCalls).toBe(2);
1425
+ expect(deps.healthProbeUrls[0]).toBe('https://mcp.siteio.example.com/health');
1426
+ });
1427
+ });
1428
+
1429
+ describe('runTeleport — health check on --sync', () => {
1430
+ test('sync happy path probes /health after restart', async () => {
1431
+ const deps = makeDeps({
1432
+ existingApp: { name: 'mcp', url: 'https://mcp.siteio.example.com' },
1433
+ deployedApp: {
1434
+ name: 'mcp',
1435
+ url: 'https://mcp.siteio.example.com',
1436
+ volumes: [`agentio-data-mcp:${DATA_VOLUME_PATH}`],
1437
+ },
1438
+ });
1439
+ await runTeleport({ name: 'mcp', sync: true }, deps);
1440
+ expect(deps.healthProbeUrls[0]).toBe('https://mcp.siteio.example.com/health');
1441
+ // No log fetch on a happy sync.
1442
+ expect(deps.calls.map((c) => c.method)).not.toContain('logsApp');
1443
+ });
1444
+
1445
+ test('sync health times out → logs fetched + thrown', async () => {
1446
+ const deps = makeDeps({
1447
+ existingApp: { name: 'mcp', url: 'https://mcp.siteio.example.com' },
1448
+ deployedApp: {
1449
+ name: 'mcp',
1450
+ url: 'https://mcp.siteio.example.com',
1451
+ volumes: [`agentio-data-mcp:${DATA_VOLUME_PATH}`],
1452
+ },
1453
+ healthProbeResponses: [],
1454
+ logsStdout: 'boom\n',
1455
+ });
1456
+ await expect(
1457
+ runTeleport({ name: 'mcp', sync: true }, deps)
1458
+ ).rejects.toThrow(/\/health never returned 200/);
1459
+ expect(deps.calls.map((c) => c.method)).toContain('logsApp');
1460
+ expect(deps.warnLines.join('\n')).toContain('boom');
1461
+ });
1462
+ });