@fluid-app/fluid-cli-mist 0.1.1 → 0.1.2

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.mjs CHANGED
@@ -131,28 +131,28 @@ async function resolveMistId(client, slugOrId) {
131
131
  }
132
132
  //#endregion
133
133
  //#region src/api/mists.ts
134
- const BASE = "/api/v202604/mists";
134
+ const BASE$3 = "/api/v202604/mists";
135
135
  function listMists(client, params = {}) {
136
- return client.get(BASE, params);
136
+ return client.get(BASE$3, params);
137
137
  }
138
138
  async function showMist(client, slug) {
139
139
  const id = await resolveMistId(client, slug);
140
- return client.get(`${BASE}/${id}`);
140
+ return client.get(`${BASE$3}/${id}`);
141
141
  }
142
142
  function createMist(client, body) {
143
- return client.post(BASE, body);
143
+ return client.post(BASE$3, body);
144
144
  }
145
145
  async function updateMist(client, slug, body) {
146
146
  const id = await resolveMistId(client, slug);
147
- return client.patch(`${BASE}/${id}`, body);
147
+ return client.patch(`${BASE$3}/${id}`, body);
148
148
  }
149
149
  async function deleteMist(client, slug) {
150
150
  const id = await resolveMistId(client, slug);
151
- return client.delete(`${BASE}/${id}`);
151
+ return client.delete(`${BASE$3}/${id}`);
152
152
  }
153
153
  async function restoreMist(client, slug) {
154
154
  const id = await resolveMistId(client, slug);
155
- return client.post(`${BASE}/${id}/restore`);
155
+ return client.post(`${BASE$3}/${id}/restore`);
156
156
  }
157
157
  //#endregion
158
158
  //#region src/lib/hostable.ts
@@ -176,22 +176,27 @@ var HostableParseError = class extends Error {
176
176
  }
177
177
  };
178
178
  /**
179
- * Parse a `<type>:<id>` shorthand, or accept already-canonical
180
- * `Droplet::Application:42` if the user prefers the Rails-style form.
179
+ * Parse a `--hostable` ref down to a droplet uuid. Hostable is retired
180
+ * (#19684/#19687) and droplets attach by their `drp_…` uuid (the numeric id
181
+ * isn't exposed anywhere the CLI can read), so the ref carries a uuid:
182
+ *
183
+ * droplet:drp_abc123 → "drp_abc123"
184
+ * drp_abc123 → "drp_abc123" (bare uuid, no prefix)
185
+ *
186
+ * A numeric ref (the old contract) is rejected with a pointer to the uuid.
181
187
  */
