@fluid-app/fluid-cli-mist 0.1.36 → 0.1.38

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
@@ -6,9 +6,9 @@ import path, { join, resolve } from "node:path";
6
6
  import os from "node:os";
7
7
  import { execa } from "execa";
8
8
  import ora from "ora";
9
+ import { randomUUID } from "node:crypto";
9
10
  import prompts from "prompts";
10
11
  import open from "open";
11
- import { randomUUID } from "node:crypto";
12
12
  //#region src/api/client.ts
13
13
  /**
14
14
  * Generic fetch wrapper for the Mist Rails API.
@@ -25,13 +25,17 @@ var MistApiError = class extends Error {
25
25
  /** HTTP status, or 0 for network-level failures. */
26
26
  status;
27
27
  details;
28
+ /** The response's top-level `errors` object (Rails' legacy envelope), when
29
+ * it has one: the fields the API refused. */
30
+ fieldErrors;
28
31
  /** Best-guess exit code per the spec: 2 for 4xx, 3 for 5xx/vendor, 4 for net. */
29
32
  exitCode;
30
- constructor(message, status, details) {
33
+ constructor(message, status, details, fieldErrors) {
31
34
  super(message);
32
35
  this.name = "MistApiError";
33
36
  this.status = status;
34
37
  this.details = details;
38
+ this.fieldErrors = fieldErrors;
35
39
  if (status === 0) this.exitCode = 4;
36
40
  else if (status >= 500) this.exitCode = 3;
37
41
  else this.exitCode = 2;
@@ -80,7 +84,9 @@ function createMistClient() {
80
84
  }
81
85
  if (!response.ok) {
82
86
  const body = parsed;
83
- throw new MistApiError(body.error?.message ?? `HTTP ${response.status}`, response.status, body.error?.details);
87
+ const message = body.error?.message ?? `HTTP ${response.status}`;
88
+ const fieldErrors = body.errors && typeof body.errors === "object" ? body.errors : void 0;
89
+ throw new MistApiError(message, response.status, body.error?.details, fieldErrors);
84
90
  }
85
91
  return parsed;
86
92
  }
@@ -1191,6 +1197,7 @@ function buildDropletCreateInput(opts) {
1191
1197
  input.embed_url = opts.embedUrl;
1192
1198
  Object.assign(input, dropletWebhookUrls(opts.embedUrl));
1193
1199
  }
1200
+ if (opts.egressProxy) input.egress_proxy_requested = true;
1194
1201
  return input;
1195
1202
  }
1196
1203
  function createDroplet(client, body) {
@@ -1238,6 +1245,10 @@ async function findDropletByEmbedUrl(client, embedUrl) {
1238
1245
  }
1239
1246
  return null;
1240
1247
  }
1248
+ /** PATCH droplet fields, e.g. the embed URL once known or the egress proxy switch. */
1249
+ function updateDroplet(client, uuid, input) {
1250
+ return client.patch(`${BASE$3}/${encodeURIComponent(uuid)}`, { droplet: input });
1251
+ }
1241
1252
  //#endregion
1242
1253
  //#region src/api/droplet_installations.ts
1243
1254
  const BASE$2 = "/api/droplet_installations";
@@ -1364,2131 +1375,2319 @@ const MANAGED_DROPLET_ENV_KEYS = [
1364
1375
  "FLUID_WEBHOOK_AUTH_TOKEN"
1365
1376
  ];
1366
1377
  //#endregion
1367
- //#region src/lib/surface-provision.ts
1368
- /**
1369
- * Surface provisioning engine — the one place the "create a Fluid surface and
1370
- * wire it to a mist" flow lives, at parity with Mist Desktop's
1371
- * mist-integration-points-api.service.ts. Exposed via `fluid mist create`
1372
- * (create a bare mist first, then run this), `fluid mist attach` (any kind),
1373
- * and `fluid mist attach-droplet` (droplet shortcut).
1374
- *
1375
- * Three surface kinds, each with its own create endpoint + attach identifier:
1376
- * - droplet → POST /api/droplets → drp_ uuid → integratable_uuid
1377
- * - drop-zone → POST /api/drop_zones → dz_ uuid → integratable_uuid
1378
- * - mobile-embed→ POST /api/company/mobile_widgets→ numeric id → integratable_id
1379
- *
1380
- * Design tenets (repo philosophy — "server is source of truth"):
1381
- * - No local progress file. State is re-derived from the API each run.
1382
- * - Attach-existing is idempotent: if an integration point already links the
1383
- * target surface (matched via the #19689 integratable_uuid echo, or the
1384
- * numeric id for mobile), skip. A droplet is 1-per-mist, so creating a new
1385
- * droplet also skips when one is already attached. Drop-zones/mobile
1386
- * embeds are additive (a mist can have many).
1387
- * - Only a droplet attach provisions managed env keys
1388
- * (FLUID_DROPLET_UUID/_SECRET/_WEBHOOK_AUTH_TOKEN); this flow only
1389
- * VERIFIES they landed — it never writes them (the API 422s on write).
1390
- * - A droplet must also be INSTALLED (POST /api/droplet_installations) —
1391
- * create + link alone leave it not-connected/inactive, so install-scoped
1392
- * features (dit/dex exchange, admin embeds, install webhooks) don't work.
1393
- * The install step is idempotent (skips when an installation exists).
1394
- */
1395
- const DEFAULT_TIMING = {
1396
- publicUrlTimeoutMs: 9e4,
1397
- publicUrlIntervalMs: 3e3,
1398
- envTimeoutMs: 12e4,
1399
- envIntervalMs: 5e3
1400
- };
1401
- const defaultSleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1402
- const KIND_LABEL$1 = {
1403
- droplet: "droplet",
1404
- "drop-zone": "drop zone",
1405
- "mobile-embed": "mobile embed"
1406
- };
1378
+ //#region ../../mist/core/src/droplet-lifecycle-repair.ts
1379
+ const DEFAULT_DROPLET_LIFECYCLE_PATHS = Object.freeze({
1380
+ installPath: "/api/webhooks/installed",
1381
+ uninstallPath: "/api/webhooks/uninstalled"
1382
+ });
1383
+ /** The note every repair summary carries: the repair fixes future delivery,
1384
+ * it does not replay `droplet.installed` for an existing installation. */
1385
+ const LIFECYCLE_REPAIR_NOTE = "This repairs future Droplet install/uninstall lifecycle delivery. It does not replay droplet.installed for an already-installed Droplet.";
1386
+ /** Strip trailing slashes; null for an absent or empty value. */
1387
+ function normalizePublicUrl(value) {
1388
+ if (!value) return null;
1389
+ return value.replace(/\/+$/, "");
1390
+ }
1391
+ /** A blank or missing path falls back; a relative one gains a leading `/`. */
1392
+ function normalizeWebhookPath(value, fallback) {
1393
+ if (typeof value !== "string") return fallback;
1394
+ const trimmed = value.trim();
1395
+ if (!trimmed) return fallback;
1396
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
1397
+ }
1407
1398
  /**
1408
- * Create (or reuse) a surface of `kind` and wire it to the mist. Idempotent
1409
- * for droplet + attach-existing; additive for new drop-zones/mobile embeds.
1399
+ * Lifecycle paths from a parsed `fluid.capabilities.json`
1400
+ * (`dropletLifecycle.installPath` / `uninstallPath`). Anything that is not
1401
+ * the expected shape yields the defaults.
1410
1402
  */
1411
- async function provisionSurface(client, options) {
1412
- const { reporter, slugOrId, kind } = options;
1413
- const timing = {
1414
- ...DEFAULT_TIMING,
1415
- ...options.timing
1403
+ function dropletLifecyclePathsFromCapabilities(capabilities) {
1404
+ if (!isRecord$1(capabilities)) return { ...DEFAULT_DROPLET_LIFECYCLE_PATHS };
1405
+ const lifecycle = capabilities["dropletLifecycle"];
1406
+ const source = isRecord$1(lifecycle) ? lifecycle : {};
1407
+ return {
1408
+ installPath: normalizeWebhookPath(source["installPath"], DEFAULT_DROPLET_LIFECYCLE_PATHS.installPath),
1409
+ uninstallPath: normalizeWebhookPath(source["uninstallPath"], DEFAULT_DROPLET_LIFECYCLE_PATHS.uninstallPath)
1416
1410
  };
1417
- const sleep = options.sleep ?? defaultSleep$1;
1418
- const label = KIND_LABEL$1[kind];
1419
- reporter.start("Resolving mist…");
1420
- const mist = (await showMist(client, slugOrId)).mist;
1421
- reporter.succeed(`Mist ${mist.slug} (${mist.state})`);
1422
- reporter.start("Checking existing integrations…");
1423
- const points = (await listIntegrationPoints(client, slugOrId)).integration_points;
1424
- let surfaceUuid = null;
1425
- let surfaceNumericId = null;
1426
- let surfaceName = null;
1427
- let surfaceCreated = false;
1428
- let embedUrlSet = false;
1429
- let integrationPoint;
1430
- const attachingExisting = options.existingUuid != null || options.existingId != null;
1431
- const alreadyLinked = attachingExisting ? findIntegrationPointBySurface(points, {
1432
- kind,
1433
- uuid: options.existingUuid,
1434
- id: options.existingId
1435
- }) : kind === "droplet" ? findDropletIntegrationPoint(points) : void 0;
1436
- if (alreadyLinked) {
1437
- reporter.skip(`${label} already attached (integration #${alreadyLinked.id}) — skipping`);
1438
- surfaceUuid = alreadyLinked.integratable_uuid ?? options.existingUuid ?? null;
1439
- surfaceNumericId = alreadyLinked.integratable_id ?? options.existingId ?? null;
1440
- integrationPoint = {
1441
- id: alreadyLinked.id,
1442
- integratableUuid: alreadyLinked.integratable_uuid ?? null,
1443
- created: false
1444
- };
1445
- } else {
1446
- reporter.succeed("No matching integration yet");
1447
- let attachUuid = options.existingUuid;
1448
- let attachId = options.existingId;
1449
- if (!attachingExisting) {
1450
- const embedUrl = await resolvePublicUrl(client, {
1451
- slugOrId,
1452
- initial: mist.public_url,
1453
- reporter,
1454
- timing,
1455
- sleep
1456
- });
1457
- if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy before creating a surface.");
1458
- const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name || mist.slug;
1459
- if (kind === "droplet") {
1460
- reporter.start("Checking for an interrupted Droplet create…");
1461
- const recovered = await findDropletByEmbedUrl(client, embedUrl);
1462
- if (recovered) {
1463
- surfaceUuid = recovered.uuid;
1464
- surfaceNumericId = recovered.id ?? null;
1465
- surfaceName = recovered.name;
1466
- attachUuid = recovered.uuid;
1467
- embedUrlSet = true;
1468
- reporter.skip(`Reusing droplet ${surfaceUuid} from an interrupted attach`);
1469
- } else {
1470
- reporter.start(`Creating ${label} "${name}"…`);
1471
- const created = await createDroplet(client, { droplet: buildDropletCreateInput({
1472
- name,
1473
- embedUrl,
1474
- requestedScopes: options.requestedScopes
1475
- }) });
1476
- surfaceUuid = created.droplet.uuid;
1477
- surfaceNumericId = created.droplet.id ?? null;
1478
- surfaceName = created.droplet.name;
1479
- attachUuid = created.droplet.uuid;
1480
- embedUrlSet = true;
1481
- surfaceCreated = true;
1482
- reporter.succeed(`Created droplet ${surfaceUuid}`);
1483
- }
1484
- } else if (kind === "drop-zone") {
1485
- reporter.start(`Creating ${label} "${name}"…`);
1486
- const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
1487
- name,
1488
- embedUrl,
1489
- zone: options.zone,
1490
- heightPx: options.heightPx
1491
- }) });
1492
- surfaceUuid = created.drop_zone.uuid;
1493
- surfaceName = created.drop_zone.name;
1494
- attachUuid = created.drop_zone.uuid;
1495
- embedUrlSet = true;
1496
- surfaceCreated = true;
1497
- reporter.succeed(`Created drop zone ${surfaceUuid}`);
1498
- } else {
1499
- reporter.start(`Creating ${label} "${name}"…`);
1500
- const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
1501
- name,
1502
- embedUrl,
1503
- coverImageUrl: options.coverImageUrl,
1504
- heightPx: options.heightPx
1505
- }) });
1506
- surfaceNumericId = created.mobile_widget.id;
1507
- surfaceName = created.mobile_widget.name;
1508
- attachId = created.mobile_widget.id;
1509
- embedUrlSet = true;
1510
- surfaceCreated = true;
1511
- reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
1512
- }
1513
- } else {
1514
- surfaceUuid = options.existingUuid ?? null;
1515
- surfaceNumericId = options.existingId ?? null;
1516
- embedUrlSet = true;
1517
- }
1518
- const attachInput = kind === "mobile-embed" ? {
1519
- kind,
1520
- integratable_id: attachId
1521
- } : {
1522
- kind,
1523
- integratable_uuid: attachUuid
1524
- };
1525
- reporter.start(`Attaching ${label}…`);
1526
- let ipResp;
1527
- try {
1528
- ipResp = await createIntegrationPoint(client, slugOrId, attachInput);
1529
- } catch (err) {
1530
- reporter.fail("Attach failed");
1531
- throw normalizeAttachError(err, attachUuid ?? String(attachId), kind);
1532
- }
1533
- integrationPoint = {
1534
- id: ipResp.integration_point.id,
1535
- integratableUuid: ipResp.integration_point.integratable_uuid ?? null,
1536
- created: true
1537
- };
1538
- if (!surfaceUuid) surfaceUuid = ipResp.integration_point.integratable_uuid ?? attachUuid ?? null;
1539
- if (surfaceNumericId == null) surfaceNumericId = ipResp.integration_point.integratable_id ?? null;
1540
- reporter.succeed(kind === "droplet" ? "Droplet attached — provisioning env + redeploy…" : `${label} attached`);
1541
- }
1542
- let installation = null;
1543
- if (kind === "droplet") {
1544
- if (!surfaceUuid && surfaceNumericId != null) try {
1545
- surfaceUuid = await findDropletUuidByNumericId(client, surfaceNumericId);
1546
- } catch {}
1547
- if (!surfaceUuid) {
1548
- reporter.fail("Droplet uuid unknown — cannot install");
1549
- throw new Error("Couldn't determine the droplet uuid, so the droplet is linked but NOT installed — it stays inactive until an installation exists. Re-run with `--existing <droplet-uuid>`, or install manually: POST /api/droplet_installations with {\"droplet_uuid\":\"<uuid>\"}.");
1550
- }
1551
- installation = await ensureDropletInstalled(client, {
1552
- dropletUuid: surfaceUuid,
1553
- scopes: options.requestedScopes ?? DEFAULT_DROPLET_SCOPES,
1554
- reporter
1555
- });
1556
- }
1557
- const managedEnv = kind === "droplet" ? await verifyManagedEnv(client, {
1558
- slugOrId,
1559
- reporter,
1560
- timing,
1561
- sleep,
1562
- enabled: options.verifyEnv !== false
1563
- }) : null;
1411
+ }
1412
+ /** The canonical Droplet URLs for a Mist served at `publicUrl`. */
1413
+ function canonicalDropletUrls(publicUrl, paths = DEFAULT_DROPLET_LIFECYCLE_PATHS) {
1414
+ const embedUrl = normalizePublicUrl(publicUrl);
1415
+ if (!embedUrl) throw new Error("publicUrl is required");
1564
1416
  return {
1565
- mist: {
1566
- id: mist.id,
1567
- slug: mist.slug,
1568
- name: mist.name,
1569
- public_url: mist.public_url,
1570
- state: mist.state
1571
- },
1572
- kind,
1573
- surface: {
1574
- uuid: surfaceUuid,
1575
- id: surfaceNumericId,
1576
- name: surfaceName
1577
- },
1578
- integrationPoint: integrationPoint ?? {
1579
- id: "",
1580
- integratableUuid: null,
1581
- created: false
1582
- },
1583
- surfaceCreated,
1584
- embedUrlSet,
1585
- installation,
1586
- managedEnv
1417
+ embedUrl,
1418
+ installWebhookUrl: `${embedUrl}${paths.installPath}`,
1419
+ uninstallWebhookUrl: `${embedUrl}${paths.uninstallPath}`
1587
1420
  };
1588
1421
  }
1589
- /**
1590
- * Install the droplet for the current company (idempotent). Checks the
1591
- * installations list first; treats the API's already-installed rejection as
1592
- * success too, so re-runs and races both converge. A hard failure throws with
1593
- * the droplet uuid + recovery steps — the created droplet and mist link stay
1594
- * in place, so an idempotent re-run finishes the job.
1595
- */
1596
- async function ensureDropletInstalled(client, args) {
1597
- const { reporter, dropletUuid } = args;
1598
- reporter.start("Installing droplet…");
1599
- let existing;
1600
- try {
1601
- existing = findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid);
1602
- } catch {}
1603
- if (existing) {
1604
- reporter.skip(`Droplet already installed${existing.uuid ? ` (${existing.uuid})` : ""} — skipping`);
1605
- return {
1606
- uuid: existing.uuid ?? null,
1607
- created: false
1608
- };
1609
- }
1610
- try {
1611
- const uuid = (await createDropletInstallation(client, {
1612
- droplet_uuid: dropletUuid,
1613
- scopes: [...args.scopes]
1614
- })).droplet_installation?.uuid ?? null;
1615
- reporter.succeed(`Droplet installed${uuid ? ` (${uuid})` : ""}`);
1616
- return {
1617
- uuid,
1618
- created: true
1619
- };
1620
- } catch (err) {
1621
- if (err instanceof MistApiError && (err.status === 409 || err.status === 422)) {
1622
- const confirmed = await lookupInstallation(client, dropletUuid);
1623
- if (confirmed) {
1624
- reporter.skip("Droplet already installed — skipping");
1625
- return {
1626
- uuid: confirmed.uuid ?? null,
1627
- created: false
1628
- };
1629
- }
1630
- }
1631
- reporter.fail("Droplet install failed");
1632
- throw normalizeInstallError(err, dropletUuid);
1633
- }
1422
+ /** Exact match on all three fields — the repair's no-op condition and its
1423
+ * post-PATCH verification. */
1424
+ function dropletUrlsMatch(droplet, canonical) {
1425
+ return droplet?.embed_url === canonical.embedUrl && droplet.install_webhook_url === canonical.installWebhookUrl && droplet.uninstall_webhook_url === canonical.uninstallWebhookUrl;
1634
1426
  }
1635
- /** Best-effort installation lookup for a droplet; null when the row can't
1636
- * be found or the list call fails. */
1637
- async function lookupInstallation(client, dropletUuid) {
1638
- try {
1639
- return findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid) ?? null;
1640
- } catch {
1641
- return null;
1642
- }
1427
+ /** The `PATCH /api/droplets/:uuid` body. Only these three fields change. */
1428
+ function dropletRepairPatch(canonical) {
1429
+ return { droplet: {
1430
+ embed_url: canonical.embedUrl,
1431
+ install_webhook_url: canonical.installWebhookUrl,
1432
+ uninstall_webhook_url: canonical.uninstallWebhookUrl
1433
+ } };
1643
1434
  }
1644
1435
  /**
1645
- * Install failures must NOT read as "wired up" — an uninstalled droplet means
1646
- * dit/dex token exchange, admin embeds, and install webhooks silently don't
1647
- * work. Surface the droplet uuid + recovery path; keep the MistApiError
1648
- * status so the error handler exits with the spec'd non-zero code.
1436
+ * The repair summary before any mutation. `alreadyCorrect` means the caller
1437
+ * must not PATCH; otherwise a dry run reports "planned" and a live run
1438
+ * reports "updated" (callers overwrite it after verification).
1649
1439
  */
