@mintmcp/hosted-cli 0.0.16 → 0.0.18

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/dist/index.js CHANGED
@@ -4,20 +4,21 @@ import { createTRPCClient, httpLink } from "@trpc/client";
4
4
  import archiver from "archiver";
5
5
  import * as fs from "fs";
6
6
  import { readFileSync } from "fs";
7
- import keytar from "keytar";
8
7
  import * as path from "path";
9
8
  import { dirname, join } from "path";
10
9
  import superjson from "superjson";
11
10
  import { fileURLToPath } from "url";
12
11
  import z, { ZodSchema } from "zod";
13
12
  import { HostedIdSchema, UserConfigUpdateSchema, } from "./api.js";
13
+ import { clearAllAuth, clearSelectedAuth, EnvSchema, formatAuthSummary, getAuth, getCurrentAuthAccount, getSelectedAuth, getTokenClaims, listAuthRecords, requestAndStoreAuth, sameStoredAuthAccount, useAuth, } from "./auth.js";
14
+ import { assertImageSupportsPlatform, buildImage, defaultBuildPlatform, defaultLocalTestImageRef, ensureDockerAvailable, ensureDockerBuildxAvailable, ensureImageAvailableLocally, loginToRegistry, pushImage, smokeTestImage, } from "./containerBuilder.js";
14
15
  import { buildPartialUpdate, toHostedTransport, } from "./updateBuilder.js";
15
16
  const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = dirname(__filename);
17
18
  const packageJson = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
18
19
  const version = packageJson.version;
19
- const EnvSchema = z.enum(["staging", "production"]);
20
20
  const TransportSchema = z.enum(["http", "stdio"]);