182
- function parseHostableRef(raw) {
188
+ function parseDropletRef(raw) {
183
189
  const trimmed = raw.trim();
184
- if (!trimmed) throw new HostableParseError(`Empty hostable reference. Use <type>:<id> where <type> is one of ${SHORTHAND_LIST}.`);
190
+ if (!trimmed) throw new HostableParseError("Empty droplet reference. Use `droplet:<uuid>` (e.g. droplet:drp_abc123).");
191
+ if (/^drp_[A-Za-z0-9]+$/.test(trimmed)) return trimmed;
185
192
  const lastColon = trimmed.lastIndexOf(":");
186
- if (lastColon <= 0 || lastColon === trimmed.length - 1) throw new HostableParseError(`Invalid hostable reference "${raw}". Expected <type>:<id>, e.g. droplet:42.`);
193
+ if (lastColon <= 0 || lastColon === trimmed.length - 1) throw new HostableParseError(`Invalid droplet reference "${raw}". Expected droplet:<uuid>, e.g. droplet:drp_abc123.`);
187
194
  const typePart = trimmed.slice(0, lastColon).trim();
188
195
  const idPart = trimmed.slice(lastColon + 1).trim();
189
- const id = Number(idPart);
190
- if (!Number.isInteger(id) || id <= 0) throw new HostableParseError(`Invalid hostable id "${idPart}". Must be a positive integer.`);
191
- return {
192
- hostable_type: canonicalizeHostableType(typePart),
193
- hostable_id: id
194
- };
196
+ if (canonicalizeHostableType(typePart) !== "Droplet::Application") throw new HostableParseError(`Only droplets can be attached now that hostable is retired. Use \`droplet:<uuid>\` (got "${raw}").`);
197
+ if (/^\d+$/.test(idPart)) throw new HostableParseError(`Droplets now attach by uuid, not numeric id. Use \`droplet:<uuid>\` (e.g. droplet:drp_abc123), not "${raw}".`);
198
+ if (!/^drp_[A-Za-z0-9]+$/.test(idPart)) throw new HostableParseError(`Invalid droplet uuid "${idPart}". Expected a drp_… uuid.`);
199
+ return idPart;
195
200
  }
196
201
  /** Map an input string to the canonical Rails class name, throwing on miss. */
197
202
  function canonicalizeHostableType(typePart) {
@@ -290,13 +295,13 @@ function handle(err) {
290
295
  console.error(chalk.yellow(typeof date === "string" ? `This mist is scheduled for destruction on ${date.slice(0, 10)}. Run \`fluid mist restore <slug>\` first.` : err.message));
291
296
  } else if (err.status === 422) {
292
297
  const details = err.details ?? {};
293
- const idErrors = details["hostable_id"];
294
- if (Array.isArray(idErrors) && idErrors.some((e) => /already taken/i.test(String(e)))) console.error(chalk.yellow("That hostable is already attached to another mist. Detach it first or pick a different one."));
295
- else if (typeof err.message === "string" && /hostable_type and hostable_id/.test(err.message)) console.error(chalk.yellow("Pass `--hostable <type>:<id>` (or both `--hostable-type` and `--hostable-id`)."));
296
- else if (typeof err.message === "string" && /Hostable not found/.test(err.message)) {
297
- const t = details["hostable_type"];
298
- const id = details["hostable_id"];
299
- console.error(chalk.yellow(`Couldn't find ${typeof t === "string" ? t : "hostable"}${id != null ? ` #${String(id)}` : ""} in this company.`));
298
+ const idErrors = details["integratable_id"] ?? details["hostable_id"];
299
+ const managedKey = typeof err.message === "string" && /managed_by|managed by mist|FLUID_DROPLET|FLUID_WEBHOOK/i.test(err.message);
300
+ if (Array.isArray(idErrors) && idErrors.some((e) => /already taken|already attached/i.test(String(e)))) console.error(chalk.yellow("That droplet is already attached to another mist. Detach it first or pick a different one."));
301
+ else if (managedKey) console.error(chalk.yellow("That env var is Mist-managed (FLUID_DROPLET_UUID / FLUID_DROPLET_SECRET / FLUID_WEBHOOK_AUTH_TOKEN) — it's set automatically when a droplet is attached and can't be written directly."));
302
+ else if (typeof err.message === "string" && /Droplet not found|integratable/i.test(err.message)) {
303
+ const id = details["integratable_id"] ?? details["hostable_id"];
304
+ console.error(chalk.yellow(`Couldn't find droplet${id != null ? ` #${String(id)}` : ""} in this company.`));
300
305
  } else console.error(chalk.red(err.message));
301
306
  } else if (err.status === 410) console.error(chalk.red("Grace period expired — vendor teardown is in flight. Create a fresh mist with `fluid mist create`."));
302
307
  else console.error(chalk.red(err.message));
@@ -393,110 +398,834 @@ const listCommand = new Command("list").description("List the mists you have acc
393
398
  ])]);
394
399
  }));
395
400
  //#endregion
401
+ //#region src/api/integration_points.ts
402
+ function basePath(mistId) {
403
+ return `/api/v202604/mists/${mistId}/integration_points`;
404
+ }
405
+ async function listIntegrationPoints(client, slug) {
406
+ const id = await resolveMistId(client, slug);
407
+ return client.get(basePath(id));
408
+ }
409
+ async function createIntegrationPoint(client, slug, input) {
410
+ const id = await resolveMistId(client, slug);
411
+ return client.post(basePath(id), { integration_point: input });
412
+ }
413
+ async function deleteIntegrationPoint(client, slug, integrationPointId) {
414
+ const id = await resolveMistId(client, slug);
415
+ return client.delete(`${basePath(id)}/${integrationPointId}`);
416
+ }
417
+ /**
418
+ * First droplet-kind integration point in a list, or undefined. A droplet is
419
+ * 1-per-mist (it provisions the managed env), so this is the idempotency key
420
+ * for the droplet create+attach flow.
421
+ */
422
+ function findDropletIntegrationPoint(integrationPoints) {
423
+ return (integrationPoints ?? []).find((ip) => ip.kind === "droplet");
424
+ }
425
+ /**
426
+ * Find an integration point that already links a specific surface — matched
427
+ * by uuid (droplet/drop-zone) or numeric id (mobile-embed), optionally scoped
428
+ * to a kind. Used to make attach-existing idempotent (avoid the server's 422
429
+ * "already taken"). Uses the `integratable_uuid` echo (#19689) when present.
430
+ */
431
+ function findIntegrationPointBySurface(integrationPoints, target) {
432
+ return (integrationPoints ?? []).find((ip) => {
433
+ if (target.kind && ip.kind !== target.kind) return false;
434
+ if (target.uuid && ip.integratable_uuid === target.uuid) return true;
435
+ if (target.id != null && ip.integratable_id === target.id) return true;
436
+ return false;
437
+ });
438
+ }
439
+ //#endregion
396
440
  //#region src/commands/show.ts
397
- /** `fluid mist show <slug>` — single mist detail. Surfaces
398
- * scheduled_destroy_at when state is pending_destroy. Spec §3. */
441
+ /** `fluid mist show <slug>` — single mist detail, including its attached
442
+ * surfaces (integration points). Surfaces scheduled_destroy_at when state is
443
+ * pending_destroy. */
444
+ /** Display ref for an attached surface: the uuid echo when present, else id. */
445
+ function surfaceRef(ip) {
446
+ if (ip.integratable_uuid) return ip.integratable_uuid;
447
+ if (ip.integratable_id != null) return `#${ip.integratable_id}`;
448
+ return "(unknown)";
449
+ }
399
450
  const showCommand = new Command("show").description("Show one mist by slug").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
400
451
  const resolved = resolveSlug(slug);
401
- const { mist } = await showMist(createMistClient(), resolved);
402
- if (opts.json) return printJson({ mist });
452
+ const client = createMistClient();
453
+ const { mist } = await showMist(client, resolved);
454
+ const integrationPoints = (await listIntegrationPoints(client, resolved)).integration_points;
455
+ if (opts.json) return printJson({
456
+ mist,
457
+ integration_points: integrationPoints
458
+ });
403
459
  const lines = [];
404
- const label = mist.name ?? mist.droplet?.name ?? mist.vendor_name;
460
+ const label = mist.name ?? mist.vendor_name;
405
461
  lines.push(`${chalk.bold(label)} ${chalk.dim(mist.slug)}`);
406
- if (mist.name && mist.name !== mist.vendor_name) lines.push(` vendor ${chalk.dim(mist.vendor_name)}`);
462
+ if (mist.vendor_name && mist.name && mist.name !== mist.vendor_name) lines.push(` vendor ${chalk.dim(mist.vendor_name)}`);
407
463
  lines.push(` state ${colorState(mist.state)}`);
408
464
  if (mist.kind) lines.push(` kind ${mist.kind}`);
409
465
  if (mist.public_url) lines.push(` url ${chalk.cyan(mist.public_url)}`);
410
- if (mist.hostable_type) lines.push(` attached ${mist.hostable_type}`);
411
- if (mist.droplet) lines.push(` droplet ${mist.droplet.name} (#${mist.droplet.id})`);
466
+ if (integrationPoints.length > 0) {
467
+ lines.push(` surfaces ${integrationPoints.length}`);
468
+ for (const ip of integrationPoints) lines.push(` ${chalk.dim("·")} ${ip.kind} ${surfaceRef(ip)} ${chalk.dim(`integration ${ip.id}`)}`);
469
+ }
412
470
  if (mist.provisioned_at) lines.push(` live ${relativeDate(mist.provisioned_at)}`);
413
471
  lines.push(` updated ${relativeDate(mist.updated_at)}`);
414
472
  if (mist.scheduled_destroy_at) lines.push(chalk.magenta(` destroy scheduled ${formatAbsolute(mist.scheduled_destroy_at)} (run \`fluid mist restore ${mist.slug}\` to undo)`));
415
473
  console.log(lines.join("\n"));
416
474
  }));
417
475
  //#endregion
