@plosson/agentio 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,720 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ createSiteioRunner,
5
+ type SpawnFn,
6
+ type SpawnOptions,
7
+ type SpawnedProcess,
8
+ } from './siteio-runner';
9
+
10
+ /**
11
+ * Unit tests for the siteio subprocess wrapper. Every test injects a
12
+ * mock `spawn` that records the argv it was called with and returns
13
+ * stubbed output. The real `siteio` binary is never invoked.
14
+ */
15
+
16
+ interface MockCall {
17
+ cmd: string[];
18
+ env?: Record<string, string>;
19
+ }
20
+
21
+ interface MockSpawnOptions {
22
+ /** Sequence of responses, one per call, consumed in order. */
23
+ responses: SpawnedProcess[];
24
+ /** Optional: make spawn itself throw (e.g. ENOENT). */
25
+ throwOn?: (opts: SpawnOptions) => Error | null;
26
+ }
27
+
28
+ function makeMockSpawn(opts: MockSpawnOptions): {
29
+ spawn: SpawnFn;
30
+ calls: MockCall[];
31
+ } {
32
+ const calls: MockCall[] = [];
33
+ let idx = 0;
34
+ const spawn: SpawnFn = async (o) => {
35
+ calls.push({ cmd: [...o.cmd], env: o.env });
36
+ const err = opts.throwOn?.(o);
37
+ if (err) throw err;
38
+ if (idx >= opts.responses.length) {
39
+ throw new Error(
40
+ `mock spawn ran out of responses at call #${idx + 1}; cmd=${o.cmd.join(' ')}`
41
+ );
42
+ }
43
+ return opts.responses[idx++];
44
+ };
45
+ return { spawn, calls };
46
+ }
47
+
48
+ const OK: SpawnedProcess = { exitCode: 0, stdout: '', stderr: '' };
49
+ const FAIL_1 = (stderr: string): SpawnedProcess => ({
50
+ exitCode: 1,
51
+ stdout: '',
52
+ stderr,
53
+ });
54
+
55
+ /* ------------------------------------------------------------------ */
56
+ /* isInstalled */
57
+ /* ------------------------------------------------------------------ */
58
+
59
+ describe('isInstalled', () => {
60
+ test('siteio --version exits 0 → true', async () => {
61
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
62
+ const runner = createSiteioRunner(spawn);
63
+ expect(await runner.isInstalled()).toBe(true);
64
+ expect(calls[0].cmd).toEqual(['siteio', '--version']);
65
+ });
66
+
67
+ test('siteio --version exits non-zero → false', async () => {
68
+ const { spawn } = makeMockSpawn({ responses: [FAIL_1('nope')] });
69
+ const runner = createSiteioRunner(spawn);
70
+ expect(await runner.isInstalled()).toBe(false);
71
+ });
72
+
73
+ test('spawn throws (ENOENT — binary missing) → false', async () => {
74
+ const { spawn } = makeMockSpawn({
75
+ responses: [],
76
+ throwOn: () => new Error('ENOENT'),
77
+ });
78
+ const runner = createSiteioRunner(spawn);
79
+ expect(await runner.isInstalled()).toBe(false);
80
+ });
81
+ });
82
+
83
+ /* ------------------------------------------------------------------ */
84
+ /* isLoggedIn */
85
+ /* ------------------------------------------------------------------ */
86
+
87
+ describe('isLoggedIn', () => {
88
+ test('siteio status exits 0 → true', async () => {
89
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
90
+ const runner = createSiteioRunner(spawn);
91
+ expect(await runner.isLoggedIn()).toBe(true);
92
+ expect(calls[0].cmd).toEqual(['siteio', 'status']);
93
+ });
94
+
95
+ test('siteio status exits non-zero → false', async () => {
96
+ const { spawn } = makeMockSpawn({
97
+ responses: [FAIL_1('not logged in')],
98
+ });
99
+ const runner = createSiteioRunner(spawn);
100
+ expect(await runner.isLoggedIn()).toBe(false);
101
+ });
102
+ });
103
+
104
+ /* ------------------------------------------------------------------ */
105
+ /* findApp */
106
+ /* ------------------------------------------------------------------ */
107
+
108
+ describe('findApp', () => {
109
+ test('returns the matching app when present (array JSON)', async () => {
110
+ const { spawn, calls } = makeMockSpawn({
111
+ responses: [
112
+ {
113
+ exitCode: 0,
114
+ stdout: JSON.stringify([
115
+ { name: 'other', url: 'https://other.example.com' },
116
+ { name: 'mcp', url: 'https://mcp.example.com' },
117
+ ]),
118
+ stderr: '',
119
+ },
120
+ ],
121
+ });
122
+ const runner = createSiteioRunner(spawn);
123
+ const app = await runner.findApp('mcp');
124
+ expect(app).not.toBeNull();
125
+ expect(app!.name).toBe('mcp');
126
+ expect(app!.url).toBe('https://mcp.example.com');
127
+ expect(calls[0].cmd).toEqual([
128
+ 'siteio',
129
+ 'apps',
130
+ 'list',
131
+ '--json',
132
+ ]);
133
+ });
134
+
135
+ test('returns null when no app matches', async () => {
136
+ const { spawn } = makeMockSpawn({
137
+ responses: [
138
+ {
139
+ exitCode: 0,
140
+ stdout: JSON.stringify([{ name: 'other' }]),
141
+ stderr: '',
142
+ },
143
+ ],
144
+ });
145
+ const runner = createSiteioRunner(spawn);
146
+ expect(await runner.findApp('mcp')).toBeNull();
147
+ });
148
+
149
+ test('returns null when the list is empty', async () => {
150
+ const { spawn } = makeMockSpawn({
151
+ responses: [{ exitCode: 0, stdout: '[]', stderr: '' }],
152
+ });
153
+ const runner = createSiteioRunner(spawn);
154
+ expect(await runner.findApp('mcp')).toBeNull();
155
+ });
156
+
157
+ test('accepts {apps: [...]} wrapper shape', async () => {
158
+ const { spawn } = makeMockSpawn({
159
+ responses: [
160
+ {
161
+ exitCode: 0,
162
+ stdout: JSON.stringify({ apps: [{ name: 'mcp' }] }),
163
+ stderr: '',
164
+ },
165
+ ],
166
+ });
167
+ const runner = createSiteioRunner(spawn);
168
+ const app = await runner.findApp('mcp');
169
+ expect(app).not.toBeNull();
170
+ expect(app!.name).toBe('mcp');
171
+ });
172
+
173
+ test('accepts {success, data: [...]} wrapper shape (real siteio CLI)', async () => {
174
+ const { spawn } = makeMockSpawn({
175
+ responses: [
176
+ {
177
+ exitCode: 0,
178
+ stdout: JSON.stringify({
179
+ success: true,
180
+ data: [{ name: 'mcp', url: 'https://mcp.x.siteio.me' }],
181
+ }),
182
+ stderr: '',
183
+ },
184
+ ],
185
+ });
186
+ const runner = createSiteioRunner(spawn);
187
+ const app = await runner.findApp('mcp');
188
+ expect(app).not.toBeNull();
189
+ expect(app!.name).toBe('mcp');
190
+ expect(app!.url).toBe('https://mcp.x.siteio.me');
191
+ });
192
+
193
+ test('{success, data: []} empty list returns null', async () => {
194
+ const { spawn } = makeMockSpawn({
195
+ responses: [
196
+ {
197
+ exitCode: 0,
198
+ stdout: JSON.stringify({ success: true, data: [] }),
199
+ stderr: '',
200
+ },
201
+ ],
202
+ });
203
+ const runner = createSiteioRunner(spawn);
204
+ expect(await runner.findApp('mcp')).toBeNull();
205
+ });
206
+
207
+ test('strips progress-line prefix before JSON (real siteio CLI quirk)', async () => {
208
+ const { spawn } = makeMockSpawn({
209
+ responses: [
210
+ {
211
+ exitCode: 0,
212
+ stdout:
213
+ '- Fetching apps\n' +
214
+ JSON.stringify({ success: true, data: [{ name: 'mcp' }] }),
215
+ stderr: '',
216
+ },
217
+ ],
218
+ });
219
+ const runner = createSiteioRunner(spawn);
220
+ const app = await runner.findApp('mcp');
221
+ expect(app).not.toBeNull();
222
+ expect(app!.name).toBe('mcp');
223
+ });
224
+
225
+ test('throws when siteio exits non-zero', async () => {
226
+ const { spawn } = makeMockSpawn({
227
+ responses: [FAIL_1('api error')],
228
+ });
229
+ const runner = createSiteioRunner(spawn);
230
+ await expect(runner.findApp('mcp')).rejects.toThrow(/failed/);
231
+ });
232
+
233
+ test('throws on unparseable JSON', async () => {
234
+ const { spawn } = makeMockSpawn({
235
+ responses: [{ exitCode: 0, stdout: 'not json', stderr: '' }],
236
+ });
237
+ const runner = createSiteioRunner(spawn);
238
+ await expect(runner.findApp('mcp')).rejects.toThrow(/unparseable/);
239
+ });
240
+
241
+ test('throws when the JSON is a shape we do not understand', async () => {
242
+ const { spawn } = makeMockSpawn({
243
+ responses: [
244
+ { exitCode: 0, stdout: JSON.stringify({ hello: 'world' }), stderr: '' },
245
+ ],
246
+ });
247
+ const runner = createSiteioRunner(spawn);
248
+ await expect(runner.findApp('mcp')).rejects.toThrow(/expected an array/);
249
+ });
250
+ });
251
+
252
+ /* ------------------------------------------------------------------ */
253
+ /* createApp */
254
+ /* ------------------------------------------------------------------ */
255
+
256
+ describe('createApp — inline mode', () => {
257
+ test('emits exact argv: siteio apps create <name> -f <path> -p <port>', async () => {
258
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
259
+ const runner = createSiteioRunner(spawn);
260
+ await runner.createApp({
261
+ name: 'mcp',
262
+ dockerfilePath: '/tmp/Dockerfile.abc123',
263
+ port: 9999,
264
+ });
265
+ expect(calls[0].cmd).toEqual([
266
+ 'siteio',
267
+ 'apps',
268
+ 'create',
269
+ 'mcp',
270
+ '-f',
271
+ '/tmp/Dockerfile.abc123',
272
+ '-p',
273
+ '9999',
274
+ ]);
275
+ });
276
+
277
+ test('throws on non-zero exit with a descriptive error', async () => {
278
+ const { spawn } = makeMockSpawn({
279
+ responses: [FAIL_1('app already exists')],
280
+ });
281
+ const runner = createSiteioRunner(spawn);
282
+ await expect(
283
+ runner.createApp({
284
+ name: 'mcp',
285
+ dockerfilePath: '/tmp/Dockerfile',
286
+ port: 9999,
287
+ })
288
+ ).rejects.toThrow(/create mcp/);
289
+ });
290
+ });
291
+
292
+ describe('createApp — git mode', () => {
293
+ test('emits: siteio apps create <name> -g <url> --branch <b> --dockerfile <path> -p <port>', async () => {
294
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
295
+ const runner = createSiteioRunner(spawn);
296
+ await runner.createApp({
297
+ name: 'mcp',
298
+ port: 9999,
299
+ git: {
300
+ repoUrl: 'https://github.com/plosson/agentio.git',
301
+ branch: 'http-mcp-server-phase-1',
302
+ dockerfilePath: 'docker/Dockerfile.teleport',
303
+ },
304
+ });
305
+ expect(calls[0].cmd).toEqual([
306
+ 'siteio',
307
+ 'apps',
308
+ 'create',
309
+ 'mcp',
310
+ '-g',
311
+ 'https://github.com/plosson/agentio.git',
312
+ '--branch',
313
+ 'http-mcp-server-phase-1',
314
+ '--dockerfile',
315
+ 'docker/Dockerfile.teleport',
316
+ '-p',
317
+ '9999',
318
+ ]);
319
+ });
320
+
321
+ test('passes port at the END so siteio parses flags in a stable order', async () => {
322
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
323
+ const runner = createSiteioRunner(spawn);
324
+ await runner.createApp({
325
+ name: 'x',
326
+ port: 1234,
327
+ git: {
328
+ repoUrl: 'https://git.example.com/r.git',
329
+ branch: 'main',
330
+ dockerfilePath: 'D',
331
+ },
332
+ });
333
+ expect(calls[0].cmd.slice(-2)).toEqual(['-p', '1234']);
334
+ });
335
+
336
+ test('throws on non-zero exit', async () => {
337
+ const { spawn } = makeMockSpawn({
338
+ responses: [FAIL_1('clone failed: not found')],
339
+ });
340
+ const runner = createSiteioRunner(spawn);
341
+ await expect(
342
+ runner.createApp({
343
+ name: 'mcp',
344
+ port: 9999,
345
+ git: {
346
+ repoUrl: 'https://example.com/missing.git',
347
+ branch: 'main',
348
+ dockerfilePath: 'D',
349
+ },
350
+ })
351
+ ).rejects.toThrow(/create mcp/);
352
+ });
353
+ });
354
+
355
+ describe('createApp — mode exclusivity', () => {
356
+ test('neither dockerfilePath nor git → throws', async () => {
357
+ const { spawn } = makeMockSpawn({ responses: [] });
358
+ const runner = createSiteioRunner(spawn);
359
+ await expect(
360
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
361
+ runner.createApp({ name: 'mcp', port: 9999 } as any)
362
+ ).rejects.toThrow(/exactly one of/);
363
+ });
364
+
365
+ test('both dockerfilePath AND git → throws', async () => {
366
+ const { spawn } = makeMockSpawn({ responses: [] });
367
+ const runner = createSiteioRunner(spawn);
368
+ await expect(
369
+ runner.createApp({
370
+ name: 'mcp',
371
+ port: 9999,
372
+ dockerfilePath: '/tmp/D',
373
+ git: {
374
+ repoUrl: 'https://x/r.git',
375
+ branch: 'main',
376
+ dockerfilePath: 'D',
377
+ },
378
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
379
+ } as any)
380
+ ).rejects.toThrow(/exactly one of/);
381
+ });
382
+ });
383
+
384
+ /* ------------------------------------------------------------------ */
385
+ /* setEnv */
386
+ /* ------------------------------------------------------------------ */
387
+
388
+ describe('setApp — env vars only', () => {
389
+ test('emits one -e flag per env var in insertion order', async () => {
390
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
391
+ const runner = createSiteioRunner(spawn);
392
+ await runner.setApp({
393
+ name: 'mcp',
394
+ envVars: {
395
+ AGENTIO_KEY: 'abc123',
396
+ AGENTIO_CONFIG: 'base64payload',
397
+ AGENTIO_SERVER_API_KEY: 'srv_xyz',
398
+ },
399
+ });
400
+ expect(calls[0].cmd).toEqual([
401
+ 'siteio',
402
+ 'apps',
403
+ 'set',
404
+ 'mcp',
405
+ '-e',
406
+ 'AGENTIO_KEY=abc123',
407
+ '-e',
408
+ 'AGENTIO_CONFIG=base64payload',
409
+ '-e',
410
+ 'AGENTIO_SERVER_API_KEY=srv_xyz',
411
+ ]);
412
+ });
413
+
414
+ test('passes values containing `=` verbatim (siteio splits on first `=`)', async () => {
415
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
416
+ const runner = createSiteioRunner(spawn);
417
+ await runner.setApp({
418
+ name: 'mcp',
419
+ envVars: {
420
+ AGENTIO_CONFIG: 'base64==padding==values',
421
+ },
422
+ });
423
+ expect(calls[0].cmd).toContain('AGENTIO_CONFIG=base64==padding==values');
424
+ });
425
+
426
+ test('passes values containing newlines verbatim', async () => {
427
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
428
+ const runner = createSiteioRunner(spawn);
429
+ await runner.setApp({
430
+ name: 'mcp',
431
+ envVars: { X: 'line1\nline2' },
432
+ });
433
+ expect(calls[0].cmd).toContain('X=line1\nline2');
434
+ });
435
+
436
+ test('throws on non-zero exit', async () => {
437
+ const { spawn } = makeMockSpawn({
438
+ responses: [FAIL_1('validation failed')],
439
+ });
440
+ const runner = createSiteioRunner(spawn);
441
+ await expect(
442
+ runner.setApp({ name: 'mcp', envVars: { FOO: 'bar' } })
443
+ ).rejects.toThrow(/set on mcp/);
444
+ });
445
+ });
446
+
447
+ describe('setApp — volumes only', () => {
448
+ test('emits one -v flag per volume mount', async () => {
449
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
450
+ const runner = createSiteioRunner(spawn);
451
+ await runner.setApp({
452
+ name: 'mcp',
453
+ volumes: { 'agentio-data-mcp': '/data' },
454
+ });
455
+ expect(calls[0].cmd).toEqual([
456
+ 'siteio',
457
+ 'apps',
458
+ 'set',
459
+ 'mcp',
460
+ '-v',
461
+ 'agentio-data-mcp:/data',
462
+ ]);
463
+ });
464
+
465
+ test('multiple volumes pass through in insertion order', async () => {
466
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
467
+ const runner = createSiteioRunner(spawn);
468
+ await runner.setApp({
469
+ name: 'mcp',
470
+ volumes: {
471
+ 'agentio-data-mcp': '/data',
472
+ 'agentio-cache-mcp': '/cache',
473
+ },
474
+ });
475
+ expect(calls[0].cmd).toEqual([
476
+ 'siteio',
477
+ 'apps',
478
+ 'set',
479
+ 'mcp',
480
+ '-v',
481
+ 'agentio-data-mcp:/data',
482
+ '-v',
483
+ 'agentio-cache-mcp:/cache',
484
+ ]);
485
+ });
486
+ });
487
+
488
+ describe('setApp — env + volumes combined', () => {
489
+ test('env flags come before volume flags in argv', async () => {
490
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
491
+ const runner = createSiteioRunner(spawn);
492
+ await runner.setApp({
493
+ name: 'mcp',
494
+ envVars: { AGENTIO_KEY: 'k', AGENTIO_CONFIG: 'c' },
495
+ volumes: { 'agentio-data-mcp': '/data' },
496
+ });
497
+ expect(calls[0].cmd).toEqual([
498
+ 'siteio',
499
+ 'apps',
500
+ 'set',
501
+ 'mcp',
502
+ '-e',
503
+ 'AGENTIO_KEY=k',
504
+ '-e',
505
+ 'AGENTIO_CONFIG=c',
506
+ '-v',
507
+ 'agentio-data-mcp:/data',
508
+ ]);
509
+ });
510
+ });
511
+
512
+ describe('setApp — input validation', () => {
513
+ test('neither envVars nor volumes provided → throws', async () => {
514
+ const { spawn } = makeMockSpawn({ responses: [] });
515
+ const runner = createSiteioRunner(spawn);
516
+ await expect(runner.setApp({ name: 'mcp' })).rejects.toThrow(
517
+ /no envVars or volumes/
518
+ );
519
+ });
520
+
521
+ test('empty objects (no entries) → throws', async () => {
522
+ const { spawn } = makeMockSpawn({ responses: [] });
523
+ const runner = createSiteioRunner(spawn);
524
+ await expect(
525
+ runner.setApp({ name: 'mcp', envVars: {}, volumes: {} })
526
+ ).rejects.toThrow(/no envVars or volumes/);
527
+ });
528
+
529
+ test('envVars: {} but volumes: {x:y} → ok', async () => {
530
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
531
+ const runner = createSiteioRunner(spawn);
532
+ await runner.setApp({
533
+ name: 'mcp',
534
+ envVars: {},
535
+ volumes: { 'agentio-data-mcp': '/data' },
536
+ });
537
+ expect(calls[0].cmd).toContain('-v');
538
+ expect(calls[0].cmd).not.toContain('-e');
539
+ });
540
+ });
541
+
542
+ /* ------------------------------------------------------------------ */
543
+ /* deploy */
544
+ /* ------------------------------------------------------------------ */
545
+
546
+ describe('deploy', () => {
547
+ test('minimal call: just the app name', async () => {
548
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
549
+ const runner = createSiteioRunner(spawn);
550
+ await runner.deploy({ name: 'mcp' });
551
+ expect(calls[0].cmd).toEqual(['siteio', 'apps', 'deploy', 'mcp']);
552
+ });
553
+
554
+ test('with dockerfilePath appends -f <path>', async () => {
555
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
556
+ const runner = createSiteioRunner(spawn);
557
+ await runner.deploy({ name: 'mcp', dockerfilePath: '/tmp/D' });
558
+ expect(calls[0].cmd).toEqual([
559
+ 'siteio',
560
+ 'apps',
561
+ 'deploy',
562
+ 'mcp',
563
+ '-f',
564
+ '/tmp/D',
565
+ ]);
566
+ });
567
+
568
+ test('with noCache appends --no-cache', async () => {
569
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
570
+ const runner = createSiteioRunner(spawn);
571
+ await runner.deploy({ name: 'mcp', noCache: true });
572
+ expect(calls[0].cmd).toEqual([
573
+ 'siteio',
574
+ 'apps',
575
+ 'deploy',
576
+ 'mcp',
577
+ '--no-cache',
578
+ ]);
579
+ });
580
+
581
+ test('with both dockerfilePath and noCache', async () => {
582
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
583
+ const runner = createSiteioRunner(spawn);
584
+ await runner.deploy({
585
+ name: 'mcp',
586
+ dockerfilePath: '/tmp/D',
587
+ noCache: true,
588
+ });
589
+ expect(calls[0].cmd).toEqual([
590
+ 'siteio',
591
+ 'apps',
592
+ 'deploy',
593
+ 'mcp',
594
+ '-f',
595
+ '/tmp/D',
596
+ '--no-cache',
597
+ ]);
598
+ });
599
+
600
+ test('throws on non-zero exit', async () => {
601
+ const { spawn } = makeMockSpawn({
602
+ responses: [FAIL_1('build failed')],
603
+ });
604
+ const runner = createSiteioRunner(spawn);
605
+ await expect(runner.deploy({ name: 'mcp' })).rejects.toThrow(
606
+ /deploy mcp/
607
+ );
608
+ });
609
+ });
610
+
611
+ /* ------------------------------------------------------------------ */
612
+ /* restartApp */
613
+ /* ------------------------------------------------------------------ */
614
+
615
+ describe('restartApp', () => {
616
+ test('emits exact argv: siteio apps restart <name>', async () => {
617
+ const { spawn, calls } = makeMockSpawn({ responses: [OK] });
618
+ const runner = createSiteioRunner(spawn);
619
+ await runner.restartApp('mcp');
620
+ expect(calls[0].cmd).toEqual(['siteio', 'apps', 'restart', 'mcp']);
621
+ });
622
+
623
+ test('throws on non-zero exit with descriptive error', async () => {
624
+ const { spawn } = makeMockSpawn({
625
+ responses: [FAIL_1('container failed to restart')],
626
+ });
627
+ const runner = createSiteioRunner(spawn);
628
+ await expect(runner.restartApp('mcp')).rejects.toThrow(/restart mcp/);
629
+ });
630
+ });
631
+
632
+ /* ------------------------------------------------------------------ */
633
+ /* appInfo */
634
+ /* ------------------------------------------------------------------ */
635
+
636
+ describe('appInfo', () => {
637
+ test('returns parsed JSON on success', async () => {
638
+ const { spawn, calls } = makeMockSpawn({
639
+ responses: [
640
+ {
641
+ exitCode: 0,
642
+ stdout: JSON.stringify({
643
+ name: 'mcp',
644
+ url: 'https://mcp.example.com',
645
+ }),
646
+ stderr: '',
647
+ },
648
+ ],
649
+ });
650
+ const runner = createSiteioRunner(spawn);
651
+ const info = await runner.appInfo('mcp');
652
+ expect(info).not.toBeNull();
653
+ expect(info!.name).toBe('mcp');
654
+ expect(info!.url).toBe('https://mcp.example.com');
655
+ expect(calls[0].cmd).toEqual([
656
+ 'siteio',
657
+ 'apps',
658
+ 'info',
659
+ 'mcp',
660
+ '--json',
661
+ ]);
662
+ });
663
+
664
+ test('returns null on non-zero exit (app not found)', async () => {
665
+ const { spawn } = makeMockSpawn({
666
+ responses: [FAIL_1('not found')],
667
+ });
668
+ const runner = createSiteioRunner(spawn);
669
+ expect(await runner.appInfo('mcp')).toBeNull();
670
+ });
671
+
672
+ test('returns null on unparseable JSON (graceful)', async () => {
673
+ const { spawn } = makeMockSpawn({
674
+ responses: [{ exitCode: 0, stdout: 'hello', stderr: '' }],
675
+ });
676
+ const runner = createSiteioRunner(spawn);
677
+ expect(await runner.appInfo('mcp')).toBeNull();
678
+ });
679
+
680
+ test('unwraps {success, data: {...}} shape (real siteio CLI)', async () => {
681
+ const { spawn } = makeMockSpawn({
682
+ responses: [
683
+ {
684
+ exitCode: 0,
685
+ stdout: JSON.stringify({
686
+ success: true,
687
+ data: { name: 'mcp', url: 'https://mcp.x.siteio.me' },
688
+ }),
689
+ stderr: '',
690
+ },
691
+ ],
692
+ });
693
+ const runner = createSiteioRunner(spawn);
694
+ const info = await runner.appInfo('mcp');
695
+ expect(info).not.toBeNull();
696
+ expect(info!.name).toBe('mcp');
697
+ expect(info!.url).toBe('https://mcp.x.siteio.me');
698
+ });
699
+
700
+ test('strips progress-line prefix from appInfo JSON', async () => {
701
+ const { spawn } = makeMockSpawn({
702
+ responses: [
703
+ {
704
+ exitCode: 0,
705
+ stdout:
706
+ '- Fetching app info\n' +
707
+ JSON.stringify({
708
+ success: true,
709
+ data: { name: 'mcp', url: 'https://mcp.x.siteio.me' },
710
+ }),
711
+ stderr: '',
712
+ },
713
+ ],
714
+ });
715
+ const runner = createSiteioRunner(spawn);
716
+ const info = await runner.appInfo('mcp');
717
+ expect(info).not.toBeNull();
718
+ expect(info!.url).toBe('https://mcp.x.siteio.me');
719
+ });
720
+ });