21
+ const parseTransport = argParser(TransportSchema);
21
22
  const TRPC_API_URL = {
22
23
  staging: "http://localhost:3000/api.trpc",
23
24
  production: "https://app.mintmcp.com/api.trpc",
@@ -26,116 +27,43 @@ const WEB_CLIENT_URL = {
26
27
  staging: "http://localhost:3000",
27
28
  production: "https://app.mintmcp.com",
28
29
  };
29
- const CLIENT_IDS = {
30
- staging: "client_01K0WB8ABW72JSBZ62V98AZMPS",
31
- production: "client_01K0WB8AHKS2J39SFKAPTHTR90",
32
- };
33
30
  // Where to look for the hosted config, relative to "--base".
34
31
  const HOSTED_CONFIG_PATHS = {
35
32
  staging: ".mintmcp/hosted-staging.json",
36
33
  production: ".mintmcp/hosted.json",
37
34
  };
38
- const HostedConfigSchema = z.object({
35
+ const HostedConfigBaseSchema = z.object({
39
36
  hostedId: HostedIdSchema,
40
- // Directory containing data to send to hosted server, relative to "--base".
41
- mntDataDir: z.string(),
42
- });
43
- const AuthSchema = z.object({
44
- accessToken: z.string(),
45
- email: z.string(),
46
- organizationId: z.string(),
37
+ // Organization that owns this hosted server. Older config files may omit it.
38
+ organizationId: z.string().optional(),
47
39
  });
48
- async function requestAuth(env) {
49
- const clientId = CLIENT_IDS[env];
50
- const deviceCodeResponse = await fetch("https://api.workos.com/user_management/authorize/device", {
51
- method: "POST",
52
- headers: {
53
- "Content-Type": "application/x-www-form-urlencoded",
54
- },
55
- body: new URLSearchParams({
56
- client_id: clientId,
57
- }),
58
- });
59
- if (!deviceCodeResponse.ok) {
60
- throw new Error(`Failed to get device code: ${await deviceCodeResponse.text()}`);
61
- }
62
- const deviceData = (await deviceCodeResponse.json());
63
- const { device_code: deviceCode, verification_uri_complete: verificationUriComplete, interval = 5, } = deviceData;
64
- console.log(`To authenticate, visit ${verificationUriComplete}`);
65
- // Poll for access token.
66
- const maxAttempts = 60;
67
- for (let attempts = 0; attempts < maxAttempts; attempts += 1) {
68
- await new Promise((resolve) => {
69
- setTimeout(resolve, interval * 1000);
70
- });
71
- const tokenResponse = await fetch("https://api.workos.com/user_management/authenticate", {
72
- method: "POST",
73
- headers: {
74
- "Content-Type": "application/x-www-form-urlencoded",
75
- },
76
- body: new URLSearchParams({
77
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
78
- device_code: deviceCode,
79
- client_id: clientId,
80
- }),
81
- });
82
- if (!tokenResponse.ok) {
83
- continue;
84
- }
85
- const tokenData = (await tokenResponse.json());
86
- if (tokenData.error) {
87
- if (tokenData.error === "authorization_pending") {
88
- continue;
89
- }
90
- else if (tokenData.error === "slow_down") {
91
- // Increase interval as requested
92
- await new Promise((resolve) => {
93
- setTimeout(resolve, 5000);
94
- });
95
- continue;
96
- }
97
- else {
98
- throw new Error(`Authentication failed: ${tokenData.error}`);
99
- }
100
- }
101
- if (tokenData.access_token) {
102
- if (!tokenData.user?.email) {
103
- throw new Error("Authentication response missing user email");
104
- }
105
- if (!tokenData.organization_id) {
106
- throw new Error("Authentication response missing organization ID");
107
- }
108
- return {
109
- accessToken: tokenData.access_token,
110
- email: tokenData.user.email,
111
- organizationId: tokenData.organization_id,
112
- };
113
- }
114
- }
115
- throw new Error("Authentication timeout");
40
+ const HostedConfigSchema = z.union([
41
+ HostedConfigBaseSchema.extend({
42
+ mode: z.literal("container"),
43
+ image: z.string().optional(),
44
+ transport: TransportSchema.optional(),
45
+ // Directory containing data to send to hosted server, relative to "--base".
46
+ mntDataDir: z.string().optional(),
47
+ }),
48
+ HostedConfigBaseSchema.extend({
49
+ // Directory containing data to send to hosted server, relative to "--base".
50
+ mntDataDir: z.string(),
51
+ }),
52
+ ]);
53
+ function storedTransport(config) {
54
+ return "mode" in config ? config.transport : undefined;
116
55
  }
117
- async function getAuth(env) {
118
- const keychainServiceName = "mintmcp-credentials";
119
- const stored = await keytar.getPassword(keychainServiceName, env);
120
- if (stored) {
121
- try {
122
- const parsedData = JSON.parse(stored);
123
- const validatedAuth = AuthSchema.parse(parsedData);
124
- return validatedAuth;
125
- }
126
- catch (error) {
127
- console.warn("Invalid stored credentials, re-authenticating:", error);
128
- // If validation fails, delete the invalid stored credentials and re-authenticate
129
- await keytar.deletePassword(keychainServiceName, env);
130
- }
131
- }
132
- const auth = await requestAuth(env);
133
- await keytar.setPassword(keychainServiceName, env, JSON.stringify(auth));
134
- return auth;
56
+ function canUseDirectImageStartup(params) {
57
+ return params.transport === "http" && params.mntDataDir == undefined;
135
58
  }
136
- async function makeAuthenticatedClient(env) {
137
- const auth = await getAuth(env);
138
- // TODO: Get the organization name because that is more human readable.
59
+ function isTRPCUnauthorized(error) {
60
+ return (error instanceof Error &&
61
+ "data" in error &&
62
+ typeof error.data === "object" &&
63
+ error.data?.code === "UNAUTHORIZED");
64
+ }
65
+ async function makeAuthenticatedClient(env, options) {
66
+ const auth = await getAuth(env, options);
139
67
  console.log(`Authenticated as ${auth.email} in ${auth.organizationId}.`);
140
68
  return createTRPCClient({
141
69
  links: [
@@ -149,6 +77,22 @@ async function makeAuthenticatedClient(env) {
149
77
  ],
150
78
  });
151
79
  }
80
+ async function withAuthRetry(env, fn) {
81
+ const client = await makeAuthenticatedClient(env);
82
+ try {
83
+ return await fn(client);
84
+ }
85
+ catch (error) {
86
+ if (isTRPCUnauthorized(error)) {
87
+ console.warn("Session expired or invalid. Refreshing credentials and retrying...");
88
+ const newClient = await makeAuthenticatedClient(env, {
89
+ forceRefresh: true,
90
+ });
91
+ return await fn(newClient);
92
+ }
93
+ throw error;
94
+ }
95
+ }
152
96
  function argParser(schema) {
153
97
  return (value) => {
154
98
  const result = schema.safeParse(value);
@@ -211,47 +155,246 @@ async function uploadData(client, mntDataDir) {
211
155
  console.log("File uploaded.");
212
156
  return { secretDataZipGcsPath: upload.secretDataZipGcsPath };
213
157
  }
214
- const DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS = {
158
+ const DEPLOY_OPTIONS_LEGACY_DEFAULTS = {
215
159
  transport: "stdio",
216
160
  // Skip install/build if already done (e.g., by startup probe or previous session).
217
161
  startupCommand: "([ -d node_modules ] || npm install) && ([ -d dist ] || npm run build) && npm run start",
218
162
  mntDataDir: ".",
219
163
  };
220
- async function createNew(client, base, options) {
221
- const { name, transport, startupCommand, mntDataDir = ".", } = {
222
- ...DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS,
164
+ const DEPLOY_OPTIONS_CONTAINER_DEFAULTS = {
165
+ transport: "http",
166
+ };
167
+ async function createNew(client, base, organizationId, options) {
168
+ const defaults = options.image
169
+ ? DEPLOY_OPTIONS_CONTAINER_DEFAULTS
170
+ : DEPLOY_OPTIONS_LEGACY_DEFAULTS;
171
+ const { name, image, transport, startupCommand, mntDataDir } = {
172
+ ...defaults,
223
173
  ...options,
224
174
  };
225
175
  if (name == undefined) {
226
176
  throw new Error("--name is a required option when creating a new server");
227
177
  }
228
- const upload = await uploadData(client, path.join(base, mntDataDir));
178
+ if (image != undefined &&
179
+ startupCommand == undefined &&
180
+ !canUseDirectImageStartup({ transport, mntDataDir })) {
181
+ throw new Error("--startup-command is required unless using --transport http without --mnt-data-dir");
182
+ }
183
+ if (image == undefined && startupCommand == undefined) {
184
+ throw new Error("Expected --startup-command to be defined after applying defaults");
185
+ }
186
+ const upload = mntDataDir != undefined || image == undefined
187
+ ? await uploadData(client, path.join(base, mntDataDir ?? "."))
188
+ : undefined;
229
189
  const config = UserConfigUpdateSchema.parse({
230
190
  userGivenName: name,
231
- command: startupCommand,
191
+ ...(image != undefined ? { image } : {}),
192
+ ...(startupCommand != undefined ? { command: startupCommand } : {}),
232
193
  transport: toHostedTransport(transport),
233
194
  replaceEnv: [],
234
- secretDataZipGcsPath: upload.secretDataZipGcsPath,
195
+ ...(upload ? { secretDataZipGcsPath: upload.secretDataZipGcsPath } : {}),
235
196
  });
236
197
  const createdServer = await client.mcpHost.createServer.mutate({
237
198
  config,
238
199
  });
239
200
  return {
240
- hostedId: createdServer.hostedId,
241
- mntDataDir,
201
+ config: HostedConfigSchema.parse({
202
+ hostedId: createdServer.hostedId,
203
+ ...(image != undefined
204
+ ? { mode: "container", image, transport }
205
+ : {}),
206
+ ...(mntDataDir != undefined ? { mntDataDir } : {}),
207
+ organizationId,
208
+ }),
209
+ gatewayId: createdServer.gatewayId,
242
210
  };
243
211
  }
244
- async function updateExisting(client, base, config, options) {
245
- const newConfig = { ...config };
246
- if (options.mntDataDir != undefined) {
247
- newConfig.mntDataDir = options.mntDataDir;
212
+ async function updateExisting(client, base, config, organizationId, options) {
213
+ if (config.organizationId != undefined &&
214
+ config.organizationId !== organizationId) {
215
+ throw new Error(`Hosted config belongs to organization ${config.organizationId}, but the selected credentials are for ${organizationId}. Switch organizations with "hosted auth use --organization-id ${config.organizationId}" before deploying.`);
248
216
  }
249
- const upload = await uploadData(client, path.join(base, newConfig.mntDataDir));
250
- await client.mcpHost.partialUpdateServer.mutate({
217
+ const nextTransport = options.transport ?? storedTransport(config);
218
+ const clearCommand = options.image != undefined &&
219
+ options.startupCommand == undefined &&
220
+ options.mntDataDir == undefined &&
221
+ nextTransport === "http";
222
+ if (options.image != undefined &&
223
+ options.startupCommand == undefined &&
224
+ !clearCommand) {
225
+ if (options.mntDataDir != undefined) {
226
+ throw new Error("--startup-command is required when using --mnt-data-dir with --image");
227
+ }
228
+ if (nextTransport == undefined) {
229
+ throw new Error("--startup-command is required unless the resulting transport is known to be http. Pass --transport http to switch to direct image startup.");
230
+ }
231
+ throw new Error("--startup-command is required unless using --transport http without --mnt-data-dir");
232
+ }
233
+ const newConfig = HostedConfigSchema.parse({
234
+ ...config,
235
+ ...(options.mntDataDir != undefined
236
+ ? { mntDataDir: options.mntDataDir }
237
+ : {}),
238
+ ...(options.image != undefined
239
+ ? {
240
+ mode: "container",
241
+ image: options.image,
242
+ ...(nextTransport != undefined ? { transport: nextTransport } : {}),
243
+ }
244
+ : {}),
245
+ ...("mode" in config && nextTransport != undefined
246
+ ? { transport: nextTransport }
247
+ : {}),
248
+ organizationId,
249
+ });
250
+ const upload = (options.image == undefined || options.mntDataDir != undefined) &&
251
+ newConfig.mntDataDir != undefined
252
+ ? await uploadData(client, path.join(base, newConfig.mntDataDir))
253
+ : undefined;
254
+ const updatedServer = await client.mcpHost.partialUpdateServer.mutate({
251
255
  hostedId: config.hostedId,
252
- update: buildPartialUpdate(options, upload.secretDataZipGcsPath),
256
+ update: buildPartialUpdate(options, upload?.secretDataZipGcsPath, {
257
+ clearCommand,
258
+ }),
259
+ });
260
+ return {
261
+ config: newConfig,
262
+ ...(updatedServer.gatewayId != undefined
263
+ ? { gatewayId: updatedServer.gatewayId }
264
+ : {}),
265
+ };
266
+ }
267
+ function connectorSettingsUrl(env, gatewayId) {
268
+ const url = new URL("/vmcps", WEB_CLIENT_URL[env]);
269
+ url.searchParams.set("id", gatewayId);
270
+ url.searchParams.set("serverTab", "connector-settings");
271
+ return url.toString();
272
+ }
273
+ function logDeployResult(env, action, result) {
274
+ if (result.gatewayId == undefined) {
275
+ console.log(`${action}.`);
276
+ return;
277
+ }
278
+ console.log(`${action} ${connectorSettingsUrl(env, result.gatewayId)}`);
279
+ }
280
+ function updateConfigAfterManagedPush(config, organizationId, managedImageRef) {
281
+ return HostedConfigSchema.parse({
282
+ ...config,
283
+ mode: "container",
284
+ image: managedImageRef,
285
+ organizationId,
286
+ ...("mode" in config && config.transport != undefined
287
+ ? { transport: config.transport }
288
+ : {}),
289
+ });
290
+ }
291
+ function managedRegistryImageRef(session) {
292
+ return `${session.registryHost}/${session.repository}:${session.imageTag}`;
293
+ }
294
+ async function buildAndPushManagedImage(params) {
295
+ if (params.config.organizationId != undefined &&
296
+ params.config.organizationId !== params.organizationId) {
297
+ throw new Error(`Hosted config belongs to organization ${params.config.organizationId}, but the selected credentials are for ${params.organizationId}. Switch organizations with "hosted auth use --organization-id ${params.config.organizationId}" before building and pushing.`);
298
+ }
299
+ const session = await params.client.mcpHost.createRegistryPushSession.mutate({
300
+ hostedId: params.config.hostedId,
301
+ });
302
+ const deployImageRef = managedRegistryImageRef(session);
303
+ const buildResult = buildImage({
304
+ dockerfile: params.dockerfile,
305
+ context: params.context,
306
+ buildArgs: params.buildArg,
307
+ ...(params.target != undefined ? { target: params.target } : {}),
308
+ platform: params.platform,
309
+ imageRef: deployImageRef,
310
+ });
311
+ loginToRegistry(session.registryHost, session.username, session.password);
312
+ pushImage(buildResult.imageRef);
313
+ const finalized = await params.client.mcpHost.finalizeRegistryPush.mutate({
314
+ hostedId: params.config.hostedId,
315
+ finalizeToken: session.finalizeToken,
316
+ });
317
+ return {
318
+ config: updateConfigAfterManagedPush(params.config, params.organizationId, deployImageRef),
319
+ ...(finalized.gatewayId != undefined
320
+ ? { gatewayId: finalized.gatewayId }
321
+ : {}),
322
+ };
323
+ }
324
+ function buildManagedImageCreateConfig(params) {
325
+ const transport = params.transport ?? "http";
326
+ if (params.name == undefined) {
327
+ throw new Error("--name is a required option when building and pushing a new managed image connector");
328
+ }
329
+ if (params.startupCommand == undefined &&
330
+ !canUseDirectImageStartup({ transport, mntDataDir: undefined })) {
331
+ throw new Error("--startup-command is required unless using --transport http");
332
+ }
333
+ return UserConfigUpdateSchema.omit({ image: true }).parse({
334
+ userGivenName: params.name,
335
+ ...(params.startupCommand != undefined
336
+ ? { command: params.startupCommand }
337
+ : {}),
338
+ transport: toHostedTransport(transport),
339
+ replaceEnv: [],
340
+ });
341
+ }
342
+ async function buildAndPushManagedImageForCreate(params) {
343
+ const config = buildManagedImageCreateConfig({
344
+ ...(params.name != undefined ? { name: params.name } : {}),
345
+ ...(params.transport != undefined ? { transport: params.transport } : {}),
346
+ ...(params.startupCommand != undefined
347
+ ? { startupCommand: params.startupCommand }
348
+ : {}),
349
+ });
350
+ const session = await params.client.mcpHost.createRegistryPushSessionForCreate.mutate({});
351
+ const deployImageRef = managedRegistryImageRef(session);
352
+ const buildResult = buildImage({
353
+ dockerfile: params.dockerfile,
354
+ context: params.context,
355
+ buildArgs: params.buildArg,
356
+ ...(params.target != undefined ? { target: params.target } : {}),
357
+ platform: params.platform,
358
+ imageRef: deployImageRef,
359
+ });
360
+ loginToRegistry(session.registryHost, session.username, session.password);
361
+ pushImage(buildResult.imageRef);
362
+ const finalized = await params.client.mcpHost.finalizeRegistryPushCreate.mutate({
363
+ finalizeToken: session.finalizeToken,
364
+ config,
365
+ });
366
+ return {
367
+ config: HostedConfigSchema.parse({
368
+ hostedId: finalized.hostedId,
369
+ organizationId: params.organizationId,
370
+ mode: "container",
371
+ image: deployImageRef,
372
+ ...(params.transport != undefined
373
+ ? { transport: params.transport }
374
+ : { transport: "http" }),
375
+ }),
376
+ gatewayId: finalized.gatewayId,
377
+ };
378
+ }
379
+ async function deployWithOptions(env, base, options) {
380
+ const auth = await getAuth(env);
381
+ await withAuthRetry(env, async (client) => {
382
+ const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
383
+ let result;
384
+ if (fs.existsSync(configPath)) {
385
+ console.log("Updating existing server...");
386
+ const configData = fs.readFileSync(configPath, "utf8");
387
+ result = await updateExisting(client, base, HostedConfigSchema.parse(JSON.parse(configData)), auth.organizationId, options);
388
+ logDeployResult(env, "Updated", result);
389
+ }
390
+ else {
391
+ console.log("Creating new server...");
392
+ result = await createNew(client, base, auth.organizationId, options);
393
+ logDeployResult(env, "Created", result);
394
+ }
395
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
396
+ fs.writeFileSync(configPath, JSON.stringify(result.config, null, 2));
253
397
  });
254
- return newConfig;
255
398
  }
256
399
  const program = new Command()
257
400
  .name("hosted")
@@ -260,33 +403,261 @@ const program = new Command()
260
403
  .option("-e, --env <environment>", "Target environment.", argParser(EnvSchema), "production")
261
404
  .option("-b, --base <base>", "base directory for config and data.", ".");
262
405
  function makeDefaultMessage(optionName) {
263
- return `Defaults to '${DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS[optionName]}' for new servers.`;
406
+ return `For new zip-based servers, defaults to '${DEPLOY_OPTIONS_LEGACY_DEFAULTS[optionName]}'.`;
407
+ }
408
+ function collect(value, previous) {
409
+ return previous.concat([value]);
410
+ }
411
+ function parsePositiveInt(value) {
412
+ const parsed = Number.parseInt(value, 10);
413
+ if (!Number.isFinite(parsed) || parsed <= 0) {
414
+ throw new InvalidArgumentError("Expected a positive integer.");
415
+ }
416
+ return parsed;
417
+ }
418
+ function shouldBuildTestImage(options) {
419
+ if (options.build) {
420
+ return true;
421
+ }
422
+ if (options.image == undefined) {
423
+ return true;
424
+ }
425
+ if (options.dockerfile !== undefined && options.dockerfile !== "Dockerfile") {
426
+ return true;
427
+ }
428
+ if (options.context !== undefined && options.context !== ".") {
429
+ return true;
430
+ }
431
+ if ((options.buildArg?.length ?? 0) > 0) {
432
+ return true;
433
+ }
434
+ return options.target != undefined;
264
435
  }
436
+ const authCommand = program
437
+ .command("auth")
438
+ .description("Manage cached hosted-cli authentication credentials.");
439
+ authCommand
440
+ .command("login")
441
+ .description("Authenticate and cache credentials for the current environment.")
442
+ .action(async () => {
443
+ const { env } = program.opts();
444
+ const auth = await requestAndStoreAuth(env);
445
+ console.log(`Cached ${formatAuthSummary(auth)}.`);
446
+ });
447
+ authCommand
448
+ .command("list")
449
+ .description("List cached credentials for the current environment.")
450
+ .action(async () => {
451
+ const { env } = program.opts();
452
+ const currentAccount = await getCurrentAuthAccount(env);
453
+ const records = await listAuthRecords(env);
454
+ if (records.length === 0) {
455
+ console.log(`No cached credentials found for ${env}.`);
456
+ return;
457
+ }
458
+ for (const record of records) {
459
+ const marker = currentAccount && sameStoredAuthAccount(record.account, currentAccount)
460
+ ? "*"
461
+ : " ";
462
+ console.log(`${marker} ${formatAuthSummary(record.auth)}`);
463
+ }
464
+ });
465
+ authCommand
466
+ .command("use")
467
+ .description("Select which cached credentials the CLI should use.")
468
+ .option("--user-id <id>", "WorkOS user ID to select.")
469
+ .option("--email <email>", "Email address to select.")
470
+ .option("--organization-id <id>", "Organization ID to select.")
471
+ .action(async (options) => {
472
+ const { env } = program.opts();
473
+ if (!options.userId && !options.email && !options.organizationId) {
474
+ throw new Error('Specify at least one filter, for example "hosted auth use --organization-id <id>".');
475
+ }
476
+ const result = await useAuth(env, options);
477
+ const message = result.mode === "switched" ? "Cached and selected" : "Selected";
478
+ console.log(`${message} ${formatAuthSummary(result.auth)}.`);
479
+ });
265
480
  program
266
481
  .command("deploy")
267
482
  .description("Create or update a hosted server.")
268
483
  .option("-n, --name <name>", "Name of the hosted server. Required for new servers.")
269
- .option("-t, --transport <transport>", `Transport (stdio or http). ${makeDefaultMessage("transport")}`, argParser(TransportSchema))
270
- .option("--startup-command <command>", `Command that runs on hosted container to start the server (runs in bash). ${makeDefaultMessage("startupCommand")}`)
484
+ .option("--image <ref>", "Container image reference (e.g., ghcr.io/org/repo:tag). Skips zip upload when --mnt-data-dir is not also supplied.")
485
+ .option("-t, --transport <transport>", `Transport (stdio or http). Defaults to 'http' for image-based creates. ${makeDefaultMessage("transport")}`, argParser(TransportSchema))
486
+ .option("--startup-command <command>", `Command that runs on hosted container to start the server (runs in bash). Required for stdio and zip-backed image deploys. ${makeDefaultMessage("startupCommand")}`)
271
487
  .option("--mnt-data-dir <directory>", `Directory to copy to /mnt on the server. ${makeDefaultMessage("mntDataDir")}`)
272
488
  .action(async (options) => {
273
489
  const { env, base } = program.opts();
274
- const client = await makeAuthenticatedClient(env);
275
- const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
276
- let newOrUpdatedConfig;
277
- if (fs.existsSync(configPath)) {
278
- console.log("Updating existing server...");
279
- const configData = fs.readFileSync(configPath, "utf8");
280
- newOrUpdatedConfig = await updateExisting(client, base, HostedConfigSchema.parse(JSON.parse(configData)), options);
281
- console.log(`Updated ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`);
490
+ await deployWithOptions(env, base, options);
491
+ });
492
+ program
493
+ .command("test-image")
494
+ .description("Run a local MCP smoke test against a container image. Builds the current Dockerfile by default; with --image, tests that existing image directly.")
495
+ .option("--image <ref>", "Existing image reference to test directly, or the output tag to build when --build or other build options are supplied.")
496
+ .option("--build", "Force a local build before testing, even when --image is supplied.")
497
+ .option("--dockerfile <path>", "Path to Dockerfile", "Dockerfile")
498
+ .option("--context <dir>", "Docker build context directory", ".")
499
+ .option("--build-arg <arg>", "Build arguments (KEY=VALUE), repeatable", collect, [])
500
+ .option("--target <target>", "Multi-stage build target")
501
+ .option("--probe-path <path>", "HTTP path to probe with MCP initialize", "/mcp")
502
+ .option("--probe-timeout-ms <ms>", "How long to wait for the local MCP probe to succeed", parsePositiveInt, 30_000)
503
+ .option("--env <entry>", "Container runtime env vars for the local smoke test (KEY=VALUE), repeatable", collect, [])
504
+ .option("--env-file <path>", "Path to a Docker env file for the local smoke test")
505
+ .action(async (options) => {
506
+ ensureDockerAvailable();
507
+ ensureDockerBuildxAvailable();
508
+ const shouldBuild = shouldBuildTestImage(options);
509
+ const imageRef = options.image ?? defaultLocalTestImageRef();
510
+ if (shouldBuild) {
511
+ buildImage({
512
+ dockerfile: options.dockerfile,
513
+ context: options.context,
514
+ buildArgs: options.buildArg,
515
+ ...(options.target != undefined ? { target: options.target } : {}),
516
+ imageRef,
517
+ });
282
518
  }
283
519
  else {
284
- console.log("Creating new server...");
285
- newOrUpdatedConfig = await createNew(client, base, options);
286
- console.log(`Created ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`);
520
+ ensureImageAvailableLocally(imageRef);
287
521
  }
522
+ await smokeTestImage({
523
+ imageRef,
524
+ probePath: options.probePath,
525
+ probeTimeoutMs: options.probeTimeoutMs,
526
+ env: options.env,
527
+ ...(options.envFile != undefined ? { envFile: options.envFile } : {}),
528
+ });
529
+ });
530
+ program
531
+ .command("build-and-push")
532
+ .description("Build a Docker image, push it through the managed registry, and finalize the hosted connector image update or creation.")
533
+ .option("--dockerfile <path>", "Path to Dockerfile", "Dockerfile")
534
+ .option("--context <dir>", "Docker build context directory", ".")
535
+ .option("--build-arg <arg>", "Build arguments (KEY=VALUE), repeatable", collect, [])
536
+ .option("--target <target>", "Multi-stage build target")
537
+ .option("--platform <platform>", `Target platform to build for before push. Defaults to '${defaultBuildPlatform()}'.`, defaultBuildPlatform())
538
+ .option("--name <name>", "Connector name when creating a new hosted connector")
539
+ .option("--transport <transport>", "Transport to configure when creating a new managed connector", parseTransport)
540
+ .option("--startup-command <command>", "Startup command to configure when creating a new managed connector")
541
+ .action(async (options) => {
542
+ const { env, base } = program.opts();
543
+ ensureDockerAvailable();
544
+ ensureDockerBuildxAvailable();
545
+ if (options.platform !== defaultBuildPlatform()) {
546
+ console.warn(`Building for ${options.platform}. Hosted deployments typically target ${defaultBuildPlatform()}; choose a different platform only when you know the runtime is compatible.`);
547
+ }
548
+ const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
549
+ const auth = await getAuth(env);
550
+ const hasExistingConfig = fs.existsSync(configPath);
551
+ const result = await withAuthRetry(env, async (client) => {
552
+ if (hasExistingConfig) {
553
+ const config = HostedConfigSchema.parse(JSON.parse(fs.readFileSync(configPath, "utf8")));
554
+ return await buildAndPushManagedImage({
555
+ client,
556
+ organizationId: auth.organizationId,
557
+ config,
558
+ dockerfile: options.dockerfile,
559
+ context: options.context,
560
+ buildArg: options.buildArg,
561
+ ...(options.target != undefined ? { target: options.target } : {}),
562
+ platform: options.platform,
563
+ });
564
+ }
565
+ return await buildAndPushManagedImageForCreate({
566
+ client,
567
+ organizationId: auth.organizationId,
568
+ ...(options.name != undefined ? { name: options.name } : {}),
569
+ ...(options.transport != undefined
570
+ ? { transport: options.transport }
571
+ : {}),
572
+ ...(options.startupCommand != undefined
573
+ ? { startupCommand: options.startupCommand }
574
+ : {}),
575
+ dockerfile: options.dockerfile,
576
+ context: options.context,
577
+ buildArg: options.buildArg,
578
+ ...(options.target != undefined ? { target: options.target } : {}),
579
+ platform: options.platform,
580
+ });
581
+ });
288
582
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
289
- fs.writeFileSync(configPath, JSON.stringify(newOrUpdatedConfig, null, 2));
583
+ fs.writeFileSync(configPath, JSON.stringify(result.config, null, 2));
584
+ logDeployResult(env, hasExistingConfig ? "Updated" : "Created", result);
585
+ });
586
+ program
587
+ .command("build-and-deploy")
588
+ .description("Build a Docker image for hosted deployment, push it, verify the target platform, and deploy it.")
589
+ .requiredOption("--image <ref>", "Image reference to build, push, and deploy (e.g., ghcr.io/org/repo:tag).")
590
+ .option("--dockerfile <path>", "Path to Dockerfile", "Dockerfile")
591
+ .option("--context <dir>", "Docker build context directory", ".")
592
+ .option("--build-arg <arg>", "Build arguments (KEY=VALUE), repeatable", collect, [])
593
+ .option("--target <target>", "Multi-stage build target")
594
+ .option("--platform <platform>", `Target platform to build for before deploy. Defaults to '${defaultBuildPlatform()}'.`, defaultBuildPlatform())
595
+ .option("-n, --name <name>", "Name of the hosted server. Required for new servers.")
596
+ .option("-t, --transport <transport>", `Transport (stdio or http). Defaults to 'http' for image-based creates. ${makeDefaultMessage("transport")}`, argParser(TransportSchema))
597
+ .option("--startup-command <command>", `Command that runs on hosted container to start the server (runs in bash). Required for stdio and zip-backed image deploys. ${makeDefaultMessage("startupCommand")}`)
598
+ .option("--mnt-data-dir <directory>", `Directory to copy to /mnt on the server. ${makeDefaultMessage("mntDataDir")}`)
599
+ .action(async (options) => {
600
+ const { env, base } = program.opts();
601
+ ensureDockerAvailable();
602
+ ensureDockerBuildxAvailable();
603
+ if (options.platform !== defaultBuildPlatform()) {
604
+ console.warn(`Building for ${options.platform}. Hosted deployments typically target ${defaultBuildPlatform()}; choose a different platform only when you know the runtime is compatible.`);
605
+ }
606
+ const buildResult = buildImage({
607
+ dockerfile: options.dockerfile,
608
+ context: options.context,
609
+ buildArgs: options.buildArg,
610
+ ...(options.target != undefined ? { target: options.target } : {}),
611
+ platform: options.platform,
612
+ imageRef: options.image,
613
+ });
614
+ pushImage(buildResult.imageRef);
615
+ assertImageSupportsPlatform(buildResult.imageRef, options.platform);
616
+ await deployWithOptions(env, base, {
617
+ image: buildResult.imageRef,
618
+ ...(options.name != undefined ? { name: options.name } : {}),
619
+ ...(options.transport != undefined
620
+ ? { transport: options.transport }
621
+ : {}),
622
+ ...(options.startupCommand != undefined
623
+ ? { startupCommand: options.startupCommand }
624
+ : {}),
625
+ ...(options.mntDataDir != undefined
626
+ ? { mntDataDir: options.mntDataDir }
627
+ : {}),
628
+ });
629
+ });
630
+ program
631
+ .command("logout")
632
+ .description("Clear stored credentials and sign out of the WorkOS browser session.")
633
+ .option("--all", "Clear all cached credentials for the current environment.")
634
+ .action(async (options) => {
635
+ const { env } = program.opts();
636
+ let logoutUrl;
637
+ const selectedAuth = await getSelectedAuth(env);
638
+ if (selectedAuth) {
639
+ const { sid } = getTokenClaims(selectedAuth.accessToken);
640
+ if (sid) {
641
+ logoutUrl = `https://api.workos.com/user_management/sessions/logout?session_id=${sid}`;
642
+ }
643
+ }
644
+ if (options.all) {
645
+ await clearAllAuth(env);
646
+ console.log(`All cached credentials cleared for ${env}.`);
647
+ }
648
+ else {
649
+ const clearedAuth = await clearSelectedAuth(env);
650
+ if (clearedAuth) {
651
+ console.log(`Cleared cached credentials for ${formatAuthSummary(clearedAuth)}.`);
652
+ }
653
+ else {
654
+ console.log(`No cached credentials found for ${env}.`);
655
+ }
656
+ }
657
+ if (logoutUrl) {
658
+ console.log("To fully sign out of the WorkOS browser session, visit:");
659
+ console.log(logoutUrl);
660
+ }
290
661
  });
291
662
  program.parse();
292
663
  //# sourceMappingURL=index.js.map