476
+ //#region src/lib/ora-reporter.ts
477
+ /**
478
+ * Spinner-backed `StepReporter` for interactive human runs. Split from
479
+ * `step-reporter.ts` so tests can use the recording reporter without pulling
480
+ * in `ora`'s ESM-only dependency chain.
481
+ */
482
+ function createOraReporter() {
483
+ const spinner = ora();
484
+ return {
485
+ start(text) {
486
+ spinner.start(text);
487
+ },
488
+ update(text) {
489
+ spinner.text = text;
490
+ },
491
+ succeed(text) {
492
+ spinner.succeed(text);
493
+ },
494
+ skip(text) {
495
+ spinner.info(text);
496
+ },
497
+ info(text) {
498
+ spinner.stopAndPersist({
499
+ symbol: "•",
500
+ text
501
+ });
502
+ },
503
+ warn(text) {
504
+ spinner.warn(text);
505
+ },
506
+ fail(text) {
507
+ spinner.fail(text);
508
+ }
509
+ };
510
+ }
511
+ //#endregion
512
+ //#region src/lib/step-reporter.ts
513
+ /** No-op reporter for `--json` mode (final payload printed by the command). */
514
+ function createSilentReporter() {
515
+ return {
516
+ start() {},
517
+ update() {},
518
+ succeed() {},
519
+ skip() {},
520
+ info() {},
521
+ warn() {},
522
+ fail() {}
523
+ };
524
+ }
525
+ //#endregion
526
+ //#region src/api/droplets.ts
527
+ const BASE$2 = "/api/droplets";
528
+ /**
529
+ * Full scope set from `Droplet::Installation::VALID_SCOPES`. We request
530
+ * them all by default so a Mist-created droplet's installations carry write
531
+ * access out of the box — matching what Mist Desktop sends. Callers can
532
+ * narrow this via the flow's `requestedScopes` option.
533
+ */
534
+ const DEFAULT_DROPLET_SCOPES = [
535
+ "main",
536
+ "prospects",
537
+ "members",
538
+ "web",
539
+ "settings",
540
+ "products",
541
+ "orders",
542
+ "users",
543
+ "website",
544
+ "header_menus",
545
+ "payments"
546
+ ];
547
+ /** Lifecycle webhook paths a Mist Next app serves (mirrors Mist Desktop). */
548
+ function dropletWebhookUrls(embedUrl) {
549
+ const base = embedUrl.replace(/\/+$/, "");
550
+ return {
551
+ install_webhook_url: `${base}/api/webhooks/installed`,
552
+ uninstall_webhook_url: `${base}/api/webhooks/uninstalled`
553
+ };
554
+ }
555
+ /**
556
+ * Build a droplet create body from a name + (optional) embed URL. When the
557
+ * embed URL is known we also derive the lifecycle webhook URLs from it.
558
+ */
559
+ function buildDropletCreateInput(opts) {
560
+ const scopes = [...opts.requestedScopes ?? DEFAULT_DROPLET_SCOPES];
561
+ const summary = "Created by Fluid Mist";
562
+ const input = {
563
+ name: opts.name,
564
+ requested_scopes: scopes,
565
+ settings: {
566
+ marketplace_page: { summary },
567
+ details_page: { summary }
568
+ }
569
+ };
570
+ if (opts.embedUrl) {
571
+ input.embed_url = opts.embedUrl;
572
+ Object.assign(input, dropletWebhookUrls(opts.embedUrl));
573
+ }
574
+ return input;
575
+ }
576
+ function createDroplet(client, body) {
577
+ return client.post(BASE$2, body);
578
+ }
579
+ /** PATCH the droplet's embed URL (+ derived webhook URLs) once known. */
580
+ function updateDroplet(client, uuid, input) {
581
+ return client.patch(`${BASE$2}/${encodeURIComponent(uuid)}`, { droplet: input });
582
+ }
583
+ /** Convenience: set embed URL + derived webhook URLs in one PATCH. */
584
+ function setDropletEmbedUrl(client, uuid, embedUrl) {
585
+ return updateDroplet(client, uuid, {
586
+ embed_url: embedUrl,
587
+ ...dropletWebhookUrls(embedUrl)
588
+ });
589
+ }
590
+ //#endregion
591
+ //#region src/api/drop_zones.ts
592
+ const BASE$1 = "/api/drop_zones";
593
+ /**
594
+ * Build a drop-zone create body. The slot id is `page__zone` (same split the
595
+ * storefront preview uses, e.g. `checkout__below_total`) → `settings.page` +
596
+ * `settings.zone`; height (px) → `settings.height`. Without a slot the zone
597
+ * never renders, so the backend needs it to know WHERE to inject.
598
+ */
599
+ function buildDropZoneCreateInput(opts) {
600
+ const [page, zone] = (opts.zone ?? "").split("__");
601
+ const settings = {};
602
+ if (page) settings.page = page;
603
+ if (zone) settings.zone = zone;
604
+ if (opts.heightPx != null) settings.height = opts.heightPx;
605
+ const input = { name: opts.name };
606
+ if (opts.embedUrl) input.embed_url = opts.embedUrl;
607
+ if (Object.keys(settings).length > 0) input.settings = settings;
608
+ return input;
609
+ }
610
+ function createDropZone(client, body) {
611
+ return client.post(BASE$1, body);
612
+ }
613
+ //#endregion
614
+ //#region src/api/mobile_widgets.ts
615
+ const BASE = "/api/company/mobile_widgets";
616
+ /**
617
+ * Build a mobile-widget create body. `cover_image_url` and `height` are
618
+ * REQUIRED by the API (422 without them); we fall back to the Fluid
619
+ * placeholder + default height when the caller doesn't supply them.
620
+ */
621
+ function buildMobileWidgetCreateInput(opts) {
622
+ return {
623
+ name: opts.name,
624
+ embed_url: opts.embedUrl,
625
+ cover_image_url: opts.coverImageUrl || "https://ik.imagekit.io/fluid/s3/app/image-placeholder.svg",
626
+ height: opts.heightPx ?? 600,
627
+ scrollable: true
628
+ };
629
+ }
630
+ function createMobileWidget(client, body) {
631
+ return client.post(BASE, body);
632
+ }
633
+ //#endregion
634
+ //#region src/api/env_vars.ts
635
+ function path$1(id, key) {
636
+ return key ? `/api/v202604/mists/${id}/env_vars/${encodeURIComponent(key)}` : `/api/v202604/mists/${id}/env_vars`;
637
+ }
638
+ async function listEnvVars(client, slug) {
639
+ const id = await resolveMistId(client, slug);
640
+ return client.get(path$1(id));
641
+ }
642
+ /** Create a new env var (the spec routes `set` to POST for new keys). */
643
+ async function createEnvVar(client, slug, key, value, targets) {
644
+ const id = await resolveMistId(client, slug);
645
+ return client.post(path$1(id), { env_var: {
646
+ key,
647
+ value,
648
+ targets
649
+ } });
650
+ }
651
+ /** Update an existing env var (the spec routes `set` to PATCH for existing keys). */
652
+ async function updateEnvVar(client, slug, key, value, targets) {
653
+ const id = await resolveMistId(client, slug);
654
+ return client.patch(path$1(id, key), { env_var: {
655
+ value,
656
+ targets
657
+ } });
658
+ }
659
+ async function deleteEnvVar(client, slug, key) {
660
+ const id = await resolveMistId(client, slug);
661
+ return client.delete(path$1(id, key));
662
+ }
663
+ //#endregion
664
+ //#region src/types.ts
665
+ /**
666
+ * Managed env keys the backend writes onto a Mist once a droplet is
667
+ * attached (backfill + redeploy). The CLI VERIFIES these appear with
668
+ * `managed_by: "mist"` — it never sets them (the API 422s on write).
669
+ *
670
+ * Source: settled backend contract (#19685 auto-provisions the droplet
671
+ * env on integration-point create).
672
+ */
673
+ const MANAGED_DROPLET_ENV_KEYS = [
674
+ "FLUID_DROPLET_UUID",
675
+ "FLUID_DROPLET_SECRET",
676
+ "FLUID_WEBHOOK_AUTH_TOKEN"
677
+ ];
678
+ //#endregion
679
+ //#region src/lib/surface-provision.ts
680
+ /**
681
+ * Surface provisioning engine — the one place the "create a Fluid surface and
682
+ * wire it to a mist" flow lives, at parity with Mist Desktop's
683
+ * mist-integration-points-api.service.ts. Exposed via `fluid mist create`
684
+ * (create a bare mist first, then run this), `fluid mist attach` (any kind),
685
+ * and `fluid mist attach-droplet` (droplet shortcut).
686
+ *
687
+ * Three surface kinds, each with its own create endpoint + attach identifier:
688
+ * - droplet → POST /api/droplets → drp_ uuid → integratable_uuid
689
+ * - drop-zone → POST /api/drop_zones → dz_ uuid → integratable_uuid
690
+ * - mobile-embed→ POST /api/company/mobile_widgets→ numeric id → integratable_id
691
+ *
692
+ * Design tenets (repo philosophy — "server is source of truth"):
693
+ * - No local progress file. State is re-derived from the API each run.
694
+ * - Attach-existing is idempotent: if an integration point already links the
695
+ * target surface (matched via the #19689 integratable_uuid echo, or the
696
+ * numeric id for mobile), skip. A droplet is 1-per-mist, so creating a new
697
+ * droplet also skips when one is already attached. Drop-zones/mobile
698
+ * embeds are additive (a mist can have many).
699
+ * - Only a droplet attach provisions managed env keys
700
+ * (FLUID_DROPLET_UUID/_SECRET/_WEBHOOK_AUTH_TOKEN); this flow only
701
+ * VERIFIES they landed — it never writes them (the API 422s on write).
702
+ */
703
+ const DEFAULT_TIMING = {
704
+ publicUrlTimeoutMs: 9e4,
705
+ publicUrlIntervalMs: 3e3,
706
+ envTimeoutMs: 12e4,
707
+ envIntervalMs: 5e3
708
+ };
709
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
710
+ const KIND_LABEL$1 = {
711
+ droplet: "droplet",
712
+ "drop-zone": "drop zone",
713
+ "mobile-embed": "mobile embed"
714
+ };
715
+ /**
716
+ * Create (or reuse) a surface of `kind` and wire it to the mist. Idempotent
717
+ * for droplet + attach-existing; additive for new drop-zones/mobile embeds.
718
+ */
719
+ async function provisionSurface(client, options) {
720
+ const { reporter, slugOrId, kind } = options;
721
+ const timing = {
722
+ ...DEFAULT_TIMING,
723
+ ...options.timing
724
+ };
725
+ const sleep = options.sleep ?? defaultSleep;
726
+ const label = KIND_LABEL$1[kind];
727
+ reporter.start("Resolving mist…");
728
+ const mist = (await showMist(client, slugOrId)).mist;
729
+ reporter.succeed(`Mist ${mist.slug} (${mist.state})`);
730
+ reporter.start("Checking existing integrations…");
731
+ const points = (await listIntegrationPoints(client, slugOrId)).integration_points;
732
+ let surfaceUuid = null;
733
+ let surfaceNumericId = null;
734
+ let surfaceName = null;
735
+ let surfaceCreated = false;
736
+ let embedUrlSet = false;
737
+ let integrationPoint;
738
+ const attachingExisting = options.existingUuid != null || options.existingId != null;
739
+ const alreadyLinked = attachingExisting ? findIntegrationPointBySurface(points, {
740
+ kind,
741
+ uuid: options.existingUuid,
742
+ id: options.existingId
743
+ }) : kind === "droplet" ? findDropletIntegrationPoint(points) : void 0;
744
+ if (alreadyLinked) {
745
+ reporter.skip(`${label} already attached (integration #${alreadyLinked.id}) — skipping`);
746
+ surfaceUuid = alreadyLinked.integratable_uuid ?? options.existingUuid ?? null;
747
+ surfaceNumericId = alreadyLinked.integratable_id ?? options.existingId ?? null;
748
+ integrationPoint = {
749
+ id: alreadyLinked.id,
750
+ integratableUuid: alreadyLinked.integratable_uuid ?? null,
751
+ created: false
752
+ };
753
+ } else {
754
+ reporter.succeed("No matching integration yet");
755
+ let attachUuid = options.existingUuid;
756
+ let attachId = options.existingId;
757
+ if (!attachingExisting) {
758
+ const embedUrl = await resolvePublicUrl(client, {
759
+ slugOrId,
760
+ initial: mist.public_url,
761
+ reporter,
762
+ timing,
763
+ sleep
764
+ });
765
+ const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name;
766
+ reporter.start(`Creating ${label} "${name}"…`);
767
+ if (kind === "droplet") {
768
+ const created = await createDroplet(client, { droplet: buildDropletCreateInput({
769
+ name,
770
+ embedUrl,
771
+ requestedScopes: options.requestedScopes
772
+ }) });
773
+ surfaceUuid = created.droplet.uuid;
774
+ surfaceNumericId = created.droplet.id ?? null;
775
+ surfaceName = created.droplet.name;
776
+ attachUuid = created.droplet.uuid;
777
+ embedUrlSet = Boolean(embedUrl);
778
+ reporter.succeed(`Created droplet ${surfaceUuid}${embedUrl ? "" : " (embed URL pending)"}`);
779
+ if (!embedUrl && surfaceUuid) {
780
+ const laterUrl = await resolvePublicUrl(client, {
781
+ slugOrId,
782
+ initial: null,
783
+ reporter,
784
+ timing,
785
+ sleep
786
+ });
787
+ if (laterUrl) {
788
+ reporter.start("Backfilling droplet embed URL…");
789
+ await setDropletEmbedUrl(client, surfaceUuid, laterUrl);
790
+ embedUrlSet = true;
791
+ reporter.succeed("Embed URL set");
792
+ } else reporter.warn("Mist public_url not ready yet — re-run once the first deploy finishes to set the embed URL");
793
+ }
794
+ } else if (kind === "drop-zone") {
795
+ if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the drop zone.");
796
+ const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
797
+ name,
798
+ embedUrl,
799
+ zone: options.zone,
800
+ heightPx: options.heightPx
801
+ }) });
802
+ surfaceUuid = created.drop_zone.uuid;
803
+ surfaceName = created.drop_zone.name;
804
+ attachUuid = created.drop_zone.uuid;
805
+ embedUrlSet = true;
806
+ reporter.succeed(`Created drop zone ${surfaceUuid}`);
807
+ } else {
808
+ if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the mobile embed.");
809
+ const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
810
+ name,
811
+ embedUrl,
812
+ coverImageUrl: options.coverImageUrl,
813
+ heightPx: options.heightPx
814
+ }) });
815
+ surfaceNumericId = created.mobile_widget.id;
816
+ surfaceName = created.mobile_widget.name;
817
+ attachId = created.mobile_widget.id;
818
+ embedUrlSet = true;
819
+ reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
820
+ }
821
+ surfaceCreated = true;
822
+ } else {
823
+ surfaceUuid = options.existingUuid ?? null;
824
+ surfaceNumericId = options.existingId ?? null;
825
+ embedUrlSet = true;
826
+ }
827
+ const attachInput = kind === "mobile-embed" ? {
828
+ kind,
829
+ integratable_id: attachId
830
+ } : {
831
+ kind,
832
+ integratable_uuid: attachUuid
833
+ };
834
+ reporter.start(`Attaching ${label}…`);
835
+ let ipResp;
836
+ try {
837
+ ipResp = await createIntegrationPoint(client, slugOrId, attachInput);
838
+ } catch (err) {
839
+ reporter.fail("Attach failed");
840
+ throw normalizeAttachError(err, attachUuid ?? String(attachId), kind);
841
+ }
842
+ integrationPoint = {
843
+ id: ipResp.integration_point.id,
844
+ integratableUuid: ipResp.integration_point.integratable_uuid ?? null,
845
+ created: true
846
+ };
847
+ if (!surfaceUuid) surfaceUuid = ipResp.integration_point.integratable_uuid ?? attachUuid ?? null;
848
+ if (surfaceNumericId == null) surfaceNumericId = ipResp.integration_point.integratable_id ?? null;
849
+ reporter.succeed(kind === "droplet" ? "Droplet attached — provisioning env + redeploy…" : `${label} attached`);
850
+ }
851
+ const managedEnv = kind === "droplet" ? await verifyManagedEnv(client, {
852
+ slugOrId,
853
+ reporter,
854
+ timing,
855
+ sleep,
856
+ enabled: options.verifyEnv !== false
857
+ }) : null;
858
+ return {
859
+ mist: {
860
+ id: mist.id,
861
+ slug: mist.slug,
862
+ name: mist.name,
863
+ public_url: mist.public_url,
864
+ state: mist.state
865
+ },
866
+ kind,
867
+ surface: {
868
+ uuid: surfaceUuid,
869
+ id: surfaceNumericId,
870
+ name: surfaceName
871
+ },
872
+ integrationPoint: integrationPoint ?? {
873
+ id: "",
874
+ integratableUuid: null,
875
+ created: false
876
+ },
877
+ surfaceCreated,
878
+ embedUrlSet,
879
+ managedEnv
880
+ };
881
+ }
882
+ /**
883
+ * If the integration-points endpoint rejects the attach identifier (e.g. an
884
+ * older API that predates #19688's attach-by-uuid support), surface an
885
+ * actionable message noting the surface WAS created. Other errors pass through.
886
+ */
887
+ function normalizeAttachError(err, surfaceRef, kind) {
888
+ if (err instanceof MistApiError && (err.status === 400 || err.status === 422)) {
889
+ const blob = `${err.message} ${JSON.stringify(err.details ?? {})}`;
890
+ if (/integratable_uuid|integratable_id|unpermitted|unknown|param/i.test(blob)) return new MistApiError(`The ${KIND_LABEL$1[kind]} ${surfaceRef} was created, but this API rejected the attach (needs the #19688 integration-points endpoint). Re-run once the API is updated.`, err.status, err.details);
891
+ }
892
+ return err instanceof Error ? err : new Error(String(err));
893
+ }
894
+ /**
895
+ * Return the mist's public_url, polling briefly if it isn't ready yet
896
+ * (async-first-deploy). Returns null if it never appears within the window.
897
+ */
898
+ async function resolvePublicUrl(client, args) {
899
+ if (args.initial) return args.initial;
900
+ args.reporter.start("Waiting for the mist's public URL…");
901
+ const deadline = Date.now() + args.timing.publicUrlTimeoutMs;
902
+ for (;;) {
903
+ const url = (await showMist(client, args.slugOrId)).mist.public_url;
904
+ if (url) {
905
+ args.reporter.succeed(`Public URL ready: ${url}`);
906
+ return url;
907
+ }
908
+ if (Date.now() >= deadline) break;
909
+ await args.sleep(args.timing.publicUrlIntervalMs);
910
+ }
911
+ return null;
912
+ }
913
+ /**
914
+ * Poll `GET …/env_vars` until the Mist-managed droplet keys appear with
915
+ * `managed_by: "mist"`. Never fails the flow — a redeploy in progress is a
916
+ * normal, transient state we simply report.
917
+ */
918
+ async function verifyManagedEnv(client, args) {
919
+ const required = [...MANAGED_DROPLET_ENV_KEYS];
920
+ if (!args.enabled) return {
921
+ ready: false,
922
+ present: [],
923
+ missing: required
924
+ };
925
+ args.reporter.start("Verifying managed environment…");
926
+ const deadline = Date.now() + args.timing.envTimeoutMs;
927
+ let present = [];
928
+ for (;;) {
929
+ const envVars = (await listEnvVars(client, args.slugOrId)).env_vars ?? [];
930
+ const managed = new Set(envVars.filter((e) => e.managed_by === "mist").map((e) => e.key));
931
+ present = required.filter((k) => managed.has(k));
932
+ if (present.length === required.length) {
933
+ args.reporter.succeed("Managed env keys present — droplet is wired up");
934
+ return {
935
+ ready: true,
936
+ present,
937
+ missing: []
938
+ };
939
+ }
940
+ if (Date.now() >= deadline) break;
941
+ args.reporter.update(`Verifying managed environment… (${present.length}/${required.length} keys, redeploy in progress)`);
942
+ await args.sleep(args.timing.envIntervalMs);
943
+ }
944
+ const missing = required.filter((k) => !present.includes(k));
945
+ args.reporter.warn(`Redeploy in progress — managed keys not visible yet (${present.length}/${required.length}). Check later with \`fluid mist env list\`. Missing: ${missing.join(", ")}`);
946
+ return {
947
+ ready: false,
948
+ present,
949
+ missing
950
+ };
951
+ }
952
+ //#endregion
953
+ //#region src/lib/surface-flags.ts
954
+ const KIND_ALIASES = {
955
+ droplet: "droplet",
956
+ droplets: "droplet",
957
+ "drop-zone": "drop-zone",
958
+ drop_zone: "drop-zone",
959
+ dropzone: "drop-zone",
960
+ zone: "drop-zone",
961
+ "mobile-embed": "mobile-embed",
962
+ mobile_embed: "mobile-embed",
963
+ mobile: "mobile-embed",
964
+ "mobile-widget": "mobile-embed",
965
+ mobilewidget: "mobile-embed"
966
+ };
967
+ const SURFACE_KIND_LIST = "droplet, drop-zone, mobile-embed";
968
+ function parseSurfaceKind(raw) {
969
+ const hit = KIND_ALIASES[raw.trim().toLowerCase()];
970
+ if (!hit) throw new Error(`Unknown surface kind "${raw}". Use one of: ${SURFACE_KIND_LIST}.`);
971
+ return hit;
972
+ }
973
+ function parseHeight(raw) {
974
+ if (raw == null) return void 0;
975
+ const n = Number(raw);
976
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid --height "${raw}". Must be a positive integer (px).`);
977
+ return n;
978
+ }
979
+ /**
980
+ * Map CLI flags to the kind-specific `provisionSurface` options, enforcing the
981
+ * per-kind requirements the API needs:
982
+ * - drop-zone requires `--zone <page__zone>` (else it never renders).
983
+ * - mobile-embed requires `--cover-image <url>` (the API 422s without one; the
984
+ * CLI has no DAM picker, so the user must supply a url).
985
+ */
986
+ function surfaceProvisionOptionsFromFlags(kind, flags) {
987
+ const heightPx = parseHeight(flags.height);
988
+ if (kind === "drop-zone") {
989
+ if (!flags.zone?.trim()) throw new Error("A drop-zone needs a slot — pass `--zone <page__zone>` (e.g. --zone checkout__below_total).");
990
+ return {
991
+ zone: flags.zone.trim(),
992
+ heightPx
993
+ };
994
+ }
995
+ if (kind === "mobile-embed") {
996
+ if (!flags.coverImage?.trim()) throw new Error("A mobile-embed needs a cover image — pass `--cover-image <url>` (the mobile API requires one; the CLI has no image picker).");
997
+ return {
998
+ coverImageUrl: flags.coverImage.trim(),
999
+ heightPx
1000
+ };
1001
+ }
1002
+ return {};
1003
+ }
1004
+ //#endregion
1005
+ //#region src/lib/surface-summary.ts
1006
+ /** Shared human-readable summary for a surface provision result. */
1007
+ const KIND_LABEL = {
1008
+ droplet: "droplet",
1009
+ "drop-zone": "drop zone",
1010
+ "mobile-embed": "mobile embed"
1011
+ };
1012
+ function printSurfaceSummary(result) {
1013
+ const ref = result.surface.uuid ?? (result.surface.id != null ? `#${result.surface.id}` : "(unknown)");
1014
+ const state = result.surfaceCreated ? "created" : "existing";
1015
+ console.log(chalk.dim(` ${KIND_LABEL[result.kind]}: ${ref} (${state}) · integration ${result.integrationPoint.id}`));
1016
+ if (result.kind === "droplet" && result.managedEnv) if (result.managedEnv.ready) console.log(chalk.dim(" env: managed keys provisioned ✓"));
1017
+ else console.log(chalk.yellow(" env: redeploy in progress — run `fluid mist env list` shortly to confirm managed keys"));
1018
+ }
1019
+ //#endregion
418
1020
  //#region src/commands/create.ts
