@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/src/index.ts CHANGED
@@ -6,7 +6,6 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
6
6
  import archiver from "archiver";
7
7
  import * as fs from "fs";
8
8
  import { readFileSync } from "fs";
9
- import keytar from "keytar";
10
9
  import * as path from "path";
11
10
  import { dirname, join } from "path";
12
11
  import superjson from "superjson";
@@ -14,10 +13,40 @@ import { fileURLToPath } from "url";
14
13
  import z, { ZodSchema } from "zod";
15
14
  import {
16
15
  type AppRouter,
16
+ type GatewayId,
17
17
  HostedIdSchema,
18
18
  type HostedTransport,
19
19
  UserConfigUpdateSchema,
20
20
  } from "./api.js";
21
+ import {
22
+ type Auth,
23
+ clearAllAuth,
24
+ clearSelectedAuth,
25
+ type Env,
26
+ EnvSchema,
27
+ formatAuthSummary,
28
+ getAuth,
29
+ getCurrentAuthAccount,
30
+ getSelectedAuth,
31
+ getTokenClaims,
32
+ listAuthRecords,
33
+ requestAndStoreAuth,
34
+ type StoredAuthAccount,
35
+ sameStoredAuthAccount,
36
+ useAuth,
37
+ } from "./auth.js";
38
+ import {
39
+ assertImageSupportsPlatform,
40
+ buildImage,
41
+ defaultBuildPlatform,
42
+ defaultLocalTestImageRef,
43
+ ensureDockerAvailable,
44
+ ensureDockerBuildxAvailable,
45
+ ensureImageAvailableLocally,
46
+ loginToRegistry,
47
+ pushImage,
48
+ smokeTestImage,
49
+ } from "./containerBuilder.js";
21
50
  import {
22
51
  buildPartialUpdate,
23
52
  type DeployOptions,
@@ -35,10 +64,8 @@ const version = packageJson.version;
35
64
  export type CliClientAppRouterInputs = inferRouterInputs<AppRouter>;
36
65
  export type CliClientAppRouterOutputs = inferRouterOutputs<AppRouter>;
37
66
 
38
- const EnvSchema = z.enum(["staging", "production"]);
39
- type Env = z.infer<typeof EnvSchema>;
40
-
41
67
  const TransportSchema = z.enum(["http", "stdio"]);
68
+ const parseTransport = argParser(TransportSchema);
42
69
 
43
70
  const TRPC_API_URL: Record<Env, string> = {
44
71
  staging: "http://localhost:3000/api.trpc",
@@ -50,147 +77,68 @@ const WEB_CLIENT_URL: Record<Env, string> = {
50
77
  production: "https://app.mintmcp.com",
51
78
  };
52
79
 
53
- const CLIENT_IDS: Record<Env, string> = {
54
- staging: "client_01K0WB8ABW72JSBZ62V98AZMPS",
55
- production: "client_01K0WB8AHKS2J39SFKAPTHTR90",
56
- };
57
-
58
80
  // Where to look for the hosted config, relative to "--base".
59
81
  const HOSTED_CONFIG_PATHS: Record<Env, string> = {
60
82
  staging: ".mintmcp/hosted-staging.json",
61
83
  production: ".mintmcp/hosted.json",
62
84
  };
63
85
 
64
- const HostedConfigSchema = z.object({
86
+ const HostedConfigBaseSchema = z.object({
65
87
  hostedId: HostedIdSchema,
66
88
 
67
- // Directory containing data to send to hosted server, relative to "--base".
68
- mntDataDir: z.string(),
89
+ // Organization that owns this hosted server. Older config files may omit it.
90
+ organizationId: z.string().optional(),
69
91
  });
70
- type HostedConfig = z.infer<typeof HostedConfigSchema>;
71
-
72
- const AuthSchema = z.object({
73
- accessToken: z.string(),
74
- email: z.string(),
75
- organizationId: z.string(),
76
- });
77
-
78
- type Auth = z.infer<typeof AuthSchema>;
79
-
80
- async function requestAuth(env: Env): Promise<Auth> {
81
- const clientId = CLIENT_IDS[env];
82
-
83
- const deviceCodeResponse = await fetch(
84
- "https://api.workos.com/user_management/authorize/device",
85
- {
86
- method: "POST",
87
- headers: {
88
- "Content-Type": "application/x-www-form-urlencoded",
89
- },
90
- body: new URLSearchParams({
91
- client_id: clientId,
92
- }),
93
- },
94
- );
95
-
96
- if (!deviceCodeResponse.ok) {
97
- throw new Error(
98
- `Failed to get device code: ${await deviceCodeResponse.text()}`,
99
- );
100
- }
101
92
 
102
- const deviceData = (await deviceCodeResponse.json()) as any;
103
- const {
104
- device_code: deviceCode,
105
- verification_uri_complete: verificationUriComplete,
106
- interval = 5,
107
- } = deviceData;
93
+ const HostedConfigSchema = z.union([
94
+ HostedConfigBaseSchema.extend({
95
+ mode: z.literal("container"),
96
+ image: z.string().optional(),
97
+ transport: TransportSchema.optional(),
108
98
 
109
- console.log(`To authenticate, visit ${verificationUriComplete}`);
110
-
111
- // Poll for access token.
112
- const maxAttempts = 60;
113
- for (let attempts = 0; attempts < maxAttempts; attempts += 1) {
114
- await new Promise<void>((resolve) => {
115
- setTimeout(resolve, interval * 1000);
116
- });
117
-
118
- const tokenResponse = await fetch(
119
- "https://api.workos.com/user_management/authenticate",
120
- {
121
- method: "POST",
122
- headers: {
123
- "Content-Type": "application/x-www-form-urlencoded",
124
- },
125
- body: new URLSearchParams({
126
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
127
- device_code: deviceCode,
128
- client_id: clientId,
129
- }),
130
- },
131
- );
132
-
133
- if (!tokenResponse.ok) {
134
- continue;
135
- }
136
-
137
- const tokenData = (await tokenResponse.json()) as any;
99
+ // Directory containing data to send to hosted server, relative to "--base".
100
+ mntDataDir: z.string().optional(),
101
+ }),
102
+ HostedConfigBaseSchema.extend({
103
+ // Directory containing data to send to hosted server, relative to "--base".
104
+ mntDataDir: z.string(),
105
+ }),
106
+ ]);
107
+ type HostedConfig = z.infer<typeof HostedConfigSchema>;
108
+ type DeployResult = {
109
+ config: HostedConfig;
110
+ gatewayId?: GatewayId;
111
+ };
138
112
 
139
- if (tokenData.error) {
140
- if (tokenData.error === "authorization_pending") {
141
- continue;
142
- } else if (tokenData.error === "slow_down") {
143
- // Increase interval as requested
144
- await new Promise<void>((resolve) => {
145
- setTimeout(resolve, 5000);
146
- });
147
- continue;
148
- } else {
149
- throw new Error(`Authentication failed: ${tokenData.error}`);
150
- }
151
- }
113
+ type RegistryPushSession =
114
+ CliClientAppRouterOutputs["mcpHost"]["createRegistryPushSession"];
152
115
 
153
- if (tokenData.access_token) {
154
- if (!tokenData.user?.email) {
155
- throw new Error("Authentication response missing user email");
156
- }
157
- if (!tokenData.organization_id) {
158
- throw new Error("Authentication response missing organization ID");
159
- }
160
- return {
161
- accessToken: tokenData.access_token,
162
- email: tokenData.user.email,
163
- organizationId: tokenData.organization_id,
164
- };
165
- }
166
- }
116
+ function storedTransport(config: HostedConfig): Transport | undefined {
117
+ return "mode" in config ? config.transport : undefined;
118
+ }
167
119
 
168
- throw new Error("Authentication timeout");
120
+ function canUseDirectImageStartup(params: {
121
+ transport: Transport | undefined;
122
+ mntDataDir: string | undefined;
123
+ }): boolean {
124
+ return params.transport === "http" && params.mntDataDir == undefined;
169
125
  }
170
126
 
171
- async function getAuth(env: Env): Promise<Auth> {
172
- const keychainServiceName = "mintmcp-credentials";
173
- const stored = await keytar.getPassword(keychainServiceName, env);
174
- if (stored) {
175
- try {
176
- const parsedData = JSON.parse(stored);
177
- const validatedAuth = AuthSchema.parse(parsedData);
178
- return validatedAuth;
179
- } catch (error) {
180
- console.warn("Invalid stored credentials, re-authenticating:", error);
181
- // If validation fails, delete the invalid stored credentials and re-authenticate
182
- await keytar.deletePassword(keychainServiceName, env);
183
- }
184
- }
185
- const auth = await requestAuth(env);
186
- await keytar.setPassword(keychainServiceName, env, JSON.stringify(auth));
187
- return auth;
127
+ function isTRPCUnauthorized(error: unknown): boolean {
128
+ return (
129
+ error instanceof Error &&
130
+ "data" in error &&
131
+ typeof (error as any).data === "object" &&
132
+ (error as any).data?.code === "UNAUTHORIZED"
133
+ );
188
134
  }
189
135
 
190
- async function makeAuthenticatedClient(env: Env) {
191
- const auth = await getAuth(env);
136
+ async function makeAuthenticatedClient(
137
+ env: Env,
138
+ options?: { forceRefresh?: boolean },
139
+ ) {
140
+ const auth = await getAuth(env, options);
192
141
 
193
- // TODO: Get the organization name because that is more human readable.
194
142
  console.log(`Authenticated as ${auth.email} in ${auth.organizationId}.`);
195
143
 
196
144
  return createTRPCClient<AppRouter>({
@@ -206,6 +154,29 @@ async function makeAuthenticatedClient(env: Env) {
206
154
  });
207
155
  }
208
156
 
157
+ type AuthenticatedClient = Awaited<ReturnType<typeof makeAuthenticatedClient>>;
158
+
159
+ async function withAuthRetry<T>(
160
+ env: Env,
161
+ fn: (client: AuthenticatedClient) => Promise<T>,
162
+ ): Promise<T> {
163
+ const client = await makeAuthenticatedClient(env);
164
+ try {
165
+ return await fn(client);
166
+ } catch (error) {
167
+ if (isTRPCUnauthorized(error)) {
168
+ console.warn(
169
+ "Session expired or invalid. Refreshing credentials and retrying...",
170
+ );
171
+ const newClient = await makeAuthenticatedClient(env, {
172
+ forceRefresh: true,
173
+ });
174
+ return await fn(newClient);
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+
209
180
  function argParser<T>(schema: ZodSchema<T>) {
210
181
  return (value: string): T => {
211
182
  const result = schema.safeParse(value);
@@ -281,7 +252,7 @@ async function uploadData(
281
252
  return { secretDataZipGcsPath: upload.secretDataZipGcsPath };
282
253
  }
283
254
 
284
- const DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS = {
255
+ const DEPLOY_OPTIONS_LEGACY_DEFAULTS = {
285
256
  transport: "stdio",
286
257
  // Skip install/build if already done (e.g., by startup probe or previous session).
287
258
  startupCommand:
@@ -289,32 +260,53 @@ const DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS = {
289
260
  mntDataDir: ".",
290
261
  } as const;
291
262
 
263
+ const DEPLOY_OPTIONS_CONTAINER_DEFAULTS = {
264
+ transport: "http",
265
+ } as const;
266
+
292
267
  async function createNew(
293
268
  client: Awaited<ReturnType<typeof makeAuthenticatedClient>>,
294
269
  base: string,
270
+ organizationId: string,
295
271
  options: DeployOptions,
296
- ): Promise<HostedConfig> {
297
- const {
298
- name,
299
- transport,
300
- startupCommand,
301
- mntDataDir = ".",
302
- } = {
303
- ...DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS,
272
+ ): Promise<DeployResult> {
273
+ const defaults = options.image
274
+ ? DEPLOY_OPTIONS_CONTAINER_DEFAULTS
275
+ : DEPLOY_OPTIONS_LEGACY_DEFAULTS;
276
+ const { name, image, transport, startupCommand, mntDataDir } = {
277
+ ...defaults,
304
278
  ...options,
305
279
  };
306
280
  if (name == undefined) {
307
281
  throw new Error("--name is a required option when creating a new server");
308
282
  }
283
+ if (
284
+ image != undefined &&
285
+ startupCommand == undefined &&
286
+ !canUseDirectImageStartup({ transport, mntDataDir })
287
+ ) {
288
+ throw new Error(
289
+ "--startup-command is required unless using --transport http without --mnt-data-dir",
290
+ );
291
+ }
292
+ if (image == undefined && startupCommand == undefined) {
293
+ throw new Error(
294
+ "Expected --startup-command to be defined after applying defaults",
295
+ );
296
+ }
309
297
 
310
- const upload = await uploadData(client, path.join(base, mntDataDir));
298
+ const upload =
299
+ mntDataDir != undefined || image == undefined
300
+ ? await uploadData(client, path.join(base, mntDataDir ?? "."))
301
+ : undefined;
311
302
 
312
303
  const config = UserConfigUpdateSchema.parse({
313
304
  userGivenName: name,
314
- command: startupCommand,
305
+ ...(image != undefined ? { image } : {}),
306
+ ...(startupCommand != undefined ? { command: startupCommand } : {}),
315
307
  transport: toHostedTransport(transport),
316
308
  replaceEnv: [],
317
- secretDataZipGcsPath: upload.secretDataZipGcsPath,
309
+ ...(upload ? { secretDataZipGcsPath: upload.secretDataZipGcsPath } : {}),
318
310
  } satisfies z.input<typeof UserConfigUpdateSchema>);
319
311
 
320
312
  const createdServer = await client.mcpHost.createServer.mutate({
@@ -322,8 +314,15 @@ async function createNew(
322
314
  });
323
315
 
324
316
  return {
325
- hostedId: createdServer.hostedId,
326
- mntDataDir,
317
+ config: HostedConfigSchema.parse({
318
+ hostedId: createdServer.hostedId,
319
+ ...(image != undefined
320
+ ? { mode: "container" as const, image, transport }
321
+ : {}),
322
+ ...(mntDataDir != undefined ? { mntDataDir } : {}),
323
+ organizationId,
324
+ }),
325
+ gatewayId: createdServer.gatewayId,
327
326
  };
328
327
  }
329
328
 
@@ -331,21 +330,284 @@ async function updateExisting(
331
330
  client: Awaited<ReturnType<typeof makeAuthenticatedClient>>,
332
331
  base: string,
333
332
  config: HostedConfig,
333
+ organizationId: string,
334
334
  options: DeployOptions,
335
- ): Promise<HostedConfig> {
336
- const newConfig = { ...config };
337
- if (options.mntDataDir != undefined) {
338
- newConfig.mntDataDir = options.mntDataDir;
335
+ ): Promise<DeployResult> {
336
+ if (
337
+ config.organizationId != undefined &&
338
+ config.organizationId !== organizationId
339
+ ) {
340
+ throw new Error(
341
+ `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.`,
342
+ );
339
343
  }
340
- const upload = await uploadData(
341
- client,
342
- path.join(base, newConfig.mntDataDir),
343
- );
344
- await client.mcpHost.partialUpdateServer.mutate({
344
+ const nextTransport = options.transport ?? storedTransport(config);
345
+ const clearCommand =
346
+ options.image != undefined &&
347
+ options.startupCommand == undefined &&
348
+ options.mntDataDir == undefined &&
349
+ nextTransport === "http";
350
+ if (
351
+ options.image != undefined &&
352
+ options.startupCommand == undefined &&
353
+ !clearCommand
354
+ ) {
355
+ if (options.mntDataDir != undefined) {
356
+ throw new Error(
357
+ "--startup-command is required when using --mnt-data-dir with --image",
358
+ );
359
+ }
360
+ if (nextTransport == undefined) {
361
+ throw new Error(
362
+ "--startup-command is required unless the resulting transport is known to be http. Pass --transport http to switch to direct image startup.",
363
+ );
364
+ }
365
+ throw new Error(
366
+ "--startup-command is required unless using --transport http without --mnt-data-dir",
367
+ );
368
+ }
369
+ const newConfig = HostedConfigSchema.parse({
370
+ ...config,
371
+ ...(options.mntDataDir != undefined
372
+ ? { mntDataDir: options.mntDataDir }
373
+ : {}),
374
+ ...(options.image != undefined
375
+ ? {
376
+ mode: "container" as const,
377
+ image: options.image,
378
+ ...(nextTransport != undefined ? { transport: nextTransport } : {}),
379
+ }
380
+ : {}),
381
+ ...("mode" in config && nextTransport != undefined
382
+ ? { transport: nextTransport }
383
+ : {}),
384
+ organizationId,
385
+ });
386
+ const upload =
387
+ (options.image == undefined || options.mntDataDir != undefined) &&
388
+ newConfig.mntDataDir != undefined
389
+ ? await uploadData(client, path.join(base, newConfig.mntDataDir))
390
+ : undefined;
391
+ const updatedServer = await client.mcpHost.partialUpdateServer.mutate({
345
392
  hostedId: config.hostedId,
346
- update: buildPartialUpdate(options, upload.secretDataZipGcsPath),
393
+ update: buildPartialUpdate(options, upload?.secretDataZipGcsPath, {
394
+ clearCommand,
395
+ }),
396
+ });
397
+ return {
398
+ config: newConfig,
399
+ ...(updatedServer.gatewayId != undefined
400
+ ? { gatewayId: updatedServer.gatewayId }
401
+ : {}),
402
+ };
403
+ }
404
+
405
+ function connectorSettingsUrl(env: Env, gatewayId: GatewayId): string {
406
+ const url = new URL("/vmcps", WEB_CLIENT_URL[env]);
407
+ url.searchParams.set("id", gatewayId);
408
+ url.searchParams.set("serverTab", "connector-settings");
409
+ return url.toString();
410
+ }
411
+
412
+ function logDeployResult(
413
+ env: Env,
414
+ action: "Created" | "Updated",
415
+ result: DeployResult,
416
+ ): void {
417
+ if (result.gatewayId == undefined) {
418
+ console.log(`${action}.`);
419
+ return;
420
+ }
421
+ console.log(`${action} ${connectorSettingsUrl(env, result.gatewayId)}`);
422
+ }
423
+
424
+ function updateConfigAfterManagedPush(
425
+ config: HostedConfig,
426
+ organizationId: string,
427
+ managedImageRef: string,
428
+ ): HostedConfig {
429
+ return HostedConfigSchema.parse({
430
+ ...config,
431
+ mode: "container" as const,
432
+ image: managedImageRef,
433
+ organizationId,
434
+ ...("mode" in config && config.transport != undefined
435
+ ? { transport: config.transport }
436
+ : {}),
437
+ });
438
+ }
439
+
440
+ function managedRegistryImageRef(session: RegistryPushSession): string {
441
+ return `${session.registryHost}/${session.repository}:${session.imageTag}`;
442
+ }
443
+
444
+ async function buildAndPushManagedImage(params: {
445
+ client: AuthenticatedClient;
446
+ organizationId: string;
447
+ config: HostedConfig;
448
+ dockerfile: string;
449
+ context: string;
450
+ buildArg: string[];
451
+ target?: string;
452
+ platform: string;
453
+ }): Promise<DeployResult> {
454
+ if (
455
+ params.config.organizationId != undefined &&
456
+ params.config.organizationId !== params.organizationId
457
+ ) {
458
+ throw new Error(
459
+ `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.`,
460
+ );
461
+ }
462
+
463
+ const session = await params.client.mcpHost.createRegistryPushSession.mutate({
464
+ hostedId: params.config.hostedId,
465
+ });
466
+
467
+ const deployImageRef = managedRegistryImageRef(session);
468
+ const buildResult = buildImage({
469
+ dockerfile: params.dockerfile,
470
+ context: params.context,
471
+ buildArgs: params.buildArg,
472
+ ...(params.target != undefined ? { target: params.target } : {}),
473
+ platform: params.platform,
474
+ imageRef: deployImageRef,
475
+ });
476
+
477
+ loginToRegistry(session.registryHost, session.username, session.password);
478
+ pushImage(buildResult.imageRef);
479
+
480
+ const finalized = await params.client.mcpHost.finalizeRegistryPush.mutate({
481
+ hostedId: params.config.hostedId,
482
+ finalizeToken: session.finalizeToken,
483
+ });
484
+
485
+ return {
486
+ config: updateConfigAfterManagedPush(
487
+ params.config,
488
+ params.organizationId,
489
+ deployImageRef,
490
+ ),
491
+ ...(finalized.gatewayId != undefined
492
+ ? { gatewayId: finalized.gatewayId }
493
+ : {}),
494
+ };
495
+ }
496
+
497
+ function buildManagedImageCreateConfig(params: {
498
+ name?: string;
499
+ transport?: Transport;
500
+ startupCommand?: string;
501
+ }) {
502
+ const transport = params.transport ?? "http";
503
+ if (params.name == undefined) {
504
+ throw new Error(
505
+ "--name is a required option when building and pushing a new managed image connector",
506
+ );
507
+ }
508
+ if (
509
+ params.startupCommand == undefined &&
510
+ !canUseDirectImageStartup({ transport, mntDataDir: undefined })
511
+ ) {
512
+ throw new Error(
513
+ "--startup-command is required unless using --transport http",
514
+ );
515
+ }
516
+ return UserConfigUpdateSchema.omit({ image: true }).parse({
517
+ userGivenName: params.name,
518
+ ...(params.startupCommand != undefined
519
+ ? { command: params.startupCommand }
520
+ : {}),
521
+ transport: toHostedTransport(transport),
522
+ replaceEnv: [],
523
+ });
524
+ }
525
+
526
+ async function buildAndPushManagedImageForCreate(params: {
527
+ client: AuthenticatedClient;
528
+ organizationId: string;
529
+ name?: string;
530
+ transport?: Transport;
531
+ startupCommand?: string;
532
+ dockerfile: string;
533
+ context: string;
534
+ buildArg: string[];
535
+ target?: string;
536
+ platform: string;
537
+ }): Promise<DeployResult> {
538
+ const config = buildManagedImageCreateConfig({
539
+ ...(params.name != undefined ? { name: params.name } : {}),
540
+ ...(params.transport != undefined ? { transport: params.transport } : {}),
541
+ ...(params.startupCommand != undefined
542
+ ? { startupCommand: params.startupCommand }
543
+ : {}),
544
+ });
545
+
546
+ const session =
547
+ await params.client.mcpHost.createRegistryPushSessionForCreate.mutate({});
548
+ const deployImageRef = managedRegistryImageRef(session);
549
+ const buildResult = buildImage({
550
+ dockerfile: params.dockerfile,
551
+ context: params.context,
552
+ buildArgs: params.buildArg,
553
+ ...(params.target != undefined ? { target: params.target } : {}),
554
+ platform: params.platform,
555
+ imageRef: deployImageRef,
556
+ });
557
+
558
+ loginToRegistry(session.registryHost, session.username, session.password);
559
+ pushImage(buildResult.imageRef);
560
+
561
+ const finalized =
562
+ await params.client.mcpHost.finalizeRegistryPushCreate.mutate({
563
+ finalizeToken: session.finalizeToken,
564
+ config,
565
+ });
566
+
567
+ return {
568
+ config: HostedConfigSchema.parse({
569
+ hostedId: finalized.hostedId,
570
+ organizationId: params.organizationId,
571
+ mode: "container" as const,
572
+ image: deployImageRef,
573
+ ...(params.transport != undefined
574
+ ? { transport: params.transport }
575
+ : { transport: "http" as const }),
576
+ }),
577
+ gatewayId: finalized.gatewayId,
578
+ };
579
+ }
580
+
581
+ async function deployWithOptions(
582
+ env: Env,
583
+ base: string,
584
+ options: DeployOptions,
585
+ ): Promise<void> {
586
+ const auth = await getAuth(env);
587
+
588
+ await withAuthRetry(env, async (client) => {
589
+ const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
590
+
591
+ let result: DeployResult;
592
+ if (fs.existsSync(configPath)) {
593
+ console.log("Updating existing server...");
594
+ const configData = fs.readFileSync(configPath, "utf8");
595
+ result = await updateExisting(
596
+ client,
597
+ base,
598
+ HostedConfigSchema.parse(JSON.parse(configData)),
599
+ auth.organizationId,
600
+ options,
601
+ );
602
+ logDeployResult(env, "Updated", result);
603
+ } else {
604
+ console.log("Creating new server...");
605
+ result = await createNew(client, base, auth.organizationId, options);
606
+ logDeployResult(env, "Created", result);
607
+ }
608
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
609
+ fs.writeFileSync(configPath, JSON.stringify(result.config, null, 2));
347
610
  });
348
- return newConfig;
349
611
  }
350
612
 
351
613
  const program = new Command()
@@ -361,11 +623,109 @@ const program = new Command()
361
623
  .option("-b, --base <base>", "base directory for config and data.", ".");
362
624
 
363
625
  function makeDefaultMessage(
364
- optionName: keyof typeof DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS,
626
+ optionName: keyof typeof DEPLOY_OPTIONS_LEGACY_DEFAULTS,
365
627
  ): string {
366
- return `Defaults to '${DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS[optionName]}' for new servers.`;
628
+ return `For new zip-based servers, defaults to '${DEPLOY_OPTIONS_LEGACY_DEFAULTS[optionName]}'.`;
629
+ }
630
+
631
+ function collect(value: string, previous: string[]): string[] {
632
+ return previous.concat([value]);
367
633
  }
368
634
 
635
+ function parsePositiveInt(value: string): number {
636
+ const parsed = Number.parseInt(value, 10);
637
+ if (!Number.isFinite(parsed) || parsed <= 0) {
638
+ throw new InvalidArgumentError("Expected a positive integer.");
639
+ }
640
+ return parsed;
641
+ }
642
+
643
+ function shouldBuildTestImage(options: {
644
+ build?: boolean;
645
+ image?: string;
646
+ dockerfile?: string;
647
+ context?: string;
648
+ buildArg?: string[];
649
+ target?: string;
650
+ }): boolean {
651
+ if (options.build) {
652
+ return true;
653
+ }
654
+ if (options.image == undefined) {
655
+ return true;
656
+ }
657
+ if (options.dockerfile !== undefined && options.dockerfile !== "Dockerfile") {
658
+ return true;
659
+ }
660
+ if (options.context !== undefined && options.context !== ".") {
661
+ return true;
662
+ }
663
+ if ((options.buildArg?.length ?? 0) > 0) {
664
+ return true;
665
+ }
666
+ return options.target != undefined;
667
+ }
668
+
669
+ const authCommand = program
670
+ .command("auth")
671
+ .description("Manage cached hosted-cli authentication credentials.");
672
+
673
+ authCommand
674
+ .command("login")
675
+ .description(
676
+ "Authenticate and cache credentials for the current environment.",
677
+ )
678
+ .action(async () => {
679
+ const { env } = program.opts();
680
+ const auth = await requestAndStoreAuth(env);
681
+ console.log(`Cached ${formatAuthSummary(auth)}.`);
682
+ });
683
+
684
+ authCommand
685
+ .command("list")
686
+ .description("List cached credentials for the current environment.")
687
+ .action(async () => {
688
+ const { env } = program.opts();
689
+ const currentAccount = await getCurrentAuthAccount(env);
690
+ const records = await listAuthRecords(env);
691
+ if (records.length === 0) {
692
+ console.log(`No cached credentials found for ${env}.`);
693
+ return;
694
+ }
695
+ for (const record of records) {
696
+ const marker =
697
+ currentAccount && sameStoredAuthAccount(record.account, currentAccount)
698
+ ? "*"
699
+ : " ";
700
+ console.log(`${marker} ${formatAuthSummary(record.auth)}`);
701
+ }
702
+ });
703
+
704
+ authCommand
705
+ .command("use")
706
+ .description("Select which cached credentials the CLI should use.")
707
+ .option("--user-id <id>", "WorkOS user ID to select.")
708
+ .option("--email <email>", "Email address to select.")
709
+ .option("--organization-id <id>", "Organization ID to select.")
710
+ .action(
711
+ async (options: {
712
+ userId?: string;
713
+ email?: string;
714
+ organizationId?: string;
715
+ }) => {
716
+ const { env } = program.opts();
717
+ if (!options.userId && !options.email && !options.organizationId) {
718
+ throw new Error(
719
+ 'Specify at least one filter, for example "hosted auth use --organization-id <id>".',
720
+ );
721
+ }
722
+ const result = await useAuth(env, options);
723
+ const message =
724
+ result.mode === "switched" ? "Cached and selected" : "Selected";
725
+ console.log(`${message} ${formatAuthSummary(result.auth)}.`);
726
+ },
727
+ );
728
+
369
729
  program
370
730
  .command("deploy")
371
731
  .description("Create or update a hosted server.")
@@ -373,14 +733,18 @@ program
373
733
  "-n, --name <name>",
374
734
  "Name of the hosted server. Required for new servers.",
375
735
  )
736
+ .option(
737
+ "--image <ref>",
738
+ "Container image reference (e.g., ghcr.io/org/repo:tag). Skips zip upload when --mnt-data-dir is not also supplied.",
739
+ )
376
740
  .option(
377
741
  "-t, --transport <transport>",
378
- `Transport (stdio or http). ${makeDefaultMessage("transport")}`,
742
+ `Transport (stdio or http). Defaults to 'http' for image-based creates. ${makeDefaultMessage("transport")}`,
379
743
  argParser(TransportSchema),
380
744
  )
381
745
  .option(
382
746
  "--startup-command <command>",
383
- `Command that runs on hosted container to start the server (runs in bash). ${makeDefaultMessage("startupCommand")}`,
747
+ `Command that runs on hosted container to start the server (runs in bash). Required for stdio and zip-backed image deploys. ${makeDefaultMessage("startupCommand")}`,
384
748
  )
385
749
  .option(
386
750
  "--mnt-data-dir <directory>",
@@ -388,32 +752,283 @@ program
388
752
  )
389
753
  .action(async (options) => {
390
754
  const { env, base } = program.opts();
391
- const client = await makeAuthenticatedClient(env);
755
+ await deployWithOptions(env, base, options);
756
+ });
757
+
758
+ program
759
+ .command("test-image")
760
+ .description(
761
+ "Run a local MCP smoke test against a container image. Builds the current Dockerfile by default; with --image, tests that existing image directly.",
762
+ )
763
+ .option(
764
+ "--image <ref>",
765
+ "Existing image reference to test directly, or the output tag to build when --build or other build options are supplied.",
766
+ )
767
+ .option(
768
+ "--build",
769
+ "Force a local build before testing, even when --image is supplied.",
770
+ )
771
+ .option("--dockerfile <path>", "Path to Dockerfile", "Dockerfile")
772
+ .option("--context <dir>", "Docker build context directory", ".")
773
+ .option(
774
+ "--build-arg <arg>",
775
+ "Build arguments (KEY=VALUE), repeatable",
776
+ collect,
777
+ [],
778
+ )
779
+ .option("--target <target>", "Multi-stage build target")
780
+ .option(
781
+ "--probe-path <path>",
782
+ "HTTP path to probe with MCP initialize",
783
+ "/mcp",
784
+ )
785
+ .option(
786
+ "--probe-timeout-ms <ms>",
787
+ "How long to wait for the local MCP probe to succeed",
788
+ parsePositiveInt,
789
+ 30_000,
790
+ )
791
+ .option(
792
+ "--env <entry>",
793
+ "Container runtime env vars for the local smoke test (KEY=VALUE), repeatable",
794
+ collect,
795
+ [],
796
+ )
797
+ .option(
798
+ "--env-file <path>",
799
+ "Path to a Docker env file for the local smoke test",
800
+ )
801
+ .action(async (options) => {
802
+ ensureDockerAvailable();
803
+ ensureDockerBuildxAvailable();
804
+
805
+ const shouldBuild = shouldBuildTestImage(options);
806
+ const imageRef = options.image ?? defaultLocalTestImageRef();
807
+
808
+ if (shouldBuild) {
809
+ buildImage({
810
+ dockerfile: options.dockerfile,
811
+ context: options.context,
812
+ buildArgs: options.buildArg,
813
+ ...(options.target != undefined ? { target: options.target } : {}),
814
+ imageRef,
815
+ });
816
+ } else {
817
+ ensureImageAvailableLocally(imageRef);
818
+ }
819
+
820
+ await smokeTestImage({
821
+ imageRef,
822
+ probePath: options.probePath,
823
+ probeTimeoutMs: options.probeTimeoutMs,
824
+ env: options.env,
825
+ ...(options.envFile != undefined ? { envFile: options.envFile } : {}),
826
+ });
827
+ });
828
+
829
+ program
830
+ .command("build-and-push")
831
+ .description(
832
+ "Build a Docker image, push it through the managed registry, and finalize the hosted connector image update or creation.",
833
+ )
834
+ .option("--dockerfile <path>", "Path to Dockerfile", "Dockerfile")
835
+ .option("--context <dir>", "Docker build context directory", ".")
836
+ .option(
837
+ "--build-arg <arg>",
838
+ "Build arguments (KEY=VALUE), repeatable",
839
+ collect,
840
+ [],
841
+ )
842
+ .option("--target <target>", "Multi-stage build target")
843
+ .option(
844
+ "--platform <platform>",
845
+ `Target platform to build for before push. Defaults to '${defaultBuildPlatform()}'.`,
846
+ defaultBuildPlatform(),
847
+ )
848
+ .option(
849
+ "--name <name>",
850
+ "Connector name when creating a new hosted connector",
851
+ )
852
+ .option(
853
+ "--transport <transport>",
854
+ "Transport to configure when creating a new managed connector",
855
+ parseTransport,
856
+ )
857
+ .option(
858
+ "--startup-command <command>",
859
+ "Startup command to configure when creating a new managed connector",
860
+ )
861
+ .action(async (options) => {
862
+ const { env, base } = program.opts();
863
+
864
+ ensureDockerAvailable();
865
+ ensureDockerBuildxAvailable();
866
+
867
+ if (options.platform !== defaultBuildPlatform()) {
868
+ console.warn(
869
+ `Building for ${options.platform}. Hosted deployments typically target ${defaultBuildPlatform()}; choose a different platform only when you know the runtime is compatible.`,
870
+ );
871
+ }
392
872
 
393
873
  const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
874
+ const auth = await getAuth(env);
875
+ const hasExistingConfig = fs.existsSync(configPath);
394
876
 
395
- let newOrUpdatedConfig: HostedConfig;
396
- if (fs.existsSync(configPath)) {
397
- console.log("Updating existing server...");
398
- const configData = fs.readFileSync(configPath, "utf8");
399
- newOrUpdatedConfig = await updateExisting(
877
+ const result = await withAuthRetry(env, async (client) => {
878
+ if (hasExistingConfig) {
879
+ const config = HostedConfigSchema.parse(
880
+ JSON.parse(fs.readFileSync(configPath, "utf8")),
881
+ );
882
+ return await buildAndPushManagedImage({
883
+ client,
884
+ organizationId: auth.organizationId,
885
+ config,
886
+ dockerfile: options.dockerfile,
887
+ context: options.context,
888
+ buildArg: options.buildArg,
889
+ ...(options.target != undefined ? { target: options.target } : {}),
890
+ platform: options.platform,
891
+ });
892
+ }
893
+
894
+ return await buildAndPushManagedImageForCreate({
400
895
  client,
401
- base,
402
- HostedConfigSchema.parse(JSON.parse(configData)),
403
- options,
404
- );
405
- console.log(
406
- `Updated ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`,
896
+ organizationId: auth.organizationId,
897
+ ...(options.name != undefined ? { name: options.name } : {}),
898
+ ...(options.transport != undefined
899
+ ? { transport: options.transport }
900
+ : {}),
901
+ ...(options.startupCommand != undefined
902
+ ? { startupCommand: options.startupCommand }
903
+ : {}),
904
+ dockerfile: options.dockerfile,
905
+ context: options.context,
906
+ buildArg: options.buildArg,
907
+ ...(options.target != undefined ? { target: options.target } : {}),
908
+ platform: options.platform,
909
+ });
910
+ });
911
+
912
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
913
+ fs.writeFileSync(configPath, JSON.stringify(result.config, null, 2));
914
+ logDeployResult(env, hasExistingConfig ? "Updated" : "Created", result);
915
+ });
916
+
917
+ program
918
+ .command("build-and-deploy")
919
+ .description(
920
+ "Build a Docker image for hosted deployment, push it, verify the target platform, and deploy it.",
921
+ )
922
+ .requiredOption(
923
+ "--image <ref>",
924
+ "Image reference to build, push, and deploy (e.g., ghcr.io/org/repo:tag).",
925
+ )
926
+ .option("--dockerfile <path>", "Path to Dockerfile", "Dockerfile")
927
+ .option("--context <dir>", "Docker build context directory", ".")
928
+ .option(
929
+ "--build-arg <arg>",
930
+ "Build arguments (KEY=VALUE), repeatable",
931
+ collect,
932
+ [],
933
+ )
934
+ .option("--target <target>", "Multi-stage build target")
935
+ .option(
936
+ "--platform <platform>",
937
+ `Target platform to build for before deploy. Defaults to '${defaultBuildPlatform()}'.`,
938
+ defaultBuildPlatform(),
939
+ )
940
+ .option(
941
+ "-n, --name <name>",
942
+ "Name of the hosted server. Required for new servers.",
943
+ )
944
+ .option(
945
+ "-t, --transport <transport>",
946
+ `Transport (stdio or http). Defaults to 'http' for image-based creates. ${makeDefaultMessage("transport")}`,
947
+ argParser(TransportSchema),
948
+ )
949
+ .option(
950
+ "--startup-command <command>",
951
+ `Command that runs on hosted container to start the server (runs in bash). Required for stdio and zip-backed image deploys. ${makeDefaultMessage("startupCommand")}`,
952
+ )
953
+ .option(
954
+ "--mnt-data-dir <directory>",
955
+ `Directory to copy to /mnt on the server. ${makeDefaultMessage("mntDataDir")}`,
956
+ )
957
+ .action(async (options) => {
958
+ const { env, base } = program.opts();
959
+
960
+ ensureDockerAvailable();
961
+ ensureDockerBuildxAvailable();
962
+
963
+ if (options.platform !== defaultBuildPlatform()) {
964
+ console.warn(
965
+ `Building for ${options.platform}. Hosted deployments typically target ${defaultBuildPlatform()}; choose a different platform only when you know the runtime is compatible.`,
407
966
  );
967
+ }
968
+
969
+ const buildResult = buildImage({
970
+ dockerfile: options.dockerfile,
971
+ context: options.context,
972
+ buildArgs: options.buildArg,
973
+ ...(options.target != undefined ? { target: options.target } : {}),
974
+ platform: options.platform,
975
+ imageRef: options.image,
976
+ });
977
+
978
+ pushImage(buildResult.imageRef);
979
+ assertImageSupportsPlatform(buildResult.imageRef, options.platform);
980
+
981
+ await deployWithOptions(env, base, {
982
+ image: buildResult.imageRef,
983
+ ...(options.name != undefined ? { name: options.name } : {}),
984
+ ...(options.transport != undefined
985
+ ? { transport: options.transport }
986
+ : {}),
987
+ ...(options.startupCommand != undefined
988
+ ? { startupCommand: options.startupCommand }
989
+ : {}),
990
+ ...(options.mntDataDir != undefined
991
+ ? { mntDataDir: options.mntDataDir }
992
+ : {}),
993
+ });
994
+ });
995
+
996
+ program
997
+ .command("logout")
998
+ .description(
999
+ "Clear stored credentials and sign out of the WorkOS browser session.",
1000
+ )
1001
+ .option("--all", "Clear all cached credentials for the current environment.")
1002
+ .action(async (options: { all?: boolean }) => {
1003
+ const { env } = program.opts();
1004
+
1005
+ let logoutUrl: string | undefined;
1006
+
1007
+ const selectedAuth = await getSelectedAuth(env);
1008
+ if (selectedAuth) {
1009
+ const { sid } = getTokenClaims(selectedAuth.accessToken);
1010
+ if (sid) {
1011
+ logoutUrl = `https://api.workos.com/user_management/sessions/logout?session_id=${sid}`;
1012
+ }
1013
+ }
1014
+
1015
+ if (options.all) {
1016
+ await clearAllAuth(env);
1017
+ console.log(`All cached credentials cleared for ${env}.`);
408
1018
  } else {
409
- console.log("Creating new server...");
410
- newOrUpdatedConfig = await createNew(client, base, options);
411
- console.log(
412
- `Created ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`,
413
- );
1019
+ const clearedAuth = await clearSelectedAuth(env);
1020
+ if (clearedAuth) {
1021
+ console.log(
1022
+ `Cleared cached credentials for ${formatAuthSummary(clearedAuth)}.`,
1023
+ );
1024
+ } else {
1025
+ console.log(`No cached credentials found for ${env}.`);
1026
+ }
1027
+ }
1028
+ if (logoutUrl) {
1029
+ console.log("To fully sign out of the WorkOS browser session, visit:");
1030
+ console.log(logoutUrl);
414
1031
  }
415
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
416
- fs.writeFileSync(configPath, JSON.stringify(newOrUpdatedConfig, null, 2));
417
1032
  });
418
1033
 
419
1034
  program.parse();