@mintmcp/hosted-cli 0.0.17 → 0.0.19

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
@@ -13,6 +13,7 @@ import { fileURLToPath } from "url";
13
13
  import z, { ZodSchema } from "zod";
14
14
  import {
15
15
  type AppRouter,
16
+ type GatewayId,
16
17
  HostedIdSchema,
17
18
  type HostedTransport,
18
19
  UserConfigUpdateSchema,
@@ -34,6 +35,18 @@ import {
34
35
  sameStoredAuthAccount,
35
36
  useAuth,
36
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";
37
50
  import {
38
51
  buildPartialUpdate,
39
52
  type DeployOptions,
@@ -52,6 +65,7 @@ export type CliClientAppRouterInputs = inferRouterInputs<AppRouter>;
52
65
  export type CliClientAppRouterOutputs = inferRouterOutputs<AppRouter>;
53
66
 
54
67
  const TransportSchema = z.enum(["http", "stdio"]);
68
+ const parseTransport = argParser(TransportSchema);
55
69
 
56
70
  const TRPC_API_URL: Record<Env, string> = {
57
71
  staging: "http://localhost:3000/api.trpc",
@@ -69,16 +83,46 @@ const HOSTED_CONFIG_PATHS: Record<Env, string> = {
69
83
  production: ".mintmcp/hosted.json",
70
84
  };
71
85
 
72
- const HostedConfigSchema = z.object({
86
+ const HostedConfigBaseSchema = z.object({
73
87
  hostedId: HostedIdSchema,
74
88
 
75
- // Directory containing data to send to hosted server, relative to "--base".
76
- mntDataDir: z.string(),
77
-
78
89
  // Organization that owns this hosted server. Older config files may omit it.
79
90
  organizationId: z.string().optional(),
80
91
  });
92
+
93
+ const HostedConfigSchema = z.union([
94
+ HostedConfigBaseSchema.extend({
95
+ mode: z.literal("container"),
96
+ image: z.string().optional(),
97
+ transport: TransportSchema.optional(),
98
+
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
+ ]);
81
107
  type HostedConfig = z.infer<typeof HostedConfigSchema>;
108
+ type DeployResult = {
109
+ config: HostedConfig;
110
+ gatewayId?: GatewayId;
111
+ };
112
+
113
+ type RegistryPushSession =
114
+ CliClientAppRouterOutputs["mcpHost"]["createRegistryPushSession"];
115
+
116
+ function storedTransport(config: HostedConfig): Transport | undefined {
117
+ return "mode" in config ? config.transport : undefined;
118
+ }
119
+
120
+ function canUseDirectImageStartup(params: {
121
+ transport: Transport | undefined;
122
+ mntDataDir: string | undefined;
123
+ }): boolean {
124
+ return params.transport === "http" && params.mntDataDir == undefined;
125
+ }
82
126
 
83
127
  function isTRPCUnauthorized(error: unknown): boolean {
84
128
  return (
@@ -208,7 +252,7 @@ async function uploadData(
208
252
  return { secretDataZipGcsPath: upload.secretDataZipGcsPath };
209
253
  }
210
254
 
211
- const DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS = {
255
+ const DEPLOY_OPTIONS_LEGACY_DEFAULTS = {
212
256
  transport: "stdio",
213
257
  // Skip install/build if already done (e.g., by startup probe or previous session).
214
258
  startupCommand:
@@ -216,33 +260,53 @@ const DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS = {
216
260
  mntDataDir: ".",
217
261
  } as const;
218
262
 
263
+ const DEPLOY_OPTIONS_CONTAINER_DEFAULTS = {
264
+ transport: "http",
265
+ } as const;
266
+
219
267
  async function createNew(
220
268
  client: Awaited<ReturnType<typeof makeAuthenticatedClient>>,
221
269
  base: string,
222
270
  organizationId: string,
223
271
  options: DeployOptions,
224
- ): Promise<HostedConfig> {
225
- const {
226
- name,
227
- transport,
228
- startupCommand,
229
- mntDataDir = ".",
230
- } = {
231
- ...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,
232
278
  ...options,
233
279
  };
234
280
  if (name == undefined) {
235
281
  throw new Error("--name is a required option when creating a new server");
236
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
+ }
237
297
 
238
- 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;
239
302
 
240
303
  const config = UserConfigUpdateSchema.parse({
241
304
  userGivenName: name,
242
- command: startupCommand,
305
+ ...(image != undefined ? { image } : {}),
306
+ ...(startupCommand != undefined ? { command: startupCommand } : {}),
243
307
  transport: toHostedTransport(transport),
244
308
  replaceEnv: [],
245
- secretDataZipGcsPath: upload.secretDataZipGcsPath,
309
+ ...(upload ? { secretDataZipGcsPath: upload.secretDataZipGcsPath } : {}),
246
310
  } satisfies z.input<typeof UserConfigUpdateSchema>);
247
311
 
248
312
  const createdServer = await client.mcpHost.createServer.mutate({
@@ -250,9 +314,15 @@ async function createNew(
250
314
  });
251
315
 
252
316
  return {
253
- hostedId: createdServer.hostedId,
254
- mntDataDir,
255
- organizationId,
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,
256
326
  };
257
327
  }
258
328
 
@@ -262,7 +332,7 @@ async function updateExisting(
262
332
  config: HostedConfig,
263
333
  organizationId: string,
264
334
  options: DeployOptions,
265
- ): Promise<HostedConfig> {
335
+ ): Promise<DeployResult> {
266
336
  if (
267
337
  config.organizationId != undefined &&
268
338
  config.organizationId !== organizationId
@@ -271,20 +341,273 @@ async function updateExisting(
271
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.`,
272
342
  );
273
343
  }
274
- const newConfig = { ...config };
275
- if (options.mntDataDir != undefined) {
276
- newConfig.mntDataDir = options.mntDataDir;
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
+ );
277
368
  }
278
- newConfig.organizationId = organizationId;
279
- const upload = await uploadData(
280
- client,
281
- path.join(base, newConfig.mntDataDir),
282
- );
283
- await client.mcpHost.partialUpdateServer.mutate({
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({
284
392
  hostedId: config.hostedId,
285
- 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));
286
610
  });
287
- return newConfig;
288
611
  }
289
612
 
290
613
  const program = new Command()
@@ -300,9 +623,47 @@ const program = new Command()
300
623
  .option("-b, --base <base>", "base directory for config and data.", ".");
301
624
 
302
625
  function makeDefaultMessage(
303
- optionName: keyof typeof DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS,
626
+ optionName: keyof typeof DEPLOY_OPTIONS_LEGACY_DEFAULTS,
304
627
  ): string {
305
- 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]);
633
+ }
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;
306
667
  }
307
668
 
308
669
  const authCommand = program
@@ -372,14 +733,18 @@ program
372
733
  "-n, --name <name>",
373
734
  "Name of the hosted server. Required for new servers.",
374
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
+ )
375
740
  .option(
376
741
  "-t, --transport <transport>",
377
- `Transport (stdio or http). ${makeDefaultMessage("transport")}`,
742
+ `Transport (stdio or http). Defaults to 'http' for image-based creates. ${makeDefaultMessage("transport")}`,
378
743
  argParser(TransportSchema),
379
744
  )
380
745
  .option(
381
746
  "--startup-command <command>",
382
- `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")}`,
383
748
  )
384
749
  .option(
385
750
  "--mnt-data-dir <directory>",
@@ -387,39 +752,244 @@ program
387
752
  )
388
753
  .action(async (options) => {
389
754
  const { env, base } = program.opts();
390
- const auth = await getAuth(env);
755
+ await deployWithOptions(env, base, options);
756
+ });
391
757
 
392
- await withAuthRetry(env, async (client) => {
393
- const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
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();
394
804
 
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(
400
- client,
401
- base,
402
- HostedConfigSchema.parse(JSON.parse(configData)),
403
- auth.organizationId,
404
- options,
405
- );
406
- console.log(
407
- `Updated ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`,
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
+ }
872
+
873
+ const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
874
+ const auth = await getAuth(env);
875
+ const hasExistingConfig = fs.existsSync(configPath);
876
+
877
+ const result = await withAuthRetry(env, async (client) => {
878
+ if (hasExistingConfig) {
879
+ const config = HostedConfigSchema.parse(
880
+ JSON.parse(fs.readFileSync(configPath, "utf8")),
408
881
  );
409
- } else {
410
- console.log("Creating new server...");
411
- newOrUpdatedConfig = await createNew(
882
+ return await buildAndPushManagedImage({
412
883
  client,
413
- base,
414
- auth.organizationId,
415
- options,
416
- );
417
- console.log(
418
- `Created ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`,
419
- );
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
+ });
420
892
  }
421
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
422
- fs.writeFileSync(configPath, JSON.stringify(newOrUpdatedConfig, null, 2));
893
+
894
+ return await buildAndPushManagedImageForCreate({
895
+ client,
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.`,
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
+ : {}),
423
993
  });
424
994
  });
425
995