@plosson/agentio 0.8.3 → 0.8.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@ import {
12
12
  type TeleportDeps,
13
13
  } from './teleport';
14
14
  import type { SiteioRunner, SiteioApp } from '../server/siteio-runner';
15
- import type { Config } from '../types/config';
15
+ import type { Config, TeleportAppRecord } from '../types/config';
16
16
 
17
17
  /**
18
18
  * Unit tests for `agentio teleport`. Every test injects a fake
@@ -41,6 +41,12 @@ interface FakeRunnerOptions {
41
41
  deployedApp?: SiteioApp | null;
42
42
  /** Stdout returned by logsApp. Default: empty string. */
43
43
  logsStdout?: string;
44
+ /**
45
+ * If set, the first call to `deploy` throws an Error with this message;
46
+ * subsequent calls succeed. Used to simulate siteio rejecting `-f` on
47
+ * an app that was originally created with `-g` (git mode).
48
+ */
49
+ failDeployFirstWith?: string;
44
50
  failOn?:
45
51
  | 'isInstalled'
46
52
  | 'isLoggedIn'
@@ -88,6 +94,14 @@ function makeFakeRunner(opts: FakeRunnerOptions = {}): {
88
94
  async deploy(args) {
89
95
  calls.push({ method: 'deploy', args });
90
96
  if (shouldFail('deploy')) throw new Error('deploy failed');
97
+ if (opts.failDeployFirstWith != null) {
98
+ const deployCallCount = calls.filter(
99
+ (c) => c.method === 'deploy'
100
+ ).length;
101
+ if (deployCallCount === 1) {
102
+ throw new Error(opts.failDeployFirstWith);
103
+ }
104
+ }
91
105
  },
92
106
  async restartApp(name) {
93
107
  calls.push({ method: 'restartApp', args: { name } });
@@ -128,6 +142,11 @@ interface FakeDepsOptions extends FakeRunnerOptions {
128
142
  * (a healthy 200). Pass [] to simulate an unreachable container.
129
143
  */
130
144
  healthProbeResponses?: Array<number | null>;
145
+ /**
146
+ * The record `getLastTeleportApp` should return. Default: null (no
147
+ * app remembered yet).
148
+ */
149
+ lastTeleportApp?: TeleportAppRecord | null;
131
150
  }
132
151
 
133
152
  interface FakeDeps extends TeleportDeps {
@@ -140,6 +159,8 @@ interface FakeDeps extends TeleportDeps {
140
159
  tempFileDeletes: string[];
141
160
  healthProbeUrls: string[];
142
161
  sleepCalls: number[];
162
+ /** Records every call to `saveLastTeleportApp`. */
163
+ savedTeleportApps: TeleportAppRecord[];
143
164
  }
144
165
 
145
166
  function makeDeps(opts: FakeDepsOptions = {}): FakeDeps {
@@ -150,6 +171,7 @@ function makeDeps(opts: FakeDepsOptions = {}): FakeDeps {
150
171
  const tempFileDeletes: string[] = [];
151
172
  const healthProbeUrls: string[] = [];
152
173
  const sleepCalls: number[] = [];
174
+ const savedTeleportApps: TeleportAppRecord[] = [];
153
175
 
154
176
  let tempCounter = 0;
155
177
  let healthProbeIdx = 0;
@@ -162,6 +184,7 @@ function makeDeps(opts: FakeDepsOptions = {}): FakeDeps {
162
184
  tempFileDeletes,
163
185
  healthProbeUrls,
164
186
  sleepCalls,
187
+ savedTeleportApps,
165
188
  runner,
166
189
  loadConfig: async () =>
167
190
  ({
@@ -205,6 +228,11 @@ function makeDeps(opts: FakeDepsOptions = {}): FakeDeps {
205
228
  sleep: async (ms) => {
206
229
  sleepCalls.push(ms);
207
230
  },
231
+ getLastTeleportApp: async () =>
232
+ 'lastTeleportApp' in opts ? (opts.lastTeleportApp ?? null) : null,
233
+ saveLastTeleportApp: async (record) => {
234
+ savedTeleportApps.push(record);
235
+ },
208
236
  log: (msg) => logLines.push(msg),
209
237
  warn: (msg) => warnLines.push(msg),
210
238
  };
@@ -433,6 +461,29 @@ describe('runTeleport — preflight failures', () => {
433
461
  // already have their bearer.
434
462
  expect(logs).not.toContain('To add to Claude Code:');
435
463
  });
464
+
465
+ test('rebuild: deploy retries without -f if existing app was created in git mode', async () => {
466
+ const deps = makeDeps({
467
+ existingApp: { name: 'mcp', url: 'https://mcp.existing.com' },
468
+ failDeployFirstWith:
469
+ 'siteio apps deploy mcp -f /tmp/Dockerfile failed (deploy mcp): exit 1\nx Cannot override Dockerfile: app was not created with -f',
470
+ });
471
+ const result = await runTeleport({ name: 'mcp' }, deps);
472
+
473
+ const deployCalls = deps.calls.filter((c) => c.method === 'deploy');
474
+ expect(deployCalls).toHaveLength(2);
475
+ // First attempt passed -f (inline Dockerfile)…
476
+ expect(
477
+ (deployCalls[0]!.args as { dockerfilePath?: string }).dockerfilePath
478
+ ).toMatch(/Dockerfile$/);
479
+ // …second attempt omits -f so siteio uses its stored git settings.
480
+ expect(
481
+ (deployCalls[1]!.args as { dockerfilePath?: string }).dockerfilePath
482
+ ).toBeUndefined();
483
+
484
+ expect(result.serverApiKey).toBe('');
485
+ expect(deps.logLines.join('\n')).toMatch(/not created with -f/i);
486
+ });
436
487
  });
437
488
 
438
489
  /* ------------------------------------------------------------------ */
@@ -1483,3 +1534,147 @@ describe('runTeleport — health check on --sync', () => {
1483
1534
  expect(deps.warnLines.join('\n')).toContain('boom');
1484
1535
  });
1485
1536
  });
1537
+
1538
+ /* ------------------------------------------------------------------ */
1539
+ /* runTeleport — remembered app name */
1540
+ /* ------------------------------------------------------------------ */
1541
+
1542
+ describe('runTeleport — remembered app name', () => {
1543
+ test('full teleport saves lastApp on success', async () => {
1544
+ const deps = makeDeps();
1545
+ const before = Date.now();
1546
+ await runTeleport({ name: 'mcp' }, deps);
1547
+ const after = Date.now();
1548
+
1549
+ expect(deps.savedTeleportApps).toHaveLength(1);
1550
+ const saved = deps.savedTeleportApps[0]!;
1551
+ expect(saved.name).toBe('mcp');
1552
+ expect(saved.url).toBe('https://mcp.siteio.example.com');
1553
+ expect(saved.deployedAt).toBeGreaterThanOrEqual(before);
1554
+ expect(saved.deployedAt).toBeLessThanOrEqual(after);
1555
+ });
1556
+
1557
+ test('sync saves lastApp on success', async () => {
1558
+ const deps = makeDeps({
1559
+ existingApp: { name: 'mcp', url: 'https://mcp.x.com' },
1560
+ deployedApp: {
1561
+ name: 'mcp',
1562
+ url: 'https://mcp.x.com',
1563
+ volumes: [{ name: 'agentio-data-mcp', mountPath: '/data' }],
1564
+ },
1565
+ });
1566
+ await runTeleport({ name: 'mcp', sync: true }, deps);
1567
+
1568
+ expect(deps.savedTeleportApps).toHaveLength(1);
1569
+ expect(deps.savedTeleportApps[0]!.name).toBe('mcp');
1570
+ expect(deps.savedTeleportApps[0]!.url).toBe('https://mcp.x.com');
1571
+ });
1572
+
1573
+ test('sync without name uses remembered app', async () => {
1574
+ const deps = makeDeps({
1575
+ existingApp: { name: 'mcp', url: 'https://mcp.x.com' },
1576
+ deployedApp: {
1577
+ name: 'mcp',
1578
+ url: 'https://mcp.x.com',
1579
+ volumes: [{ name: 'agentio-data-mcp', mountPath: '/data' }],
1580
+ },
1581
+ lastTeleportApp: {
1582
+ name: 'mcp',
1583
+ url: 'https://mcp.x.com',
1584
+ deployedAt: 1_000_000,
1585
+ },
1586
+ });
1587
+ const result = await runTeleport({ sync: true }, deps);
1588
+ expect(result.name).toBe('mcp');
1589
+ // Confirm it actually reached the siteio calls with the remembered name.
1590
+ const findCall = deps.calls.find((c) => c.method === 'findApp');
1591
+ expect(findCall!.args).toEqual({ name: 'mcp' });
1592
+ // And announced the fallback to the user.
1593
+ expect(deps.logLines.join('\n')).toContain(
1594
+ 'Using remembered teleport app "mcp"'
1595
+ );
1596
+ });
1597
+
1598
+ test('sync without name errors if nothing remembered', async () => {
1599
+ const deps = makeDeps({ lastTeleportApp: null });
1600
+ await expect(runTeleport({ sync: true }, deps)).rejects.toThrow(
1601
+ /no remembered teleport app/i
1602
+ );
1603
+ // Did not reach siteio.
1604
+ expect(deps.calls.map((c) => c.method)).not.toContain('findApp');
1605
+ });
1606
+
1607
+ test('full teleport without name uses remembered app', async () => {
1608
+ const deps = makeDeps({
1609
+ lastTeleportApp: {
1610
+ name: 'mcp',
1611
+ url: 'https://mcp.x.com',
1612
+ deployedAt: 1_000_000,
1613
+ },
1614
+ });
1615
+ const result = await runTeleport({}, deps);
1616
+ expect(result.name).toBe('mcp');
1617
+ const createCall = deps.calls.find((c) => c.method === 'createApp');
1618
+ // First time on this fresh runner, no existing app — falls through
1619
+ // to create. The create call must use the remembered name.
1620
+ expect((createCall!.args as { name: string }).name).toBe('mcp');
1621
+ });
1622
+
1623
+ test('explicit name overrides remembered app', async () => {
1624
+ const deps = makeDeps({
1625
+ lastTeleportApp: {
1626
+ name: 'old-app',
1627
+ url: 'https://old.x.com',
1628
+ deployedAt: 1_000_000,
1629
+ },
1630
+ });
1631
+ await runTeleport({ name: 'new-app' }, deps);
1632
+ expect(deps.savedTeleportApps).toHaveLength(1);
1633
+ expect(deps.savedTeleportApps[0]!.name).toBe('new-app');
1634
+ const createCall = deps.calls.find((c) => c.method === 'createApp');
1635
+ expect((createCall!.args as { name: string }).name).toBe('new-app');
1636
+ });
1637
+
1638
+ test('dry-run does NOT save lastApp', async () => {
1639
+ const deps = makeDeps();
1640
+ await runTeleport({ name: 'mcp', dryRun: true }, deps);
1641
+ expect(deps.savedTeleportApps).toHaveLength(0);
1642
+ });
1643
+
1644
+ test('sync dry-run does NOT save lastApp', async () => {
1645
+ const deps = makeDeps({ existingApp: { name: 'mcp' } });
1646
+ await runTeleport(
1647
+ { name: 'mcp', sync: true, dryRun: true },
1648
+ deps
1649
+ );
1650
+ expect(deps.savedTeleportApps).toHaveLength(0);
1651
+ });
1652
+
1653
+ test('failed full teleport (bad health) does NOT save lastApp', async () => {
1654
+ const deps = makeDeps({
1655
+ healthProbeResponses: [],
1656
+ logsStdout: 'crash\n',
1657
+ });
1658
+ await expect(runTeleport({ name: 'mcp' }, deps)).rejects.toThrow(
1659
+ /\/health never returned 200/
1660
+ );
1661
+ expect(deps.savedTeleportApps).toHaveLength(0);
1662
+ });
1663
+
1664
+ test('failed sync (bad health) does NOT save lastApp', async () => {
1665
+ const deps = makeDeps({
1666
+ existingApp: { name: 'mcp', url: 'https://mcp.x.com' },
1667
+ deployedApp: {
1668
+ name: 'mcp',
1669
+ url: 'https://mcp.x.com',
1670
+ volumes: [{ name: 'agentio-data-mcp', mountPath: '/data' }],
1671
+ },
1672
+ healthProbeResponses: [],
1673
+ logsStdout: 'crash\n',
1674
+ });
1675
+ await expect(
1676
+ runTeleport({ name: 'mcp', sync: true }, deps)
1677
+ ).rejects.toThrow(/\/health never returned 200/);
1678
+ expect(deps.savedTeleportApps).toHaveLength(0);
1679
+ });
1680
+ });
@@ -5,14 +5,14 @@ import { join } from 'path';
5
5
  import { tmpdir } from 'os';
6
6
 
7
7
  import { generateExportData } from './config';
8
- import { loadConfig } from '../config/config-manager';
8
+ import { loadConfig, saveConfig } from '../config/config-manager';
9
9
  import { generateTeleportDockerfile } from '../server/dockerfile-gen';
10
10
  import {
11
11
  createSiteioRunner,
12
12
  type SiteioRunner,
13
13
  } from '../server/siteio-runner';
14
14
  import { handleError, CliError } from '../utils/errors';
15
- import type { Config } from '../types/config';
15
+ import type { Config, TeleportAppRecord } from '../types/config';
16
16
 
17
17
  /**
18
18
  * `agentio mcp teleport <name>` — one-command deploy of the local agentio
@@ -141,6 +141,17 @@ export interface TeleportDeps {
141
141
  probeHealth: (url: string) => Promise<number | null>;
142
142
  /** Resolved after `ms` milliseconds. Injected for testability. */
143
143
  sleep: (ms: number) => Promise<void>;
144
+ /**
145
+ * Load the most recently teleported app. Used to resolve an
146
+ * omitted `<name>` argument (e.g. `agentio mcp teleport --sync`).
147
+ * Returns null if nothing has been remembered yet.
148
+ */
149
+ getLastTeleportApp: () => Promise<TeleportAppRecord | null>;
150
+ /**
151
+ * Persist the most recently teleported app so future invocations
152
+ * without a `<name>` can default to it.
153
+ */
154
+ saveLastTeleportApp: (record: TeleportAppRecord) => Promise<void>;
144
155
  log: (msg: string) => void;
145
156
  warn: (msg: string) => void;
146
157
  }
@@ -187,7 +198,12 @@ export async function waitForHealth(
187
198
  }
188
199
 
189
200
  export interface TeleportOptions {
190
- name: string;
201
+ /**
202
+ * Siteio app name. Optional: if omitted, `runTeleport` will fall
203
+ * back to the most recently teleported app recorded in config
204
+ * (`config.teleport.lastApp`). Required for the very first deploy.
205
+ */
206
+ name?: string;
191
207
  dockerfileOnly?: boolean;
192
208
  output?: string;
193
209
  dryRun?: boolean;
@@ -227,6 +243,32 @@ export interface TeleportResult {
227
243
  claudeMcpAddCommand: string | null;
228
244
  }
229
245
 
246
+ /**
247
+ * Resolve the siteio app name, falling back to the remembered one if
248
+ * the caller omitted `<name>`. Validates the resulting name. Throws a
249
+ * user-facing CliError if nothing is provided and nothing is remembered.
250
+ */
251
+ async function resolveAppName(
252
+ requested: string | undefined,
253
+ deps: TeleportDeps
254
+ ): Promise<string> {
255
+ if (requested) {
256
+ validateAppName(requested);
257
+ return requested;
258
+ }
259
+ const last = await deps.getLastTeleportApp();
260
+ if (!last) {
261
+ throw new CliError(
262
+ 'INVALID_PARAMS',
263
+ 'No app name provided and no remembered teleport app',
264
+ 'Pass <name> explicitly the first time, e.g. `agentio mcp teleport mcp`. The name is remembered after the first successful deploy.'
265
+ );
266
+ }
267
+ validateAppName(last.name);
268
+ deps.log(`Using remembered teleport app "${last.name}".`);
269
+ return last.name;
270
+ }
271
+
230
272
  /**
231
273
  * Sync mode: re-export local config and push it to an existing siteio
232
274
  * app, then restart. Same dependency-injection model as runTeleport
@@ -236,6 +278,7 @@ async function runSync(
236
278
  opts: TeleportOptions,
237
279
  deps: TeleportDeps
238
280
  ): Promise<TeleportResult> {
281
+ const name = await resolveAppName(opts.name, deps);
239
282
  // Preflight: same as full teleport.
240
283
  deps.log('Checking siteio…');
241
284
  if (!(await deps.runner.isInstalled())) {
@@ -268,13 +311,13 @@ async function runSync(
268
311
 
269
312
  // Sync requires the app to ALREADY EXIST. This is the inverse of the
270
313
  // normal teleport check.
271
- deps.log(`Checking that siteio app "${opts.name}" exists…`);
272
- const existing = await deps.runner.findApp(opts.name);
314
+ deps.log(`Checking that siteio app "${name}" exists…`);
315
+ const existing = await deps.runner.findApp(name);
273
316
  if (!existing) {
274
317
  throw new CliError(
275
318
  'NOT_FOUND',
276
- `No siteio app named "${opts.name}" to sync to`,
277
- `Run \`agentio mcp teleport ${opts.name}\` (without --sync) first to create it.`
319
+ `No siteio app named "${name}" to sync to`,
320
+ `Run \`agentio mcp teleport ${name}\` (without --sync) first to create it.`
278
321
  );
279
322
  }
280
323
 
@@ -285,11 +328,11 @@ async function runSync(
285
328
  // Detect whether /data is already mounted as a persistent volume.
286
329
  // If not, attach it as part of this sync (one-time backfill for apps
287
330
  // teleported before the volume was a default).
288
- const detail = await deps.runner.appInfo(opts.name);
331
+ const detail = await deps.runner.appInfo(name);
289
332
  const needsVolumeBackfill = !hasDataVolumeMount(detail);
290
333
  if (needsVolumeBackfill) {
291
334
  deps.log(
292
- `No persistent volume mounted at ${DATA_VOLUME_PATH} — will attach ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH} as part of this sync.`
335
+ `No persistent volume mounted at ${DATA_VOLUME_PATH} — will attach ${volumeNameFor(name)}:${DATA_VOLUME_PATH} as part of this sync.`
293
336
  );
294
337
  }
295
338
 
@@ -297,22 +340,22 @@ async function runSync(
297
340
  if (opts.dryRun) {
298
341
  deps.log('--- Dry run: the following commands would be executed ---');
299
342
  const dryParts = [
300
- `siteio apps set ${opts.name}`,
343
+ `siteio apps set ${name}`,
301
344
  '-e AGENTIO_KEY=<redacted>',
302
345
  `-e AGENTIO_CONFIG=<${exported.config.length} chars>`,
303
346
  ];
304
347
  if (needsVolumeBackfill) {
305
348
  dryParts.push(
306
- `-v ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH}`
349
+ `-v ${volumeNameFor(name)}:${DATA_VOLUME_PATH}`
307
350
  );
308
351
  }
309
352
  deps.log(dryParts.join(' '));
310
- deps.log(`siteio apps restart ${opts.name}`);
353
+ deps.log(`siteio apps restart ${name}`);
311
354
  deps.log(
312
355
  '(AGENTIO_SERVER_API_KEY is intentionally NOT touched — operator key on the remote stays the same.)'
313
356
  );
314
357
  return {
315
- name: opts.name,
358
+ name: name,
316
359
  serverApiKey: '',
317
360
  claudeMcpAddCommand: null,
318
361
  };
@@ -333,20 +376,20 @@ async function runSync(
333
376
  // REPLACES the volumes list on update (env merges; volumes don't),
334
377
  // so attaching when something else is mounted would clobber it.
335
378
  await deps.runner.setApp({
336
- name: opts.name,
379
+ name: name,
337
380
  envVars: {
338
381
  AGENTIO_KEY: exported.key,
339
382
  AGENTIO_CONFIG: exported.config,
340
383
  },
341
384
  ...(needsVolumeBackfill
342
385
  ? {
343
- volumes: { [volumeNameFor(opts.name)]: DATA_VOLUME_PATH },
386
+ volumes: { [volumeNameFor(name)]: DATA_VOLUME_PATH },
344
387
  }
345
388
  : {}),
346
389
  });
347
390
 
348
391
  deps.log('Restarting container so the new env vars take effect…');
349
- await deps.runner.restartApp(opts.name);
392
+ await deps.runner.restartApp(name);
350
393
 
351
394
  // We already fetched appInfo earlier for volume detection; reuse
352
395
  // its URL field rather than calling again. Same fallback as the
@@ -354,7 +397,7 @@ async function runSync(
354
397
  // generated subdomain URL, so fall back to findApp if it's missing.
355
398
  let url = typeof detail?.url === 'string' ? detail.url : undefined;
356
399
  if (!url) {
357
- const listed = await deps.runner.findApp(opts.name);
400
+ const listed = await deps.runner.findApp(name);
358
401
  if (typeof listed?.url === 'string') url = listed.url;
359
402
  }
360
403
 
@@ -368,7 +411,7 @@ async function runSync(
368
411
  deps.warn(
369
412
  `Container failed to report healthy after ${Math.round(HEALTH_TIMEOUT_MS / 1000)}s. Fetching logs…`
370
413
  );
371
- const logs = await deps.runner.logsApp(opts.name, {
414
+ const logs = await deps.runner.logsApp(name, {
372
415
  tail: HEALTH_FAILURE_LOG_TAIL,
373
416
  });
374
417
  deps.warn('--- container logs (tail) ---');
@@ -376,12 +419,21 @@ async function runSync(
376
419
  deps.warn('--- end logs ---');
377
420
  throw new CliError(
378
421
  'API_ERROR',
379
- `Sync to "${opts.name}" restarted the container but /health never returned 200`,
422
+ `Sync to "${name}" restarted the container but /health never returned 200`,
380
423
  'Inspect the logs above. The previous config is gone — the next sync (or a manual `siteio apps restart`) will still see the broken state until you fix the root cause.'
381
424
  );
382
425
  }
383
426
  }
384
427
 
428
+ // Remember the app so a future bare `--sync` (or any name-less
429
+ // teleport invocation) can default to it. Only reached once the
430
+ // restart + health check have both succeeded.
431
+ await deps.saveLastTeleportApp({
432
+ name,
433
+ url,
434
+ deployedAt: Date.now(),
435
+ });
436
+
385
437
  deps.log('');
386
438
  deps.log('Sync complete!');
387
439
  if (url) {
@@ -408,7 +460,7 @@ async function runSync(
408
460
  }
409
461
 
410
462
  return {
411
- name: opts.name,
463
+ name: name,
412
464
  url,
413
465
  // We did not generate a new server key in sync mode.
414
466
  serverApiKey: '',
@@ -424,8 +476,6 @@ export async function runTeleport(
424
476
  opts: TeleportOptions,
425
477
  deps: TeleportDeps
426
478
  ): Promise<TeleportResult> {
427
- validateAppName(opts.name);
428
-
429
479
  // Sync mode short-circuits — different command shape, different
430
480
  // preflight (app must EXIST, not absent), no Dockerfile work, no
431
481
  // create. Mutual exclusion with the "create new app" flags.
@@ -461,6 +511,11 @@ export async function runTeleport(
461
511
  return runSync(opts, deps);
462
512
  }
463
513
 
514
+ // Resolve the app name up front so every downstream path (including
515
+ // dockerfile-only and dry-run) uses a single consistent value, and
516
+ // so an omitted name cleanly falls back to the remembered app.
517
+ const name = await resolveAppName(opts.name, deps);
518
+
464
519
  // dockerfile-only: skip every siteio interaction, just emit the
465
520
  // Dockerfile to stdout or a file and return.
466
521
  if (opts.dockerfileOnly) {
@@ -476,7 +531,7 @@ export async function runTeleport(
476
531
  process.stdout.write(content);
477
532
  }
478
533
  return {
479
- name: opts.name,
534
+ name: name,
480
535
  serverApiKey: '',
481
536
  claudeMcpAddCommand: null,
482
537
  };
@@ -519,16 +574,16 @@ export async function runTeleport(
519
574
  // and their issued bearers), backfill /data if needed, and redeploy
520
575
  // the freshly generated image. If it doesn't exist, fall through to
521
576
  // the fresh-deploy path.
522
- deps.log(`Checking if siteio app "${opts.name}" already exists…`);
523
- const existing = await deps.runner.findApp(opts.name);
577
+ deps.log(`Checking if siteio app "${name}" already exists…`);
578
+ const existing = await deps.runner.findApp(name);
524
579
  const isRebuild = Boolean(existing);
525
580
  if (isRebuild) {
526
581
  deps.log(
527
- `Found existing siteio app "${opts.name}" — will rebuild image in place (API key and clients preserved).`
582
+ `Found existing siteio app "${name}" — will rebuild image in place (API key and clients preserved).`
528
583
  );
529
584
  } else {
530
585
  deps.log(
531
- `No existing siteio app "${opts.name}" — will create a fresh one.`
586
+ `No existing siteio app "${name}" — will create a fresh one.`
532
587
  );
533
588
  }
534
589
 
@@ -550,11 +605,11 @@ export async function runTeleport(
550
605
  // the persistent volume if it's missing (same logic as --sync).
551
606
  let needsVolumeBackfill = false;
552
607
  if (isRebuild) {
553
- const detail = await deps.runner.appInfo(opts.name);
608
+ const detail = await deps.runner.appInfo(name);
554
609
  needsVolumeBackfill = !hasDataVolumeMount(detail);
555
610
  if (needsVolumeBackfill) {
556
611
  deps.log(
557
- `No persistent volume mounted at ${DATA_VOLUME_PATH} — will attach ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH} as part of this rebuild.`
612
+ `No persistent volume mounted at ${DATA_VOLUME_PATH} — will attach ${volumeNameFor(name)}:${DATA_VOLUME_PATH} as part of this rebuild.`
558
613
  );
559
614
  }
560
615
  }
@@ -590,16 +645,16 @@ export async function runTeleport(
590
645
  if (!isRebuild) {
591
646
  if (gitSettings) {
592
647
  deps.log(
593
- `siteio apps create ${opts.name} -g ${gitSettings.repoUrl} --branch ${gitSettings.branch} --dockerfile ${TELEPORT_DOCKERFILE_PATH} -p 9999`
648
+ `siteio apps create ${name} -g ${gitSettings.repoUrl} --branch ${gitSettings.branch} --dockerfile ${TELEPORT_DOCKERFILE_PATH} -p 9999`
594
649
  );
595
650
  } else {
596
651
  deps.log(
597
- `siteio apps create ${opts.name} -f <tempfile> -p 9999`
652
+ `siteio apps create ${name} -f <tempfile> -p 9999`
598
653
  );
599
654
  }
600
655
  }
601
656
  const setParts = [
602
- `siteio apps set ${opts.name}`,
657
+ `siteio apps set ${name}`,
603
658
  '-e AGENTIO_KEY=<redacted>',
604
659
  `-e AGENTIO_CONFIG=<${exported.config.length} chars>`,
605
660
  ];
@@ -607,11 +662,11 @@ export async function runTeleport(
607
662
  setParts.push(`-e AGENTIO_SERVER_API_KEY=${serverApiKey}`);
608
663
  }
609
664
  if (!isRebuild || needsVolumeBackfill) {
610
- setParts.push(`-v ${volumeNameFor(opts.name)}:${DATA_VOLUME_PATH}`);
665
+ setParts.push(`-v ${volumeNameFor(name)}:${DATA_VOLUME_PATH}`);
611
666
  }
612
667
  deps.log(setParts.join(' '));
613
668
  deps.log(
614
- `siteio apps deploy ${opts.name}${opts.noCache ? ' --no-cache' : ''}`
669
+ `siteio apps deploy ${name}${opts.noCache ? ' --no-cache' : ''}`
615
670
  );
616
671
  if (isRebuild) {
617
672
  deps.log(
@@ -628,7 +683,7 @@ export async function runTeleport(
628
683
  );
629
684
  }
630
685
  return {
631
- name: opts.name,
686
+ name: name,
632
687
  serverApiKey,
633
688
  claudeMcpAddCommand: null,
634
689
  };
@@ -643,10 +698,10 @@ export async function runTeleport(
643
698
 
644
699
  try {
645
700
  if (!isRebuild) {
646
- deps.log(`Creating siteio app "${opts.name}"…`);
701
+ deps.log(`Creating siteio app "${name}"…`);
647
702
  if (gitSettings) {
648
703
  await deps.runner.createApp({
649
- name: opts.name,
704
+ name: name,
650
705
  port: 9999,
651
706
  git: {
652
707
  repoUrl: gitSettings.repoUrl,
@@ -656,7 +711,7 @@ export async function runTeleport(
656
711
  });
657
712
  } else {
658
713
  await deps.runner.createApp({
659
- name: opts.name,
714
+ name: name,
660
715
  dockerfilePath: tempPath!,
661
716
  port: 9999,
662
717
  });
@@ -686,10 +741,10 @@ export async function runTeleport(
686
741
  }
687
742
 
688
743
  await deps.runner.setApp({
689
- name: opts.name,
744
+ name: name,
690
745
  envVars,
691
746
  ...(attachVolume
692
- ? { volumes: { [volumeNameFor(opts.name)]: DATA_VOLUME_PATH } }
747
+ ? { volumes: { [volumeNameFor(name)]: DATA_VOLUME_PATH } }
693
748
  : {}),
694
749
  });
695
750
 
@@ -698,17 +753,39 @@ export async function runTeleport(
698
753
  ? 'Rebuilding (this may take a minute — Docker is rebuilding your image)…'
699
754
  : 'Deploying (this may take a minute — Docker is building your image)…'
700
755
  );
701
- await deps.runner.deploy({
702
- name: opts.name,
703
- // In git mode, there's no -f to re-pass on deploy — siteio uses
704
- // the stored git settings from create.
705
- ...(tempPath ? { dockerfilePath: tempPath } : {}),
706
- noCache: opts.noCache,
707
- });
756
+ try {
757
+ await deps.runner.deploy({
758
+ name: name,
759
+ // In git mode, there's no -f to re-pass on deploy — siteio uses
760
+ // the stored git settings from create.
761
+ ...(tempPath ? { dockerfilePath: tempPath } : {}),
762
+ noCache: opts.noCache,
763
+ });
764
+ } catch (err) {
765
+ // The existing app may have been created in git mode (no -f). siteio
766
+ // refuses `deploy -f` in that case with a specific error — retry
767
+ // without -f so it falls back to the stored git settings.
768
+ if (
769
+ isRebuild &&
770
+ tempPath &&
771
+ err instanceof Error &&
772
+ /not created with -f/i.test(err.message)
773
+ ) {
774
+ deps.log(
775
+ 'Existing app was not created with -f — retrying deploy without inline Dockerfile (siteio will rebuild from its stored git settings).'
776
+ );
777
+ await deps.runner.deploy({
778
+ name: name,
779
+ noCache: opts.noCache,
780
+ });
781
+ } else {
782
+ throw err;
783
+ }
784
+ }
708
785
 
709
786
  // Try to surface the deployed URL. Non-fatal if siteio doesn't
710
787
  // give us one back.
711
- const info = await deps.runner.appInfo(opts.name);
788
+ const info = await deps.runner.appInfo(name);
712
789
  let url = typeof info?.url === 'string' ? info.url : undefined;
713
790
  // siteio's `apps info --json` output omits the generated subdomain
714
791
  // URL (domains: [] in the payload) even though the app is reachable
@@ -716,7 +793,7 @@ export async function runTeleport(
716
793
  // level. Fall back to findApp so the post-deploy health check can
717
794
  // still run even when siteio doesn't surface url in info.
718
795
  if (!url) {
719
- const listed = await deps.runner.findApp(opts.name);
796
+ const listed = await deps.runner.findApp(name);
720
797
  if (typeof listed?.url === 'string') url = listed.url;
721
798
  }
722
799
 
@@ -731,7 +808,7 @@ export async function runTeleport(
731
808
  deps.warn(
732
809
  `Container failed to report healthy after ${Math.round(HEALTH_TIMEOUT_MS / 1000)}s. Fetching logs…`
733
810
  );
734
- const logs = await deps.runner.logsApp(opts.name, {
811
+ const logs = await deps.runner.logsApp(name, {
735
812
  tail: HEALTH_FAILURE_LOG_TAIL,
736
813
  });
737
814
  deps.warn('--- container logs (tail) ---');
@@ -739,14 +816,14 @@ export async function runTeleport(
739
816
  deps.warn('--- end logs ---');
740
817
  throw new CliError(
741
818
  'API_ERROR',
742
- `Deploy "${opts.name}" started but /health never returned 200`,
819
+ `Deploy "${name}" started but /health never returned 200`,
743
820
  'Inspect the logs above. Common causes: permission errors on mounted volumes, missing env vars, binary not found for the container arch.'
744
821
  );
745
822
  }
746
823
  } else {
747
824
  deps.warn(
748
825
  'Skipping health check: siteio did not return a URL for this app. ' +
749
- `Run \`siteio apps info ${opts.name}\` and curl <url>/health manually to verify.`
826
+ `Run \`siteio apps info ${name}\` and curl <url>/health manually to verify.`
750
827
  );
751
828
  }
752
829
 
@@ -754,6 +831,15 @@ export async function runTeleport(
754
831
  ? `claude mcp add --scope local --transport http agentio "${url}/mcp?services=rss"`
755
832
  : null;
756
833
 
834
+ // Remember the app so a future bare `--sync` (or any name-less
835
+ // teleport invocation) can default to it. Only reached once the
836
+ // deploy + health check have both succeeded.
837
+ await deps.saveLastTeleportApp({
838
+ name,
839
+ url,
840
+ deployedAt: Date.now(),
841
+ });
842
+
757
843
  deps.log('');
758
844
  deps.log(isRebuild ? 'Rebuild complete!' : 'Teleport complete!');
759
845
  if (url) {
@@ -762,7 +848,7 @@ export async function runTeleport(
762
848
  deps.log(` MCP: ${url}/mcp`);
763
849
  } else {
764
850
  deps.log(
765
- ` URL: (siteio did not return a URL — run \`siteio apps info ${opts.name}\` to look it up)`
851
+ ` URL: (siteio did not return a URL — run \`siteio apps info ${name}\` to look it up)`
766
852
  );
767
853
  }
768
854
  if (isRebuild) {
@@ -781,7 +867,7 @@ export async function runTeleport(
781
867
  }
782
868
 
783
869
  return {
784
- name: opts.name,
870
+ name: name,
785
871
  url,
786
872
  serverApiKey,
787
873
  claudeMcpAddCommand: claudeCmd,
@@ -847,6 +933,29 @@ async function defaultSleep(ms: number): Promise<void> {
847
933
  return new Promise((resolve) => setTimeout(resolve, ms));
848
934
  }
849
935
 
936
+ /**
937
+ * Default `getLastTeleportApp`: read `config.teleport.lastApp` from
938
+ * `~/.config/agentio/config.json`. Returns null if the config has no
939
+ * teleport memory yet.
940
+ */
941
+ async function defaultGetLastTeleportApp(): Promise<TeleportAppRecord | null> {
942
+ const config = (await loadConfig()) as Config;
943
+ return config.teleport?.lastApp ?? null;
944
+ }
945
+
946
+ /**
947
+ * Default `saveLastTeleportApp`: merge into `config.teleport.lastApp`
948
+ * and persist. Single-slot — the most recent successful teleport
949
+ * overwrites whatever was there before.
950
+ */
951
+ async function defaultSaveLastTeleportApp(
952
+ record: TeleportAppRecord
953
+ ): Promise<void> {
954
+ const config = (await loadConfig()) as Config;
955
+ config.teleport = { ...(config.teleport ?? {}), lastApp: record };
956
+ await saveConfig(config);
957
+ }
958
+
850
959
  async function defaultDetectGitOriginUrl(): Promise<string | null> {
851
960
  try {
852
961
  const proc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
@@ -877,8 +986,8 @@ export function registerTeleportCommand(parent: Command): void {
877
986
  'Deploy the agentio HTTP MCP server to a siteio-managed remote in one command'
878
987
  )
879
988
  .argument(
880
- '<name>',
881
- 'Siteio app name (becomes the subdomain: e.g. "mcp" → mcp.<your-siteio-domain>)'
989
+ '[name]',
990
+ 'Siteio app name (becomes the subdomain: e.g. "mcp" → mcp.<your-siteio-domain>). Optional on subsequent runs — defaults to the most recently deployed app.'
882
991
  )
883
992
  .option(
884
993
  '--dockerfile-only',
@@ -908,7 +1017,7 @@ export function registerTeleportCommand(parent: Command): void {
908
1017
  '--sync',
909
1018
  '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.'
910
1019
  )
911
- .action(async (name: string, options) => {
1020
+ .action(async (name: string | undefined, options) => {
912
1021
  try {
913
1022
  const runner = createSiteioRunner();
914
1023
  await runTeleport(
@@ -937,6 +1046,8 @@ export function registerTeleportCommand(parent: Command): void {
937
1046
  detectGitOriginUrl: defaultDetectGitOriginUrl,
938
1047
  probeHealth: defaultProbeHealth,
939
1048
  sleep: defaultSleep,
1049
+ getLastTeleportApp: defaultGetLastTeleportApp,
1050
+ saveLastTeleportApp: defaultSaveLastTeleportApp,
940
1051
  log: (msg) => console.log(msg),
941
1052
  warn: (msg) => console.error(msg),
942
1053
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Embedded favicon served at `/favicon.ico` and `/favicon.png`. Keeps MCP
3
+ * clients (e.g. Claude's connector UI) from falling back to the generic
4
+ * globe icon. Bytes are inlined so every build target — dev, `bun build
5
+ * --target node`, and `bun build --compile` — ships it without filesystem
6
+ * lookups.
7
+ *
8
+ * Source: `site/logo.png` (120x120 PNG).
9
+ */
10
+
11
+ const FAVICON_BASE64 =
12
+ 'iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4EAIAAADmln3GAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqAQMQAyhWYExTAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTAxLTAzVDE2OjAzOjM5KzAwOjAwTY3PQAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wMS0wM1QxNjowMzozOSswMDowMDzQd/wAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjYtMDEtMDNUMTY6MDM6MzkrMDA6MDBrxVYjAAAMjklEQVR42u3dd1QU1x4H8Dtll04EREBFEBSVIiWCFAUBsccSTYyFGKOIPp9HeXmWYIklqKioiSHRaKIGsSTHEiNRH6iRpgRDsUSKiGIE9IkNZZfdnZn3x7z3Ts5LZnbdyt73+/zlcX87d+71e2an3DsScXFpaS9eIACwQJp6BwDQJwg0wAoEGmAFAg2wAoEGWIFAA6xAoAFWINAAKxBogBUINMAKBBpgBQINsAKBBliBQAOsQKABViDQACsQaIAVCDTACgQaYAUCDbACgQZYgUADrECgAVYg0AArEGiAFQg0wAoEGmAFAg2wAoEGWIFAA6xAoAFWINAAKxBogBUINMAKBBpgBQINsAKBBliBQAOsQKABViDQACsQaIAVCDTACgQaYAUCDbACgQZYgUADrECgAVYg0AArEGiAFQg0wAoEGmAFAg2w8i9H6natiosOZAAAAABJRU5ErkJggg==';
13
+
14
+ export const FAVICON_BYTES = Uint8Array.from(
15
+ Buffer.from(FAVICON_BASE64, 'base64')
16
+ );
17
+ export const FAVICON_CONTENT_TYPE = 'image/png';
@@ -2,6 +2,7 @@ import { handleMcpRequest } from './mcp-http';
2
2
  import type { OAuthStore } from './oauth-store';
3
3
  import { requireBearer, routeOAuth } from './oauth';
4
4
  import { routeSetup } from './setup-page';
5
+ import { FAVICON_BYTES, FAVICON_CONTENT_TYPE } from './favicon';
5
6
 
6
7
  /**
7
8
  * Context passed to every fetch handler invocation. Built once at boot in
@@ -36,6 +37,16 @@ export async function handleRequest(
36
37
  return jsonResponse({ ok: true });
37
38
  }
38
39
 
40
+ if (url.pathname === '/favicon.ico' || url.pathname === '/favicon.png') {
41
+ return new Response(FAVICON_BYTES, {
42
+ status: 200,
43
+ headers: {
44
+ 'content-type': FAVICON_CONTENT_TYPE,
45
+ 'cache-control': 'public, max-age=86400',
46
+ },
47
+ });
48
+ }
49
+
39
50
  // Root setup page — operator-facing UI (API-key gated) for picking
40
51
  // profiles and generating the MCP URL.
41
52
  const setupResponse = await routeSetup(req, ctx);
@@ -188,6 +188,7 @@ function loginFormHtml(errorMessage?: string): string {
188
188
  <html lang="en">
189
189
  <head>
190
190
  <meta charset="utf-8">
191
+ <link rel="icon" type="image/png" href="/favicon.png">
191
192
  <title>agentio MCP setup</title>
192
193
  <style>${BASE_CSS}</style>
193
194
  </head>
@@ -307,6 +308,7 @@ ${sectionsHtml}
307
308
  <html lang="en">
308
309
  <head>
309
310
  <meta charset="utf-8">
311
+ <link rel="icon" type="image/png" href="/favicon.png">
310
312
  <title>agentio MCP setup</title>
311
313
  <style>${BASE_CSS}${PROFILES_CSS}</style>
312
314
  </head>
@@ -40,6 +40,23 @@ export interface ProfileEntry {
40
40
  // Helper type for backward compatibility during migration
41
41
  export type ProfileValue = string | ProfileEntry;
42
42
 
43
+ /**
44
+ * Record of a successfully teleported siteio app. Persisted so that
45
+ * `agentio mcp teleport --sync` (and other name-optional variants) can
46
+ * default to the most recently deployed app instead of forcing the
47
+ * user to re-type the name every time.
48
+ */
49
+ export interface TeleportAppRecord {
50
+ name: string;
51
+ url?: string;
52
+ /** Unix epoch milliseconds of the last successful deploy or sync. */
53
+ deployedAt: number;
54
+ }
55
+
56
+ export interface TeleportConfig {
57
+ lastApp?: TeleportAppRecord;
58
+ }
59
+
43
60
  export interface Config {
44
61
  profiles: {
45
62
  gdocs?: ProfileValue[];
@@ -60,6 +77,7 @@ export interface Config {
60
77
  env?: Record<string, string>;
61
78
  gateway?: GatewayConfig;
62
79
  server?: ServerConfig;
80
+ teleport?: TeleportConfig;
63
81
  }
64
82
 
65
83
  export type ServiceName = 'gdocs' | 'gdrive' | 'gmail' | 'gcal' | 'gtasks' | 'gchat' | 'gsheets' | 'github' | 'jira' | 'slack' | 'telegram' | 'whatsapp' | 'discourse' | 'sql';