1650
- function normalizeInstallError(err, dropletUuid) {
1651
- const message = `Droplet ${dropletUuid} was created and linked to the mist, but installing it failed: ${err instanceof Error ? err.message : String(err)}. The droplet and its link were left in place — re-run this command to finish the install, or install manually: POST /api/droplet_installations with {"droplet_uuid":"${dropletUuid}"}.`;
1652
- return err instanceof MistApiError ? new MistApiError(message, err.status, err.details) : new Error(message);
1440
+ function planLifecycleRepair(input) {
1441
+ const alreadyCorrect = dropletUrlsMatch(input.current, input.canonical);
1442
+ return {
1443
+ alreadyCorrect,
1444
+ summary: {
1445
+ status: alreadyCorrect ? "unchanged" : input.dryRun ? "planned" : "updated",
1446
+ mistId: input.mistId,
1447
+ mistSlug: input.mistSlug,
1448
+ dropletUuid: input.dropletUuid,
1449
+ embedUrl: input.canonical.embedUrl,
1450
+ installWebhookUrl: input.canonical.installWebhookUrl,
1451
+ uninstallWebhookUrl: input.canonical.uninstallWebhookUrl,
1452
+ previous: {
1453
+ embedUrl: input.current?.embed_url ?? null,
1454
+ installWebhookUrl: input.current?.install_webhook_url ?? null,
1455
+ uninstallWebhookUrl: input.current?.uninstall_webhook_url ?? null
1456
+ },
1457
+ note: LIFECYCLE_REPAIR_NOTE
1458
+ }
1459
+ };
1653
1460
  }
1654
- /**
1655
- * If the integration-points endpoint rejects the attach identifier (e.g. an
1656
- * older API that predates #19688's attach-by-uuid support), surface an
1657
- * actionable message noting the surface WAS created. Other errors pass through.
1658
- */
1659
- function normalizeAttachError(err, surfaceRef, kind) {
1660
- if (err instanceof MistApiError && (err.status === 400 || err.status === 422)) {
1661
- const blob = `${err.message} ${JSON.stringify(err.details ?? {})}`;
1662
- 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);
1663
- }
1664
- return err instanceof Error ? err : new Error(String(err));
1461
+ function isRecord$1(value) {
1462
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1665
1463
  }
1666
- /**
1667
- * Return the mist's public_url, polling briefly if it isn't ready yet
1668
- * (async-first-deploy). Returns null if it never appears within the window.
1669
- */
1670
- async function resolvePublicUrl(client, args) {
1671
- if (args.initial) return args.initial;
1672
- args.reporter.start("Waiting for the mist's public URL…");
1673
- const deadline = Date.now() + args.timing.publicUrlTimeoutMs;
1674
- for (;;) {
1675
- const url = (await showMist(client, args.slugOrId)).mist.public_url;
1676
- if (url) {
1677
- args.reporter.succeed(`Public URL ready: ${url}`);
1678
- return url;
1679
- }
1680
- if (Date.now() >= deadline) break;
1681
- await args.sleep(args.timing.publicUrlIntervalMs);
1682
- }
1683
- return null;
1464
+ //#endregion
1465
+ //#region ../../mist/core/src/lifecycle-types.ts
1466
+ const lifecycleOperationStates = [
1467
+ "queued",
1468
+ "running",
1469
+ "succeeded",
1470
+ "failed",
1471
+ "requires_action"
1472
+ ];
1473
+ const lifecycleOperationKinds = ["replacement"];
1474
+ const connectionStates = [
1475
+ "pending",
1476
+ "connected",
1477
+ "degraded",
1478
+ "requires_action"
1479
+ ];
1480
+ function isTerminalOperation(operation) {
1481
+ return operation.state === "succeeded" || operation.state === "failed" || operation.state === "requires_action";
1684
1482
  }
1685
- /**
1686
- * Poll `GET …/env_vars` until the Mist-managed droplet keys appear with
1687
- * `managed_by: "mist"`. Never fails the flow — a redeploy in progress is a
1688
- * normal, transient state we simply report.
1689
- */
1690
- async function verifyManagedEnv(client, args) {
1691
- const required = [...MANAGED_DROPLET_ENV_KEYS];
1692
- if (!args.enabled) return {
1693
- ready: false,
1694
- present: [],
1695
- missing: required
1483
+ //#endregion
1484
+ //#region ../../mist/core/src/recovery-classification.ts
1485
+ function classifyLifecycleRecovery(result) {
1486
+ if (result.status === "not_found") return {
1487
+ status: "requires_action",
1488
+ action: "rediscover_operation"
1696
1489
  };
1697
- args.reporter.start("Verifying managed environment…");
1698
- const deadline = Date.now() + args.timing.envTimeoutMs;
1699
- let present = [];
1700
- for (;;) {
1701
- const envVars = (await listEnvVars(client, args.slugOrId)).env_vars ?? [];
1702
- const managed = new Set(envVars.filter((e) => e.managed_by === "mist").map((e) => e.key));
1703
- present = required.filter((k) => managed.has(k));
1704
- if (present.length === required.length) {
1705
- args.reporter.succeed("Managed env keys present — droplet is wired up");
1706
- return {
1707
- ready: true,
1708
- present,
1709
- missing: []
1710
- };
1711
- }
1712
- if (Date.now() >= deadline) break;
1713
- args.reporter.update(`Verifying managed environment… (${present.length}/${required.length} keys, redeploy in progress)`);
1714
- await args.sleep(args.timing.envIntervalMs);
1715
- }
1716
- const missing = required.filter((k) => !present.includes(k));
1717
- 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(", ")}`);
1718
- return {
1719
- ready: false,
1720
- present,
1721
- missing
1490
+ if (result.status === "timed_out") return {
1491
+ status: "retryable",
1492
+ action: "resume_status_polling"
1722
1493
  };
1494
+ return classifyTerminalOperation(result.operation);
1723
1495
  }
1724
- //#endregion
1725
- //#region src/lib/surface-flags.ts
1726
- const KIND_ALIASES = {
1727
- droplet: "droplet",
1728
- droplets: "droplet",
1729
- "drop-zone": "drop-zone",
1730
- drop_zone: "drop-zone",
1731
- dropzone: "drop-zone",
1732
- zone: "drop-zone",
1733
- "mobile-embed": "mobile-embed",
1734
- mobile_embed: "mobile-embed",
1735
- mobile: "mobile-embed",
1736
- "mobile-widget": "mobile-embed",
1737
- mobilewidget: "mobile-embed"
1738
- };
1739
- const SURFACE_KIND_LIST = "droplet, drop-zone, mobile-embed";
1740
- function parseSurfaceKind(raw) {
1741
- const hit = KIND_ALIASES[raw.trim().toLowerCase()];
1742
- if (!hit) throw new Error(`Unknown surface kind "${raw}". Use one of: ${SURFACE_KIND_LIST}.`);
1743
- return hit;
1496
+ function classifyTerminalOperation(operation) {
1497
+ if (operation.state === "succeeded") return {
1498
+ status: "complete",
1499
+ action: null
1500
+ };
1501
+ if (operation.state === "failed") return {
1502
+ status: "failed",
1503
+ action: operation.recovery_action ?? "verify_lifecycle_destination"
1504
+ };
1505
+ return {
1506
+ status: "requires_action",
1507
+ action: operation.recovery_action ?? "reconcile_lifecycle"
1508
+ };
1744
1509
  }
1745
- function parseHeight(raw) {
1746
- if (raw == null) return void 0;
1747
- const n = Number(raw);
1748
- if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid --height "${raw}". Must be a positive integer (px).`);
1749
- return n;
1510
+ const REPLACEMENT_IDEMPOTENCY_SCOPE = "single replacement attempt; a later approved retry receives a new key";
1511
+ const REPLACEMENT_OWNERSHIP_NOTE = "Fluid owns revocation, uninstall delivery, terminalization, replacement creation, and retry recovery as one durable operation.";
1512
+ function replacementOperationsPath(mistId, installationUuid) {
1513
+ return `/api/v202604/mists/${encodeURIComponent(mistId)}/droplet_installations/${encodeURIComponent(installationUuid)}/replacement_operations`;
1750
1514
  }
1751
- /**
1752
- * Map CLI flags to the kind-specific `provisionSurface` options, enforcing the
1753
- * per-kind requirements the API needs:
1754
- * - drop-zone requires `--zone <page__zone>` (else it never renders).
1755
- * - mobile-embed requires `--cover-image <url>` (the API 422s without one; the
1756
- * CLI has no DAM picker, so the user must supply a url).
1757
- */
1758
- function surfaceProvisionOptionsFromFlags(kind, flags) {
1759
- const heightPx = parseHeight(flags.height);
1760
- if (kind === "drop-zone") {
1761
- if (!flags.zone?.trim()) throw new Error("A drop-zone needs a slot — pass `--zone <page__zone>` (e.g. --zone checkout__below_total).");
1762
- return {
1763
- zone: flags.zone.trim(),
1764
- heightPx
1765
- };
1766
- }
1767
- if (kind === "mobile-embed") {
1768
- 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).");
1769
- return {
1770
- coverImageUrl: flags.coverImage.trim(),
1771
- heightPx
1772
- };
1773
- }
1774
- return {};
1515
+ function replacementOperationStatusPath(mistId, installationUuid, operationId) {
1516
+ return `${replacementOperationsPath(mistId, installationUuid)}/${encodeURIComponent(operationId)}`;
1775
1517
  }
1776
- //#endregion
1777
- //#region src/lib/surface-summary.ts
1778
- /** Shared human-readable summary for a surface provision result. */
1779
- const KIND_LABEL = {
1780
- droplet: "droplet",
1781
- "drop-zone": "drop zone",
1782
- "mobile-embed": "mobile embed"
1783
- };
1784
- function printSurfaceSummary(result) {
1785
- const ref = result.surface.uuid ?? (result.surface.id != null ? `#${result.surface.id}` : "(unknown)");
1786
- const state = result.surfaceCreated ? "created" : "existing";
1787
- console.log(chalk.dim(` ${KIND_LABEL[result.kind]}: ${ref} (${state}) · integration ${result.integrationPoint.id}`));
1788
- if (result.kind === "droplet" && result.installation) {
1789
- const ref = result.installation.uuid ?? "(uuid unknown)";
1790
- console.log(chalk.dim(` installation: ${ref} (${result.installation.created ? "installed" : "already installed"})`));
1791
- }
1792
- if (result.kind === "droplet" && result.managedEnv) if (result.managedEnv.ready) console.log(chalk.dim(" env: managed keys provisioned ✓"));
1793
- else console.log(chalk.yellow(" env: redeploy in progress — run `fluid mist env list` shortly to confirm managed keys"));
1518
+ /** The operation id a 409 "active_operation_exists" response points at. */
1519
+ function parseActiveOperationId(value) {
1520
+ if (!isRecord(value)) return null;
1521
+ const error = value["error"];
1522
+ if (!isRecord(error)) return null;
1523
+ const details = error["details"];
1524
+ if (!isRecord(details)) return null;
1525
+ const operationId = details["operation_id"];
1526
+ return typeof operationId === "string" ? operationId : null;
1527
+ }
1528
+ /** Seconds from a `Retry-After` header; undefined when absent or invalid. */
1529
+ function parseRetryAfterHeader(value) {
1530
+ if (!value) return void 0;
1531
+ const seconds = Number(value);
1532
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : void 0;
1533
+ }
1534
+ /** Parse and validate `{ replacement_operation: {...} }`. Throws on a
1535
+ * malformed body rather than trusting a partial operation. */
1536
+ function parseReplacementOperation(value) {
1537
+ const key = "replacement_operation";
1538
+ if (!isRecord(value)) throw new Error("Lifecycle response must be an object");
1539
+ const candidate = value[key];
1540
+ if (!isRecord(candidate)) throw new Error(`Lifecycle response is missing ${key}`);
1541
+ const id = candidate["id"];
1542
+ const kind = candidate["kind"];
1543
+ const state = candidate["state"];
1544
+ const stage = candidate["stage"];
1545
+ const mistId = candidate["mist_id"];
1546
+ const createdAt = candidate["created_at"];
1547
+ const updatedAt = candidate["updated_at"];
1548
+ if (typeof id !== "string" || !isLifecycleOperationKind(kind) || !isLifecycleOperationState(state) || typeof stage !== "string" || typeof mistId !== "string" || typeof createdAt !== "string" || typeof updatedAt !== "string") throw new Error("Lifecycle operation response is malformed");
1549
+ const dropletUuid = candidate["droplet_uuid"];
1550
+ const installationUuid = candidate["installation_uuid"];
1551
+ const connectionState = candidate["connection_state"];
1552
+ const errorCode = candidate["error_code"];
1553
+ const recoveryAction = candidate["recovery_action"];
1554
+ const terminalAt = candidate["terminal_at"];
1555
+ const retryAfterSeconds = candidate["retry_after_seconds"];
1556
+ if (!isOptionalNullableString(dropletUuid) || !isOptionalNullableString(installationUuid) || !isOptionalConnectionState(connectionState) || !isOptionalNullableString(errorCode) || !isOptionalNullableString(recoveryAction) || !isOptionalNullableString(terminalAt) || !isOptionalNullableNumber(retryAfterSeconds)) throw new Error("Lifecycle operation response has malformed optional fields");
1557
+ return {
1558
+ id,
1559
+ kind,
1560
+ state,
1561
+ stage,
1562
+ mist_id: mistId,
1563
+ created_at: createdAt,
1564
+ updated_at: updatedAt,
1565
+ ...dropletUuid !== void 0 ? { droplet_uuid: dropletUuid } : {},
1566
+ ...installationUuid !== void 0 ? { installation_uuid: installationUuid } : {},
1567
+ ...connectionState !== void 0 ? { connection_state: connectionState } : {},
1568
+ ...errorCode !== void 0 ? { error_code: errorCode } : {},
1569
+ ...recoveryAction !== void 0 ? { recovery_action: recoveryAction } : {},
1570
+ ...terminalAt !== void 0 ? { terminal_at: terminalAt } : {},
1571
+ ...retryAfterSeconds !== void 0 ? { retry_after_seconds: retryAfterSeconds } : {}
1572
+ };
1794
1573
  }
1795
- //#endregion
1796
- //#region src/commands/create.ts
1797
1574
  /**
1798
- * `fluid mist create` — provision a new mist, optionally creating and attaching
1799
- * a surface in one step. All attach paths go through integration points
1800
- * (hostable is retired — #19684/#19687).
1801
- *
1802
- * --droplet [name] Create a NEW droplet and attach it. Bare →
1803
- * droplet name defaults to the mist name. The
1804
- * "one command that sets everything up" path.
1805
- * --kind <kind> [flags] Create + attach a surface of any kind:
1806
- * droplet | drop-zone | mobile-embed. Kind flags:
1807
- * drop-zone --zone <page__zone> --height <px>
1808
- * mobile-embed --cover-image <url> --height <px>
1809
- * --hostable droplet:<uuid> Attach an EXISTING droplet by its drp_… uuid.
1810
- *
1811
- * With none of these, creates a plain standalone mist.
1575
+ * Guard the request before any network call. A resume must name the
1576
+ * installation it started on: status is scoped to the SOURCE installation,
1577
+ * the backend terminalizes that source mid-flight, and polling whichever
1578
+ * installation is attached now would report a false "not_found".
1812
1579
  */
1813
- 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 () => {
1814
- const wantDroplet = opts.droplet !== void 0;
1815
- const wantKind = !!opts.kind;
1816
- const wantHostable = !!opts.hostable;
1817
- if ([
1818
- wantDroplet,
1819
- wantKind,
1820
- wantHostable
1821
- ].filter(Boolean).length > 1) throw new Error("Pass only one of `--droplet`, `--kind`, or `--hostable` — they're mutually exclusive.");
1822
- let kind;
1823
- let existingDropletUuid;
1824
- let nameOverride;
1825
- if (wantDroplet) {
1826
- kind = "droplet";
1827
- nameOverride = typeof opts.droplet === "string" ? opts.droplet.trim() : void 0;
1828
- } else if (wantKind) kind = parseSurfaceKind(opts.kind);
1829
- else if (wantHostable) {
1830
- kind = "droplet";
1831
- existingDropletUuid = parseDropletRef(opts.hostable);
1832
- }
1833
- const mistBody = { kind: opts.mistKind ?? "next_app" };
1834
- if (opts.name?.trim()) mistBody.name = opts.name.trim();
1835
- const client = createMistClient();
1836
- const reporter = opts.json ? createSilentReporter() : createOraReporter();
1837
- reporter.start("Setting up your Mist Cloud environment…");
1838
- let created;
1839
- try {
1840
- created = await createMist(client, { mist: mistBody });
1841
- } catch (err) {
1842
- reporter.fail("Provisioning failed");
1843
- throw err;
1844
- }
1845
- const mist = created.mist;
1846
- reporter.succeed(`Provisioned ${mist.slug}`);
1847
- let provision;
1848
- if (kind) provision = await provisionSurface(client, {
1849
- slugOrId: mist.slug,
1850
- kind,
1851
- name: nameOverride ?? opts.name?.trim(),
1852
- existingUuid: existingDropletUuid,
1853
- verifyEnv: opts.wait !== false,
1854
- reporter,
1855
- ...surfaceProvisionOptionsFromFlags(kind, opts)
1856
- });
1857
- if (opts.json) return printJson(provision ? {
1858
- mist,
1859
- provision
1860
- } : created);
1861
- const label = mistLabel(mist.name, mist.vendor_name);
1862
- console.log();
1863
- console.log(`${label} ${chalk.dim(mist.slug)} ${colorState(mist.state)}`);
1864
- if (mist.public_url) console.log(` ${chalk.cyan(mist.public_url)}`);
1865
- if (provision) printSurfaceSummary(provision);
1866
- console.log();
1867
- console.log(chalk.dim(`Clone with: fluid mist clone ${mist.slug}`));
1868
- }));
1869
- //#endregion
1870
- //#region src/commands/attach.ts
1580
+ function checkReplacementRequest(args) {
1581
+ const resumeOperationId = args.resumeOperationId?.trim() || null;
1582
+ if (resumeOperationId && !args.installationUuid) return {
1583
+ ok: false,
1584
+ code: "resume_requires_installation"
1585
+ };
1586
+ return {
1587
+ ok: true,
1588
+ resumeOperationId
1589
+ };
1590
+ }
1591
+ /** Starting a new replacement needs explicit confirmation; resuming only
1592
+ * reads an operation the user already approved. */
1593
+ function replacementStartNeedsConfirmation(args, resumeOperationId) {
1594
+ return !resumeOperationId && args.confirmDestructiveReinstall !== true;
1595
+ }
1871
1596
  /**
1872
- * `fluid mist attach --kind <kind> [flags] [slug]` — create a surface and wire
1873
- * it to an existing mist. One command for all three kinds:
1874
- *
1875
- * --kind droplet (managed-env-provisioning droplet)
1876
- * --kind drop-zone --zone page__zone [--height <px>]
1877
- * --kind mobile-embed --cover-image <url> [--height <px>]
1878
- *
1879
- * Attach an EXISTING surface instead of creating one with `--existing <ref>`:
1880
- * droplet/drop-zone → droplet:drp_… / drop-zone:dz_… / bare uuid
1881
- * mobile-embed → mobile-embed:<numericId>
1882
- *
1883
- * Idempotent for droplet + attach-existing (skips when already linked).
1597
+ * The installation to replace. An explicitly named `installationUuid`
1598
+ * always wins: it is the recovery path for a source that a failed attempt
1599
+ * already terminalized (which may sit beside a newer active installation
1600
+ * of the same Droplet), and a resume must watch the installation its
1601
+ * operation started on, never a rediscovered one. Without a name, the
1602
+ * company's active installation of the attached Droplet is used.
1884
1603
  */
1885
- /** Parse `--existing <ref>` into a uuid or numeric id for the given kind. */
1886
- function parseExisting(kind, raw) {
1887
- const trimmed = (raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw).trim();
1888
- if (!trimmed) throw new Error(`Empty --existing reference "${raw}".`);
1889
- if (kind === "mobile-embed") {
1890
- const id = Number(trimmed);
1891
- if (!Number.isInteger(id) || id <= 0) throw new Error(`mobile-embed attaches by numeric id — pass \`--existing <id>\` (got "${raw}").`);
1892
- return { existingId: id };
1893
- }
1894
- return { existingUuid: trimmed };
1604
+ function selectReplacementInstallation(input) {
1605
+ if (input.installationUuid) return input.installations.find((candidate) => candidate.uuid === input.installationUuid && candidate.droplet_uuid === input.dropletUuid) ?? {
1606
+ uuid: input.installationUuid,
1607
+ droplet_uuid: input.dropletUuid
1608
+ };
1609
+ if (input.resumeOperationId) return void 0;
1610
+ return input.installations.find((candidate) => candidate.droplet_uuid === input.dropletUuid);
1611
+ }
1612
+ function buildReplacementPlan(input) {
1613
+ const { installation } = input;
1614
+ return {
1615
+ status: input.dryRun ? "planned" : input.resumeOperationId ? "watching" : "pending",
1616
+ mistId: input.mistId,
1617
+ dropletUuid: input.dropletUuid,
1618
+ installationUuid: installation.uuid,
1619
+ lifecycleRepair: input.lifecycleRepair,
1620
+ replacementPath: replacementOperationsPath(input.mistId, installation.uuid),
1621
+ idempotencyKey: input.resumeOperationId ? null : input.idempotencyKey,
1622
+ idempotencyScope: REPLACEMENT_IDEMPOTENCY_SCOPE,
1623
+ irreversible: true,
1624
+ preservedByBackend: {
1625
+ scopes: Array.isArray(installation.scopes),
1626
+ role_id: typeof installation.role_id === "string" || typeof installation.role_id === "number"
1627
+ },
1628
+ note: REPLACEMENT_OWNERSHIP_NOTE
1629
+ };
1895
1630
  }
1896
- 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 () => {
1897
- const kind = parseSurfaceKind(opts.kind);
1898
- const resolved = resolveSlug(slug);
1899
- const client = createMistClient();
1900
- const reporter = opts.json ? createSilentReporter() : createOraReporter();
1901
- const existing = opts.existing ? parseExisting(kind, opts.existing) : void 0;
1902
- const result = await provisionSurface(client, {
1903
- slugOrId: resolved,
1904
- kind,
1905
- name: opts.name?.trim(),
1906
- verifyEnv: opts.wait !== false,
1907
- reporter,
1908
- ...existing,
1909
- ...existing ? {} : surfaceProvisionOptionsFromFlags(kind, opts)
1910
- });
1911
- if (opts.json) return printJson(result);
1912
- console.log();
1913
- console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1914
- printSurfaceSummary(result);
1915
- }));
1916
- //#endregion
1917
- //#region src/commands/attach-droplet.ts
1918
1631
  /**
1919
- * `fluid mist attach-droplet [name] [slug]` — droplet shortcut for
1920
- * `fluid mist attach --kind droplet`. Creates a droplet and wires it to an
1921
- * existing standalone mist (provisioning the managed env). Idempotent: if a
1922
- * droplet is already attached, it skips create + attach and just verifies env.
1923
- *
1924
- * name Optional droplet name (defaults to the mist's name).
1925
- * slug Optional mist slug (defaults to the `.mist` file in CWD).
1632
+ * The final result of one watch. A timed-out poll is this caller's watch
1633
+ * budget expiring, NOT the backend giving up: it reports "still_running"
1634
+ * with the ids needed to resume, and is not an error. Only "succeeded" is
1635
+ * complete.
1926
1636
  */
1927
- 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 () => {
1928
- const resolved = resolveSlug(slug);
1929
- const client = createMistClient();
1930
- const reporter = opts.json ? createSilentReporter() : createOraReporter();
1931
- const result = await provisionSurface(client, {
1932
- slugOrId: resolved,
1933
- kind: "droplet",
1934
- name: name?.trim() || void 0,
1935
- verifyEnv: opts.wait !== false,
1936
- reporter
1937
- });
1938
- if (opts.json) return printJson(result);
1939
- console.log();
1940
- console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1941
- printSurfaceSummary(result);
1942
- }));
1637
+ function summarizeReplacementWatch(input) {
1638
+ const { pollResult, startedOperation } = input;
1639
+ const recovery = classifyLifecycleRecovery(pollResult);
1640
+ const stillRunning = pollResult.status === "timed_out";
1641
+ const latestOperation = pollResult.status === "terminal" ? pollResult.operation : pollResult.status === "timed_out" ? pollResult.operation ?? startedOperation : startedOperation;
1642
+ return {
1643
+ result: {
1644
+ ...input.plan,
1645
+ status: pollResult.status === "terminal" ? pollResult.operation.state : stillRunning ? "still_running" : pollResult.status,
1646
+ replacementOperation: latestOperation,
1647
+ recovery,
1648
+ ...stillRunning ? {
1649
+ watchedForMs: input.watchedForMs,
1650
+ resumeOperationId: input.operationId,
1651
+ nextStep: input.nextStep({
1652
+ operationId: input.operationId,
1653
+ installationUuid: input.installationUuid,
1654
+ stage: latestOperation?.stage ?? "unknown"
1655
+ })
1656
+ } : {}
1657
+ },
1658
+ isError: !stillRunning && recovery.status !== "complete"
1659
+ };
1660
+ }
1661
+ function isLifecycleOperationKind(value) {
1662
+ return lifecycleOperationKinds.some((candidate) => candidate === value);
1663
+ }
1664
+ function isLifecycleOperationState(value) {
1665
+ return lifecycleOperationStates.some((candidate) => candidate === value);
1666
+ }
1667
+ function isOptionalNullableString(value) {
1668
+ return value === void 0 || value === null || typeof value === "string";
1669
+ }
1670
+ function isOptionalNullableNumber(value) {
1671
+ return value === void 0 || value === null || typeof value === "number";
1672
+ }
1673
+ function isOptionalConnectionState(value) {
1674
+ return value === void 0 || value === null || connectionStates.some((candidate) => candidate === value);
1675
+ }
1676
+ function isRecord(value) {
1677
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1678
+ }
1943
1679
  //#endregion