419
1021
  /**
420
- * `fluid mist create` — POST /mists. Synchronous; 30–60s.
1022
+ * `fluid mist create` — provision a new mist, optionally creating and attaching
1023
+ * a surface in one step. All attach paths go through integration points
1024
+ * (hostable is retired — #19684/#19687).
421
1025
  *
422
- * Updated for `fluid` PR #18678: the API no longer creates Droplets inline.
423
- * Pass an existing hostable via `--hostable <type>:<id>` (shorthand) or the
424
- * pair `--hostable-type` / `--hostable-id`. Optional `--name <label>` sets
425
- * the display label; if blank, the API falls back to the hostable's name.
1026
+ * --droplet [name] Create a NEW droplet and attach it. Bare →
1027
+ * droplet name defaults to the mist name. The
1028
+ * "one command that sets everything up" path.
1029
+ * --kind <kind> [flags] Create + attach a surface of any kind:
1030
+ * droplet | drop-zone | mobile-embed. Kind flags:
1031
+ * drop-zone --zone <page__zone> --height <px>
1032
+ * mobile-embed --cover-image <url> --height <px>
1033
+ * --hostable droplet:<uuid> Attach an EXISTING droplet by its drp_… uuid.
1034
+ *
1035
+ * With none of these, creates a plain standalone mist.
426
1036
  */
