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