1944
- //#region src/commands/update.ts
1945
- /**
1946
- * `fluid mist update` — mutate an existing mist. Hostable is retired
1947
- * (#19684/#19687), so attach/detach operate on integration points:
1948
- *
1949
- * --name <label> Rename the mist (PATCH /mists/:id).
1950
- * --hostable droplet:<uuid> Attach a droplet by uuid. No-ops if that same
1951
- * droplet is already attached; replaces it if a
1952
- * different droplet is attached (droplet is
1953
- * 1-per-mist — it provisions the managed env).
1954
- * --detach Remove the droplet integration point.
1955
- *
1956
- * The integration-point link is immutable (no update route, no `active`
1957
- * field) — enablement lives on the surface record, so there's no toggle here.
1958
- * All ops re-derive state from the integration-points index (which echoes
1959
- * `integratable_uuid` since #19689) and only make the calls that change it.
1960
- */
1961
- 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 () => {
1962
- if (opts.hostable && opts.detach) throw new Error("Pass either `--hostable` OR `--detach`, not both — they're mutually exclusive.");
1963
- if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass `--name`, `--hostable`, or `--detach`.");
1964
- const resolved = resolveSlug(slug);
1965
- const client = createMistClient();
1966
- const notes = [];
1967
- let renameResp;
1968
- if (opts.name?.trim()) {
1969
- renameResp = await updateMist(client, resolved, { mist: { name: opts.name.trim() } });
1970
- if (!opts.json) {
1971
- const m = renameResp.mist;
1972
- console.log(`${mistLabel(m.name, m.vendor_name)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
1680
+ //#region ../../mist/core/src/operation-polling.ts
1681
+ async function pollLifecycleOperation({ fetchStatus, timeoutMs = 1800 * 1e3, minimumIntervalMs = 2e3, sleep: sleepOverride, now = Date.now, abortSignal }) {
1682
+ const sleep = sleepOverride ?? ((durationMs) => defaultSleep$1(durationMs, abortSignal));
1683
+ const startedAt = now();
1684
+ let latestOperation;
1685
+ while (now() - startedAt < timeoutMs) {
1686
+ abortSignal?.throwIfAborted();
1687
+ const response = await fetchStatus();
1688
+ abortSignal?.throwIfAborted();
1689
+ if (response.status === 404) return { status: "not_found" };
1690
+ const transientServerError = response.status >= 500 && response.status < 600;
1691
+ if (response.status !== 200 && response.status !== 429 && !transientServerError) throw new Error(`Lifecycle status request failed with HTTP ${response.status}`);
1692
+ if (response.operation) {
1693
+ latestOperation = response.operation;
1694
+ if (isTerminalOperation(response.operation)) return {
1695
+ status: "terminal",
1696
+ operation: response.operation
1697
+ };
1973
1698
  }
1699
+ const retryAfterMs = Math.max(minimumIntervalMs, (response.retryAfterSeconds ?? response.operation?.retry_after_seconds ?? 0) * 1e3);
1700
+ await sleep(Math.min(retryAfterMs, Math.max(0, timeoutMs - (now() - startedAt))));
1701
+ abortSignal?.throwIfAborted();
1974
1702
  }
1975
- if (opts.hostable || opts.detach) {
1976
- const existing = findDropletIntegrationPoint((await listIntegrationPoints(client, resolved)).integration_points);
1977
- if (opts.hostable) {
1978
- const dropletUuid = parseDropletRef(opts.hostable);
1979
- if (existing && existing.integratable_uuid === dropletUuid) notes.push(`droplet ${dropletUuid} already attached (integration ${existing.id})`);
1980
- else {
1981
- if (existing) {
1982
- await deleteIntegrationPoint(client, resolved, existing.id);
1983
- notes.push(`replaced integration ${existing.id}`);
1984
- }
1985
- const ip = await createIntegrationPoint(client, resolved, {
1986
- kind: "droplet",
1987
- integratable_uuid: dropletUuid
1988
- });
1989
- notes.push(`attached droplet ${dropletUuid} (integration ${ip.integration_point.id})`);
1990
- }
1991
- } else if (opts.detach) if (existing) {
1992
- await deleteIntegrationPoint(client, resolved, existing.id);
1993
- notes.push(`detached droplet (removed integration ${existing.id})`);
1994
- } else notes.push("already standalone (no droplet integration)");
1995
- }
1996
- if (opts.json) return printJson(renameResp ? {
1997
- ...renameResp,
1998
- slug: resolved,
1999
- notes
2000
- } : {
2001
- slug: resolved,
2002
- notes
1703
+ return latestOperation ? {
1704
+ status: "timed_out",
1705
+ operation: latestOperation
1706
+ } : { status: "timed_out" };
1707
+ }
1708
+ function defaultSleep$1(durationMs, abortSignal) {
1709
+ return new Promise((resolve, reject) => {
1710
+ const onAbort = () => {
1711
+ clearTimeout(timer);
1712
+ reject(abortSignal?.reason);
1713
+ };
1714
+ const timer = setTimeout(() => {
1715
+ abortSignal?.removeEventListener("abort", onAbort);
1716
+ resolve();
1717
+ }, durationMs);
1718
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
2003
1719
  });
2004
- for (const note of notes) console.log(chalk.dim(` ${note}`));
2005
- }));
1720
+ }
2006
1721
  //#endregion
2007
- //#region src/commands/delete.ts
2008
- /**
2009
- * `fluid mist delete` — scheduled teardown 15 days out (spec §3 "15-day
2010
- * grace period"). DO NOT poll for completion.
2011
- */
2012
- const deleteCommand = new Command("delete").description("Schedule a mist for destruction (15-day grace window)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("-y, --yes", "Skip confirmation prompt").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
2013
- const resolved = resolveSlug(slug);
2014
- const client = createMistClient();
2015
- if (!opts.yes) {
2016
- const { mist } = await showMist(client, resolved);
2017
- const fifteenDaysOut = new Date(Date.now() + 15 * 864e5).toISOString().slice(0, 10);
2018
- console.log();
2019
- console.log(`About to schedule ${chalk.bold(mist.name ?? mist.vendor_name ?? mist.slug)} (${chalk.dim(mist.slug)}) for destruction on ${chalk.yellow(fifteenDaysOut)}.`);
2020
- console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
2021
- const { confirmed } = await prompts({
2022
- type: "confirm",
2023
- name: "confirmed",
2024
- message: "Continue?",
2025
- initial: false
2026
- }, { onCancel: () => process.exit(130) });
2027
- if (!confirmed) {
2028
- console.log(chalk.dim("Aborted."));
2029
- process.exit(0);
1722
+ //#region src/api/droplet_replacements.ts
1723
+ async function startDropletReplacement(client, input) {
1724
+ const response = await send(client, replacementOperationsPath(input.mistId, input.installationUuid), {
1725
+ method: "POST",
1726
+ headers: { "Content-Type": "application/json" },
1727
+ body: JSON.stringify({ idempotency_key: input.idempotencyKey })
1728
+ });
1729
+ const body = await readJson(response);
1730
+ if (response.ok) return parseReplacementOperation(body);
1731
+ const activeOperationId = response.status === 409 ? parseActiveOperationId(body) : null;
1732
+ if (activeOperationId) return fetchDropletReplacement(client, {
1733
+ mistId: input.mistId,
1734
+ installationUuid: input.installationUuid,
1735
+ operationId: activeOperationId
1736
+ });
1737
+ const retryAfterSeconds = parseRetryAfterHeader(response.headers.get("Retry-After"));
1738
+ const retryHint = retryAfterSeconds === void 0 ? "" : `; retry after ${retryAfterSeconds}s`;
1739
+ throw new MistApiError(`Droplet replacement start failed with HTTP ${response.status}${retryHint}`, response.status);
1740
+ }
1741
+ async function fetchDropletReplacement(client, input) {
1742
+ const response = await send(client, replacementOperationStatusPath(input.mistId, input.installationUuid, input.operationId), { method: "GET" });
1743
+ if (!response.ok) throw new MistApiError(`Active replacement status failed with HTTP ${response.status}`, response.status);
1744
+ return parseReplacementOperation(await readJson(response));
1745
+ }
1746
+ /** Poll an operation until it is terminal, gone, or `timeoutMs` elapses. */
1747
+ function pollDropletReplacement(client, input) {
1748
+ const statusPath = replacementOperationStatusPath(input.mistId, input.installationUuid, input.operationId);
1749
+ return pollLifecycleOperation({
1750
+ timeoutMs: input.timeoutMs,
1751
+ ...input.sleep ? { sleep: input.sleep } : {},
1752
+ fetchStatus: async () => {
1753
+ const response = await send(client, statusPath, { method: "GET" });
1754
+ const retryAfterSeconds = parseRetryAfterHeader(response.headers.get("Retry-After"));
1755
+ if (response.status === 404 || response.status === 429) return {
1756
+ status: response.status,
1757
+ retryAfterSeconds
1758
+ };
1759
+ if (!response.ok) return { status: response.status };
1760
+ return {
1761
+ status: response.status,
1762
+ operation: parseReplacementOperation(await readJson(response)),
1763
+ retryAfterSeconds
1764
+ };
2030
1765
  }
1766
+ });
1767
+ }
1768
+ async function send(client, path, init) {
1769
+ try {
1770
+ return await client.request(path, init);
1771
+ } catch (err) {
1772
+ throw new MistApiError(`Network error: ${err instanceof Error ? err.message : String(err)}`, 0);
1773
+ }
1774
+ }
1775
+ async function readJson(response) {
1776
+ const text = await response.text();
1777
+ if (!text) return null;
1778
+ try {
1779
+ return JSON.parse(text);
1780
+ } catch {
1781
+ return null;
2031
1782
  }
2032
- const { mist } = await deleteMist(client, resolved);
2033
- if (opts.json) return printJson({ mist });
2034
- console.log(chalk.magenta(`Scheduled for destruction on ${mist.scheduled_destroy_at ? formatAbsolute(mist.scheduled_destroy_at) : "the 15-day mark"}.`));
2035
- console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
2036
- }));
2037
- //#endregion
2038
- //#region src/commands/restore.ts
2039
- /** `fluid mist restore` — un-delete within the 15-day window. Spec §3. */
2040
- const restoreCommand = new Command("restore").description("Cancel a scheduled mist destruction (15-day window)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
2041
- const resolved = resolveSlug(slug);
2042
- const { mist } = await restoreMist(createMistClient(), resolved);
2043
- if (opts.json) return printJson({ mist });
2044
- console.log(`Restored: ${mist.public_url ? chalk.cyan(mist.public_url) : mist.name ?? mist.vendor_name ?? mist.slug} (${colorState(mist.state)})`);
2045
- }));
2046
- //#endregion
2047
- //#region src/api/git_credential.ts
2048
- async function createGitCredential(client, slug) {
2049
- const id = await resolveMistId(client, slug);
2050
- return client.post(`/api/v202604/mists/${id}/git_credential`);
2051
1783
  }
2052
1784
  //#endregion
2053
- //#region src/commands/clone.ts
2054
- /**
2055
- * `fluid mist clone <slug>` — mints a fresh git credential and clones
2056
- * into ./<vendor-name>/, writes a `.mist` marker file. Spec §3 + §11.
2057
- */
1785
+ //#region src/lib/reinstall-plan-store.ts
2058
1786
  /**
2059
- * Render a path for the copy-pasteable `cd` hint so the printed command
2060
- * enters the directory that was actually created:
2061
- * - Prefixes `./` when the path starts with `~` (blocks tilde expansion —
2062
- * the clone went into a literal `~…` directory under CWD) or `-` (stops
2063
- * `cd` from parsing it as an option).
2064
- * - Otherwise single-quotes anything beyond safe characters, escaping
2065
- * embedded single quotes POSIX-style (`'\''`).
1787
+ * Plan receipts for `fluid mist droplet reinstall`.
1788
+ *
1789
+ * Mist Desktop's agent must show a dry-run plan and get explicit approval
1790
+ * before it may confirm the irreversible replacement. A CLI has no
1791
+ * conversation to hold that sequence, so `--dry-run` records a receipt of
1792
+ * the exact Droplet, installation and lifecycle URLs it showed, and
1793
+ * `--confirm` refuses unless a receipt for the same plan is younger than
1794
+ * {@link REINSTALL_PLAN_TTL_MS}. `--confirm` claims the receipt atomically
1795
+ * (a rename only one process can win) before checking it, so one plan
1796
+ * approves at most one attempt and every retry needs a fresh plan.
1797
+ *
1798
+ * Receipts live under the Fluid config dir (`~/.fluid/mist/reinstall-plans`)
1799
+ * and hold only ids and a timestamp — no tokens or secrets.
2066
1800
  */
2067
- function quotePathIfNeeded(path) {
2068
- const prefixed = /^[~-]/.test(path) ? `./${path}` : path;
2069
- if (/^[A-Za-z0-9_./-]+$/.test(prefixed)) return prefixed;
2070
- return `'${prefixed.replace(/'/g, "'\\''")}'`;
1801
+ /** How long a shown plan stays valid for `--confirm`. */
1802
+ const REINSTALL_PLAN_TTL_MS = 900 * 1e3;
1803
+ function createFileReinstallPlanStore(dir = join(getConfigDir(), "mist", "reinstall-plans")) {
1804
+ const fileFor = (mistId) => join(dir, `${encodeURIComponent(mistId)}.json`);
1805
+ return {
1806
+ save(receipt) {
1807
+ mkdirSync(dir, {
1808
+ recursive: true,
1809
+ mode: 448
1810
+ });
1811
+ writeFileSync(fileFor(receipt.mistId), JSON.stringify(receipt, null, 2) + "\n", {
1812
+ encoding: "utf8",
1813
+ mode: 384
1814
+ });
1815
+ },
1816
+ load(mistId) {
1817
+ try {
1818
+ const parsed = JSON.parse(readFileSync(fileFor(mistId), "utf8"));
1819
+ return isReceipt(parsed) && parsed.mistId === mistId ? parsed : null;
1820
+ } catch {
1821
+ return null;
1822
+ }
1823
+ },
1824
+ claim(mistId) {
1825
+ const claimed = `${fileFor(mistId)}.claimed-${process.pid}-${randomUUID()}`;
1826
+ try {
1827
+ renameSync(fileFor(mistId), claimed);
1828
+ } catch {
1829
+ return null;
1830
+ }
1831
+ try {
1832
+ const parsed = JSON.parse(readFileSync(claimed, "utf8"));
1833
+ return isReceipt(parsed) && parsed.mistId === mistId ? parsed : null;
1834
+ } catch {
1835
+ return null;
1836
+ } finally {
1837
+ rmSync(claimed, { force: true });
1838
+ }
1839
+ }
1840
+ };
2071
1841
  }
2072
- const cloneCommand = new Command("clone").description("Clone the mist's GitHub repo (mints a fresh token per call)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--dir <dir>", "Target directory (defaults to ./<vendor-name>/)").action((slug, opts) => runAction(async () => {
2073
- await ensureGitAvailable();
2074
- const resolvedSlug = resolveSlug(slug);
2075
- const client = createMistClient();
2076
- const spinner = ora("Resolving mist…").start();
2077
- const { mist } = await showMist(client, resolvedSlug);
2078
- spinner.text = "Minting git credential…";
2079
- const { git_credential: cred } = await createGitCredential(client, mist.slug);
2080
- const dirName = mist.name ?? mist.vendor_name ?? mist.slug;
2081
- const targetDir = resolve(opts.dir ?? dirName);
2082
- if (existsSync(targetDir)) {
2083
- spinner.fail(`Directory ${targetDir} already exists. Pass --dir or remove it first.`);
2084
- process.exit(1);
2085
- }
2086
- spinner.text = `Cloning into ${dirName}/…`;
2087
- try {
2088
- await gitClone(cred.remote_url, targetDir);
2089
- } catch (err) {
2090
- spinner.fail("git clone failed");
2091
- throw err;
2092
- }
2093
- writeMistFile(targetDir, mist);
2094
- spinner.succeed(`Cloned into ${chalk.cyan(targetDir)}`);
2095
- const displayPath = opts.dir ?? join(".", dirName);
2096
- console.log(chalk.dim(` cd ${quotePathIfNeeded(displayPath)}`));
2097
- if (mist.public_url) console.log(chalk.dim(` ${chalk.cyan(mist.public_url)}`));
2098
- }));
2099
- //#endregion
2100
- //#region src/api/deployments.ts
2101
- async function listDeployments(client, slug, limit) {
2102
- const id = await resolveMistId(client, slug);
2103
- return client.get(`/api/v202604/mists/${id}/deployments`, limit != null ? { limit } : void 0);
1842
+ /** Whether `receipt` approves executing the freshly computed plan. */
1843
+ function checkPlanReceipt(receipt, fresh, now) {
1844
+ if (!receipt) return {
1845
+ ok: false,
1846
+ code: "plan_required"
1847
+ };
1848
+ const plannedAt = Date.parse(receipt.plannedAt);
1849
+ if (!Number.isFinite(plannedAt) || now - plannedAt > 9e5) return {
1850
+ ok: false,
1851
+ code: "plan_stale"
1852
+ };
1853
+ if (plannedAt > now) return {
1854
+ ok: false,
1855
+ code: "plan_stale"
1856
+ };
1857
+ if (receipt.dropletUuid !== fresh.dropletUuid || receipt.installationUuid !== fresh.installationUuid || receipt.embedUrl !== fresh.embedUrl || receipt.installWebhookUrl !== fresh.installWebhookUrl || receipt.uninstallWebhookUrl !== fresh.uninstallWebhookUrl) return {
1858
+ ok: false,
1859
+ code: "plan_changed"
1860
+ };
1861
+ return { ok: true };
2104
1862
  }
2105
- async function showDeployment(client, slug, deploymentId) {
2106
- const id = await resolveMistId(client, slug);
2107
- return client.get(`/api/v202604/mists/${id}/deployments/${deploymentId}`);
1863
+ function isReceipt(value) {
1864
+ if (typeof value !== "object" || value === null) return false;
1865
+ const record = value;
1866
+ return typeof record["mistId"] === "string" && typeof record["dropletUuid"] === "string" && typeof record["installationUuid"] === "string" && typeof record["embedUrl"] === "string" && typeof record["installWebhookUrl"] === "string" && typeof record["uninstallWebhookUrl"] === "string" && typeof record["plannedAt"] === "string";
2108
1867
  }
2109
1868
  //#endregion
2110
- //#region src/api/me.ts
2111
- async function fetchCurrentUser(client) {
2112
- const raw = await client.get("/api/me");
2113
- const composed = [raw.first_name, raw.last_name].filter(Boolean).join(" ").trim();
2114
- const fullName = raw.full_name ?? (composed.length > 0 ? composed : null);
1869
+ //#region src/lib/droplet-repair.ts
1870
+ /**
1871
+ * Orchestration for `fluid mist droplet repair` and
1872
+ * `fluid mist droplet reinstall`, ported from Mist Desktop's
1873
+ * `repair_droplet_lifecycle_webhooks` and `repair_and_reinstall_droplet`
1874
+ * tools (`apps/mist-desktop/src/main/tools/index.ts` in the fluid repo).
1875
+ *
1876
+ * The planning and safety rules are shared through `@fluid-app/mist-core`
1877
+ * (`droplet-lifecycle-repair`, `droplet-replacement`); this module only
1878
+ * wires them to the CLI's API client, the `.mist` marker and the plan
1879
+ * receipt store. Every function takes its client and collaborators as
1880
+ * arguments so tests drive it with fakes.
1881
+ */
1882
+ /** A refusal: the command stops before (or instead of) mutating anything
1883
+ * and exits non-zero. `code` is stable for scripts. */
1884
+ var DropletRepairRefusal = class extends Error {
1885
+ code;
1886
+ constructor(code, message) {
1887
+ super(message);
1888
+ this.name = "DropletRepairRefusal";
1889
+ this.code = code;
1890
+ }
1891
+ };
1892
+ /**
1893
+ * Resolve the Mist (slug argument or the `.mist` in `cwd`), its public URL,
1894
+ * the Droplet it is attached to, and the canonical Droplet URLs. Refuses
1895
+ * when the Mist has no public URL or no attached Droplet.
1896
+ */
1897
+ async function resolveRepairTarget(client, input) {
1898
+ const slug = resolveSlug(input.slug, input.cwd);
1899
+ const marker = readMistFileFrom(input.cwd);
1900
+ const isCwdProject = marker !== null && marker.slug === slug;
1901
+ const mistId = await resolveMistId(client, slug);
1902
+ const { mist } = await showMist(client, mistId);
1903
+ const publicUrl = normalizePublicUrl(mist.public_url);
1904
+ if (!publicUrl) throw new DropletRepairRefusal("no_public_url", "Mist has no public_url yet. Publish the Mist first (`fluid mist push`), then repair lifecycle webhooks.");
1905
+ const dropletUuid = await resolveAttachedDropletUuid(client, mist, mistId, publicUrl);
1906
+ const paths = isCwdProject ? readLifecyclePaths(input.cwd) : null;
2115
1907
  return {
2116
- id: raw.id,
2117
- public_id: raw.public_id ?? String(raw.id),
2118
- full_name: fullName,
2119
- first_name: raw.first_name ?? null,
2120
- last_name: raw.last_name ?? null,
2121
- email: raw.email ?? null
1908
+ mistId,
1909
+ mistSlug: mist.slug ?? slug,
1910
+ dropletUuid,
1911
+ canonical: canonicalDropletUrls(publicUrl, paths ?? DEFAULT_DROPLET_LIFECYCLE_PATHS),
1912
+ lifecyclePathsSource: paths ? "fluid.capabilities.json" : "defaults"
2122
1913
  };
2123
1914
  }
2124
- //#endregion
2125
- //#region src/commands/push.ts
2126
1915
  /**
2127
- * `fluid mist push [--watch]` — mints a credential, pushes to origin, and
2128
- * (with --watch) polls /deployments until READY/ERROR. Spec §3 + §4.
1916
+ * The Mist's own Droplet. The server-owned link wins (the Mist record, then
1917
+ * its droplet integration point); matching `embed_url` is only a legacy
1918
+ * fallback, because a broken embed URL is exactly what the repair fixes.
2129
1919
  */
2130
- /** Used when there's no `-m` and no readable diff to describe. */
2131
- const FALLBACK_ACTION = "Publish from Mist Desktop";
2132
- const POLL_MS$1 = 2e3;
2133
- const LOCATE_TIMEOUT_MS = 2 * 6e4;
2134
- const READY_TIMEOUT_MS = 5 * 6e4;
2135
- const PUBLIC_URL_TIMEOUT_MS = 5e3;
2136
- function sleep$1(ms) {
2137
- return new Promise((r) => setTimeout(r, ms));
2138
- }
2139
- async function withTimeout(promise, timeoutMs) {
2140
- let timer = null;
1920
+ async function resolveAttachedDropletUuid(client, mist, mistId, publicUrl) {
1921
+ if (typeof mist.droplet?.uuid === "string" && mist.droplet.uuid.length > 0) return mist.droplet.uuid;
2141
1922
  try {
2142
- return await Promise.race([promise, new Promise((_, reject) => {
2143
- timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timed out after ${timeoutMs / 1e3}s`)), timeoutMs);
2144
- })]);
2145
- } finally {
2146
- if (timer) clearTimeout(timer);
1923
+ const { integration_points } = await listIntegrationPoints(client, mistId);
1924
+ const attached = findDropletIntegrationPoint(integration_points);
1925
+ if (attached?.integratable_uuid) return attached.integratable_uuid;
1926
+ if (attached?.integratable_id != null) {
1927
+ const uuid = await findDropletUuidByNumericId(client, attached.integratable_id);
1928
+ if (uuid) return uuid;
1929
+ }
1930
+ } catch (err) {
1931
+ if (err instanceof MistApiError && err.status === 0) throw err;
1932
+ }
1933
+ const byEmbedUrl = publicUrl ? await findDropletByEmbedUrl(client, publicUrl) : null;
1934
+ if (byEmbedUrl) {
1935
+ if (!byEmbedUrl.uuid) throw new DropletRepairRefusal("droplet_not_found", `Droplet with embed_url ${publicUrl} was found, but its API response did not include a uuid.`);
1936
+ return byEmbedUrl.uuid;
2147
1937
  }
1938
+ throw new DropletRepairRefusal("droplet_not_found", "This Mist is not attached to a Droplet, and no Droplet with a matching embed_url was found. Attach or recreate the Droplet first (`fluid mist attach-droplet`), then retry.");
2148
1939
  }
2149
- const pushCommand = new Command("push").description("Commit working-tree changes and push to the Mist-managed remote (triggers a hosted deployment)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--watch", "Poll /deployments until ready or error").option("--branch <name>", "Branch to push (default: main)").option("--no-commit", "Skip the auto-commit step (push existing commits only)").option("-m, --message <message>", "Commit message for the auto-commit (default: timestamped Publish)").action((slug, opts) => runAction(async () => {
2150
- await ensureGitAvailable();
2151
- const resolved = resolveSlug(slug);
2152
- const client = createMistClient();
2153
- const branch = opts.branch ?? "main";
2154
- const cwd = process.cwd();
2155
- const driftSpinner = ora("Checking for remote changes…").start();
1940
+ /** Lifecycle paths from `fluid.capabilities.json`, or null when absent or
1941
+ * unreadable (the caller then uses the defaults). */
1942
+ function readLifecyclePaths(cwd) {
1943
+ const file = join(cwd, "fluid.capabilities.json");
1944
+ if (!existsSync(file)) return null;
2156
1945
  try {
2157
- const { git_credential: fetchCred } = await createGitCredential(client, resolved);
2158
- await gitFetch(cwd, fetchCred.remote_url, branch);
2159
- const { ahead, behind } = await gitAheadBehindVsFetchHead(cwd);
2160
- if (behind > 0 && ahead === 0) {
2161
- const wasDirty = await hasUncommittedChanges(cwd);
2162
- driftSpinner.text = `Pulling ${behind} commit${behind === 1 ? "" : "s"} from origin/main…`;
2163
- if (wasDirty) await gitStash(cwd, "mist-push: pre-pull stash");
2164
- try {
2165
- await gitMergeFastForward(cwd);
2166
- } finally {
2167
- if (wasDirty) await gitStashPop(cwd);
2168
- }
2169
- driftSpinner.succeed(`Pulled ${behind} commit${behind === 1 ? "" : "s"} from origin/main`);
2170
- } else if (behind > 0 && ahead > 0) {
2171
- driftSpinner.fail(`Your branch and origin/main have diverged (you're ${ahead} ahead, ${behind} behind).`);
2172
- console.error(chalk.yellow(" Resolve in a terminal: `git pull --rebase`, fix any conflicts, then re-run Publish."));
2173
- process.exit(1);
2174
- } else driftSpinner.succeed("Up to date with origin/main");
2175
- } catch (err) {
2176
- if (err instanceof Error && err.message.includes("Resolve in a terminal")) throw err;
2177
- driftSpinner.fail("Couldn't sync with origin/main");
2178
- throw err;
1946
+ return dropletLifecyclePathsFromCapabilities(JSON.parse(readFileSync(file, "utf8")));
1947
+ } catch {
1948
+ return null;
2179
1949
  }
2180
- if (opts.commit !== false) {
2181
- if (await hasUncommittedChanges(cwd)) {
2182
- const explicit = opts.message?.trim();
2183
- const actor = await resolveGitSyncActor(async () => {
2184
- try {
2185
- return gitSyncActorFromMe(await withTimeout(fetchCurrentUser(client), 5e3));
2186
- } catch (err) {
2187
- const reason = err instanceof Error ? err.message : String(err);
2188
- console.warn(chalk.yellow(` (skipping user-stamp on commit — couldn't fetch /api/me: ${reason})`));
2189
- return null;
2190
- }
2191
- });
2192
- let subject = "";
2193
- const commitSpinner = ora("Committing changes…").start();
2194
- try {
2195
- await gitAutoCommit(cwd, (changes) => {
2196
- subject = gitSyncCommitSubject(explicit || summarizeChanges(changes) || FALLBACK_ACTION, actor);
2197
- return subject;
2198
- });
2199
- commitSpinner.succeed(subject ? `Committed — ${subject}` : "Committed");
2200
- } catch (err) {
2201
- commitSpinner.fail("Commit failed");
2202
- throw err;
2203
- }
2204
- }
2205
- }
2206
- const spinner = ora("Minting git credential…").start();
2207
- const { git_credential: cred } = await createGitCredential(client, resolved);
2208
- spinner.text = `Pushing ${branch} to origin…`;
2209
- try {
2210
- await gitPush(cwd, cred.remote_url, branch);
2211
- } catch (err) {
2212
- spinner.fail("git push failed");
2213
- throw err;
2214
- }
2215
- spinner.succeed("Push complete");
2216
- if (!opts.watch) return;
2217
- let pushedSha = null;
2218
- try {
2219
- const { execa } = await import("execa");
2220
- pushedSha = (await execa("git", ["rev-parse", "HEAD"], { cwd })).stdout.trim();
2221
- } catch {}
2222
- const locateStart = Date.now();
2223
- let deploymentId = null;
2224
- let attempt = 0;
2225
- while (Date.now() - locateStart < LOCATE_TIMEOUT_MS) {
2226
- attempt++;
2227
- const { deployments } = await listDeployments(client, resolved, 5);
2228
- const match = pushedSha ? deployments.find((d) => d.commit_sha === pushedSha) : deployments[0];
2229
- if (match) {
2230
- deploymentId = match.id;
2231
- console.log(`Located deployment ${match.id} (${match.state})`);
2232
- break;
2233
- }
2234
- if (attempt === 1) console.log(`Locating deployment for commit ${pushedSha?.slice(0, 7) ?? "(unknown)"}…`);
2235
- else if (attempt % 5 === 0) console.log(` …still waiting (${attempt * 2}s elapsed)`);
2236
- await sleep$1(POLL_MS$1);
2237
- }
2238
- if (!deploymentId) {
2239
- console.warn(chalk.yellow(`Push succeeded, but hosting has not started commit ${pushedSha?.slice(0, 7) ?? "(unknown)"} after ${LOCATE_TIMEOUT_MS / 1e3}s.`));
2240
- console.warn(chalk.yellow("The deployment is still queued and may appear later. Run `fluid mist deployments` to check it — do not retry or rewrite the commit just because the hosting queue is slow."));
2241
- return;
2242
- }
2243
- const readyStart = Date.now();
2244
- let lastState = null;
2245
- while (Date.now() - readyStart < READY_TIMEOUT_MS) {
2246
- const { deployment } = await showDeployment(client, resolved, deploymentId);
2247
- if (deployment.state !== lastState) {
2248
- console.log(` ${deployment.state}…`);
2249
- lastState = deployment.state;
2250
- }
2251
- if (deployment.state === "ready") {
2252
- let publicUrl = null;
2253
- try {
2254
- publicUrl = (await withTimeout(showMist(client, resolved), PUBLIC_URL_TIMEOUT_MS)).mist.public_url;
2255
- } catch (err) {
2256
- const reason = err instanceof Error ? err.message : String(err);
2257
- console.warn(chalk.yellow(` Deployment is ready, but the public URL lookup failed: ${reason}`));
2258
- }
2259
- console.log(readyDeploymentMessage(publicUrl, resolved));
2260
- return;
2261
- }
2262
- if (deployment.state === "error" || deployment.state === "canceled") {
2263
- console.error(`✘ Deployment ${deployment.state}`);
2264
- process.exit(3);
2265
- }
2266
- await sleep$1(POLL_MS$1);
2267
- }
2268
- console.error(`Watch timed out after ${READY_TIMEOUT_MS / 1e3}s. Last state: ${lastState}.`);
2269
- console.error("Run `fluid mist deployments` to check the latest state.");
2270
- process.exit(3);
2271
- }));
2272
- //#endregion
2273
- //#region src/commands/pull.ts
2274
- /** `fluid mist pull` — git pull origin main. Spec §3. */
2275
- const pullCommand = new Command("pull").description("git pull from the Mist-managed remote").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--branch <name>", "Branch to pull (default: main)").action((slug, opts) => runAction(async () => {
2276
- await ensureGitAvailable();
2277
- const resolved = resolveSlug(slug);
2278
- const client = createMistClient();
2279
- const branch = opts.branch ?? "main";
2280
- const spinner = ora("Minting git credential…").start();
2281
- const { git_credential: cred } = await createGitCredential(client, resolved);
2282
- spinner.text = `Pulling ${branch}…`;
2283
- try {
2284
- await gitPull(process.cwd(), cred.remote_url, branch);
2285
- } catch (err) {
2286
- spinner.fail("git pull failed");
2287
- throw err;
2288
- }
2289
- spinner.succeed("Pull complete");
2290
- }));
2291
- //#endregion
2292
- //#region src/commands/open.ts
2293
- /** `fluid mist open` — opens public_url in the default browser. Spec §3. */
2294
- const openCommand = new Command("open").description("Open the mist's public URL in your browser").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").action((slug) => runAction(async () => {
2295
- const resolved = resolveSlug(slug);
2296
- const { mist } = await showMist(createMistClient(), resolved);
2297
- if (!mist.public_url) {
2298
- console.error(chalk.yellow(`Mist ${mist.slug} has no public URL yet (state: ${mist.state}).`));
2299
- process.exit(1);
2300
- }
2301
- console.log(chalk.cyan(mist.public_url));
2302
- await open(mist.public_url);
2303
- }));
2304
- //#endregion
2305
- //#region src/commands/deployments.ts
2306
- /** `fluid mist deployments` — list recent deployments. Spec §3. */
2307
- const STATE_COLOR = {
2308
- ready: chalk.green,
2309
- building: chalk.yellow,
2310
- queued: chalk.dim,
2311
- error: chalk.red,
2312
- canceled: chalk.red
2313
- };
2314
- const deploymentsCommand = new Command("deployments").description("List recent deployments for a mist").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--limit <n>", "How many to fetch (default 10)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
2315
- const resolved = resolveSlug(slug);
2316
- const response = await listDeployments(createMistClient(), resolved, opts.limit ? parseInt(opts.limit, 10) : 10);
2317
- if (opts.json) return printJson(response);
2318
- const deployments = response.deployments ?? [];
2319
- if (deployments.length === 0) {
2320
- console.log(chalk.dim("No deployments yet."));
2321
- return;
2322
- }
2323
- printTable([[
2324
- chalk.bold("ID"),
2325
- chalk.bold("STATE"),
2326
- chalk.bold("COMMIT"),
2327
- chalk.bold("MESSAGE"),
2328
- chalk.bold("AGE")
2329
- ], ...deployments.map((d) => [
2330
- d.id,
2331
- (STATE_COLOR[d.state] ?? chalk.white)(d.state),
2332
- d.commit_sha ? d.commit_sha.slice(0, 7) : chalk.dim("—"),
2333
- d.commit_message ? d.commit_message.slice(0, 48) : chalk.dim("—"),
2334
- relativeDate(d.created_at)
2335
- ])]);
2336
- }));
2337
- //#endregion
2338
- //#region src/api/logs.ts
2339
- async function fetchLogs(client, slug, params = {}) {
2340
- const id = await resolveMistId(client, slug);
2341
- return client.get(`/api/v202604/mists/${id}/logs`, params);
2342
- }
2343
- //#endregion
2344
- //#region src/commands/logs.ts
2345
- /** `fluid mist logs [--tail]` — poll /logs?since=<last> every 2s. Spec §4. */
2346
- const POLL_MS = 2e3;
2347
- function sleep(ms) {
2348
- return new Promise((r) => setTimeout(r, ms));
2349
- }
2350
- function levelColor(level) {
2351
- return level === "stderr" ? chalk.red : chalk.dim;
2352
- }
2353
- function printLine(entry) {
2354
- const ts = entry.timestamp.slice(11, 19);
2355
- process.stdout.write(`${chalk.dim(ts)} ${levelColor(entry.level)(entry.level.padEnd(6))} ${entry.message}\n`);
2356
1950
  }