427
- const createCommand = new Command("create").description(`Provision a new mist (optionally attached to an existing hostable: ${SHORTHAND_LIST})`).option("--hostable <ref>", "Attach to an existing hostable. Shorthand: <type>:<id> (e.g. droplet:42)").option("--hostable-type <type>", `Hostable type when not using shorthand (${SHORTHAND_LIST})`).option("--hostable-id <id>", "Hostable id when not using shorthand").option("--name <label>", "Optional display label (defaults to hostable name)").option("--kind <kind>", "Mist kind (default: next_app)").option("--json", "Emit raw API JSON").action((opts) => runAction(async () => {
428
- const mist = {};
429
- if (opts.kind) mist.kind = opts.kind;
430
- if (opts.name?.trim()) mist.name = opts.name.trim();
431
- const hasShorthand = !!opts.hostable;
432
- const hasPair = !!opts.hostableType || !!opts.hostableId;
433
- if (hasShorthand && hasPair) throw new Error("Use either `--hostable <type>:<id>` OR `--hostable-type` + `--hostable-id`, not both.");
434
- if (hasShorthand) {
435
- const pair = parseHostableRef(opts.hostable);
436
- mist.hostable_type = pair.hostable_type;
437
- mist.hostable_id = pair.hostable_id;
438
- } else if (hasPair) {
439
- if (!opts.hostableType || !opts.hostableId) throw new Error("`--hostable-type` and `--hostable-id` must be passed together.");
440
- const id = Number(opts.hostableId);
441
- if (!Number.isInteger(id) || id <= 0) throw new Error(`Invalid --hostable-id "${opts.hostableId}". Must be a positive integer.`);
442
- mist.hostable_type = canonicalizeHostableType(opts.hostableType);
443
- mist.hostable_id = id;
1037
+ const createCommand = new Command("create").description("Provision a new mist (optionally create + attach a surface in one step)").option("--droplet [name]", "Create a droplet and attach it. Bare uses the mist name; pass a value to override.").option("--kind <kind>", "Create + attach a surface: droplet | drop-zone | mobile-embed").option("--hostable <ref>", "Attach an existing droplet by uuid: droplet:<uuid> (e.g. droplet:drp_abc123)").option("--name <label>", "Display label for the mist (and the surface)").option("--zone <slot>", "drop-zone slot id: page__zone (e.g. checkout__below_total)").option("--height <px>", "Embed height in px (drop-zone + mobile-embed)").option("--cover-image <url>", "Mobile-embed cover image url (required for mobile-embed)").option("--mist-kind <kind>", "Mist kind (default: next_app)").option("--no-wait", "Don't wait for managed env keys to appear after attach").option("--json", "Emit raw JSON").action((opts) => runAction(async () => {
1038
+ const wantDroplet = opts.droplet !== void 0;
1039
+ const wantKind = !!opts.kind;
1040
+ const wantHostable = !!opts.hostable;
1041
+ if ([
1042
+ wantDroplet,
1043
+ wantKind,
1044
+ wantHostable
1045
+ ].filter(Boolean).length > 1) throw new Error("Pass only one of `--droplet`, `--kind`, or `--hostable` — they're mutually exclusive.");
1046
+ let kind;
1047
+ let existingDropletUuid;
1048
+ let nameOverride;
1049
+ if (wantDroplet) {
1050
+ kind = "droplet";
1051
+ nameOverride = typeof opts.droplet === "string" ? opts.droplet.trim() : void 0;
1052
+ } else if (wantKind) kind = parseSurfaceKind(opts.kind);
1053
+ else if (wantHostable) {
1054
+ kind = "droplet";
1055
+ existingDropletUuid = parseDropletRef(opts.hostable);
444
1056
  }
1057
+ const mistBody = { kind: opts.mistKind ?? "next_app" };
1058
+ if (opts.name?.trim()) mistBody.name = opts.name.trim();
445
1059
  const client = createMistClient();
446
- const spinner = ora("Setting up your Mist Cloud environment…").start();
1060
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
1061
+ reporter.start("Setting up your Mist Cloud environment…");
1062
+ let created;
447
1063
  try {
448
- const response = await createMist(client, { mist });
449
- spinner.succeed("Provisioned");
450
- if (opts.json) return printJson(response);
451
- const m = response.mist;
452
- console.log();
453
- const label = m.name ?? m.droplet?.name ?? m.vendor_name;
454
- console.log(`${chalk.bold(label)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
455
- if (m.public_url) console.log(` ${chalk.cyan(m.public_url)}`);
456
- if (m.hostable_type && m.hostable_id != null) console.log(chalk.dim(` attached: ${m.hostable_type} #${m.hostable_id}`));
457
- console.log();
458
- console.log(chalk.dim(`Clone with: fluid mist clone ${m.slug}`));
1064
+ created = await createMist(client, { mist: mistBody });
459
1065
  } catch (err) {
460
- spinner.fail("Provisioning failed");
1066
+ reporter.fail("Provisioning failed");
461
1067
  throw err;
462
1068
  }
1069
+ const mist = created.mist;
1070
+ reporter.succeed(`Provisioned ${mist.slug}`);
1071
+ let provision;
1072
+ if (kind) provision = await provisionSurface(client, {
1073
+ slugOrId: mist.slug,
1074
+ kind,
1075
+ name: nameOverride ?? opts.name?.trim(),
1076
+ existingUuid: existingDropletUuid,
1077
+ verifyEnv: opts.wait !== false,
1078
+ reporter,
1079
+ ...surfaceProvisionOptionsFromFlags(kind, opts)
1080
+ });
1081
+ if (opts.json) return printJson(provision ? {
1082
+ mist,
1083
+ provision
1084
+ } : created);
1085
+ const label = mist.name ?? mist.vendor_name;
1086
+ console.log();
1087
+ console.log(`${chalk.bold(label)} ${chalk.dim(mist.slug)} ${colorState(mist.state)}`);
1088
+ if (mist.public_url) console.log(` ${chalk.cyan(mist.public_url)}`);
1089
+ if (provision) printSurfaceSummary(provision);
1090
+ console.log();
1091
+ console.log(chalk.dim(`Clone with: fluid mist clone ${mist.slug}`));
1092
+ }));
1093
+ //#endregion
1094
+ //#region src/commands/attach.ts
1095
+ /**
1096
+ * `fluid mist attach --kind <kind> [flags] [slug]` — create a surface and wire
1097
+ * it to an existing mist. One command for all three kinds:
1098
+ *
1099
+ * --kind droplet (managed-env-provisioning droplet)
1100
+ * --kind drop-zone --zone page__zone [--height <px>]
1101
+ * --kind mobile-embed --cover-image <url> [--height <px>]
1102
+ *
1103
+ * Attach an EXISTING surface instead of creating one with `--existing <ref>`:
1104
+ * droplet/drop-zone → droplet:drp_… / drop-zone:dz_… / bare uuid
1105
+ * mobile-embed → mobile-embed:<numericId>
1106
+ *
1107
+ * Idempotent for droplet + attach-existing (skips when already linked).
1108
+ */
1109
+ /** Parse `--existing <ref>` into a uuid or numeric id for the given kind. */
1110
+ function parseExisting(kind, raw) {
1111
+ const trimmed = (raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw).trim();
1112
+ if (!trimmed) throw new Error(`Empty --existing reference "${raw}".`);
1113
+ if (kind === "mobile-embed") {
1114
+ const id = Number(trimmed);
1115
+ if (!Number.isInteger(id) || id <= 0) throw new Error(`mobile-embed attaches by numeric id — pass \`--existing <id>\` (got "${raw}").`);
1116
+ return { existingId: id };
1117
+ }
1118
+ return { existingUuid: trimmed };
1119
+ }
1120
+ const attachCommand = new Command("attach").description(`Create a surface (${SURFACE_KIND_LIST}) and wire it to an existing mist`).argument("[slug]", "Mist slug (defaults to the .mist file in CWD)").requiredOption("--kind <kind>", `Surface kind: ${SURFACE_KIND_LIST}`).option("--name <label>", "Surface name (defaults to the mist's name)").option("--zone <slot>", "drop-zone slot id: page__zone (e.g. checkout__below_total)").option("--height <px>", "Embed height in px (drop-zone + mobile-embed)").option("--cover-image <url>", "Mobile-embed cover image url (required for mobile-embed)").option("--existing <ref>", "Attach an existing surface instead of creating one (uuid, or numeric id for mobile-embed)").option("--no-wait", "Don't wait for managed env keys to appear after attach").option("--json", "Emit raw JSON").action((slug, opts) => runAction(async () => {
1121
+ const kind = parseSurfaceKind(opts.kind);
1122
+ const resolved = resolveSlug(slug);
1123
+ const client = createMistClient();
1124
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
1125
+ const existing = opts.existing ? parseExisting(kind, opts.existing) : void 0;
1126
+ const result = await provisionSurface(client, {
1127
+ slugOrId: resolved,
1128
+ kind,
1129
+ name: opts.name?.trim(),
1130
+ verifyEnv: opts.wait !== false,
1131
+ reporter,
1132
+ ...existing,
1133
+ ...existing ? {} : surfaceProvisionOptionsFromFlags(kind, opts)
1134
+ });
1135
+ if (opts.json) return printJson(result);
1136
+ console.log();
1137
+ console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1138
+ printSurfaceSummary(result);
1139
+ }));
1140
+ //#endregion
1141
+ //#region src/commands/attach-droplet.ts
1142
+ /**
1143
+ * `fluid mist attach-droplet [name] [slug]` — droplet shortcut for
1144
+ * `fluid mist attach --kind droplet`. Creates a droplet and wires it to an
1145
+ * existing standalone mist (provisioning the managed env). Idempotent: if a
1146
+ * droplet is already attached, it skips create + attach and just verifies env.
1147
+ *
1148
+ * name Optional droplet name (defaults to the mist's name).
1149
+ * slug Optional mist slug (defaults to the `.mist` file in CWD).
1150
+ */
1151
+ const attachDropletCommand = new Command("attach-droplet").description("Create a droplet and wire it to an existing standalone mist (idempotent)").argument("[name]", "Droplet name (defaults to the mist's name)").argument("[slug]", "Mist slug (defaults to the .mist file in CWD)").option("--no-wait", "Don't wait for managed env keys to appear after attach").option("--json", "Emit raw JSON").action((name, slug, opts) => runAction(async () => {
1152
+ const resolved = resolveSlug(slug);
1153
+ const client = createMistClient();
1154
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
1155
+ const result = await provisionSurface(client, {
1156
+ slugOrId: resolved,
1157
+ kind: "droplet",
1158
+ name: name?.trim() || void 0,
1159
+ verifyEnv: opts.wait !== false,
1160
+ reporter
1161
+ });
1162
+ if (opts.json) return printJson(result);
1163
+ console.log();
1164
+ console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1165
+ printSurfaceSummary(result);
463
1166
  }));
464
1167
  //#endregion
465
1168
  //#region src/commands/update.ts
466
1169
  /**
467
- * `fluid mist update` — PATCH /mists/:id. Covers three operations on one
468
- * endpoint:
1170
+ * `fluid mist update` — mutate an existing mist. Hostable is retired
1171
+ * (#19684/#19687), so attach/detach operate on integration points:
469
1172
  *
470
- * - Rename: --name <label>
471
- * - Attach: --hostable <type>:<id>
472
- * - Detach: --detach (sends explicit nulls for both keys)
1173
+ * --name <label> Rename the mist (PATCH /mists/:id).
1174
+ * --hostable droplet:<uuid> Attach a droplet by uuid. No-ops if that same
1175
+ * droplet is already attached; replaces it if a
1176
+ * different droplet is attached (droplet is
1177
+ * 1-per-mist — it provisions the managed env).
1178
+ * --detach Remove the droplet integration point.
473
1179
  *
474
- * `--hostable` and `--detach` are mutually exclusive at the CLI surface so
475
- * the user can't accidentally express contradictory intent. The wire-level
476
- * rule (both keys must be paired or both null) is enforced server-side.
1180
+ * The integration-point link is immutable (no update route, no `active`
1181
+ * field) — enablement lives on the surface record, so there's no toggle here.
1182
+ * All ops re-derive state from the integration-points index (which echoes
1183
+ * `integratable_uuid` since #19689) and only make the calls that change it.
477
1184
  */
478
- const updateCommand = new Command("update").description("Rename a mist, attach/replace a hostable, or detach one").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--name <label>", "Set the display label").option("--hostable <ref>", `Attach or replace the hostable. Shorthand: <type>:<id> (${SHORTHAND_LIST})`).option("--detach", "Detach the hostable (sends explicit nulls)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
1185
+ const updateCommand = new Command("update").description("Rename a mist, or attach/detach its droplet").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--name <label>", "Set the display label").option("--hostable <ref>", "Attach a droplet: droplet:<uuid> (e.g. droplet:drp_abc123)").option("--detach", "Remove the droplet integration point").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
479
1186
  if (opts.hostable && opts.detach) throw new Error("Pass either `--hostable` OR `--detach`, not both — they're mutually exclusive.");
480
- if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass at least one of `--name`, `--hostable`, or `--detach`.");
481
- const body = { mist: {} };
482
- if (opts.name?.trim()) body.mist.name = opts.name.trim();
483
- if (opts.hostable) {
484
- const pair = parseHostableRef(opts.hostable);
485
- body.mist.hostable_type = pair.hostable_type;
486
- body.mist.hostable_id = pair.hostable_id;
1187
+ if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass `--name`, `--hostable`, or `--detach`.");
1188
+ const resolved = resolveSlug(slug);
1189
+ const client = createMistClient();
1190
+ const notes = [];
1191
+ let renameResp;
1192
+ if (opts.name?.trim()) {
1193
+ renameResp = await updateMist(client, resolved, { mist: { name: opts.name.trim() } });
1194
+ if (!opts.json) {
1195
+ const m = renameResp.mist;
1196
+ console.log(`${chalk.bold(m.name ?? m.vendor_name)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
1197
+ }
487
1198
  }
488
- if (opts.detach) {
489
- body.mist.hostable_type = null;
490
- body.mist.hostable_id = null;
1199
+ if (opts.hostable || opts.detach) {
1200
+ const existing = findDropletIntegrationPoint((await listIntegrationPoints(client, resolved)).integration_points);
1201
+ if (opts.hostable) {
1202
+ const dropletUuid = parseDropletRef(opts.hostable);
1203
+ if (existing && existing.integratable_uuid === dropletUuid) notes.push(`droplet ${dropletUuid} already attached (integration ${existing.id})`);
1204
+ else {
1205
+ if (existing) {
1206
+ await deleteIntegrationPoint(client, resolved, existing.id);
1207
+ notes.push(`replaced integration ${existing.id}`);
1208
+ }
1209
+ const ip = await createIntegrationPoint(client, resolved, {
1210
+ kind: "droplet",
1211
+ integratable_uuid: dropletUuid
1212
+ });
1213
+ notes.push(`attached droplet ${dropletUuid} (integration ${ip.integration_point.id})`);
1214
+ }
1215
+ } else if (opts.detach) if (existing) {
1216
+ await deleteIntegrationPoint(client, resolved, existing.id);
1217
+ notes.push(`detached droplet (removed integration ${existing.id})`);
1218
+ } else notes.push("already standalone (no droplet integration)");
491
1219
  }
492
- const resolved = resolveSlug(slug);
493
- const response = await updateMist(createMistClient(), resolved, body);
494
- if (opts.json) return printJson(response);
495
- const m = response.mist;
496
- const label = m.name ?? m.droplet?.name ?? m.vendor_name;
497
- console.log(`${chalk.bold(label)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
498
- if (m.hostable_type && m.hostable_id != null) console.log(chalk.dim(` attached: ${m.hostable_type} #${m.hostable_id}`));
499
- else console.log(chalk.dim(` standalone (no hostable)`));
1220
+ if (opts.json) return printJson(renameResp ? {
1221
+ ...renameResp,
1222
+ slug: resolved,
1223
+ notes
1224
+ } : {
1225
+ slug: resolved,
1226
+ notes
1227
+ });
1228
+ for (const note of notes) console.log(chalk.dim(` ${note}`));
500
1229
  }));
501
1230
  //#endregion
502
1231
  //#region src/commands/delete.ts
@@ -1207,36 +1936,6 @@ const logsCommand = new Command("logs").description("Fetch deployment logs (use
1207
1936
  }
1208
1937
  }));
1209
1938
  //#endregion
1210
- //#region src/api/env_vars.ts
1211
- function path$1(id, key) {
1212
- return key ? `/api/v202604/mists/${id}/env_vars/${encodeURIComponent(key)}` : `/api/v202604/mists/${id}/env_vars`;
1213
- }
1214
- async function listEnvVars(client, slug) {
1215
- const id = await resolveMistId(client, slug);
1216
- return client.get(path$1(id));
1217
- }
1218
- /** Create a new env var (the spec routes `set` to POST for new keys). */
1219
- async function createEnvVar(client, slug, key, value, targets) {
1220
- const id = await resolveMistId(client, slug);
1221
- return client.post(path$1(id), { env_var: {
1222
- key,
1223
- value,
1224
- targets
1225
- } });
1226
- }
1227
- /** Update an existing env var (the spec routes `set` to PATCH for existing keys). */
1228
- async function updateEnvVar(client, slug, key, value, targets) {
1229
- const id = await resolveMistId(client, slug);
1230
- return client.patch(path$1(id, key), { env_var: {
1231
- value,
1232
- targets
1233
- } });
1234
- }
1235
- async function deleteEnvVar(client, slug, key) {
1236
- const id = await resolveMistId(client, slug);
1237
- return client.delete(path$1(id, key));
1238
- }
1239
- //#endregion
1240
1939
  //#region src/commands/env/list.ts
1241
1940
  /** `fluid mist env list` — list env vars; mark managed_by entries. Spec §4. */
1242
1941
  function maskValue(value) {
@@ -1378,6 +2077,8 @@ const plugin = {
1378
2077
  mist.addCommand(listCommand);
1379
2078
  mist.addCommand(showCommand);
1380
2079
  mist.addCommand(createCommand);
2080
+ mist.addCommand(attachCommand);
2081
+ mist.addCommand(attachDropletCommand);
1381
2082
  mist.addCommand(updateCommand);
1382
2083
  mist.addCommand(deleteCommand);
1383
2084
  mist.addCommand(restoreCommand);