2357
- const logsCommand = new Command("logs").description("Fetch deployment logs (use --tail to stream)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--tail", "Stream new logs every 2s").option("--limit <n>", "Initial fetch size (default 200)").option("--deployment <id>", "Logs from a specific deployment ID").option("--json", "Emit raw API JSON (one batch; ignored when --tail)").action((slug, opts) => runAction(async () => {
2358
- const resolved = resolveSlug(slug);
2359
- const client = createMistClient();
2360
- const limit = opts.limit ? parseInt(opts.limit, 10) : 200;
2361
- const initial = await fetchLogs(client, resolved, {
2362
- ...opts.deployment ? { deployment_id: opts.deployment } : {},
2363
- limit
1951
+ /** Read the Droplet and report the planned repair without mutating. */
1952
+ async function planDropletRepair(client, target, dryRun) {
1953
+ const { droplet } = await getDroplet(client, target.dropletUuid);
1954
+ const { alreadyCorrect, summary } = planLifecycleRepair({
1955
+ mistId: target.mistId,
1956
+ mistSlug: target.mistSlug,
1957
+ dropletUuid: target.dropletUuid,
1958
+ canonical: target.canonical,
1959
+ current: droplet,
1960
+ dryRun
2364
1961
  });
2365
- if (opts.json && !opts.tail) return printJson(initial);
2366
- for (const entry of initial.logs) printLine(entry);
2367
- if (!opts.tail) return;
2368
- process.stdout.write(`${chalk.dim(`Streaming production logs for ${resolved}${initial.deployment_id ? ` (deployment ${initial.deployment_id})` : ""} — waiting for new entries…`)}\n`);
2369
- let since = initial.logs.length > 0 ? initial.logs[initial.logs.length - 1].timestamp : (/* @__PURE__ */ new Date()).toISOString();
2370
- while (true) {
2371
- await sleep(POLL_MS);
2372
- const batch = await fetchLogs(client, resolved, {
2373
- ...opts.deployment ? { deployment_id: opts.deployment } : {},
2374
- since,
2375
- limit
2376
- });
2377
- for (const entry of batch.logs) {
2378
- printLine(entry);
2379
- since = entry.timestamp;
1962
+ return {
1963
+ alreadyCorrect,
1964
+ summary: {
1965
+ ...summary,
1966
+ lifecyclePathsSource: target.lifecyclePathsSource
2380
1967
  }
2381
- }
2382
- }));
2383
- //#endregion
2384
- //#region src/lib/encrypted-env.ts
2385
- /**
2386
- * Detects Vercel-encrypted env var ciphertext.
2387
- *
2388
- * `GET /api/v202604/mists/:id/env_vars` is a thin proxy to the Mist's
2389
- * Vercel project env — it returns values ENCRYPTED, never plaintext (see
2390
- * apps/mist-desktop/src/main/services/mist-env-vars.service.ts:25-28,
2391
- * which documents the same server behavior for the desktop app's copy of
2392
- * this API). The ciphertext is a base64-encoded JSON envelope of shape
2393
- * `{"v":"v2","c":"<base64 ciphertext>","k":[<byte array>]}`.
2394
- *
2395
- * `fluid mist env pull` uses this to avoid writing that ciphertext into
2396
- * `.env.local` as if it were a usable value.
2397
- */
2398
- const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
2399
- function looksLikeBase64(value) {
2400
- return value.length > 0 && value.length % 4 === 0 && BASE64_RE.test(value);
2401
- }
2402
- function hasEncryptedEnvelopeShape(value) {
2403
- return typeof value === "object" && value !== null && !Array.isArray(value) && "v" in value && "c" in value && "k" in value;
1968
+ };
2404
1969
  }
2405
1970
  /**
2406
- * True when `value` is the Vercel encrypted-env envelope: a base64 string
2407
- * that decodes to a JSON object with the `{ v, c, k }` shape. Returns
2408
- * false for empty strings, non-base64 strings, and base64 strings that
2409
- * decode to plaintext or to JSON that doesn't match the envelope shape —
2410
- * so real plaintext secrets that happen to look like base64 are never
2411
- * misclassified as ciphertext.
1971
+ * PATCH only the embed and lifecycle webhook URLs, then re-read the Droplet
1972
+ * to verify. A no-op when they already match.
2412
1973
  */
2413
- function isVercelEncryptedEnvValue(value) {
2414
- if (!looksLikeBase64(value)) return false;
2415
- let decoded;
1974
+ async function applyDropletRepair(client, target) {
1975
+ const planned = await planDropletRepair(client, target, false);
1976
+ if (planned.alreadyCorrect) return {
1977
+ summary: planned.summary,
1978
+ isError: false
1979
+ };
1980
+ await client.patch(`/api/droplets/${encodeURIComponent(target.dropletUuid)}`, dropletRepairPatch(target.canonical));
1981
+ let verifyStatus = 200;
1982
+ let verified = false;
2416
1983
  try {
2417
- decoded = Buffer.from(value, "base64").toString("utf8");
2418
- } catch {
2419
- return false;
1984
+ const { droplet } = await getDroplet(client, target.dropletUuid);
1985
+ verified = dropletUrlsMatch(droplet, target.canonical);
1986
+ } catch (err) {
1987
+ verifyStatus = err instanceof MistApiError ? err.status : 0;
2420
1988
  }
2421
- let parsed;
2422
- try {
2423
- parsed = JSON.parse(decoded);
2424
- } catch {
2425
- return false;
2426
- }
2427
- return hasEncryptedEnvelopeShape(parsed);
2428
- }
2429
- //#endregion
2430
- //#region src/commands/env/list.ts
2431
- /** `fluid mist env list` — list env vars; mark managed_by entries. Spec §4. */
2432
- function maskValue(value) {
2433
- if (value.length <= 8) return "•".repeat(value.length);
2434
- return `${value.slice(0, 4)}${"•".repeat(8)}${value.slice(-2)}`;
1989
+ return {
1990
+ summary: {
1991
+ ...planned.summary,
1992
+ status: verified ? "updated" : "error",
1993
+ verified,
1994
+ verifyStatus
1995
+ },
1996
+ isError: !verified
1997
+ };
2435
1998
  }
1999
+ const PLAN_TTL_MINUTES = REINSTALL_PLAN_TTL_MS / 6e4;
2436
2000
  /**
2437
- * Values come back Vercel-encrypted ciphertext (see
2438
- * `../../lib/encrypted-env.ts`) — never show that raw blob, masked or not,
2439
- * since it isn't the real secret and can run to ~1KB of base64.
2001
+ * `fluid mist droplet reinstall`. Same sequencing as Mist's
2002
+ * `repair_and_reinstall_droplet`: plan first, explicit approval, one
2003
+ * backend-owned replacement, resumable watching. Refusals return a
2004
+ * non-zero exit code; nothing irreversible starts without `--confirm`
2005
+ * matching a fresh `--dry-run` plan.
2440
2006
  */
2441
- function displayValue(v, showValues) {
2442
- if (isVercelEncryptedEnvValue(v.value)) return chalk.dim("<encrypted>");
2443
- return showValues ? v.value : maskValue(v.value);
2444
- }
2445
- const envListCommand = new Command("list").description("List a mist's env vars").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--show-values", "Print full values instead of masking them").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
2446
- const resolved = resolveSlug(slug);
2447
- const response = await listEnvVars(createMistClient(), resolved);
2448
- if (opts.json) return printJson(response);
2449
- const vars = response.env_vars ?? [];
2450
- if (vars.length === 0) {
2451
- console.log(chalk.dim("No env vars."));
2452
- return;
2453
- }
2454
- printTable([[
2455
- chalk.bold("KEY"),
2456
- chalk.bold("VALUE"),
2457
- chalk.bold("TARGETS"),
2458
- chalk.bold("MANAGED")
2459
- ], ...vars.map((v) => [
2460
- v.key,
2461
- displayValue(v, opts.showValues),
2462
- v.targets.join(","),
2463
- v.managed_by === "mist" ? chalk.magenta("mist") : chalk.dim("user")
2464
- ])]);
2465
- }));
2466
- //#endregion
2467
- //#region ../../mist/core/src/secret-generator.ts
2468
- const MIN_BYTES = 16;
2469
- const MAX_BYTES = 128;
2470
- const DEFAULT_BYTES = 32;
2471
- /** Return a random secret. Pure + synchronous so it's trivially
2472
- * testable (entropy + encoding + clamping) without any I/O. */
2473
- function generateSecret(options = {}) {
2474
- const encoding = options.encoding ?? "base64url";
2475
- const requested = options.bytes ?? DEFAULT_BYTES;
2476
- const bytes = Math.min(MAX_BYTES, Math.max(MIN_BYTES, Math.trunc(requested)));
2477
- const buf = new Uint8Array(bytes);
2478
- globalThis.crypto.getRandomValues(buf);
2479
- return {
2480
- secret: encode(buf, encoding),
2481
- bytes,
2482
- encoding
2007
+ async function runDropletReinstall(deps, opts) {
2008
+ const now = deps.now ?? Date.now;
2009
+ const refusal = (code, message) => ({
2010
+ output: {
2011
+ status: "refused",
2012
+ code,
2013
+ message
2014
+ },
2015
+ exitCode: 1
2016
+ });
2017
+ if (opts.dryRun && opts.confirm) return refusal("conflicting_flags", "Pass either --dry-run (show the plan) or --confirm (execute it), not both.");
2018
+ if (opts.dryRun && opts.resumeOperationId?.trim()) return refusal("conflicting_flags", "--resume keeps watching a replacement that already started; it does not take --dry-run. Run it with --installation only.");
2019
+ const request = {
2020
+ dryRun: opts.dryRun === true,
2021
+ confirmDestructiveReinstall: opts.confirm === true,
2022
+ installationUuid: opts.installationUuid?.trim() || void 0,
2023
+ resumeOperationId: opts.resumeOperationId
2483
2024
  };
2025
+ const requestCheck = checkReplacementRequest(request);
2026
+ if (!requestCheck.ok) return refusal(requestCheck.code, "--resume also needs --installation with the installationUuid from that same \"still_running\" result. Replacement status is scoped to the installation the operation started on, so resuming without it would watch the wrong one.");
2027
+ const { resumeOperationId } = requestCheck;
2028
+ if (!request.dryRun && replacementStartNeedsConfirmation(request, resumeOperationId)) return refusal("confirmation_required", opts.yes ? "--yes does not approve an irreversible reinstall. Run with --dry-run, review the exact dropletUuid and installationUuid, then rerun with --confirm." : "Refused to start an irreversible replacement without explicit confirmation. Run with --dry-run, review the exact dropletUuid and installationUuid, then rerun with --confirm.");
2029
+ try {
2030
+ const target = await resolveRepairTarget(deps.client, {
2031
+ slug: deps.slug,
2032
+ cwd: deps.cwd
2033
+ });
2034
+ const plannedRepair = await planDropletRepair(deps.client, target, true);
2035
+ const installation = selectReplacementInstallation({
2036
+ installations: await listAllDropletInstallations(deps.client),
2037
+ dropletUuid: target.dropletUuid,
2038
+ installationUuid: request.installationUuid,
2039
+ resumeOperationId
2040
+ });
2041
+ if (!installation?.uuid) return refusal("installation_not_found", `No active installation for attached Droplet ${target.dropletUuid} was found. If a previous approved replacement terminalized the source, rerun with that plan's --installation <uuid>; otherwise install the Droplet once from Fluid first.`);
2042
+ const installationUuid = installation.uuid;
2043
+ const idempotencyKey = deps.newIdempotencyKey();
2044
+ const planInput = {
2045
+ dryRun: request.dryRun,
2046
+ resumeOperationId,
2047
+ mistId: target.mistId,
2048
+ dropletUuid: target.dropletUuid,
2049
+ installation: {
2050
+ ...installation,
2051
+ uuid: installationUuid
2052
+ },
2053
+ idempotencyKey
2054
+ };
2055
+ if (request.dryRun) {
2056
+ deps.planStore.save({
2057
+ mistId: target.mistId,
2058
+ dropletUuid: target.dropletUuid,
2059
+ installationUuid,
2060
+ ...target.canonical,
2061
+ plannedAt: new Date(now()).toISOString()
2062
+ });
2063
+ const confirmCommand = reinstallCommand(target.mistSlug, ["--confirm", ...request.installationUuid ? ["--installation", installationUuid] : []]);
2064
+ return {
2065
+ output: {
2066
+ ...buildReplacementPlan({
2067
+ ...planInput,
2068
+ lifecycleRepair: plannedRepair.summary
2069
+ }),
2070
+ nextStep: `Review the dropletUuid and installationUuid above. This replacement is irreversible. To approve it, run \`${confirmCommand}\` within ${PLAN_TTL_MINUTES} minutes.`
2071
+ },
2072
+ exitCode: 0
2073
+ };
2074
+ }
2075
+ let lifecycleRepair = plannedRepair.summary;
2076
+ let startedOperation = null;
2077
+ if (!resumeOperationId) {
2078
+ const receiptCheck = checkPlanReceipt(deps.planStore.claim(target.mistId), {
2079
+ dropletUuid: target.dropletUuid,
2080
+ installationUuid,
2081
+ ...target.canonical
2082
+ }, now());
2083
+ if (!receiptCheck.ok) return refusal(receiptCheck.code, planReceiptMessage(receiptCheck.code));
2084
+ const repaired = await applyDropletRepair(deps.client, target);
2085
+ lifecycleRepair = repaired.summary;
2086
+ if (repaired.isError) return {
2087
+ output: {
2088
+ status: "error",
2089
+ code: "lifecycle_repair_failed",
2090
+ message: "The Droplet lifecycle URLs did not verify after the repair, so no replacement was started.",
2091
+ lifecycleRepair
2092
+ },
2093
+ exitCode: 1
2094
+ };
2095
+ startedOperation = await startDropletReplacement(deps.client, {
2096
+ mistId: target.mistId,
2097
+ installationUuid,
2098
+ idempotencyKey
2099
+ });
2100
+ }
2101
+ const plan = buildReplacementPlan({
2102
+ ...planInput,
2103
+ lifecycleRepair
2104
+ });
2105
+ const operationId = resumeOperationId ?? startedOperation?.id;
2106
+ if (!operationId) return {
2107
+ output: {
2108
+ ...plan,
2109
+ status: "error",
2110
+ code: "missing_operation_id",
2111
+ message: "Fluid accepted the replacement but did not return an operation id to watch."
2112
+ },
2113
+ exitCode: 1
2114
+ };
2115
+ const watchedForMs = opts.watchTimeoutMs ?? 12e4;
2116
+ const watch = summarizeReplacementWatch({
2117
+ plan,
2118
+ pollResult: await pollDropletReplacement(deps.client, {
2119
+ mistId: target.mistId,
2120
+ installationUuid,
2121
+ operationId,
2122
+ timeoutMs: watchedForMs,
2123
+ ...deps.sleep ? { sleep: deps.sleep } : {}
2124
+ }),
2125
+ startedOperation,
2126
+ operationId,
2127
+ installationUuid,
2128
+ watchedForMs,
2129
+ nextStep: ({ operationId: id, installationUuid: source, stage }) => `Fluid is still working on this replacement: it is not finished and it did not fail. Current stage: ${stage}. Keep watching with \`${reinstallCommand(target.mistSlug, [
2130
+ "--resume",
2131
+ id,
2132
+ "--installation",
2133
+ source
2134
+ ])}\`. The reinstall is complete only when status is "succeeded".`
2135
+ });
2136
+ return {
2137
+ output: { ...watch.result },
2138
+ exitCode: watch.isError ? 1 : 0
2139
+ };
2140
+ } catch (err) {
2141
+ if (err instanceof DropletRepairRefusal) return refusal(err.code, err.message);
2142
+ throw err;
2143
+ }
2484
2144
  }
2485
- function encode(buf, encoding) {
2486
- if (encoding === "hex") return Array.from(buf, (byte) => byte.toString(16).padStart(2, "0")).join("");
2487
- const base64 = btoa(String.fromCharCode(...buf));
2488
- if (encoding === "base64") return base64;
2489
- return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2145
+ function reinstallCommand(slug, flags) {
2146
+ return [
2147
+ "fluid mist droplet reinstall",
2148
+ slug,
2149
+ ...flags
2150
+ ].join(" ");
2151
+ }
2152
+ function planReceiptMessage(code) {
2153
+ switch (code) {
2154
+ case "plan_required": return "Refused: no reinstall plan has been shown for this Mist. Run with --dry-run first, review the plan, then rerun with --confirm.";
2155
+ case "plan_stale": return `Refused: the reinstall plan is older than ${PLAN_TTL_MINUTES} minutes. Run with --dry-run again, review the plan, then rerun with --confirm.`;
2156
+ case "plan_changed": return "Refused: the Droplet, installation or lifecycle URLs changed since the plan was shown. Run with --dry-run again, review the new plan, then rerun with --confirm.";
2157
+ }
2490
2158
  }
2491
2159
  //#endregion
2492
- //#region src/commands/env/set.ts
2160
+ //#region src/lib/droplet-egress.ts
2161
+ /**
2162
+ * Ask Fluid for, or withdraw, egress through its egress proxy for the Mist's
2163
+ * attached Droplet (`fluid mist droplet egress-proxy`). The owner's ask is the
2164
+ * droplet's `egress_proxy_requested`; the grant the proxy honours,
2165
+ * `egress_proxy_enabled`, is set by a Fluid root admin, who approves it.
2166
+ * Withdrawing the request also withdraws the grant.
2167
+ */
2168
+ /** Why a droplet response can lack the egress fields. Fluid returns them only
2169
+ * to company admins (and root admins), and older Fluids not at all. */
2170
+ const EGRESS_NOT_RETURNED = "Fluid did not return the droplet's egress fields: only a company admin can see or ask for egress, or this Fluid predates it.";
2171
+ /** Fluid refused the egress request outright (401): it accepts one only
2172
+ * from a company admin. Distinct from an older Fluid, which ignores the
2173
+ * field and omits it from the response. */
2174
+ const EGRESS_REFUSED = "Fluid refused the egress request: only a company admin can ask for egress. The droplet was set up without it.";
2175
+ /** Whether an API error is Fluid refusing the egress request: a 401 whose
2176
+ * `errors` names `egress_proxy_requested`. Matched exactly, so a real
2177
+ * authentication failure is reported as one rather than as a refusal. */
2178
+ function isEgressRefusal(error) {
2179
+ return error instanceof MistApiError && error.status === 401 && error.fieldErrors?.egress_proxy_requested !== void 0;
2180
+ }
2181
+ /** The API answered without the egress fields. */
2182
+ var EgressProxyUnsupportedError = class extends Error {
2183
+ constructor() {
2184
+ super(EGRESS_NOT_RETURNED);
2185
+ this.name = "EgressProxyUnsupportedError";
2186
+ }
2187
+ };
2493
2188
  /**
2494
- * `fluid mist env set <slug> KEY=value [KEY=value ...]` — POST for new
2495
- * keys, PATCH for existing. Spec §3 + §4.
2496
- *
2497
- * `fluid mist env set [slug] KEY --generate [--bytes N]` mints a random
2498
- * secret for KEY instead (the generator Mist's `set_mist_env_var` uses),
2499
- * writes it, and prints it exactly once.
2189
+ * Set the attached droplet's egress request. Reads it first so an
2190
+ * already-correct droplet is left untouched, then PATCHes and trusts only
2191
+ * the values the API returns.
2500
2192
  */
2501
- /** Env var name rule enforced by Rails (mirrors Mist's ENV_KEY_RE). */
2502
- const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
2503
- const GENERATED_SECRET_NOTICE = "This secret is shown once and won't be shown again; Fluid stores it encrypted. Copy it now if you also need it in .env.local. Redeploy (fluid mist push) for the change to take effect.";
2504
- function parsePair(pair) {
2505
- const eq = pair.indexOf("=");
2506
- if (eq <= 0) throw new Error(`Invalid pair "${pair}". Use KEY=value (no leading spaces).`);
2193
+ async function setDropletEgressProxy(client, input) {
2194
+ const slug = resolveSlug(input.slug, input.cwd);
2195
+ const mistId = await resolveMistId(client, slug);
2196
+ const { mist } = await showMist(client, mistId);
2197
+ const dropletUuid = await resolveAttachedDropletUuid(client, mist, mistId, mist.public_url ?? null);
2198
+ const mistSlug = mist.slug ?? slug;
2199
+ const current = (await getDroplet(client, dropletUuid)).droplet;
2200
+ if (current.egress_proxy_requested === void 0) throw new EgressProxyUnsupportedError();
2201
+ const grantLeftOn = !input.requested && current.egress_proxy_enabled === true;
2202
+ if (current.egress_proxy_requested === input.requested && !grantLeftOn) return {
2203
+ mistSlug,
2204
+ dropletUuid,
2205
+ requested: current.egress_proxy_requested,
2206
+ granted: current.egress_proxy_enabled === true,
2207
+ changed: false
2208
+ };
2209
+ const updated = (await updateDroplet(client, dropletUuid, { egress_proxy_requested: input.requested })).droplet;
2210
+ if (updated.egress_proxy_requested === void 0) throw new EgressProxyUnsupportedError();
2507
2211
  return {
2508
- key: pair.slice(0, eq),
2509
- value: pair.slice(eq + 1)
2212
+ mistSlug,
2213
+ dropletUuid,
2214
+ requested: updated.egress_proxy_requested,
2215
+ granted: updated.egress_proxy_enabled === true,
2216
+ changed: true
2510
2217
  };
2511
2218
  }
2512
- function parseBytes(value) {
2513
- const bytes = Number(value);
2514
- if (!Number.isInteger(bytes) || bytes < 16 || bytes > 128) throw new InvalidArgumentError("Use a whole number from 16 to 128.");
2515
- return bytes;
2516
- }
2517
- /**
2518
- * Generate a secret for `key` and write it to the Mist. Refuses invalid
2519
- * and Mist-managed keys BEFORE generating, so a secret is only ever
2520
- * returned once it has been stored.
2521
- */
2522
- async function setGeneratedEnvVar(client, slug, key, bytes) {
2523
- if (!ENV_KEY_RE.test(key)) throw new Error(`Invalid env var name "${key}". Use letters, digits and underscores, not starting with a digit.`);
2524
- const current = ((await listEnvVars(client, slug)).env_vars ?? []).find((v) => v.key === key);
2525
- if (current?.managed_by === "mist") throw new Error(`"${key}" is managed by Fluid and can't be set; it is provisioned automatically for the Mist.`);
2526
- const secret = generateSecret(bytes === void 0 ? {} : { bytes });
2527
- if (current) await updateEnvVar(client, slug, key, secret.secret);
2528
- else await createEnvVar(client, slug, key, secret.secret);
2529
- return {
2530
- key,
2531
- action: current ? "updated" : "created",
2532
- generatedSecret: secret.secret,
2533
- bytes: secret.bytes,
2534
- encoding: secret.encoding,
2535
- note: GENERATED_SECRET_NOTICE
2536
- };
2537
- }
2538
- const envSetCommand = new Command("set").description("Set one or more env vars (KEY=value KEY=value …), or generate a secret for KEY with --generate").argument("<slug-or-pair>", "The mist slug, or the first KEY=value pair (or KEY with --generate)").argument("[pairs...]", "Additional KEY=value pairs (or KEY with --generate)").option("--generate", "Generate a random secret as the value of one KEY and print it once").option("--bytes <n>", "Entropy for --generate, in bytes (16-128, default 32)", parseBytes).option("--json", "Emit JSON (with --generate)").action(async (slugOrPair, pairs, opts) => {
2539
- await runAction(async () => {
2540
- if (opts.generate) {
2541
- await runGenerate([slugOrPair, ...pairs], opts);
2542
- return;
2543
- }
2544
- if (opts.bytes !== void 0) {
2545
- console.error(chalk.red("--bytes only applies with --generate."));
2546
- process.exit(1);
2547
- }
2548
- let slug;
2549
- let rawPairs;
2550
- if (slugOrPair.includes("=")) {
2551
- slug = resolveSlug();
2552
- rawPairs = [slugOrPair, ...pairs];
2553
- } else {
2554
- slug = resolveSlug(slugOrPair);
2555
- rawPairs = pairs;
2556
- }
2557
- if (rawPairs.length === 0) {
2558
- console.error(chalk.red("Pass at least one KEY=value pair."));
2559
- process.exit(1);
2560
- }
2561
- const client = createMistClient();
2562
- const existing = await listEnvVars(client, slug);
2563
- const existingKeys = new Set((existing.env_vars ?? []).map((v) => v.key));
2564
- for (const raw of rawPairs) {
2565
- const { key, value } = parsePair(raw);
2566
- if (existingKeys.has(key)) {
2567
- await updateEnvVar(client, slug, key, value);
2568
- console.log(`${chalk.green("updated")} ${key}`);
2569
- } else {
2570
- await createEnvVar(client, slug, key, value);
2571
- console.log(`${chalk.cyan("created")} ${key}`);
2572
- }
2573
- }
2574
- });
2575
- });
2576
- /** `env set [slug] KEY --generate`: exactly one KEY, without a value. */
2577
- async function runGenerate(args, opts) {
2578
- if (args.length > 2) {
2579
- console.error(chalk.red("--generate sets one key at a time: env set [slug] KEY --generate"));
2580
- process.exit(1);
2581
- }
2582
- const [first, second] = args;
2583
- const key = (second ?? first ?? "").trim();
2584
- if (key.includes("=")) {
2585
- console.error(chalk.red(`--generate creates the value itself; pass the key alone (e.g. ${key.split("=")[0]}).`));
2586
- process.exit(1);
2587
- }
2588
- const slug = second === void 0 ? resolveSlug() : resolveSlug(first);
2589
- const result = await setGeneratedEnvVar(createMistClient(), slug, key, opts.bytes);
2590
- if (opts.json) {
2591
- printJson(result);
2592
- return;
2593
- }
2594
- const verb = result.action === "updated" ? chalk.green("updated") : chalk.cyan("created");
2595
- console.log(`${verb} ${result.key} (${result.bytes}-byte generated secret)`);
2596
- console.log();
2597
- console.log(result.generatedSecret);
2598
- console.log();
2599
- console.log(chalk.yellow(result.note));
2600
- }
2601
- //#endregion
2602
- //#region src/commands/env/unset.ts
2603
- /** `fluid mist env unset <slug> KEY [KEY ...]` — DELETE per key. Spec §3. */
2604
- const envUnsetCommand = new Command("unset").description("Delete one or more env vars").argument("<slug-or-key>", "The mist slug, or the first KEY (if .mist file is present)").argument("[keys...]", "Additional keys").action(async (slugOrKey, keys) => {
2605
- await runAction(async () => {
2606
- let slug;
2607
- let toDelete;
2608
- if (/^[A-Z_][A-Z0-9_]*$/.test(slugOrKey)) {
2609
- slug = resolveSlug();
2610
- toDelete = [slugOrKey, ...keys];
2611
- } else {
2612
- slug = resolveSlug(slugOrKey);
2613
- toDelete = keys;
2614
- }
2615
- if (toDelete.length === 0) {
2616
- console.error(chalk.red("Pass at least one KEY."));
2617
- process.exit(1);
2618
- }
2619
- const client = createMistClient();
2620
- for (const key of toDelete) {
2621
- await deleteEnvVar(client, slug, key);
2622
- console.log(`${chalk.red("deleted")} ${key}`);
2623
- }
2624
- });
2625
- });
2626
2219
  //#endregion
2627
- //#region src/commands/env/pull.ts
2220
+ //#region src/lib/surface-provision.ts
2628
2221
  /**
2629
- * `fluid mist env pull` — writes `.env.local` with user-managed vars only.
2630
- * Skips `managed_by: "mist"` entries (DATABASE_URL, FLUID_*) since local
2631
- * dev uses PGlite, not the production database. Spec §4 + §5.
2222
+ * Surface provisioning engine — the one place the "create a Fluid surface and
2223
+ * wire it to a mist" flow lives, at parity with Mist Desktop's
2224
+ * mist-integration-points-api.service.ts. Exposed via `fluid mist create`
2225
+ * (create a bare mist first, then run this), `fluid mist attach` (any kind),
2226
+ * and `fluid mist attach-droplet` (droplet shortcut).
2632
2227
  *
2633
- * Also skips user-managed vars whose value comes back as Vercel-encrypted
2634
- * ciphertext (`GET .../env_vars` is a proxy to Vercel's project env, which
2635
- * never returns plaintext — see `../../lib/encrypted-env.ts`). Writing that
2636
- * ciphertext into `.env.local` verbatim would silently produce an unusable
2637
- * file, so those keys are collected and surfaced as a warning instead.
2228
+ * Three surface kinds, each with its own create endpoint + attach identifier:
2229
+ * - droplet → POST /api/droplets → drp_ uuid → integratable_uuid
2230
+ * - drop-zone → POST /api/drop_zones → dz_ uuid → integratable_uuid
2231
+ * - mobile-embed→ POST /api/company/mobile_widgets→ numeric id → integratable_id
2638
2232
  *
2639
- * The output file is MERGED, not replaced: pulled plaintext values update
2640
- * their existing lines (or are appended), while every other existing line
2641
- * — comments, locally maintained secrets for encrypted/managed keys, and
2642
- * local-only vars — is preserved verbatim. Replacing the file wholesale
2643
- * would delete manually set values for skipped keys, and an all-encrypted
2644
- * response would truncate the file to zero bytes right before telling the
2645
- * user to set those same values locally.
2233
+ * Design tenets (repo philosophy — "server is source of truth"):
2234
+ * - No local progress file. State is re-derived from the API each run.
2235
+ * - Attach-existing is idempotent: if an integration point already links the
2236
+ * target surface (matched via the #19689 integratable_uuid echo, or the
2237
+ * numeric id for mobile), skip. A droplet is 1-per-mist, so creating a new
2238
+ * droplet also skips when one is already attached. Drop-zones/mobile
2239
+ * embeds are additive (a mist can have many).
2240
+ * - Only a droplet attach provisions managed env keys
2241
+ * (FLUID_DROPLET_UUID/_SECRET/_WEBHOOK_AUTH_TOKEN); this flow only
2242
+ * VERIFIES they landed — it never writes them (the API 422s on write).
2243
+ * - A droplet must also be INSTALLED (POST /api/droplet_installations) —
2244
+ * create + link alone leave it not-connected/inactive, so install-scoped
2245
+ * features (dit/dex exchange, admin embeds, install webhooks) don't work.
2246
+ * The install step is idempotent (skips when an installation exists).
2646
2247
  */
2647
- function escapeValue(v) {
2648
- if (/[\s#"']/.test(v)) return `"${v.replace(/(["\\$`])/g, "\\$1")}"`;
2649
- return v;
2650
- }
2248
+ const DEFAULT_TIMING = {
2249
+ publicUrlTimeoutMs: 9e4,
2250
+ publicUrlIntervalMs: 3e3,
2251
+ envTimeoutMs: 12e4,
2252
+ envIntervalMs: 5e3
2253
+ };
2254
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2255
+ const KIND_LABEL$1 = {
2256
+ droplet: "droplet",
2257
+ "drop-zone": "drop zone",
2258
+ "mobile-embed": "mobile embed"
2259
+ };
2651
2260
  /**
2652
- * Splits the raw env-var list into what `env pull` can safely write vs.
2653
- * what it must skip, and why. Pure — no I/O — so it's unit-testable
2654
- * without mocking the API client or the filesystem.
2261
+ * Create (or reuse) a surface of `kind` and wire it to the mist. Idempotent
2262
+ * for droplet + attach-existing; additive for new drop-zones/mobile embeds.
2655
2263
  */
2656
- function partitionPullEnvVars(envVars) {
2657
- const plaintext = [];
2658
- const encrypted = [];
2659
- const managed = [];
2660
- for (const v of envVars) if (v.managed_by === "mist") managed.push(v);
2661
- else if (isVercelEncryptedEnvValue(v.value)) encrypted.push(v);
2662
- else plaintext.push(v);
2663
- return {
2664
- plaintext,
2665
- encrypted,
2666
- managed
2264
+ async function provisionSurface(client, options) {
2265
+ const { reporter, slugOrId, kind } = options;
2266
+ const timing = {
2267
+ ...DEFAULT_TIMING,
2268
+ ...options.timing
2667
2269
  };
2668
- }
2669
- /** Matches a `KEY=` / `export KEY=` assignment line and captures the key. */
2670
- const ENV_ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
2671
- /**
2672
- * Merges pulled plaintext vars into the existing env-file contents.
2673
- * Existing assignment lines for pulled keys are updated in place; every
2674
- * other line (comments, blank lines, locally maintained values for keys
2675
- * the pull skipped) is preserved verbatim; new keys are appended. Pure —
2676
- * no I/O — so it's unit-testable without touching the filesystem.
2677
- */
2678
- function mergeEnvContents(existing, plaintext) {
2679
- const updates = new Map(plaintext.map((v) => [v.key, `${v.key}=${escapeValue(v.value)}`]));
2680
- const lines = existing.length > 0 ? existing.split("\n") : [];
2681
- if (lines.at(-1) === "") lines.pop();
2682
- const out = [];
2683
- for (const line of lines) {
2684
- const key = ENV_ASSIGNMENT.exec(line)?.[1];
2685
- const replacement = key === void 0 ? void 0 : updates.get(key);
2686
- if (key !== void 0 && replacement !== void 0) {
2687
- out.push(replacement);
2688
- updates.delete(key);
2689
- } else out.push(line);
2690
- }
2691
- for (const rendered of updates.values()) out.push(rendered);
2692
- return out.length > 0 ? out.join("\n") + "\n" : "";
2693
- }
2694
- const envPullCommand = new Command("pull").description("Write user-managed env vars to .env.local in the CWD").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--out <path>", "Output path (default: .env.local)").action((slug, opts) => runAction(async () => {
2695
- const resolved = resolveSlug(slug);
2696
- const { env_vars } = await listEnvVars(createMistClient(), resolved);
2697
- const { plaintext, encrypted, managed } = partitionPullEnvVars(env_vars);
2698
- const outName = opts.out ?? ".env.local";
2699
- const target = join(process.cwd(), outName);
2700
- const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
2701
- writeFileSync(target, mergeEnvContents(existing, plaintext), "utf8");
2702
- console.log(chalk.green(`Wrote ${plaintext.length} var(s) to ${target.replace(process.cwd() + "/", "")}${existing.length > 0 ? " (existing entries for other keys preserved)" : ""}.`));
2703
- if (managed.length > 0) console.log(chalk.dim(`Skipped ${managed.length} Mist-managed var(s): ${managed.map((v) => v.key).join(", ")} (local dev uses PGlite)`));
2704
- if (encrypted.length > 0) console.log(chalk.yellow(`Skipped ${encrypted.length} var(s) whose values are stored encrypted and can't be pulled: ${encrypted.map((v) => v.key).join(", ")} — set them locally in ${outName} yourself (values are visible in the Vercel dashboard) or re-set them via \`fluid mist env set\`.`));
2705
- }));
2706
- //#endregion
2707
- //#region src/commands/env/index.ts
2708
- /** `fluid mist env` — sub-command aggregator. */
2709
- const envCommand = new Command("env").description("Manage a mist's environment variables").addCommand(envListCommand).addCommand(envSetCommand).addCommand(envUnsetCommand).addCommand(envPullCommand);
2710
- //#endregion
2711
- //#region ../../mist/core/src/droplet-lifecycle-repair.ts
2712
- const DEFAULT_DROPLET_LIFECYCLE_PATHS = Object.freeze({
2713
- installPath: "/api/webhooks/installed",
2714
- uninstallPath: "/api/webhooks/uninstalled"
2715
- });
2716
- /** The note every repair summary carries: the repair fixes future delivery,
2717
- * it does not replay `droplet.installed` for an existing installation. */
2718
- const LIFECYCLE_REPAIR_NOTE = "This repairs future Droplet install/uninstall lifecycle delivery. It does not replay droplet.installed for an already-installed Droplet.";
2719
- /** Strip trailing slashes; null for an absent or empty value. */
2720
- function normalizePublicUrl(value) {
2721
- if (!value) return null;
2722
- return value.replace(/\/+$/, "");
2723
- }
2724
- /** A blank or missing path falls back; a relative one gains a leading `/`. */
2725
- function normalizeWebhookPath(value, fallback) {
2726
- if (typeof value !== "string") return fallback;
2727
- const trimmed = value.trim();
2728
- if (!trimmed) return fallback;
2729
- return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
2730
- }
2731
- /**
2732
- * Lifecycle paths from a parsed `fluid.capabilities.json`
2733
- * (`dropletLifecycle.installPath` / `uninstallPath`). Anything that is not
2734
- * the expected shape yields the defaults.
2735
- */
2736
- function dropletLifecyclePathsFromCapabilities(capabilities) {
2737
- if (!isRecord$1(capabilities)) return { ...DEFAULT_DROPLET_LIFECYCLE_PATHS };
2738
- const lifecycle = capabilities["dropletLifecycle"];
2739
- const source = isRecord$1(lifecycle) ? lifecycle : {};
2270
+ const sleep = options.sleep ?? defaultSleep;
2271
+ const label = KIND_LABEL$1[kind];
2272
+ reporter.start("Resolving mist…");
2273
+ const mist = (await showMist(client, slugOrId)).mist;
2274
+ reporter.succeed(`Mist ${mist.slug} (${mist.state})`);
2275
+ reporter.start("Checking existing integrations…");
2276
+ const points = (await listIntegrationPoints(client, slugOrId)).integration_points;
2277
+ let surfaceUuid = null;
2278
+ let surfaceNumericId = null;
2279
+ let surfaceName = null;
2280
+ let surfaceCreated = false;
2281
+ let embedUrlSet = false;
2282
+ let createdEgressProxy;
2283
+ let integrationPoint;
2284
+ const attachingExisting = options.existingUuid != null || options.existingId != null;
2285
+ const alreadyLinked = attachingExisting ? findIntegrationPointBySurface(points, {
2286
+ kind,
2287
+ uuid: options.existingUuid,
2288
+ id: options.existingId
2289
+ }) : kind === "droplet" ? findDropletIntegrationPoint(points) : void 0;
2290
+ if (alreadyLinked) {
2291
+ reporter.skip(`${label} already attached (integration #${alreadyLinked.id}) — skipping`);
2292
+ surfaceUuid = alreadyLinked.integratable_uuid ?? options.existingUuid ?? null;
2293
+ surfaceNumericId = alreadyLinked.integratable_id ?? options.existingId ?? null;
2294
+ integrationPoint = {
2295
+ id: alreadyLinked.id,
2296
+ integratableUuid: alreadyLinked.integratable_uuid ?? null,
2297
+ created: false
2298
+ };
2299
+ } else {
2300
+ reporter.succeed("No matching integration yet");
2301
+ let attachUuid = options.existingUuid;
2302
+ let attachId = options.existingId;
2303
+ if (!attachingExisting) {
2304
+ const embedUrl = await resolvePublicUrl(client, {
2305
+ slugOrId,
2306
+ initial: mist.public_url,
2307
+ reporter,
2308
+ timing,
2309
+ sleep
2310
+ });
2311
+ if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy before creating a surface.");
2312
+ const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name || mist.slug;
2313
+ if (kind === "droplet") {
2314
+ reporter.start("Checking for an interrupted Droplet create…");
2315
+ const recovered = await findDropletByEmbedUrl(client, embedUrl);
2316
+ if (recovered) {
2317
+ surfaceUuid = recovered.uuid;
2318
+ surfaceNumericId = recovered.id ?? null;
2319
+ surfaceName = recovered.name;
2320
+ attachUuid = recovered.uuid;
2321
+ embedUrlSet = true;
2322
+ reporter.skip(`Reusing droplet ${surfaceUuid} from an interrupted attach`);
2323
+ } else {
2324
+ reporter.start(`Creating ${label} "${name}"…`);
2325
+ const createInput = (egressProxy) => buildDropletCreateInput({
2326
+ name,
2327
+ embedUrl,
2328
+ requestedScopes: options.requestedScopes,
2329
+ egressProxy
2330
+ });
2331
+ let created;
2332
+ try {
2333
+ created = await createDroplet(client, { droplet: createInput(options.egressProxy) });
2334
+ } catch (err) {
2335
+ if (!(options.egressProxy && isEgressRefusal(err))) throw err;
2336
+ created = await createDroplet(client, { droplet: createInput(false) });
2337
+ }
2338
+ createdEgressProxy = created.droplet;
2339
+ surfaceUuid = created.droplet.uuid;
2340
+ surfaceNumericId = created.droplet.id ?? null;
2341
+ surfaceName = created.droplet.name;
2342
+ attachUuid = created.droplet.uuid;
2343
+ embedUrlSet = true;
2344
+ surfaceCreated = true;
2345
+ reporter.succeed(`Created droplet ${surfaceUuid}`);
2346
+ }
2347
+ } else if (kind === "drop-zone") {
2348
+ reporter.start(`Creating ${label} "${name}"…`);
2349
+ const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
2350
+ name,
2351
+ embedUrl,
2352
+ zone: options.zone,
2353
+ heightPx: options.heightPx
2354
+ }) });
2355
+ surfaceUuid = created.drop_zone.uuid;
2356
+ surfaceName = created.drop_zone.name;
2357
+ attachUuid = created.drop_zone.uuid;
2358
+ embedUrlSet = true;
2359
+ surfaceCreated = true;
2360
+ reporter.succeed(`Created drop zone ${surfaceUuid}`);
2361
+ } else {
2362
+ reporter.start(`Creating ${label} "${name}"…`);
2363
+ const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
2364
+ name,
2365
+ embedUrl,
2366
+ coverImageUrl: options.coverImageUrl,
2367
+ heightPx: options.heightPx
2368
+ }) });
2369
+ surfaceNumericId = created.mobile_widget.id;
2370
+ surfaceName = created.mobile_widget.name;
2371
+ attachId = created.mobile_widget.id;
2372
+ embedUrlSet = true;
2373
+ surfaceCreated = true;
2374
+ reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
2375
+ }
2376
+ } else {
2377
+ surfaceUuid = options.existingUuid ?? null;
2378
+ surfaceNumericId = options.existingId ?? null;
2379
+ embedUrlSet = true;
2380
+ }
2381
+ const attachInput = kind === "mobile-embed" ? {
2382
+ kind,
2383
+ integratable_id: attachId
2384
+ } : {
2385
+ kind,
2386
+ integratable_uuid: attachUuid
2387
+ };
2388
+ reporter.start(`Attaching ${label}…`);
2389
+ let ipResp;
2390
+ try {
2391
+ ipResp = await createIntegrationPoint(client, slugOrId, attachInput);
2392
+ } catch (err) {
2393
+ reporter.fail("Attach failed");
2394
+ throw normalizeAttachError(err, attachUuid ?? String(attachId), kind);
2395
+ }
2396
+ integrationPoint = {
2397
+ id: ipResp.integration_point.id,
2398
+ integratableUuid: ipResp.integration_point.integratable_uuid ?? null,
2399
+ created: true
2400
+ };
2401
+ if (!surfaceUuid) surfaceUuid = ipResp.integration_point.integratable_uuid ?? attachUuid ?? null;
2402
+ if (surfaceNumericId == null) surfaceNumericId = ipResp.integration_point.integratable_id ?? null;
2403
+ reporter.succeed(kind === "droplet" ? "Droplet attached — provisioning env + redeploy…" : `${label} attached`);
2404
+ }
2405
+ let installation = null;
2406
+ let egressProxy = null;
2407
+ if (kind === "droplet") {
2408
+ if (!surfaceUuid && surfaceNumericId != null) try {
2409
+ surfaceUuid = await findDropletUuidByNumericId(client, surfaceNumericId);
2410
+ } catch {}
2411
+ if (!surfaceUuid) {
2412
+ reporter.fail("Droplet uuid unknown — cannot install");
2413
+ throw new Error("Couldn't determine the droplet uuid, so the droplet is linked but NOT installed — it stays inactive until an installation exists. Re-run with `--existing <droplet-uuid>`, or install manually: POST /api/droplet_installations with {\"droplet_uuid\":\"<uuid>\"}.");
2414
+ }
2415
+ if (options.egressProxy) egressProxy = await ensureEgressProxy(client, {
2416
+ dropletUuid: surfaceUuid,
2417
+ created: createdEgressProxy,
2418
+ reporter
2419
+ });
2420
+ installation = await ensureDropletInstalled(client, {
2421
+ dropletUuid: surfaceUuid,
2422
+ scopes: options.requestedScopes ?? DEFAULT_DROPLET_SCOPES,
2423
+ reporter
2424
+ });
2425
+ }
2426
+ const managedEnv = kind === "droplet" ? await verifyManagedEnv(client, {
2427
+ slugOrId,
2428
+ reporter,
2429
+ timing,
2430
+ sleep,
2431
+ enabled: options.verifyEnv !== false
2432
+ }) : null;
2740
2433
  return {
2741
- installPath: normalizeWebhookPath(source["installPath"], DEFAULT_DROPLET_LIFECYCLE_PATHS.installPath),
2742
- uninstallPath: normalizeWebhookPath(source["uninstallPath"], DEFAULT_DROPLET_LIFECYCLE_PATHS.uninstallPath)
2434
+ mist: {
2435
+ id: mist.id,
2436
+ slug: mist.slug,
2437
+ name: mist.name,
2438
+ public_url: mist.public_url,
2439
+ state: mist.state
2440
+ },
2441
+ kind,
2442
+ surface: {
2443
+ uuid: surfaceUuid,
2444
+ id: surfaceNumericId,
2445
+ name: surfaceName
2446
+ },
2447
+ integrationPoint: integrationPoint ?? {
2448
+ id: "",
2449
+ integratableUuid: null,
2450
+ created: false
2451
+ },
2452
+ surfaceCreated,
2453
+ embedUrlSet,
2454
+ installation,
2455
+ managedEnv,
2456
+ egressProxy
2743
2457
  };
2744
2458
  }
2745
- /** The canonical Droplet URLs for a Mist served at `publicUrl`. */
2746
- function canonicalDropletUrls(publicUrl, paths = DEFAULT_DROPLET_LIFECYCLE_PATHS) {
2747
- const embedUrl = normalizePublicUrl(publicUrl);
2748
- if (!embedUrl) throw new Error("publicUrl is required");
2459
+ /**
2460
+ * Make sure the droplet has asked Fluid for egress through its proxy. A
2461
+ * droplet this run created already carries the request; one that existed
2462
+ * (reused, already linked, or `--existing`) is read first and PATCHed only if
2463
+ * it has not asked yet, so the flag is never silently ignored and an existing
2464
+ * request is not reported as new. Fluid refusing the request (only a company
2465
+ * admin can ask) warns and lets provisioning carry on; a response without the
2466
+ * fields (a non-admin, or an older Fluid) warns instead of claiming success.
2467
+ */
2468
+ async function ensureEgressProxy(client, args) {
2469
+ const { reporter } = args;
2470
+ let record = args.created;
2471
+ let changed = record?.egress_proxy_requested === true;
2472
+ if (record === void 0) record = await readDropletIfAllowed(client, args.dropletUuid);
2473
+ if (record?.egress_proxy_requested !== true) {
2474
+ reporter.start("Asking Fluid for egress through its proxy…");
2475
+ try {
2476
+ record = (await updateDroplet(client, args.dropletUuid, { egress_proxy_requested: true })).droplet;
2477
+ } catch (err) {
2478
+ if (!isEgressRefusal(err)) throw err;
2479
+ reporter.warn(EGRESS_REFUSED);
2480
+ return {
2481
+ requested: false,
2482
+ granted: false,
2483
+ changed: false
2484
+ };
2485
+ }
2486
+ changed = record.egress_proxy_requested === true;
2487
+ }
2488
+ const requested = record?.egress_proxy_requested === true;
2489
+ const granted = record?.egress_proxy_enabled === true;
2490
+ if (granted) reporter.succeed("Egress granted — the droplet can reach Exigo through Fluid");
2491
+ else if (requested) reporter.succeed("Egress requested — a Fluid admin must approve it before the proxy admits the droplet");
2492
+ else reporter.warn(`${EGRESS_NOT_RETURNED} Re-run \`fluid mist droplet egress-proxy on\` as a company admin.`);
2749
2493
  return {
2750
- embedUrl,
2751
- installWebhookUrl: `${embedUrl}${paths.installPath}`,
2752
- uninstallWebhookUrl: `${embedUrl}${paths.uninstallPath}`
2494
+ requested,
2495
+ granted,
2496
+ changed
2753
2497
  };
2754
2498
  }
2755
- /** Exact match on all three fields — the repair's no-op condition and its
2756
- * post-PATCH verification. */
2757
- function dropletUrlsMatch(droplet, canonical) {
2758
- return droplet?.embed_url === canonical.embedUrl && droplet.install_webhook_url === canonical.installWebhookUrl && droplet.uninstall_webhook_url === canonical.uninstallWebhookUrl;
2759
- }
2760
- /** The `PATCH /api/droplets/:uuid` body. Only these three fields change. */
2761
- function dropletRepairPatch(canonical) {
2762
- return { droplet: {
2763
- embed_url: canonical.embedUrl,
2764
- install_webhook_url: canonical.installWebhookUrl,
2765
- uninstall_webhook_url: canonical.uninstallWebhookUrl
2766
- } };
2767
- }
2768
2499
  /**
2769
- * The repair summary before any mutation. `alreadyCorrect` means the caller
2770
- * must not PATCH; otherwise a dry run reports "planned" and a live run
2771
- * reports "updated" (callers overwrite it after verification).
2500
+ * Read an existing droplet so an existing request is not written again. Fluid
2501
+ * gates reading a droplet (`droplets.view`) separately from updating it
2502
+ * (`droplets.update`), so a caller allowed to update may not be allowed to
2503
+ * read: then this returns undefined and the caller just makes the request,
2504
+ * which is idempotent. Any other failure propagates.
2772
2505
  */
2773
- function planLifecycleRepair(input) {
2774
- const alreadyCorrect = dropletUrlsMatch(input.current, input.canonical);
2775
- return {
2776
- alreadyCorrect,
2777
- summary: {
2778
- status: alreadyCorrect ? "unchanged" : input.dryRun ? "planned" : "updated",
2779
- mistId: input.mistId,
2780
- mistSlug: input.mistSlug,
2781
- dropletUuid: input.dropletUuid,
2782
- embedUrl: input.canonical.embedUrl,
2783
- installWebhookUrl: input.canonical.installWebhookUrl,
2784
- uninstallWebhookUrl: input.canonical.uninstallWebhookUrl,
2785
- previous: {
2786
- embedUrl: input.current?.embed_url ?? null,
2787
- installWebhookUrl: input.current?.install_webhook_url ?? null,
2788
- uninstallWebhookUrl: input.current?.uninstall_webhook_url ?? null
2789
- },
2790
- note: LIFECYCLE_REPAIR_NOTE
2791
- }
2792
- };
2793
- }
2794
- function isRecord$1(value) {
2795
- return typeof value === "object" && value !== null && !Array.isArray(value);
2796
- }
2797
- //#endregion
2798
- //#region ../../mist/core/src/lifecycle-types.ts
2799
- const lifecycleOperationStates = [
2800
- "queued",
2801
- "running",
2802
- "succeeded",
2803
- "failed",
2804
- "requires_action"
2805
- ];
2806
- const lifecycleOperationKinds = ["replacement"];
2807
- const connectionStates = [
2808
- "pending",
2809
- "connected",
2810
- "degraded",
2811
- "requires_action"
2812
- ];
2813
- function isTerminalOperation(operation) {
2814
- return operation.state === "succeeded" || operation.state === "failed" || operation.state === "requires_action";
2506
+ async function readDropletIfAllowed(client, dropletUuid) {
2507
+ try {
2508
+ return (await getDroplet(client, dropletUuid)).droplet;
2509
+ } catch (err) {
2510
+ if (err instanceof MistApiError && err.status === 403) return void 0;
2511
+ throw err;
2512
+ }
2815
2513
  }
2816
- //#endregion
2817
- //#region ../../mist/core/src/recovery-classification.ts
2818
- function classifyLifecycleRecovery(result) {
2819
- if (result.status === "not_found") return {
2820
- status: "requires_action",
2821
- action: "rediscover_operation"
2822
- };
2823
- if (result.status === "timed_out") return {
2824
- status: "retryable",
2825
- action: "resume_status_polling"
2826
- };
2827
- return classifyTerminalOperation(result.operation);
2828
- }
2829
- function classifyTerminalOperation(operation) {
2830
- if (operation.state === "succeeded") return {
2831
- status: "complete",
2832
- action: null
2833
- };
2834
- if (operation.state === "failed") return {
2835
- status: "failed",
2836
- action: operation.recovery_action ?? "verify_lifecycle_destination"
2837
- };
2838
- return {
2839
- status: "requires_action",
2840
- action: operation.recovery_action ?? "reconcile_lifecycle"
2841
- };
2842
- }
2843
- const REPLACEMENT_IDEMPOTENCY_SCOPE = "single replacement attempt; a later approved retry receives a new key";
2844
- const REPLACEMENT_OWNERSHIP_NOTE = "Fluid owns revocation, uninstall delivery, terminalization, replacement creation, and retry recovery as one durable operation.";
2845
- function replacementOperationsPath(mistId, installationUuid) {
2846
- return `/api/v202604/mists/${encodeURIComponent(mistId)}/droplet_installations/${encodeURIComponent(installationUuid)}/replacement_operations`;
2847
- }
2848
- function replacementOperationStatusPath(mistId, installationUuid, operationId) {
2849
- return `${replacementOperationsPath(mistId, installationUuid)}/${encodeURIComponent(operationId)}`;
2850
- }
2851
- /** The operation id a 409 "active_operation_exists" response points at. */
2852
- function parseActiveOperationId(value) {
2853
- if (!isRecord(value)) return null;
2854
- const error = value["error"];
2855
- if (!isRecord(error)) return null;
2856
- const details = error["details"];
2857
- if (!isRecord(details)) return null;
2858
- const operationId = details["operation_id"];
2859
- return typeof operationId === "string" ? operationId : null;
2514
+ /**
2515
+ * Install the droplet for the current company (idempotent). Checks the
2516
+ * installations list first; treats the API's already-installed rejection as
2517
+ * success too, so re-runs and races both converge. A hard failure throws with
2518
+ * the droplet uuid + recovery steps — the created droplet and mist link stay
2519
+ * in place, so an idempotent re-run finishes the job.
2520
+ */
2521
+ async function ensureDropletInstalled(client, args) {
2522
+ const { reporter, dropletUuid } = args;
2523
+ reporter.start("Installing droplet…");
2524
+ let existing;
2525
+ try {
2526
+ existing = findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid);
2527
+ } catch {}
2528
+ if (existing) {
2529
+ reporter.skip(`Droplet already installed${existing.uuid ? ` (${existing.uuid})` : ""} — skipping`);
2530
+ return {
2531
+ uuid: existing.uuid ?? null,
2532
+ created: false
2533
+ };
2534
+ }
2535
+ try {
2536
+ const uuid = (await createDropletInstallation(client, {
2537
+ droplet_uuid: dropletUuid,
2538
+ scopes: [...args.scopes]
2539
+ })).droplet_installation?.uuid ?? null;
2540
+ reporter.succeed(`Droplet installed${uuid ? ` (${uuid})` : ""}`);
2541
+ return {
2542
+ uuid,
2543
+ created: true
2544
+ };
2545
+ } catch (err) {
2546
+ if (err instanceof MistApiError && (err.status === 409 || err.status === 422)) {
2547
+ const confirmed = await lookupInstallation(client, dropletUuid);
2548
+ if (confirmed) {
2549
+ reporter.skip("Droplet already installed — skipping");
2550
+ return {
2551
+ uuid: confirmed.uuid ?? null,
2552
+ created: false
2553
+ };
2554
+ }
2555
+ }
2556
+ reporter.fail("Droplet install failed");
2557
+ throw normalizeInstallError(err, dropletUuid);
2558
+ }
2860
2559
  }
2861
- /** Seconds from a `Retry-After` header; undefined when absent or invalid. */
2862
- function parseRetryAfterHeader(value) {
2863
- if (!value) return void 0;
2864
- const seconds = Number(value);
2865
- return Number.isFinite(seconds) && seconds >= 0 ? seconds : void 0;
2560
+ /** Best-effort installation lookup for a droplet; null when the row can't
2561
+ * be found or the list call fails. */
2562
+ async function lookupInstallation(client, dropletUuid) {
2563
+ try {
2564
+ return findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid) ?? null;
2565
+ } catch {
2566
+ return null;
2567
+ }
2866
2568
  }
2867
- /** Parse and validate `{ replacement_operation: {...} }`. Throws on a
2868
- * malformed body rather than trusting a partial operation. */
2869
- function parseReplacementOperation(value) {
2870
- const key = "replacement_operation";
2871
- if (!isRecord(value)) throw new Error("Lifecycle response must be an object");
2872
- const candidate = value[key];
2873
- if (!isRecord(candidate)) throw new Error(`Lifecycle response is missing ${key}`);
2874
- const id = candidate["id"];
2875
- const kind = candidate["kind"];
2876
- const state = candidate["state"];
2877
- const stage = candidate["stage"];
2878
- const mistId = candidate["mist_id"];
2879
- const createdAt = candidate["created_at"];
2880
- const updatedAt = candidate["updated_at"];
2881
- if (typeof id !== "string" || !isLifecycleOperationKind(kind) || !isLifecycleOperationState(state) || typeof stage !== "string" || typeof mistId !== "string" || typeof createdAt !== "string" || typeof updatedAt !== "string") throw new Error("Lifecycle operation response is malformed");
2882
- const dropletUuid = candidate["droplet_uuid"];
2883
- const installationUuid = candidate["installation_uuid"];
2884
- const connectionState = candidate["connection_state"];
2885
- const errorCode = candidate["error_code"];
2886
- const recoveryAction = candidate["recovery_action"];
2887
- const terminalAt = candidate["terminal_at"];
2888
- const retryAfterSeconds = candidate["retry_after_seconds"];
2889
- if (!isOptionalNullableString(dropletUuid) || !isOptionalNullableString(installationUuid) || !isOptionalConnectionState(connectionState) || !isOptionalNullableString(errorCode) || !isOptionalNullableString(recoveryAction) || !isOptionalNullableString(terminalAt) || !isOptionalNullableNumber(retryAfterSeconds)) throw new Error("Lifecycle operation response has malformed optional fields");
2890
- return {
2891
- id,
2892
- kind,
2893
- state,
2894
- stage,
2895
- mist_id: mistId,
2896
- created_at: createdAt,
2897
- updated_at: updatedAt,
2898
- ...dropletUuid !== void 0 ? { droplet_uuid: dropletUuid } : {},
2899
- ...installationUuid !== void 0 ? { installation_uuid: installationUuid } : {},
2900
- ...connectionState !== void 0 ? { connection_state: connectionState } : {},
2901
- ...errorCode !== void 0 ? { error_code: errorCode } : {},
2902
- ...recoveryAction !== void 0 ? { recovery_action: recoveryAction } : {},
2903
- ...terminalAt !== void 0 ? { terminal_at: terminalAt } : {},
2904
- ...retryAfterSeconds !== void 0 ? { retry_after_seconds: retryAfterSeconds } : {}
2905
- };
2569
+ /**
2570
+ * Install failures must NOT read as "wired up" — an uninstalled droplet means
2571
+ * dit/dex token exchange, admin embeds, and install webhooks silently don't
2572
+ * work. Surface the droplet uuid + recovery path; keep the MistApiError
2573
+ * status so the error handler exits with the spec'd non-zero code.
2574
+ */
2575
+ function normalizeInstallError(err, dropletUuid) {
2576
+ const message = `Droplet ${dropletUuid} was created and linked to the mist, but installing it failed: ${err instanceof Error ? err.message : String(err)}. The droplet and its link were left in place — re-run this command to finish the install, or install manually: POST /api/droplet_installations with {"droplet_uuid":"${dropletUuid}"}.`;
2577
+ return err instanceof MistApiError ? new MistApiError(message, err.status, err.details) : new Error(message);
2906
2578
  }
2907
2579
  /**
2908
- * Guard the request before any network call. A resume must name the
2909
- * installation it started on: status is scoped to the SOURCE installation,
2910
- * the backend terminalizes that source mid-flight, and polling whichever
2911
- * installation is attached now would report a false "not_found".
2580
+ * If the integration-points endpoint rejects the attach identifier (e.g. an
2581
+ * older API that predates #19688's attach-by-uuid support), surface an
2582
+ * actionable message noting the surface WAS created. Other errors pass through.
2912
2583
  */
2913
- function checkReplacementRequest(args) {
2914
- const resumeOperationId = args.resumeOperationId?.trim() || null;
2915
- if (resumeOperationId && !args.installationUuid) return {
2916
- ok: false,
2917
- code: "resume_requires_installation"
2918
- };
2919
- return {
2920
- ok: true,
2921
- resumeOperationId
2922
- };
2584
+ function normalizeAttachError(err, surfaceRef, kind) {
2585
+ if (err instanceof MistApiError && (err.status === 400 || err.status === 422)) {
2586
+ const blob = `${err.message} ${JSON.stringify(err.details ?? {})}`;
2587
+ 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);
2588
+ }
2589
+ return err instanceof Error ? err : new Error(String(err));
2923
2590
  }
2924
- /** Starting a new replacement needs explicit confirmation; resuming only
2925
- * reads an operation the user already approved. */
2926
- function replacementStartNeedsConfirmation(args, resumeOperationId) {
2927
- return !resumeOperationId && args.confirmDestructiveReinstall !== true;
2591
+ /**
2592
+ * Return the mist's public_url, polling briefly if it isn't ready yet
2593
+ * (async-first-deploy). Returns null if it never appears within the window.
2594
+ */
2595
+ async function resolvePublicUrl(client, args) {
2596
+ if (args.initial) return args.initial;
2597
+ args.reporter.start("Waiting for the mist's public URL…");
2598
+ const deadline = Date.now() + args.timing.publicUrlTimeoutMs;
2599
+ for (;;) {
2600
+ const url = (await showMist(client, args.slugOrId)).mist.public_url;
2601
+ if (url) {
2602
+ args.reporter.succeed(`Public URL ready: ${url}`);
2603
+ return url;
2604
+ }
2605
+ if (Date.now() >= deadline) break;
2606
+ await args.sleep(args.timing.publicUrlIntervalMs);
2607
+ }
2608
+ return null;
2928
2609
  }
2929
2610
  /**
2930
- * The installation to replace. An explicitly named `installationUuid`
2931
- * always wins: it is the recovery path for a source that a failed attempt
2932
- * already terminalized (which may sit beside a newer active installation
2933
- * of the same Droplet), and a resume must watch the installation its
2934
- * operation started on, never a rediscovered one. Without a name, the
2935
- * company's active installation of the attached Droplet is used.
2611
+ * Poll `GET …/env_vars` until the Mist-managed droplet keys appear with
2612
+ * `managed_by: "mist"`. Never fails the flow — a redeploy in progress is a
2613
+ * normal, transient state we simply report.
2936
2614
  */
2937
- function selectReplacementInstallation(input) {
2938
- if (input.installationUuid) return input.installations.find((candidate) => candidate.uuid === input.installationUuid && candidate.droplet_uuid === input.dropletUuid) ?? {
2939
- uuid: input.installationUuid,
2940
- droplet_uuid: input.dropletUuid
2615
+ async function verifyManagedEnv(client, args) {
2616
+ const required = [...MANAGED_DROPLET_ENV_KEYS];
2617
+ if (!args.enabled) return {
2618
+ ready: false,
2619
+ present: [],
2620
+ missing: required
2941
2621
  };
2942
- if (input.resumeOperationId) return void 0;
2943
- return input.installations.find((candidate) => candidate.droplet_uuid === input.dropletUuid);
2944
- }
2945
- function buildReplacementPlan(input) {
2946
- const { installation } = input;
2622
+ args.reporter.start("Verifying managed environment…");
2623
+ const deadline = Date.now() + args.timing.envTimeoutMs;
2624
+ let present = [];
2625
+ for (;;) {
2626
+ const envVars = (await listEnvVars(client, args.slugOrId)).env_vars ?? [];
2627
+ const managed = new Set(envVars.filter((e) => e.managed_by === "mist").map((e) => e.key));
2628
+ present = required.filter((k) => managed.has(k));
2629
+ if (present.length === required.length) {
2630
+ args.reporter.succeed("Managed env keys present — droplet is wired up");
2631
+ return {
2632
+ ready: true,
2633
+ present,
2634
+ missing: []
2635
+ };
2636
+ }
2637
+ if (Date.now() >= deadline) break;
2638
+ args.reporter.update(`Verifying managed environment… (${present.length}/${required.length} keys, redeploy in progress)`);
2639
+ await args.sleep(args.timing.envIntervalMs);
2640
+ }
2641
+ const missing = required.filter((k) => !present.includes(k));
2642
+ 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(", ")}`);
2947
2643
  return {
2948
- status: input.dryRun ? "planned" : input.resumeOperationId ? "watching" : "pending",
2949
- mistId: input.mistId,
2950
- dropletUuid: input.dropletUuid,
2951
- installationUuid: installation.uuid,
2952
- lifecycleRepair: input.lifecycleRepair,
2953
- replacementPath: replacementOperationsPath(input.mistId, installation.uuid),
2954
- idempotencyKey: input.resumeOperationId ? null : input.idempotencyKey,
2955
- idempotencyScope: REPLACEMENT_IDEMPOTENCY_SCOPE,
2956
- irreversible: true,
2957
- preservedByBackend: {
2958
- scopes: Array.isArray(installation.scopes),
2959
- role_id: typeof installation.role_id === "string" || typeof installation.role_id === "number"
2960
- },
2961
- note: REPLACEMENT_OWNERSHIP_NOTE
2644
+ ready: false,
2645
+ present,
2646
+ missing
2962
2647
  };
2963
2648
  }
2649
+ //#endregion
2650
+ //#region src/lib/surface-flags.ts
2651
+ const EGRESS_PROXY_FLAG_DESCRIPTION = "Ask Fluid to let the droplet reach external systems such as Exigo through its egress proxy (a Fluid admin approves it)";
2964
2652
  /**
2965
- * The final result of one watch. A timed-out poll is this caller's watch
2966
- * budget expiring, NOT the backend giving up: it reports "still_running"
2967
- * with the ids needed to resume, and is not an error. Only "succeeded" is
2968
- * complete.
2653
+ * `--egress-proxy` only means something for a droplet: only a droplet's
2654
+ * installation token authenticates to the proxy. Refuse it on other kinds
2655
+ * rather than silently ignoring it.
2969
2656
  */
2970
- function summarizeReplacementWatch(input) {
2971
- const { pollResult, startedOperation } = input;
2972
- const recovery = classifyLifecycleRecovery(pollResult);
2973
- const stillRunning = pollResult.status === "timed_out";
2974
- const latestOperation = pollResult.status === "terminal" ? pollResult.operation : pollResult.status === "timed_out" ? pollResult.operation ?? startedOperation : startedOperation;
2657
+ function egressProxyFromFlags(kind, flags) {
2658
+ if (!flags.egressProxy) return void 0;
2659
+ if (kind !== "droplet") throw new Error("`--egress-proxy` applies to droplets only — drop it, or use `--kind droplet`.");
2660
+ return true;
2661
+ }
2662
+ const KIND_ALIASES = {
2663
+ droplet: "droplet",
2664
+ droplets: "droplet",
2665
+ "drop-zone": "drop-zone",
2666
+ drop_zone: "drop-zone",
2667
+ dropzone: "drop-zone",
2668
+ zone: "drop-zone",
2669
+ "mobile-embed": "mobile-embed",
2670
+ mobile_embed: "mobile-embed",
2671
+ mobile: "mobile-embed",
2672
+ "mobile-widget": "mobile-embed",
2673
+ mobilewidget: "mobile-embed"
2674
+ };
2675
+ const SURFACE_KIND_LIST = "droplet, drop-zone, mobile-embed";
2676
+ function parseSurfaceKind(raw) {
2677
+ const hit = KIND_ALIASES[raw.trim().toLowerCase()];
2678
+ if (!hit) throw new Error(`Unknown surface kind "${raw}". Use one of: ${SURFACE_KIND_LIST}.`);
2679
+ return hit;
2680
+ }
2681
+ function parseHeight(raw) {
2682
+ if (raw == null) return void 0;
2683
+ const n = Number(raw);
2684
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid --height "${raw}". Must be a positive integer (px).`);
2685
+ return n;
2686
+ }
2687
+ /**
2688
+ * Map CLI flags to the kind-specific `provisionSurface` options, enforcing the
2689
+ * per-kind requirements the API needs:
2690
+ * - drop-zone requires `--zone <page__zone>` (else it never renders).
2691
+ * - mobile-embed requires `--cover-image <url>` (the API 422s without one; the
2692
+ * CLI has no DAM picker, so the user must supply a url).
2693
+ */
2694
+ function surfaceProvisionOptionsFromFlags(kind, flags) {
2695
+ const heightPx = parseHeight(flags.height);
2696
+ if (kind === "drop-zone") {
2697
+ if (!flags.zone?.trim()) throw new Error("A drop-zone needs a slot — pass `--zone <page__zone>` (e.g. --zone checkout__below_total).");
2698
+ return {
2699
+ zone: flags.zone.trim(),
2700
+ heightPx
2701
+ };
2702
+ }
2703
+ if (kind === "mobile-embed") {
2704
+ 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).");
2705
+ return {
2706
+ coverImageUrl: flags.coverImage.trim(),
2707
+ heightPx
2708
+ };
2709
+ }
2710
+ return {};
2711
+ }
2712
+ //#endregion
2713
+ //#region src/lib/surface-summary.ts
2714
+ /** Shared human-readable summary for a surface provision result. */
2715
+ const KIND_LABEL = {
2716
+ droplet: "droplet",
2717
+ "drop-zone": "drop zone",
2718
+ "mobile-embed": "mobile embed"
2719
+ };
2720
+ function printSurfaceSummary(result) {
2721
+ const ref = result.surface.uuid ?? (result.surface.id != null ? `#${result.surface.id}` : "(unknown)");
2722
+ const state = result.surfaceCreated ? "created" : "existing";
2723
+ console.log(chalk.dim(` ${KIND_LABEL[result.kind]}: ${ref} (${state}) · integration ${result.integrationPoint.id}`));
2724
+ if (result.kind === "droplet" && result.installation) {
2725
+ const ref = result.installation.uuid ?? "(uuid unknown)";
2726
+ console.log(chalk.dim(` installation: ${ref} (${result.installation.created ? "installed" : "already installed"})`));
2727
+ }
2728
+ if (result.kind === "droplet" && result.managedEnv) if (result.managedEnv.ready) console.log(chalk.dim(" env: managed keys provisioned ✓"));
2729
+ else console.log(chalk.yellow(" env: redeploy in progress — run `fluid mist env list` shortly to confirm managed keys"));
2730
+ if (result.kind === "droplet" && result.egressProxy) console.log(result.egressProxy.granted ? chalk.dim(" egress proxy: granted ✓") : result.egressProxy.requested ? chalk.yellow(" egress proxy: requested — waiting for a Fluid admin to approve it") : chalk.yellow(" egress proxy: not requested — only a company admin can ask for it, or this Fluid predates it"));
2731
+ }
2732
+ //#endregion
2733
+ //#region src/commands/create.ts
2734
+ /**
2735
+ * `fluid mist create` — provision a new mist, optionally creating and attaching
2736
+ * a surface in one step. All attach paths go through integration points
2737
+ * (hostable is retired — #19684/#19687).
2738
+ *
2739
+ * --droplet [name] Create a NEW droplet and attach it. Bare →
2740
+ * droplet name defaults to the mist name. The
2741
+ * "one command that sets everything up" path.
2742
+ * --kind <kind> [flags] Create + attach a surface of any kind:
2743
+ * droplet | drop-zone | mobile-embed. Kind flags:
2744
+ * drop-zone --zone <page__zone> --height <px>
2745
+ * mobile-embed --cover-image <url> --height <px>
2746
+ * --hostable droplet:<uuid> Attach an EXISTING droplet by its drp_… uuid.
2747
+ * --egress-proxy With a droplet: let it reach external systems
2748
+ * such as Exigo through Fluid's egress proxy.
2749
+ *
2750
+ * With none of these, creates a plain standalone mist.
2751
+ */
2752
+ 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("--egress-proxy", EGRESS_PROXY_FLAG_DESCRIPTION).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 () => {
2753
+ const wantDroplet = opts.droplet !== void 0;
2754
+ const wantKind = !!opts.kind;
2755
+ const wantHostable = !!opts.hostable;
2756
+ if ([
2757
+ wantDroplet,
2758
+ wantKind,
2759
+ wantHostable
2760
+ ].filter(Boolean).length > 1) throw new Error("Pass only one of `--droplet`, `--kind`, or `--hostable` — they're mutually exclusive.");
2761
+ let kind;
2762
+ let existingDropletUuid;
2763
+ let nameOverride;
2764
+ if (wantDroplet) {
2765
+ kind = "droplet";
2766
+ nameOverride = typeof opts.droplet === "string" ? opts.droplet.trim() : void 0;
2767
+ } else if (wantKind) kind = parseSurfaceKind(opts.kind);
2768
+ else if (wantHostable) {
2769
+ kind = "droplet";
2770
+ existingDropletUuid = parseDropletRef(opts.hostable);
2771
+ }
2772
+ if (opts.egressProxy && !kind) throw new Error("`--egress-proxy` needs a droplet — add `--droplet`, `--kind droplet`, or `--hostable droplet:<uuid>`.");
2773
+ const egressProxy = kind ? egressProxyFromFlags(kind, opts) : void 0;
2774
+ const mistBody = { kind: opts.mistKind ?? "next_app" };
2775
+ if (opts.name?.trim()) mistBody.name = opts.name.trim();
2776
+ const client = createMistClient();
2777
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
2778
+ reporter.start("Setting up your Mist Cloud environment…");
2779
+ let created;
2780
+ try {
2781
+ created = await createMist(client, { mist: mistBody });
2782
+ } catch (err) {
2783
+ reporter.fail("Provisioning failed");
2784
+ throw err;
2785
+ }
2786
+ const mist = created.mist;
2787
+ reporter.succeed(`Provisioned ${mist.slug}`);
2788
+ let provision;
2789
+ if (kind) provision = await provisionSurface(client, {
2790
+ slugOrId: mist.slug,
2791
+ kind,
2792
+ name: nameOverride ?? opts.name?.trim(),
2793
+ existingUuid: existingDropletUuid,
2794
+ egressProxy,
2795
+ verifyEnv: opts.wait !== false,
2796
+ reporter,
2797
+ ...surfaceProvisionOptionsFromFlags(kind, opts)
2798
+ });
2799
+ if (opts.json) return printJson(provision ? {
2800
+ mist,
2801
+ provision
2802
+ } : created);
2803
+ const label = mistLabel(mist.name, mist.vendor_name);
2804
+ console.log();
2805
+ console.log(`${label} ${chalk.dim(mist.slug)} ${colorState(mist.state)}`);
2806
+ if (mist.public_url) console.log(` ${chalk.cyan(mist.public_url)}`);
2807
+ if (provision) printSurfaceSummary(provision);
2808
+ console.log();
2809
+ console.log(chalk.dim(`Clone with: fluid mist clone ${mist.slug}`));
2810
+ }));
2811
+ //#endregion
2812
+ //#region src/commands/attach.ts
2813
+ /**
2814
+ * `fluid mist attach --kind <kind> [flags] [slug]` — create a surface and wire
2815
+ * it to an existing mist. One command for all three kinds:
2816
+ *
2817
+ * --kind droplet (managed-env-provisioning droplet)
2818
+ * --kind drop-zone --zone page__zone [--height <px>]
2819
+ * --kind mobile-embed --cover-image <url> [--height <px>]
2820
+ *
2821
+ * Attach an EXISTING surface instead of creating one with `--existing <ref>`:
2822
+ * droplet/drop-zone → droplet:drp_… / drop-zone:dz_… / bare uuid
2823
+ * mobile-embed → mobile-embed:<numericId>
2824
+ *
2825
+ * `--egress-proxy` (droplet only) lets the droplet reach external systems
2826
+ * such as Exigo through Fluid's egress proxy — on a new droplet, and on an
2827
+ * existing or already-attached one.
2828
+ *
2829
+ * Idempotent for droplet + attach-existing (skips when already linked).
2830
+ */
2831
+ /** Parse `--existing <ref>` into a uuid or numeric id for the given kind. */
2832
+ function parseExisting(kind, raw) {
2833
+ const trimmed = (raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw).trim();
2834
+ if (!trimmed) throw new Error(`Empty --existing reference "${raw}".`);
2835
+ if (kind === "mobile-embed") {
2836
+ const id = Number(trimmed);
2837
+ if (!Number.isInteger(id) || id <= 0) throw new Error(`mobile-embed attaches by numeric id — pass \`--existing <id>\` (got "${raw}").`);
2838
+ return { existingId: id };
2839
+ }
2840
+ return { existingUuid: trimmed };
2841
+ }
2842
+ 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("--egress-proxy", EGRESS_PROXY_FLAG_DESCRIPTION).option("--no-wait", "Don't wait for managed env keys to appear after attach").option("--json", "Emit raw JSON").action((slug, opts) => runAction(async () => {
2843
+ const kind = parseSurfaceKind(opts.kind);
2844
+ const egressProxy = egressProxyFromFlags(kind, opts);
2845
+ const resolved = resolveSlug(slug);
2846
+ const client = createMistClient();
2847
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
2848
+ const existing = opts.existing ? parseExisting(kind, opts.existing) : void 0;
2849
+ const result = await provisionSurface(client, {
2850
+ slugOrId: resolved,
2851
+ kind,
2852
+ name: opts.name?.trim(),
2853
+ egressProxy,
2854
+ verifyEnv: opts.wait !== false,
2855
+ reporter,
2856
+ ...existing,
2857
+ ...existing ? {} : surfaceProvisionOptionsFromFlags(kind, opts)
2858
+ });
2859
+ if (opts.json) return printJson(result);
2860
+ console.log();
2861
+ console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
2862
+ printSurfaceSummary(result);
2863
+ }));
2864
+ //#endregion
2865
+ //#region src/commands/attach-droplet.ts
2866
+ /**
2867
+ * `fluid mist attach-droplet [name] [slug]` — droplet shortcut for
2868
+ * `fluid mist attach --kind droplet`. Creates a droplet and wires it to an
2869
+ * existing standalone mist (provisioning the managed env). Idempotent: if a
2870
+ * droplet is already attached, it skips create + attach and just verifies env.
2871
+ *
2872
+ * name Optional droplet name (defaults to the mist's name).
2873
+ * slug Optional mist slug (defaults to the `.mist` file in CWD).
2874
+ *
2875
+ * `--egress-proxy` lets the droplet reach external systems such as Exigo
2876
+ * through Fluid's egress proxy, including one that was already attached.
2877
+ */
2878
+ 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("--egress-proxy", EGRESS_PROXY_FLAG_DESCRIPTION).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 () => {
2879
+ const resolved = resolveSlug(slug);
2880
+ const client = createMistClient();
2881
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
2882
+ const result = await provisionSurface(client, {
2883
+ slugOrId: resolved,
2884
+ kind: "droplet",
2885
+ name: name?.trim() || void 0,
2886
+ egressProxy: opts.egressProxy === true || void 0,
2887
+ verifyEnv: opts.wait !== false,
2888
+ reporter
2889
+ });
2890
+ if (opts.json) return printJson(result);
2891
+ console.log();
2892
+ console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
2893
+ printSurfaceSummary(result);
2894
+ }));
2895
+ //#endregion
2896
+ //#region src/commands/update.ts
2897
+ /**
2898
+ * `fluid mist update` — mutate an existing mist. Hostable is retired
2899
+ * (#19684/#19687), so attach/detach operate on integration points:
2900
+ *
2901
+ * --name <label> Rename the mist (PATCH /mists/:id).
2902
+ * --hostable droplet:<uuid> Attach a droplet by uuid. No-ops if that same
2903
+ * droplet is already attached; replaces it if a
2904
+ * different droplet is attached (droplet is
2905
+ * 1-per-mist — it provisions the managed env).
2906
+ * --detach Remove the droplet integration point.
2907
+ *
2908
+ * The integration-point link is immutable (no update route, no `active`
2909
+ * field) — enablement lives on the surface record, so there's no toggle here.
2910
+ * All ops re-derive state from the integration-points index (which echoes
2911
+ * `integratable_uuid` since #19689) and only make the calls that change it.
2912
+ */
2913
+ 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 () => {
2914
+ if (opts.hostable && opts.detach) throw new Error("Pass either `--hostable` OR `--detach`, not both — they're mutually exclusive.");
2915
+ if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass `--name`, `--hostable`, or `--detach`.");
2916
+ const resolved = resolveSlug(slug);
2917
+ const client = createMistClient();
2918
+ const notes = [];
2919
+ let renameResp;
2920
+ if (opts.name?.trim()) {
2921
+ renameResp = await updateMist(client, resolved, { mist: { name: opts.name.trim() } });
2922
+ if (!opts.json) {
2923
+ const m = renameResp.mist;
2924
+ console.log(`${mistLabel(m.name, m.vendor_name)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
2925
+ }
2926
+ }
2927
+ if (opts.hostable || opts.detach) {
2928
+ const existing = findDropletIntegrationPoint((await listIntegrationPoints(client, resolved)).integration_points);
2929
+ if (opts.hostable) {
2930
+ const dropletUuid = parseDropletRef(opts.hostable);
2931
+ if (existing && existing.integratable_uuid === dropletUuid) notes.push(`droplet ${dropletUuid} already attached (integration ${existing.id})`);
2932
+ else {
2933
+ if (existing) {
2934
+ await deleteIntegrationPoint(client, resolved, existing.id);
2935
+ notes.push(`replaced integration ${existing.id}`);
2936
+ }
2937
+ const ip = await createIntegrationPoint(client, resolved, {
2938
+ kind: "droplet",
2939
+ integratable_uuid: dropletUuid
2940
+ });
2941
+ notes.push(`attached droplet ${dropletUuid} (integration ${ip.integration_point.id})`);
2942
+ }
2943
+ } else if (opts.detach) if (existing) {
2944
+ await deleteIntegrationPoint(client, resolved, existing.id);
2945
+ notes.push(`detached droplet (removed integration ${existing.id})`);
2946
+ } else notes.push("already standalone (no droplet integration)");
2947
+ }
2948
+ if (opts.json) return printJson(renameResp ? {
2949
+ ...renameResp,
2950
+ slug: resolved,
2951
+ notes
2952
+ } : {
2953
+ slug: resolved,
2954
+ notes
2955
+ });
2956
+ for (const note of notes) console.log(chalk.dim(` ${note}`));
2957
+ }));
2958
+ //#endregion
2959
+ //#region src/commands/delete.ts
2960
+ /**
2961
+ * `fluid mist delete` — scheduled teardown 15 days out (spec §3 "15-day
2962
+ * grace period"). DO NOT poll for completion.
2963
+ */
2964
+ const deleteCommand = new Command("delete").description("Schedule a mist for destruction (15-day grace window)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("-y, --yes", "Skip confirmation prompt").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
2965
+ const resolved = resolveSlug(slug);
2966
+ const client = createMistClient();
2967
+ if (!opts.yes) {
2968
+ const { mist } = await showMist(client, resolved);
2969
+ const fifteenDaysOut = new Date(Date.now() + 15 * 864e5).toISOString().slice(0, 10);
2970
+ console.log();
2971
+ console.log(`About to schedule ${chalk.bold(mist.name ?? mist.vendor_name ?? mist.slug)} (${chalk.dim(mist.slug)}) for destruction on ${chalk.yellow(fifteenDaysOut)}.`);
2972
+ console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
2973
+ const { confirmed } = await prompts({
2974
+ type: "confirm",
2975
+ name: "confirmed",
2976
+ message: "Continue?",
2977
+ initial: false
2978
+ }, { onCancel: () => process.exit(130) });
2979
+ if (!confirmed) {
2980
+ console.log(chalk.dim("Aborted."));
2981
+ process.exit(0);
2982
+ }
2983
+ }
2984
+ const { mist } = await deleteMist(client, resolved);
2985
+ if (opts.json) return printJson({ mist });
2986
+ console.log(chalk.magenta(`Scheduled for destruction on ${mist.scheduled_destroy_at ? formatAbsolute(mist.scheduled_destroy_at) : "the 15-day mark"}.`));
2987
+ console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
2988
+ }));
2989
+ //#endregion
2990
+ //#region src/commands/restore.ts
2991
+ /** `fluid mist restore` — un-delete within the 15-day window. Spec §3. */
2992
+ const restoreCommand = new Command("restore").description("Cancel a scheduled mist destruction (15-day window)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
2993
+ const resolved = resolveSlug(slug);
2994
+ const { mist } = await restoreMist(createMistClient(), resolved);
2995
+ if (opts.json) return printJson({ mist });
2996
+ console.log(`Restored: ${mist.public_url ? chalk.cyan(mist.public_url) : mist.name ?? mist.vendor_name ?? mist.slug} (${colorState(mist.state)})`);
2997
+ }));
2998
+ //#endregion
2999
+ //#region src/api/git_credential.ts
3000
+ async function createGitCredential(client, slug) {
3001
+ const id = await resolveMistId(client, slug);
3002
+ return client.post(`/api/v202604/mists/${id}/git_credential`);
3003
+ }
3004
+ //#endregion
3005
+ //#region src/commands/clone.ts
3006
+ /**
3007
+ * `fluid mist clone <slug>` — mints a fresh git credential and clones
3008
+ * into ./<vendor-name>/, writes a `.mist` marker file. Spec §3 + §11.
3009
+ */
3010
+ /**
3011
+ * Render a path for the copy-pasteable `cd` hint so the printed command
3012
+ * enters the directory that was actually created:
3013
+ * - Prefixes `./` when the path starts with `~` (blocks tilde expansion —
3014
+ * the clone went into a literal `~…` directory under CWD) or `-` (stops
3015
+ * `cd` from parsing it as an option).
3016
+ * - Otherwise single-quotes anything beyond safe characters, escaping
3017
+ * embedded single quotes POSIX-style (`'\''`).
3018
+ */
3019
+ function quotePathIfNeeded(path) {
3020
+ const prefixed = /^[~-]/.test(path) ? `./${path}` : path;
3021
+ if (/^[A-Za-z0-9_./-]+$/.test(prefixed)) return prefixed;
3022
+ return `'${prefixed.replace(/'/g, "'\\''")}'`;
3023
+ }
3024
+ const cloneCommand = new Command("clone").description("Clone the mist's GitHub repo (mints a fresh token per call)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--dir <dir>", "Target directory (defaults to ./<vendor-name>/)").action((slug, opts) => runAction(async () => {
3025
+ await ensureGitAvailable();
3026
+ const resolvedSlug = resolveSlug(slug);
3027
+ const client = createMistClient();
3028
+ const spinner = ora("Resolving mist…").start();
3029
+ const { mist } = await showMist(client, resolvedSlug);
3030
+ spinner.text = "Minting git credential…";
3031
+ const { git_credential: cred } = await createGitCredential(client, mist.slug);
3032
+ const dirName = mist.name ?? mist.vendor_name ?? mist.slug;
3033
+ const targetDir = resolve(opts.dir ?? dirName);
3034
+ if (existsSync(targetDir)) {
3035
+ spinner.fail(`Directory ${targetDir} already exists. Pass --dir or remove it first.`);
3036
+ process.exit(1);
3037
+ }
3038
+ spinner.text = `Cloning into ${dirName}/…`;
3039
+ try {
3040
+ await gitClone(cred.remote_url, targetDir);
3041
+ } catch (err) {
3042
+ spinner.fail("git clone failed");
3043
+ throw err;
3044
+ }
3045
+ writeMistFile(targetDir, mist);
3046
+ spinner.succeed(`Cloned into ${chalk.cyan(targetDir)}`);
3047
+ const displayPath = opts.dir ?? join(".", dirName);
3048
+ console.log(chalk.dim(` cd ${quotePathIfNeeded(displayPath)}`));
3049
+ if (mist.public_url) console.log(chalk.dim(` ${chalk.cyan(mist.public_url)}`));
3050
+ }));
3051
+ //#endregion
3052
+ //#region src/api/deployments.ts
3053
+ async function listDeployments(client, slug, limit) {
3054
+ const id = await resolveMistId(client, slug);
3055
+ return client.get(`/api/v202604/mists/${id}/deployments`, limit != null ? { limit } : void 0);
3056
+ }
3057
+ async function showDeployment(client, slug, deploymentId) {
3058
+ const id = await resolveMistId(client, slug);
3059
+ return client.get(`/api/v202604/mists/${id}/deployments/${deploymentId}`);
3060
+ }
3061
+ //#endregion
3062
+ //#region src/api/me.ts
3063
+ async function fetchCurrentUser(client) {
3064
+ const raw = await client.get("/api/me");
3065
+ const composed = [raw.first_name, raw.last_name].filter(Boolean).join(" ").trim();
3066
+ const fullName = raw.full_name ?? (composed.length > 0 ? composed : null);
2975
3067
  return {
2976
- result: {
2977
- ...input.plan,
2978
- status: pollResult.status === "terminal" ? pollResult.operation.state : stillRunning ? "still_running" : pollResult.status,
2979
- replacementOperation: latestOperation,
2980
- recovery,
2981
- ...stillRunning ? {
2982
- watchedForMs: input.watchedForMs,
2983
- resumeOperationId: input.operationId,
2984
- nextStep: input.nextStep({
2985
- operationId: input.operationId,
2986
- installationUuid: input.installationUuid,
2987
- stage: latestOperation?.stage ?? "unknown"
2988
- })
2989
- } : {}
2990
- },
2991
- isError: !stillRunning && recovery.status !== "complete"
3068
+ id: raw.id,
3069
+ public_id: raw.public_id ?? String(raw.id),
3070
+ full_name: fullName,
3071
+ first_name: raw.first_name ?? null,
3072
+ last_name: raw.last_name ?? null,
3073
+ email: raw.email ?? null
2992
3074
  };
2993
3075
  }
2994
- function isLifecycleOperationKind(value) {
2995
- return lifecycleOperationKinds.some((candidate) => candidate === value);
3076
+ //#endregion
3077
+ //#region src/commands/push.ts
3078
+ /**
3079
+ * `fluid mist push [--watch]` — mints a credential, pushes to origin, and
3080
+ * (with --watch) polls /deployments until READY/ERROR. Spec §3 + §4.
3081
+ */
3082
+ /** Used when there's no `-m` and no readable diff to describe. */
3083
+ const FALLBACK_ACTION = "Publish from Mist Desktop";
3084
+ const POLL_MS$1 = 2e3;
3085
+ const LOCATE_TIMEOUT_MS = 2 * 6e4;
3086
+ const READY_TIMEOUT_MS = 5 * 6e4;
3087
+ const PUBLIC_URL_TIMEOUT_MS = 5e3;
3088
+ function sleep$1(ms) {
3089
+ return new Promise((r) => setTimeout(r, ms));
2996
3090
  }
2997
- function isLifecycleOperationState(value) {
2998
- return lifecycleOperationStates.some((candidate) => candidate === value);
3091
+ async function withTimeout(promise, timeoutMs) {
3092
+ let timer = null;
3093
+ try {
3094
+ return await Promise.race([promise, new Promise((_, reject) => {
3095
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timed out after ${timeoutMs / 1e3}s`)), timeoutMs);
3096
+ })]);
3097
+ } finally {
3098
+ if (timer) clearTimeout(timer);
3099
+ }
2999
3100
  }
3000
- function isOptionalNullableString(value) {
3001
- return value === void 0 || value === null || typeof value === "string";
3101
+ const pushCommand = new Command("push").description("Commit working-tree changes and push to the Mist-managed remote (triggers a hosted deployment)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--watch", "Poll /deployments until ready or error").option("--branch <name>", "Branch to push (default: main)").option("--no-commit", "Skip the auto-commit step (push existing commits only)").option("-m, --message <message>", "Commit message for the auto-commit (default: timestamped Publish)").action((slug, opts) => runAction(async () => {
3102
+ await ensureGitAvailable();
3103
+ const resolved = resolveSlug(slug);
3104
+ const client = createMistClient();
3105
+ const branch = opts.branch ?? "main";
3106
+ const cwd = process.cwd();
3107
+ const driftSpinner = ora("Checking for remote changes…").start();
3108
+ try {
3109
+ const { git_credential: fetchCred } = await createGitCredential(client, resolved);
3110
+ await gitFetch(cwd, fetchCred.remote_url, branch);
3111
+ const { ahead, behind } = await gitAheadBehindVsFetchHead(cwd);
3112
+ if (behind > 0 && ahead === 0) {
3113
+ const wasDirty = await hasUncommittedChanges(cwd);
3114
+ driftSpinner.text = `Pulling ${behind} commit${behind === 1 ? "" : "s"} from origin/main…`;
3115
+ if (wasDirty) await gitStash(cwd, "mist-push: pre-pull stash");
3116
+ try {
3117
+ await gitMergeFastForward(cwd);
3118
+ } finally {
3119
+ if (wasDirty) await gitStashPop(cwd);
3120
+ }
3121
+ driftSpinner.succeed(`Pulled ${behind} commit${behind === 1 ? "" : "s"} from origin/main`);
3122
+ } else if (behind > 0 && ahead > 0) {
3123
+ driftSpinner.fail(`Your branch and origin/main have diverged (you're ${ahead} ahead, ${behind} behind).`);
3124
+ console.error(chalk.yellow(" Resolve in a terminal: `git pull --rebase`, fix any conflicts, then re-run Publish."));
3125
+ process.exit(1);
3126
+ } else driftSpinner.succeed("Up to date with origin/main");
3127
+ } catch (err) {
3128
+ if (err instanceof Error && err.message.includes("Resolve in a terminal")) throw err;
3129
+ driftSpinner.fail("Couldn't sync with origin/main");
3130
+ throw err;
3131
+ }
3132
+ if (opts.commit !== false) {
3133
+ if (await hasUncommittedChanges(cwd)) {
3134
+ const explicit = opts.message?.trim();
3135
+ const actor = await resolveGitSyncActor(async () => {
3136
+ try {
3137
+ return gitSyncActorFromMe(await withTimeout(fetchCurrentUser(client), 5e3));
3138
+ } catch (err) {
3139
+ const reason = err instanceof Error ? err.message : String(err);
3140
+ console.warn(chalk.yellow(` (skipping user-stamp on commit — couldn't fetch /api/me: ${reason})`));
3141
+ return null;
3142
+ }
3143
+ });
3144
+ let subject = "";
3145
+ const commitSpinner = ora("Committing changes…").start();
3146
+ try {
3147
+ await gitAutoCommit(cwd, (changes) => {
3148
+ subject = gitSyncCommitSubject(explicit || summarizeChanges(changes) || FALLBACK_ACTION, actor);
3149
+ return subject;
3150
+ });
3151
+ commitSpinner.succeed(subject ? `Committed — ${subject}` : "Committed");
3152
+ } catch (err) {
3153
+ commitSpinner.fail("Commit failed");
3154
+ throw err;
3155
+ }
3156
+ }
3157
+ }
3158
+ const spinner = ora("Minting git credential…").start();
3159
+ const { git_credential: cred } = await createGitCredential(client, resolved);
3160
+ spinner.text = `Pushing ${branch} to origin…`;
3161
+ try {
3162
+ await gitPush(cwd, cred.remote_url, branch);
3163
+ } catch (err) {
3164
+ spinner.fail("git push failed");
3165
+ throw err;
3166
+ }
3167
+ spinner.succeed("Push complete");
3168
+ if (!opts.watch) return;
3169
+ let pushedSha = null;
3170
+ try {
3171
+ const { execa } = await import("execa");
3172
+ pushedSha = (await execa("git", ["rev-parse", "HEAD"], { cwd })).stdout.trim();
3173
+ } catch {}
3174
+ const locateStart = Date.now();
3175
+ let deploymentId = null;
3176
+ let attempt = 0;
3177
+ while (Date.now() - locateStart < LOCATE_TIMEOUT_MS) {
3178
+ attempt++;
3179
+ const { deployments } = await listDeployments(client, resolved, 5);
3180
+ const match = pushedSha ? deployments.find((d) => d.commit_sha === pushedSha) : deployments[0];
3181
+ if (match) {
3182
+ deploymentId = match.id;
3183
+ console.log(`Located deployment ${match.id} (${match.state})`);
3184
+ break;
3185
+ }
3186
+ if (attempt === 1) console.log(`Locating deployment for commit ${pushedSha?.slice(0, 7) ?? "(unknown)"}…`);
3187
+ else if (attempt % 5 === 0) console.log(` …still waiting (${attempt * 2}s elapsed)`);
3188
+ await sleep$1(POLL_MS$1);
3189
+ }
3190
+ if (!deploymentId) {
3191
+ console.warn(chalk.yellow(`Push succeeded, but hosting has not started commit ${pushedSha?.slice(0, 7) ?? "(unknown)"} after ${LOCATE_TIMEOUT_MS / 1e3}s.`));
3192
+ console.warn(chalk.yellow("The deployment is still queued and may appear later. Run `fluid mist deployments` to check it — do not retry or rewrite the commit just because the hosting queue is slow."));
3193
+ return;
3194
+ }
3195
+ const readyStart = Date.now();
3196
+ let lastState = null;
3197
+ while (Date.now() - readyStart < READY_TIMEOUT_MS) {
3198
+ const { deployment } = await showDeployment(client, resolved, deploymentId);
3199
+ if (deployment.state !== lastState) {
3200
+ console.log(` ${deployment.state}…`);
3201
+ lastState = deployment.state;
3202
+ }
3203
+ if (deployment.state === "ready") {
3204
+ let publicUrl = null;
3205
+ try {
3206
+ publicUrl = (await withTimeout(showMist(client, resolved), PUBLIC_URL_TIMEOUT_MS)).mist.public_url;
3207
+ } catch (err) {
3208
+ const reason = err instanceof Error ? err.message : String(err);
3209
+ console.warn(chalk.yellow(` Deployment is ready, but the public URL lookup failed: ${reason}`));
3210
+ }
3211
+ console.log(readyDeploymentMessage(publicUrl, resolved));
3212
+ return;
3213
+ }
3214
+ if (deployment.state === "error" || deployment.state === "canceled") {
3215
+ console.error(`✘ Deployment ${deployment.state}`);
3216
+ process.exit(3);
3217
+ }
3218
+ await sleep$1(POLL_MS$1);
3219
+ }
3220
+ console.error(`Watch timed out after ${READY_TIMEOUT_MS / 1e3}s. Last state: ${lastState}.`);
3221
+ console.error("Run `fluid mist deployments` to check the latest state.");
3222
+ process.exit(3);
3223
+ }));
3224
+ //#endregion
3225
+ //#region src/commands/pull.ts
3226
+ /** `fluid mist pull` — git pull origin main. Spec §3. */
3227
+ const pullCommand = new Command("pull").description("git pull from the Mist-managed remote").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--branch <name>", "Branch to pull (default: main)").action((slug, opts) => runAction(async () => {
3228
+ await ensureGitAvailable();
3229
+ const resolved = resolveSlug(slug);
3230
+ const client = createMistClient();
3231
+ const branch = opts.branch ?? "main";
3232
+ const spinner = ora("Minting git credential…").start();
3233
+ const { git_credential: cred } = await createGitCredential(client, resolved);
3234
+ spinner.text = `Pulling ${branch}…`;
3235
+ try {
3236
+ await gitPull(process.cwd(), cred.remote_url, branch);
3237
+ } catch (err) {
3238
+ spinner.fail("git pull failed");
3239
+ throw err;
3240
+ }
3241
+ spinner.succeed("Pull complete");
3242
+ }));
3243
+ //#endregion
3244
+ //#region src/commands/open.ts
3245
+ /** `fluid mist open` — opens public_url in the default browser. Spec §3. */
3246
+ const openCommand = new Command("open").description("Open the mist's public URL in your browser").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").action((slug) => runAction(async () => {
3247
+ const resolved = resolveSlug(slug);
3248
+ const { mist } = await showMist(createMistClient(), resolved);
3249
+ if (!mist.public_url) {
3250
+ console.error(chalk.yellow(`Mist ${mist.slug} has no public URL yet (state: ${mist.state}).`));
3251
+ process.exit(1);
3252
+ }
3253
+ console.log(chalk.cyan(mist.public_url));
3254
+ await open(mist.public_url);
3255
+ }));
3256
+ //#endregion
3257
+ //#region src/commands/deployments.ts
3258
+ /** `fluid mist deployments` — list recent deployments. Spec §3. */
3259
+ const STATE_COLOR = {
3260
+ ready: chalk.green,
3261
+ building: chalk.yellow,
3262
+ queued: chalk.dim,
3263
+ error: chalk.red,
3264
+ canceled: chalk.red
3265
+ };
3266
+ const deploymentsCommand = new Command("deployments").description("List recent deployments for a mist").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--limit <n>", "How many to fetch (default 10)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
3267
+ const resolved = resolveSlug(slug);
3268
+ const response = await listDeployments(createMistClient(), resolved, opts.limit ? parseInt(opts.limit, 10) : 10);
3269
+ if (opts.json) return printJson(response);
3270
+ const deployments = response.deployments ?? [];
3271
+ if (deployments.length === 0) {
3272
+ console.log(chalk.dim("No deployments yet."));
3273
+ return;
3274
+ }
3275
+ printTable([[
3276
+ chalk.bold("ID"),
3277
+ chalk.bold("STATE"),
3278
+ chalk.bold("COMMIT"),
3279
+ chalk.bold("MESSAGE"),
3280
+ chalk.bold("AGE")
3281
+ ], ...deployments.map((d) => [
3282
+ d.id,
3283
+ (STATE_COLOR[d.state] ?? chalk.white)(d.state),
3284
+ d.commit_sha ? d.commit_sha.slice(0, 7) : chalk.dim("—"),
3285
+ d.commit_message ? d.commit_message.slice(0, 48) : chalk.dim("—"),
3286
+ relativeDate(d.created_at)
3287
+ ])]);
3288
+ }));
3289
+ //#endregion
3290
+ //#region src/api/logs.ts
3291
+ async function fetchLogs(client, slug, params = {}) {
3292
+ const id = await resolveMistId(client, slug);
3293
+ return client.get(`/api/v202604/mists/${id}/logs`, params);
3002
3294
  }
3003
- function isOptionalNullableNumber(value) {
3004
- return value === void 0 || value === null || typeof value === "number";
3295
+ //#endregion
3296
+ //#region src/commands/logs.ts
3297
+ /** `fluid mist logs [--tail]` — poll /logs?since=<last> every 2s. Spec §4. */
3298
+ const POLL_MS = 2e3;
3299
+ function sleep(ms) {
3300
+ return new Promise((r) => setTimeout(r, ms));
3005
3301
  }
3006
- function isOptionalConnectionState(value) {
3007
- return value === void 0 || value === null || connectionStates.some((candidate) => candidate === value);
3302
+ function levelColor(level) {
3303
+ return level === "stderr" ? chalk.red : chalk.dim;
3008
3304
  }
3009
- function isRecord(value) {
3010
- return typeof value === "object" && value !== null && !Array.isArray(value);
3305
+ function printLine(entry) {
3306
+ const ts = entry.timestamp.slice(11, 19);
3307
+ process.stdout.write(`${chalk.dim(ts)} ${levelColor(entry.level)(entry.level.padEnd(6))} ${entry.message}\n`);
3011
3308
  }
3012
- //#endregion
3013
- //#region ../../mist/core/src/operation-polling.ts
3014
- async function pollLifecycleOperation({ fetchStatus, timeoutMs = 1800 * 1e3, minimumIntervalMs = 2e3, sleep: sleepOverride, now = Date.now, abortSignal }) {
3015
- const sleep = sleepOverride ?? ((durationMs) => defaultSleep(durationMs, abortSignal));
3016
- const startedAt = now();
3017
- let latestOperation;
3018
- while (now() - startedAt < timeoutMs) {
3019
- abortSignal?.throwIfAborted();
3020
- const response = await fetchStatus();
3021
- abortSignal?.throwIfAborted();
3022
- if (response.status === 404) return { status: "not_found" };
3023
- const transientServerError = response.status >= 500 && response.status < 600;
3024
- if (response.status !== 200 && response.status !== 429 && !transientServerError) throw new Error(`Lifecycle status request failed with HTTP ${response.status}`);
3025
- if (response.operation) {
3026
- latestOperation = response.operation;
3027
- if (isTerminalOperation(response.operation)) return {
3028
- status: "terminal",
3029
- operation: response.operation
3030
- };
3309
+ const logsCommand = new Command("logs").description("Fetch deployment logs (use --tail to stream)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--tail", "Stream new logs every 2s").option("--limit <n>", "Initial fetch size (default 200)").option("--deployment <id>", "Logs from a specific deployment ID").option("--json", "Emit raw API JSON (one batch; ignored when --tail)").action((slug, opts) => runAction(async () => {
3310
+ const resolved = resolveSlug(slug);
3311
+ const client = createMistClient();
3312
+ const limit = opts.limit ? parseInt(opts.limit, 10) : 200;
3313
+ const initial = await fetchLogs(client, resolved, {
3314
+ ...opts.deployment ? { deployment_id: opts.deployment } : {},
3315
+ limit
3316
+ });
3317
+ if (opts.json && !opts.tail) return printJson(initial);
3318
+ for (const entry of initial.logs) printLine(entry);
3319
+ if (!opts.tail) return;
3320
+ process.stdout.write(`${chalk.dim(`Streaming production logs for ${resolved}${initial.deployment_id ? ` (deployment ${initial.deployment_id})` : ""} — waiting for new entries…`)}\n`);
3321
+ let since = initial.logs.length > 0 ? initial.logs[initial.logs.length - 1].timestamp : (/* @__PURE__ */ new Date()).toISOString();
3322
+ while (true) {
3323
+ await sleep(POLL_MS);
3324
+ const batch = await fetchLogs(client, resolved, {
3325
+ ...opts.deployment ? { deployment_id: opts.deployment } : {},
3326
+ since,
3327
+ limit
3328
+ });
3329
+ for (const entry of batch.logs) {
3330
+ printLine(entry);
3331
+ since = entry.timestamp;
3031
3332
  }
3032
- const retryAfterMs = Math.max(minimumIntervalMs, (response.retryAfterSeconds ?? response.operation?.retry_after_seconds ?? 0) * 1e3);
3033
- await sleep(Math.min(retryAfterMs, Math.max(0, timeoutMs - (now() - startedAt))));
3034
- abortSignal?.throwIfAborted();
3035
3333
  }
3036
- return latestOperation ? {
3037
- status: "timed_out",
3038
- operation: latestOperation
3039
- } : { status: "timed_out" };
3040
- }
3041
- function defaultSleep(durationMs, abortSignal) {
3042
- return new Promise((resolve, reject) => {
3043
- const onAbort = () => {
3044
- clearTimeout(timer);
3045
- reject(abortSignal?.reason);
3046
- };
3047
- const timer = setTimeout(() => {
3048
- abortSignal?.removeEventListener("abort", onAbort);
3049
- resolve();
3050
- }, durationMs);
3051
- abortSignal?.addEventListener("abort", onAbort, { once: true });
3052
- });
3053
- }
3334
+ }));
3054
3335
  //#endregion
3055
- //#region src/api/droplet_replacements.ts
3056
- async function startDropletReplacement(client, input) {
3057
- const response = await send(client, replacementOperationsPath(input.mistId, input.installationUuid), {
3058
- method: "POST",
3059
- headers: { "Content-Type": "application/json" },
3060
- body: JSON.stringify({ idempotency_key: input.idempotencyKey })
3061
- });
3062
- const body = await readJson(response);
3063
- if (response.ok) return parseReplacementOperation(body);
3064
- const activeOperationId = response.status === 409 ? parseActiveOperationId(body) : null;
3065
- if (activeOperationId) return fetchDropletReplacement(client, {
3066
- mistId: input.mistId,
3067
- installationUuid: input.installationUuid,
3068
- operationId: activeOperationId
3069
- });
3070
- const retryAfterSeconds = parseRetryAfterHeader(response.headers.get("Retry-After"));
3071
- const retryHint = retryAfterSeconds === void 0 ? "" : `; retry after ${retryAfterSeconds}s`;
3072
- throw new MistApiError(`Droplet replacement start failed with HTTP ${response.status}${retryHint}`, response.status);
3073
- }
3074
- async function fetchDropletReplacement(client, input) {
3075
- const response = await send(client, replacementOperationStatusPath(input.mistId, input.installationUuid, input.operationId), { method: "GET" });
3076
- if (!response.ok) throw new MistApiError(`Active replacement status failed with HTTP ${response.status}`, response.status);
3077
- return parseReplacementOperation(await readJson(response));
3336
+ //#region src/lib/encrypted-env.ts
3337
+ /**
3338
+ * Detects Vercel-encrypted env var ciphertext.
3339
+ *
3340
+ * `GET /api/v202604/mists/:id/env_vars` is a thin proxy to the Mist's
3341
+ * Vercel project env — it returns values ENCRYPTED, never plaintext (see
3342
+ * apps/mist-desktop/src/main/services/mist-env-vars.service.ts:25-28,
3343
+ * which documents the same server behavior for the desktop app's copy of
3344
+ * this API). The ciphertext is a base64-encoded JSON envelope of shape
3345
+ * `{"v":"v2","c":"<base64 ciphertext>","k":[<byte array>]}`.
3346
+ *
3347
+ * `fluid mist env pull` uses this to avoid writing that ciphertext into
3348
+ * `.env.local` as if it were a usable value.
3349
+ */
3350
+ const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
3351
+ function looksLikeBase64(value) {
3352
+ return value.length > 0 && value.length % 4 === 0 && BASE64_RE.test(value);
3078
3353
  }
3079
- /** Poll an operation until it is terminal, gone, or `timeoutMs` elapses. */
3080
- function pollDropletReplacement(client, input) {
3081
- const statusPath = replacementOperationStatusPath(input.mistId, input.installationUuid, input.operationId);
3082
- return pollLifecycleOperation({
3083
- timeoutMs: input.timeoutMs,
3084
- ...input.sleep ? { sleep: input.sleep } : {},
3085
- fetchStatus: async () => {
3086
- const response = await send(client, statusPath, { method: "GET" });
3087
- const retryAfterSeconds = parseRetryAfterHeader(response.headers.get("Retry-After"));
3088
- if (response.status === 404 || response.status === 429) return {
3089
- status: response.status,
3090
- retryAfterSeconds
3091
- };
3092
- if (!response.ok) return { status: response.status };
3093
- return {
3094
- status: response.status,
3095
- operation: parseReplacementOperation(await readJson(response)),
3096
- retryAfterSeconds
3097
- };
3098
- }
3099
- });
3354
+ function hasEncryptedEnvelopeShape(value) {
3355
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "v" in value && "c" in value && "k" in value;
3100
3356
  }
3101
- async function send(client, path, init) {
3357
+ /**
3358
+ * True when `value` is the Vercel encrypted-env envelope: a base64 string
3359
+ * that decodes to a JSON object with the `{ v, c, k }` shape. Returns
3360
+ * false for empty strings, non-base64 strings, and base64 strings that
3361
+ * decode to plaintext or to JSON that doesn't match the envelope shape —
3362
+ * so real plaintext secrets that happen to look like base64 are never
3363
+ * misclassified as ciphertext.
3364
+ */
3365
+ function isVercelEncryptedEnvValue(value) {
3366
+ if (!looksLikeBase64(value)) return false;
3367
+ let decoded;
3102
3368
  try {
3103
- return await client.request(path, init);
3104
- } catch (err) {
3105
- throw new MistApiError(`Network error: ${err instanceof Error ? err.message : String(err)}`, 0);
3369
+ decoded = Buffer.from(value, "base64").toString("utf8");
3370
+ } catch {
3371
+ return false;
3106
3372
  }
3107
- }
3108
- async function readJson(response) {
3109
- const text = await response.text();
3110
- if (!text) return null;
3373
+ let parsed;
3111
3374
  try {
3112
- return JSON.parse(text);
3375
+ parsed = JSON.parse(decoded);
3113
3376
  } catch {
3114
- return null;
3377
+ return false;
3115
3378
  }
3379
+ return hasEncryptedEnvelopeShape(parsed);
3116
3380
  }
3117
3381
  //#endregion
3118
- //#region src/lib/reinstall-plan-store.ts
3382
+ //#region src/commands/env/list.ts
3383
+ /** `fluid mist env list` — list env vars; mark managed_by entries. Spec §4. */
3384
+ function maskValue(value) {
3385
+ if (value.length <= 8) return "•".repeat(value.length);
3386
+ return `${value.slice(0, 4)}${"•".repeat(8)}${value.slice(-2)}`;
3387
+ }
3119
3388
  /**
3120
- * Plan receipts for `fluid mist droplet reinstall`.
3121
- *
3122
- * Mist Desktop's agent must show a dry-run plan and get explicit approval
3123
- * before it may confirm the irreversible replacement. A CLI has no
3124
- * conversation to hold that sequence, so `--dry-run` records a receipt of
3125
- * the exact Droplet, installation and lifecycle URLs it showed, and
3126
- * `--confirm` refuses unless a receipt for the same plan is younger than
3127
- * {@link REINSTALL_PLAN_TTL_MS}. `--confirm` claims the receipt atomically
3128
- * (a rename only one process can win) before checking it, so one plan
3129
- * approves at most one attempt and every retry needs a fresh plan.
3130
- *
3131
- * Receipts live under the Fluid config dir (`~/.fluid/mist/reinstall-plans`)
3132
- * and hold only ids and a timestamp — no tokens or secrets.
3389
+ * Values come back Vercel-encrypted ciphertext (see
3390
+ * `../../lib/encrypted-env.ts`) — never show that raw blob, masked or not,
3391
+ * since it isn't the real secret and can run to ~1KB of base64.
3133
3392
  */
3134
- /** How long a shown plan stays valid for `--confirm`. */
3135
- const REINSTALL_PLAN_TTL_MS = 900 * 1e3;
3136
- function createFileReinstallPlanStore(dir = join(getConfigDir(), "mist", "reinstall-plans")) {
3137
- const fileFor = (mistId) => join(dir, `${encodeURIComponent(mistId)}.json`);
3138
- return {
3139
- save(receipt) {
3140
- mkdirSync(dir, {
3141
- recursive: true,
3142
- mode: 448
3143
- });
3144
- writeFileSync(fileFor(receipt.mistId), JSON.stringify(receipt, null, 2) + "\n", {
3145
- encoding: "utf8",
3146
- mode: 384
3147
- });
3148
- },
3149
- load(mistId) {
3150
- try {
3151
- const parsed = JSON.parse(readFileSync(fileFor(mistId), "utf8"));
3152
- return isReceipt(parsed) && parsed.mistId === mistId ? parsed : null;
3153
- } catch {
3154
- return null;
3155
- }
3156
- },
3157
- claim(mistId) {
3158
- const claimed = `${fileFor(mistId)}.claimed-${process.pid}-${randomUUID()}`;
3159
- try {
3160
- renameSync(fileFor(mistId), claimed);
3161
- } catch {
3162
- return null;
3163
- }
3164
- try {
3165
- const parsed = JSON.parse(readFileSync(claimed, "utf8"));
3166
- return isReceipt(parsed) && parsed.mistId === mistId ? parsed : null;
3167
- } catch {
3168
- return null;
3169
- } finally {
3170
- rmSync(claimed, { force: true });
3171
- }
3172
- }
3173
- };
3393
+ function displayValue(v, showValues) {
3394
+ if (isVercelEncryptedEnvValue(v.value)) return chalk.dim("<encrypted>");
3395
+ return showValues ? v.value : maskValue(v.value);
3174
3396
  }
3175
- /** Whether `receipt` approves executing the freshly computed plan. */
3176
- function checkPlanReceipt(receipt, fresh, now) {
3177
- if (!receipt) return {
3178
- ok: false,
3179
- code: "plan_required"
3180
- };
3181
- const plannedAt = Date.parse(receipt.plannedAt);
3182
- if (!Number.isFinite(plannedAt) || now - plannedAt > 9e5) return {
3183
- ok: false,
3184
- code: "plan_stale"
3185
- };
3186
- if (plannedAt > now) return {
3187
- ok: false,
3188
- code: "plan_stale"
3189
- };
3190
- if (receipt.dropletUuid !== fresh.dropletUuid || receipt.installationUuid !== fresh.installationUuid || receipt.embedUrl !== fresh.embedUrl || receipt.installWebhookUrl !== fresh.installWebhookUrl || receipt.uninstallWebhookUrl !== fresh.uninstallWebhookUrl) return {
3191
- ok: false,
3192
- code: "plan_changed"
3397
+ const envListCommand = new Command("list").description("List a mist's env vars").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--show-values", "Print full values instead of masking them").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
3398
+ const resolved = resolveSlug(slug);
3399
+ const response = await listEnvVars(createMistClient(), resolved);
3400
+ if (opts.json) return printJson(response);
3401
+ const vars = response.env_vars ?? [];
3402
+ if (vars.length === 0) {
3403
+ console.log(chalk.dim("No env vars."));
3404
+ return;
3405
+ }
3406
+ printTable([[
3407
+ chalk.bold("KEY"),
3408
+ chalk.bold("VALUE"),
3409
+ chalk.bold("TARGETS"),
3410
+ chalk.bold("MANAGED")
3411
+ ], ...vars.map((v) => [
3412
+ v.key,
3413
+ displayValue(v, opts.showValues),
3414
+ v.targets.join(","),
3415
+ v.managed_by === "mist" ? chalk.magenta("mist") : chalk.dim("user")
3416
+ ])]);
3417
+ }));
3418
+ //#endregion
3419
+ //#region ../../mist/core/src/secret-generator.ts
3420
+ const MIN_BYTES = 16;
3421
+ const MAX_BYTES = 128;
3422
+ const DEFAULT_BYTES = 32;
3423
+ /** Return a random secret. Pure + synchronous so it's trivially
3424
+ * testable (entropy + encoding + clamping) without any I/O. */
3425
+ function generateSecret(options = {}) {
3426
+ const encoding = options.encoding ?? "base64url";
3427
+ const requested = options.bytes ?? DEFAULT_BYTES;
3428
+ const bytes = Math.min(MAX_BYTES, Math.max(MIN_BYTES, Math.trunc(requested)));
3429
+ const buf = new Uint8Array(bytes);
3430
+ globalThis.crypto.getRandomValues(buf);
3431
+ return {
3432
+ secret: encode(buf, encoding),
3433
+ bytes,
3434
+ encoding
3193
3435
  };
3194
- return { ok: true };
3195
3436
  }
3196
- function isReceipt(value) {
3197
- if (typeof value !== "object" || value === null) return false;
3198
- const record = value;
3199
- return typeof record["mistId"] === "string" && typeof record["dropletUuid"] === "string" && typeof record["installationUuid"] === "string" && typeof record["embedUrl"] === "string" && typeof record["installWebhookUrl"] === "string" && typeof record["uninstallWebhookUrl"] === "string" && typeof record["plannedAt"] === "string";
3437
+ function encode(buf, encoding) {
3438
+ if (encoding === "hex") return Array.from(buf, (byte) => byte.toString(16).padStart(2, "0")).join("");
3439
+ const base64 = btoa(String.fromCharCode(...buf));
3440
+ if (encoding === "base64") return base64;
3441
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3200
3442
  }
3201
3443
  //#endregion
3202
- //#region src/lib/droplet-repair.ts
3444
+ //#region src/commands/env/set.ts
3203
3445
  /**
3204
- * Orchestration for `fluid mist droplet repair` and
3205
- * `fluid mist droplet reinstall`, ported from Mist Desktop's
3206
- * `repair_droplet_lifecycle_webhooks` and `repair_and_reinstall_droplet`
3207
- * tools (`apps/mist-desktop/src/main/tools/index.ts` in the fluid repo).
3446
+ * `fluid mist env set <slug> KEY=value [KEY=value ...]` — POST for new
3447
+ * keys, PATCH for existing. Spec §3 + §4.
3208
3448
  *
3209
- * The planning and safety rules are shared through `@fluid-app/mist-core`
3210
- * (`droplet-lifecycle-repair`, `droplet-replacement`); this module only
3211
- * wires them to the CLI's API client, the `.mist` marker and the plan
3212
- * receipt store. Every function takes its client and collaborators as
3213
- * arguments so tests drive it with fakes.
3214
- */
3215
- /** A refusal: the command stops before (or instead of) mutating anything
3216
- * and exits non-zero. `code` is stable for scripts. */
3217
- var DropletRepairRefusal = class extends Error {
3218
- code;
3219
- constructor(code, message) {
3220
- super(message);
3221
- this.name = "DropletRepairRefusal";
3222
- this.code = code;
3223
- }
3224
- };
3225
- /**
3226
- * Resolve the Mist (slug argument or the `.mist` in `cwd`), its public URL,
3227
- * the Droplet it is attached to, and the canonical Droplet URLs. Refuses
3228
- * when the Mist has no public URL or no attached Droplet.
3449
+ * `fluid mist env set [slug] KEY --generate [--bytes N]` mints a random
3450
+ * secret for KEY instead (the generator Mist's `set_mist_env_var` uses),
3451
+ * writes it, and prints it exactly once.
3229
3452
  */
3230
- async function resolveRepairTarget(client, input) {
3231
- const slug = resolveSlug(input.slug, input.cwd);
3232
- const marker = readMistFileFrom(input.cwd);
3233
- const isCwdProject = marker !== null && marker.slug === slug;
3234
- const mistId = await resolveMistId(client, slug);
3235
- const { mist } = await showMist(client, mistId);
3236
- const publicUrl = normalizePublicUrl(mist.public_url);
3237
- if (!publicUrl) throw new DropletRepairRefusal("no_public_url", "Mist has no public_url yet. Publish the Mist first (`fluid mist push`), then repair lifecycle webhooks.");
3238
- const dropletUuid = await resolveAttachedDropletUuid(client, mist, mistId, publicUrl);
3239
- const paths = isCwdProject ? readLifecyclePaths(input.cwd) : null;
3453
+ /** Env var name rule enforced by Rails (mirrors Mist's ENV_KEY_RE). */
3454
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3455
+ const GENERATED_SECRET_NOTICE = "This secret is shown once and won't be shown again; Fluid stores it encrypted. Copy it now if you also need it in .env.local. Redeploy (fluid mist push) for the change to take effect.";
3456
+ function parsePair(pair) {
3457
+ const eq = pair.indexOf("=");
3458
+ if (eq <= 0) throw new Error(`Invalid pair "${pair}". Use KEY=value (no leading spaces).`);
3240
3459
  return {
3241
- mistId,
3242
- mistSlug: mist.slug ?? slug,
3243
- dropletUuid,
3244
- canonical: canonicalDropletUrls(publicUrl, paths ?? DEFAULT_DROPLET_LIFECYCLE_PATHS),
3245
- lifecyclePathsSource: paths ? "fluid.capabilities.json" : "defaults"
3460
+ key: pair.slice(0, eq),
3461
+ value: pair.slice(eq + 1)
3246
3462
  };
3247
3463
  }
3464
+ function parseBytes(value) {
3465
+ const bytes = Number(value);
3466
+ if (!Number.isInteger(bytes) || bytes < 16 || bytes > 128) throw new InvalidArgumentError("Use a whole number from 16 to 128.");
3467
+ return bytes;
3468
+ }
3248
3469
  /**
3249
- * The Mist's own Droplet. The server-owned link wins (the Mist record, then
3250
- * its droplet integration point); matching `embed_url` is only a legacy
3251
- * fallback, because a broken embed URL is exactly what the repair fixes.
3470
+ * Generate a secret for `key` and write it to the Mist. Refuses invalid
3471
+ * and Mist-managed keys BEFORE generating, so a secret is only ever
3472
+ * returned once it has been stored.
3252
3473
  */
3253
- async function resolveAttachedDropletUuid(client, mist, mistId, publicUrl) {
3254
- if (typeof mist.droplet?.uuid === "string" && mist.droplet.uuid.length > 0) return mist.droplet.uuid;
3255
- try {
3256
- const { integration_points } = await listIntegrationPoints(client, mistId);
3257
- const attached = findDropletIntegrationPoint(integration_points);
3258
- if (attached?.integratable_uuid) return attached.integratable_uuid;
3259
- if (attached?.integratable_id != null) {
3260
- const uuid = await findDropletUuidByNumericId(client, attached.integratable_id);
3261
- if (uuid) return uuid;
3474
+ async function setGeneratedEnvVar(client, slug, key, bytes) {
3475
+ if (!ENV_KEY_RE.test(key)) throw new Error(`Invalid env var name "${key}". Use letters, digits and underscores, not starting with a digit.`);
3476
+ const current = ((await listEnvVars(client, slug)).env_vars ?? []).find((v) => v.key === key);
3477
+ if (current?.managed_by === "mist") throw new Error(`"${key}" is managed by Fluid and can't be set; it is provisioned automatically for the Mist.`);
3478
+ const secret = generateSecret(bytes === void 0 ? {} : { bytes });
3479
+ if (current) await updateEnvVar(client, slug, key, secret.secret);
3480
+ else await createEnvVar(client, slug, key, secret.secret);
3481
+ return {
3482
+ key,
3483
+ action: current ? "updated" : "created",
3484
+ generatedSecret: secret.secret,
3485
+ bytes: secret.bytes,
3486
+ encoding: secret.encoding,
3487
+ note: GENERATED_SECRET_NOTICE
3488
+ };
3489
+ }
3490
+ const envSetCommand = new Command("set").description("Set one or more env vars (KEY=value KEY=value …), or generate a secret for KEY with --generate").argument("<slug-or-pair>", "The mist slug, or the first KEY=value pair (or KEY with --generate)").argument("[pairs...]", "Additional KEY=value pairs (or KEY with --generate)").option("--generate", "Generate a random secret as the value of one KEY and print it once").option("--bytes <n>", "Entropy for --generate, in bytes (16-128, default 32)", parseBytes).option("--json", "Emit JSON (with --generate)").action(async (slugOrPair, pairs, opts) => {
3491
+ await runAction(async () => {
3492
+ if (opts.generate) {
3493
+ await runGenerate([slugOrPair, ...pairs], opts);
3494
+ return;
3262
3495
  }
3263
- } catch (err) {
3264
- if (err instanceof MistApiError && err.status === 0) throw err;
3496
+ if (opts.bytes !== void 0) {
3497
+ console.error(chalk.red("--bytes only applies with --generate."));
3498
+ process.exit(1);
3499
+ }
3500
+ let slug;
3501
+ let rawPairs;
3502
+ if (slugOrPair.includes("=")) {
3503
+ slug = resolveSlug();
3504
+ rawPairs = [slugOrPair, ...pairs];
3505
+ } else {
3506
+ slug = resolveSlug(slugOrPair);
3507
+ rawPairs = pairs;
3508
+ }
3509
+ if (rawPairs.length === 0) {
3510
+ console.error(chalk.red("Pass at least one KEY=value pair."));
3511
+ process.exit(1);
3512
+ }
3513
+ const client = createMistClient();
3514
+ const existing = await listEnvVars(client, slug);
3515
+ const existingKeys = new Set((existing.env_vars ?? []).map((v) => v.key));
3516
+ for (const raw of rawPairs) {
3517
+ const { key, value } = parsePair(raw);
3518
+ if (existingKeys.has(key)) {
3519
+ await updateEnvVar(client, slug, key, value);
3520
+ console.log(`${chalk.green("updated")} ${key}`);
3521
+ } else {
3522
+ await createEnvVar(client, slug, key, value);
3523
+ console.log(`${chalk.cyan("created")} ${key}`);
3524
+ }
3525
+ }
3526
+ });
3527
+ });
3528
+ /** `env set [slug] KEY --generate`: exactly one KEY, without a value. */
3529
+ async function runGenerate(args, opts) {
3530
+ if (args.length > 2) {
3531
+ console.error(chalk.red("--generate sets one key at a time: env set [slug] KEY --generate"));
3532
+ process.exit(1);
3265
3533
  }
3266
- const byEmbedUrl = await findDropletByEmbedUrl(client, publicUrl);
3267
- if (byEmbedUrl) {
3268
- if (!byEmbedUrl.uuid) throw new DropletRepairRefusal("droplet_not_found", `Droplet with embed_url ${publicUrl} was found, but its API response did not include a uuid.`);
3269
- return byEmbedUrl.uuid;
3534
+ const [first, second] = args;
3535
+ const key = (second ?? first ?? "").trim();
3536
+ if (key.includes("=")) {
3537
+ console.error(chalk.red(`--generate creates the value itself; pass the key alone (e.g. ${key.split("=")[0]}).`));
3538
+ process.exit(1);
3270
3539
  }
3271
- throw new DropletRepairRefusal("droplet_not_found", "This Mist is not attached to a Droplet, and no Droplet with a matching embed_url was found. Attach or recreate the Droplet first (`fluid mist attach-droplet`), then retry.");
3272
- }
3273
- /** Lifecycle paths from `fluid.capabilities.json`, or null when absent or
3274
- * unreadable (the caller then uses the defaults). */
3275
- function readLifecyclePaths(cwd) {
3276
- const file = join(cwd, "fluid.capabilities.json");
3277
- if (!existsSync(file)) return null;
3278
- try {
3279
- return dropletLifecyclePathsFromCapabilities(JSON.parse(readFileSync(file, "utf8")));
3280
- } catch {
3281
- return null;
3540
+ const slug = second === void 0 ? resolveSlug() : resolveSlug(first);
3541
+ const result = await setGeneratedEnvVar(createMistClient(), slug, key, opts.bytes);
3542
+ if (opts.json) {
3543
+ printJson(result);
3544
+ return;
3282
3545
  }
3546
+ const verb = result.action === "updated" ? chalk.green("updated") : chalk.cyan("created");
3547
+ console.log(`${verb} ${result.key} (${result.bytes}-byte generated secret)`);
3548
+ console.log();
3549
+ console.log(result.generatedSecret);
3550
+ console.log();
3551
+ console.log(chalk.yellow(result.note));
3283
3552
  }
3284
- /** Read the Droplet and report the planned repair without mutating. */
3285
- async function planDropletRepair(client, target, dryRun) {
3286
- const { droplet } = await getDroplet(client, target.dropletUuid);
3287
- const { alreadyCorrect, summary } = planLifecycleRepair({
3288
- mistId: target.mistId,
3289
- mistSlug: target.mistSlug,
3290
- dropletUuid: target.dropletUuid,
3291
- canonical: target.canonical,
3292
- current: droplet,
3293
- dryRun
3294
- });
3295
- return {
3296
- alreadyCorrect,
3297
- summary: {
3298
- ...summary,
3299
- lifecyclePathsSource: target.lifecyclePathsSource
3553
+ //#endregion
3554
+ //#region src/commands/env/unset.ts
3555
+ /** `fluid mist env unset <slug> KEY [KEY ...]` — DELETE per key. Spec §3. */
3556
+ const envUnsetCommand = new Command("unset").description("Delete one or more env vars").argument("<slug-or-key>", "The mist slug, or the first KEY (if .mist file is present)").argument("[keys...]", "Additional keys").action(async (slugOrKey, keys) => {
3557
+ await runAction(async () => {
3558
+ let slug;
3559
+ let toDelete;
3560
+ if (/^[A-Z_][A-Z0-9_]*$/.test(slugOrKey)) {
3561
+ slug = resolveSlug();
3562
+ toDelete = [slugOrKey, ...keys];
3563
+ } else {
3564
+ slug = resolveSlug(slugOrKey);
3565
+ toDelete = keys;
3300
3566
  }
3301
- };
3567
+ if (toDelete.length === 0) {
3568
+ console.error(chalk.red("Pass at least one KEY."));
3569
+ process.exit(1);
3570
+ }
3571
+ const client = createMistClient();
3572
+ for (const key of toDelete) {
3573
+ await deleteEnvVar(client, slug, key);
3574
+ console.log(`${chalk.red("deleted")} ${key}`);
3575
+ }
3576
+ });
3577
+ });
3578
+ //#endregion
3579
+ //#region src/commands/env/pull.ts
3580
+ /**
3581
+ * `fluid mist env pull` — writes `.env.local` with user-managed vars only.
3582
+ * Skips `managed_by: "mist"` entries (DATABASE_URL, FLUID_*) since local
3583
+ * dev uses PGlite, not the production database. Spec §4 + §5.
3584
+ *
3585
+ * Also skips user-managed vars whose value comes back as Vercel-encrypted
3586
+ * ciphertext (`GET .../env_vars` is a proxy to Vercel's project env, which
3587
+ * never returns plaintext — see `../../lib/encrypted-env.ts`). Writing that
3588
+ * ciphertext into `.env.local` verbatim would silently produce an unusable
3589
+ * file, so those keys are collected and surfaced as a warning instead.
3590
+ *
3591
+ * The output file is MERGED, not replaced: pulled plaintext values update
3592
+ * their existing lines (or are appended), while every other existing line
3593
+ * — comments, locally maintained secrets for encrypted/managed keys, and
3594
+ * local-only vars — is preserved verbatim. Replacing the file wholesale
3595
+ * would delete manually set values for skipped keys, and an all-encrypted
3596
+ * response would truncate the file to zero bytes right before telling the
3597
+ * user to set those same values locally.
3598
+ */
3599
+ function escapeValue(v) {
3600
+ if (/[\s#"']/.test(v)) return `"${v.replace(/(["\\$`])/g, "\\$1")}"`;
3601
+ return v;
3302
3602
  }
3303
3603
  /**
3304
- * PATCH only the embed and lifecycle webhook URLs, then re-read the Droplet
3305
- * to verify. A no-op when they already match.
3604
+ * Splits the raw env-var list into what `env pull` can safely write vs.
3605
+ * what it must skip, and why. Pure — no I/O — so it's unit-testable
3606
+ * without mocking the API client or the filesystem.
3306
3607
  */
3307
- async function applyDropletRepair(client, target) {
3308
- const planned = await planDropletRepair(client, target, false);
3309
- if (planned.alreadyCorrect) return {
3310
- summary: planned.summary,
3311
- isError: false
3312
- };
3313
- await client.patch(`/api/droplets/${encodeURIComponent(target.dropletUuid)}`, dropletRepairPatch(target.canonical));
3314
- let verifyStatus = 200;
3315
- let verified = false;
3316
- try {
3317
- const { droplet } = await getDroplet(client, target.dropletUuid);
3318
- verified = dropletUrlsMatch(droplet, target.canonical);
3319
- } catch (err) {
3320
- verifyStatus = err instanceof MistApiError ? err.status : 0;
3321
- }
3608
+ function partitionPullEnvVars(envVars) {
3609
+ const plaintext = [];
3610
+ const encrypted = [];
3611
+ const managed = [];
3612
+ for (const v of envVars) if (v.managed_by === "mist") managed.push(v);
3613
+ else if (isVercelEncryptedEnvValue(v.value)) encrypted.push(v);
3614
+ else plaintext.push(v);
3322
3615
  return {
3323
- summary: {
3324
- ...planned.summary,
3325
- status: verified ? "updated" : "error",
3326
- verified,
3327
- verifyStatus
3328
- },
3329
- isError: !verified
3616
+ plaintext,
3617
+ encrypted,
3618
+ managed
3330
3619
  };
3331
3620
  }
3332
- const PLAN_TTL_MINUTES = REINSTALL_PLAN_TTL_MS / 6e4;
3621
+ /** Matches a `KEY=` / `export KEY=` assignment line and captures the key. */
3622
+ const ENV_ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
3333
3623
  /**
3334
- * `fluid mist droplet reinstall`. Same sequencing as Mist's
3335
- * `repair_and_reinstall_droplet`: plan first, explicit approval, one
3336
- * backend-owned replacement, resumable watching. Refusals return a
3337
- * non-zero exit code; nothing irreversible starts without `--confirm`
3338
- * matching a fresh `--dry-run` plan.
3624
+ * Merges pulled plaintext vars into the existing env-file contents.
3625
+ * Existing assignment lines for pulled keys are updated in place; every
3626
+ * other line (comments, blank lines, locally maintained values for keys
3627
+ * the pull skipped) is preserved verbatim; new keys are appended. Pure —
3628
+ * no I/O — so it's unit-testable without touching the filesystem.
3339
3629
  */
3340
- async function runDropletReinstall(deps, opts) {
3341
- const now = deps.now ?? Date.now;
3342
- const refusal = (code, message) => ({
3343
- output: {
3344
- status: "refused",
3345
- code,
3346
- message
3347
- },
3348
- exitCode: 1
3349
- });
3350
- if (opts.dryRun && opts.confirm) return refusal("conflicting_flags", "Pass either --dry-run (show the plan) or --confirm (execute it), not both.");
3351
- if (opts.dryRun && opts.resumeOperationId?.trim()) return refusal("conflicting_flags", "--resume keeps watching a replacement that already started; it does not take --dry-run. Run it with --installation only.");
3352
- const request = {
3353
- dryRun: opts.dryRun === true,
3354
- confirmDestructiveReinstall: opts.confirm === true,
3355
- installationUuid: opts.installationUuid?.trim() || void 0,
3356
- resumeOperationId: opts.resumeOperationId
3357
- };
3358
- const requestCheck = checkReplacementRequest(request);
3359
- if (!requestCheck.ok) return refusal(requestCheck.code, "--resume also needs --installation with the installationUuid from that same \"still_running\" result. Replacement status is scoped to the installation the operation started on, so resuming without it would watch the wrong one.");
3360
- const { resumeOperationId } = requestCheck;
3361
- if (!request.dryRun && replacementStartNeedsConfirmation(request, resumeOperationId)) return refusal("confirmation_required", opts.yes ? "--yes does not approve an irreversible reinstall. Run with --dry-run, review the exact dropletUuid and installationUuid, then rerun with --confirm." : "Refused to start an irreversible replacement without explicit confirmation. Run with --dry-run, review the exact dropletUuid and installationUuid, then rerun with --confirm.");
3362
- try {
3363
- const target = await resolveRepairTarget(deps.client, {
3364
- slug: deps.slug,
3365
- cwd: deps.cwd
3366
- });
3367
- const plannedRepair = await planDropletRepair(deps.client, target, true);
3368
- const installation = selectReplacementInstallation({
3369
- installations: await listAllDropletInstallations(deps.client),
3370
- dropletUuid: target.dropletUuid,
3371
- installationUuid: request.installationUuid,
3372
- resumeOperationId
3373
- });
3374
- if (!installation?.uuid) return refusal("installation_not_found", `No active installation for attached Droplet ${target.dropletUuid} was found. If a previous approved replacement terminalized the source, rerun with that plan's --installation <uuid>; otherwise install the Droplet once from Fluid first.`);
3375
- const installationUuid = installation.uuid;
3376
- const idempotencyKey = deps.newIdempotencyKey();
3377
- const planInput = {
3378
- dryRun: request.dryRun,
3379
- resumeOperationId,
3380
- mistId: target.mistId,
3381
- dropletUuid: target.dropletUuid,
3382
- installation: {
3383
- ...installation,
3384
- uuid: installationUuid
3385
- },
3386
- idempotencyKey
3387
- };
3388
- if (request.dryRun) {
3389
- deps.planStore.save({
3390
- mistId: target.mistId,
3391
- dropletUuid: target.dropletUuid,
3392
- installationUuid,
3393
- ...target.canonical,
3394
- plannedAt: new Date(now()).toISOString()
3395
- });
3396
- const confirmCommand = reinstallCommand(target.mistSlug, ["--confirm", ...request.installationUuid ? ["--installation", installationUuid] : []]);
3397
- return {
3398
- output: {
3399
- ...buildReplacementPlan({
3400
- ...planInput,
3401
- lifecycleRepair: plannedRepair.summary
3402
- }),
3403
- nextStep: `Review the dropletUuid and installationUuid above. This replacement is irreversible. To approve it, run \`${confirmCommand}\` within ${PLAN_TTL_MINUTES} minutes.`
3404
- },
3405
- exitCode: 0
3406
- };
3407
- }
3408
- let lifecycleRepair = plannedRepair.summary;
3409
- let startedOperation = null;
3410
- if (!resumeOperationId) {
3411
- const receiptCheck = checkPlanReceipt(deps.planStore.claim(target.mistId), {
3412
- dropletUuid: target.dropletUuid,
3413
- installationUuid,
3414
- ...target.canonical
3415
- }, now());
3416
- if (!receiptCheck.ok) return refusal(receiptCheck.code, planReceiptMessage(receiptCheck.code));
3417
- const repaired = await applyDropletRepair(deps.client, target);
3418
- lifecycleRepair = repaired.summary;
3419
- if (repaired.isError) return {
3420
- output: {
3421
- status: "error",
3422
- code: "lifecycle_repair_failed",
3423
- message: "The Droplet lifecycle URLs did not verify after the repair, so no replacement was started.",
3424
- lifecycleRepair
3425
- },
3426
- exitCode: 1
3427
- };
3428
- startedOperation = await startDropletReplacement(deps.client, {
3429
- mistId: target.mistId,
3430
- installationUuid,
3431
- idempotencyKey
3432
- });
3433
- }
3434
- const plan = buildReplacementPlan({
3435
- ...planInput,
3436
- lifecycleRepair
3437
- });
3438
- const operationId = resumeOperationId ?? startedOperation?.id;
3439
- if (!operationId) return {
3440
- output: {
3441
- ...plan,
3442
- status: "error",
3443
- code: "missing_operation_id",
3444
- message: "Fluid accepted the replacement but did not return an operation id to watch."
3445
- },
3446
- exitCode: 1
3447
- };
3448
- const watchedForMs = opts.watchTimeoutMs ?? 12e4;
3449
- const watch = summarizeReplacementWatch({
3450
- plan,
3451
- pollResult: await pollDropletReplacement(deps.client, {
3452
- mistId: target.mistId,
3453
- installationUuid,
3454
- operationId,
3455
- timeoutMs: watchedForMs,
3456
- ...deps.sleep ? { sleep: deps.sleep } : {}
3457
- }),
3458
- startedOperation,
3459
- operationId,
3460
- installationUuid,
3461
- watchedForMs,
3462
- nextStep: ({ operationId: id, installationUuid: source, stage }) => `Fluid is still working on this replacement: it is not finished and it did not fail. Current stage: ${stage}. Keep watching with \`${reinstallCommand(target.mistSlug, [
3463
- "--resume",
3464
- id,
3465
- "--installation",
3466
- source
3467
- ])}\`. The reinstall is complete only when status is "succeeded".`
3468
- });
3469
- return {
3470
- output: { ...watch.result },
3471
- exitCode: watch.isError ? 1 : 0
3472
- };
3473
- } catch (err) {
3474
- if (err instanceof DropletRepairRefusal) return refusal(err.code, err.message);
3475
- throw err;
3630
+ function mergeEnvContents(existing, plaintext) {
3631
+ const updates = new Map(plaintext.map((v) => [v.key, `${v.key}=${escapeValue(v.value)}`]));
3632
+ const lines = existing.length > 0 ? existing.split("\n") : [];
3633
+ if (lines.at(-1) === "") lines.pop();
3634
+ const out = [];
3635
+ for (const line of lines) {
3636
+ const key = ENV_ASSIGNMENT.exec(line)?.[1];
3637
+ const replacement = key === void 0 ? void 0 : updates.get(key);
3638
+ if (key !== void 0 && replacement !== void 0) {
3639
+ out.push(replacement);
3640
+ updates.delete(key);
3641
+ } else out.push(line);
3476
3642
  }
3643
+ for (const rendered of updates.values()) out.push(rendered);
3644
+ return out.length > 0 ? out.join("\n") + "\n" : "";
3477
3645
  }
3478
- function reinstallCommand(slug, flags) {
3479
- return [
3480
- "fluid mist droplet reinstall",
3646
+ const envPullCommand = new Command("pull").description("Write user-managed env vars to .env.local in the CWD").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--out <path>", "Output path (default: .env.local)").action((slug, opts) => runAction(async () => {
3647
+ const resolved = resolveSlug(slug);
3648
+ const { env_vars } = await listEnvVars(createMistClient(), resolved);
3649
+ const { plaintext, encrypted, managed } = partitionPullEnvVars(env_vars);
3650
+ const outName = opts.out ?? ".env.local";
3651
+ const target = join(process.cwd(), outName);
3652
+ const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
3653
+ writeFileSync(target, mergeEnvContents(existing, plaintext), "utf8");
3654
+ console.log(chalk.green(`Wrote ${plaintext.length} var(s) to ${target.replace(process.cwd() + "/", "")}${existing.length > 0 ? " (existing entries for other keys preserved)" : ""}.`));
3655
+ if (managed.length > 0) console.log(chalk.dim(`Skipped ${managed.length} Mist-managed var(s): ${managed.map((v) => v.key).join(", ")} (local dev uses PGlite)`));
3656
+ if (encrypted.length > 0) console.log(chalk.yellow(`Skipped ${encrypted.length} var(s) whose values are stored encrypted and can't be pulled: ${encrypted.map((v) => v.key).join(", ")} — set them locally in ${outName} yourself (values are visible in the Vercel dashboard) or re-set them via \`fluid mist env set\`.`));
3657
+ }));
3658
+ //#endregion
3659
+ //#region src/commands/env/index.ts
3660
+ /** `fluid mist env` — sub-command aggregator. */
3661
+ const envCommand = new Command("env").description("Manage a mist's environment variables").addCommand(envListCommand).addCommand(envSetCommand).addCommand(envUnsetCommand).addCommand(envPullCommand);
3662
+ //#endregion
3663
+ //#region src/commands/droplet/egress-proxy.ts
3664
+ /**
3665
+ * `fluid mist droplet egress-proxy <on|off> [slug] [--json]`
3666
+ *
3667
+ * Ask Fluid (on) to let the Mist's attached Droplet reach external systems
3668
+ * such as Exigo through its egress proxy, or withdraw the request (off). A
3669
+ * Fluid admin approves the request; withdrawing it also removes the grant.
3670
+ * Leaves an already-correct droplet untouched. Safe Mode treats this as a
3671
+ * write (apps/mist-desktop fluid-cli-policy).
3672
+ */
3673
+ function parseEgressProxyState(value) {
3674
+ const normalized = value.trim().toLowerCase();
3675
+ if (normalized === "on") return true;
3676
+ if (normalized === "off") return false;
3677
+ throw new InvalidArgumentError("Use \"on\" or \"off\".");
3678
+ }
3679
+ const dropletEgressProxyCommand = new Command("egress-proxy").description("Ask Fluid (on) or stop asking (off) to let the attached Droplet reach external systems such as Exigo through its egress proxy; a Fluid admin approves it").argument("<state>", "on | off", parseEgressProxyState).argument("[slug]", "Mist slug (defaults to the .mist file in CWD)").option("--json", "Emit JSON").action((requested, slug, opts) => runAction(async () => {
3680
+ const result = await setDropletEgressProxy(createMistClient(), {
3481
3681
  slug,
3482
- ...flags
3483
- ].join(" ");
3484
- }
3485
- function planReceiptMessage(code) {
3486
- switch (code) {
3487
- case "plan_required": return "Refused: no reinstall plan has been shown for this Mist. Run with --dry-run first, review the plan, then rerun with --confirm.";
3488
- case "plan_stale": return `Refused: the reinstall plan is older than ${PLAN_TTL_MINUTES} minutes. Run with --dry-run again, review the plan, then rerun with --confirm.`;
3489
- case "plan_changed": return "Refused: the Droplet, installation or lifecycle URLs changed since the plan was shown. Run with --dry-run again, review the new plan, then rerun with --confirm.";
3490
- }
3491
- }
3682
+ cwd: process.cwd(),
3683
+ requested
3684
+ });
3685
+ if (opts.json) return printJson(result);
3686
+ const target = `droplet ${result.dropletUuid} (${result.mistSlug})`;
3687
+ if (!result.requested) console.log(result.changed ? `${chalk.green("✓")} Egress request withdrawn for ${target}` : chalk.dim(`No egress request for ${target}`));
3688
+ else if (result.granted) console.log(`${chalk.green("✓")} Egress granted for ${target}`);
3689
+ else console.log(`${chalk.green("✓")} Egress requested for ${target}. A Fluid admin must approve it before the proxy admits the droplet.`);
3690
+ }));
3492
3691
  //#endregion
3493
3692
  //#region src/commands/droplet/reinstall.ts
3494
3693
  /**
@@ -3633,7 +3832,7 @@ function printSummary(summary, options = {}) {
3633
3832
  //#endregion
3634
3833
  //#region src/commands/droplet/index.ts
3635
3834
  /** `fluid mist droplet` — sub-command aggregator for the attached Droplet. */
3636
- const dropletCommand = new Command("droplet").description("Repair or reinstall the Mist's attached Droplet").addCommand(dropletRepairCommand).addCommand(dropletReinstallCommand);
3835
+ const dropletCommand = new Command("droplet").description("Repair, reinstall, or set egress for the Mist's attached Droplet").addCommand(dropletRepairCommand).addCommand(dropletReinstallCommand).addCommand(dropletEgressProxyCommand);
3637
3836
  //#endregion
3638
3837
  //#region src/index.ts
3639
3838
  /**