@fluid-app/fluid-cli-mist 0.1.31 → 0.1.32

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
@@ -1,13 +1,14 @@
1
- import { Command } from "commander";
1
+ import { Command, InvalidArgumentError, Option } from "commander";
2
2
  import chalk from "chalk";
3
- import { getAuthToken, gitSyncActorFromMe, gitSyncCommitSubject, parseNameStatusZ, resolveGitSyncActor, summarizeChanges } from "@fluid-app/fluid-cli";
4
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { getAuthToken, getConfigDir, gitSyncActorFromMe, gitSyncCommitSubject, parseNameStatusZ, resolveGitSyncActor, summarizeChanges } from "@fluid-app/fluid-cli";
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  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
9
  import prompts from "prompts";
10
10
  import open from "open";
11
+ import { randomUUID } from "node:crypto";
11
12
  //#region src/api/client.ts
12
13
  /**
13
14
  * Generic fetch wrapper for the Mist Rails API.
@@ -815,6 +816,9 @@ function writeMistFile(dir, mist) {
815
816
  };
816
817
  writeFileSync(join(dir, MIST_FILE), JSON.stringify(payload, null, 2) + "\n", "utf8");
817
818
  }
819
+ function readMistFileFrom(dir = process.cwd()) {
820
+ return readMistFile(dir);
821
+ }
818
822
  //#endregion
819
823
  //#region src/lib/error-handler.ts
820
824
  /**
@@ -1192,6 +1196,9 @@ function buildDropletCreateInput(opts) {
1192
1196
  function createDroplet(client, body) {
1193
1197
  return client.post(BASE$3, body);
1194
1198
  }
1199
+ function getDroplet(client, uuid) {
1200
+ return client.get(`${BASE$3}/${encodeURIComponent(uuid)}`);
1201
+ }
1195
1202
  /** Page size + runaway guard for walking the page-paginated droplets list. */
1196
1203
  const PER_PAGE$1 = 100;
1197
1204
  const MAX_PAGES$1 = 25;
@@ -1391,7 +1398,7 @@ const DEFAULT_TIMING = {
1391
1398
  envTimeoutMs: 12e4,
1392
1399
  envIntervalMs: 5e3
1393
1400
  };
1394
- const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1401
+ const defaultSleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1395
1402
  const KIND_LABEL$1 = {
1396
1403
  droplet: "droplet",
1397
1404
  "drop-zone": "drop zone",
@@ -1407,7 +1414,7 @@ async function provisionSurface(client, options) {
1407
1414
  ...DEFAULT_TIMING,
1408
1415
  ...options.timing
1409
1416
  };
1410
- const sleep = options.sleep ?? defaultSleep;
1417
+ const sleep = options.sleep ?? defaultSleep$1;
1411
1418
  const label = KIND_LABEL$1[kind];
1412
1419
  reporter.start("Resolving mist…");
1413
1420
  const mist = (await showMist(client, slugOrId)).mist;
@@ -2358,6 +2365,7 @@ const logsCommand = new Command("logs").description("Fetch deployment logs (use
2358
2365
  if (opts.json && !opts.tail) return printJson(initial);
2359
2366
  for (const entry of initial.logs) printLine(entry);
2360
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`);
2361
2369
  let since = initial.logs.length > 0 ? initial.logs[initial.logs.length - 1].timestamp : (/* @__PURE__ */ new Date()).toISOString();
2362
2370
  while (true) {
2363
2371
  await sleep(POLL_MS);
@@ -2456,11 +2464,43 @@ const envListCommand = new Command("list").description("List a mist's env vars")
2456
2464
  ])]);
2457
2465
  }));
2458
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
2483
+ };
2484
+ }
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(/=+$/, "");
2490
+ }
2491
+ //#endregion
2459
2492
  //#region src/commands/env/set.ts
2460
2493
  /**
2461
2494
  * `fluid mist env set <slug> KEY=value [KEY=value ...]` — POST for new
2462
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.
2463
2500
  */
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.";
2464
2504
  function parsePair(pair) {
2465
2505
  const eq = pair.indexOf("=");
2466
2506
  if (eq <= 0) throw new Error(`Invalid pair "${pair}". Use KEY=value (no leading spaces).`);
@@ -2469,8 +2509,42 @@ function parsePair(pair) {
2469
2509
  value: pair.slice(eq + 1)
2470
2510
  };
2471
2511
  }
2472
- const envSetCommand = new Command("set").description("Set one or more env vars (KEY=value KEY=value …)").argument("<slug-or-pair>", "The mist slug, or the first KEY=value pair").argument("[pairs...]", "Additional KEY=value pairs").action(async (slugOrPair, pairs) => {
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) => {
2473
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
+ }
2474
2548
  let slug;
2475
2549
  let rawPairs;
2476
2550
  if (slugOrPair.includes("=")) {
@@ -2499,6 +2573,31 @@ const envSetCommand = new Command("set").description("Set one or more env vars (
2499
2573
  }
2500
2574
  });
2501
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
+ }
2502
2601
  //#endregion
2503
2602
  //#region src/commands/env/unset.ts
2504
2603
  /** `fluid mist env unset <slug> KEY [KEY ...]` — DELETE per key. Spec §3. */
@@ -2609,6 +2708,933 @@ const envPullCommand = new Command("pull").description("Write user-managed env v
2609
2708
  /** `fluid mist env` — sub-command aggregator. */
2610
2709
  const envCommand = new Command("env").description("Manage a mist's environment variables").addCommand(envListCommand).addCommand(envSetCommand).addCommand(envUnsetCommand).addCommand(envPullCommand);
2611
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 : {};
2740
+ return {
2741
+ installPath: normalizeWebhookPath(source["installPath"], DEFAULT_DROPLET_LIFECYCLE_PATHS.installPath),
2742
+ uninstallPath: normalizeWebhookPath(source["uninstallPath"], DEFAULT_DROPLET_LIFECYCLE_PATHS.uninstallPath)
2743
+ };
2744
+ }
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");
2749
+ return {
2750
+ embedUrl,
2751
+ installWebhookUrl: `${embedUrl}${paths.installPath}`,
2752
+ uninstallWebhookUrl: `${embedUrl}${paths.uninstallPath}`
2753
+ };
2754
+ }
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
+ /**
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).
2772
+ */
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";
2815
+ }
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;
2860
+ }
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;
2866
+ }
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
+ };
2906
+ }
2907
+ /**
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".
2912
+ */
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
+ };
2923
+ }
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;
2928
+ }
2929
+ /**
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.
2936
+ */
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
2941
+ };
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;
2947
+ 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
2962
+ };
2963
+ }
2964
+ /**
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.
2969
+ */
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;
2975
+ 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"
2992
+ };
2993
+ }
2994
+ function isLifecycleOperationKind(value) {
2995
+ return lifecycleOperationKinds.some((candidate) => candidate === value);
2996
+ }
2997
+ function isLifecycleOperationState(value) {
2998
+ return lifecycleOperationStates.some((candidate) => candidate === value);
2999
+ }
3000
+ function isOptionalNullableString(value) {
3001
+ return value === void 0 || value === null || typeof value === "string";
3002
+ }
3003
+ function isOptionalNullableNumber(value) {
3004
+ return value === void 0 || value === null || typeof value === "number";
3005
+ }
3006
+ function isOptionalConnectionState(value) {
3007
+ return value === void 0 || value === null || connectionStates.some((candidate) => candidate === value);
3008
+ }
3009
+ function isRecord(value) {
3010
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3011
+ }
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
+ };
3031
+ }
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
+ }
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
+ }
3054
+ //#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));
3078
+ }
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
+ });
3100
+ }
3101
+ async function send(client, path, init) {
3102
+ 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);
3106
+ }
3107
+ }
3108
+ async function readJson(response) {
3109
+ const text = await response.text();
3110
+ if (!text) return null;
3111
+ try {
3112
+ return JSON.parse(text);
3113
+ } catch {
3114
+ return null;
3115
+ }
3116
+ }
3117
+ //#endregion
3118
+ //#region src/lib/reinstall-plan-store.ts
3119
+ /**
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.
3133
+ */
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
+ };
3174
+ }
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"
3193
+ };
3194
+ return { ok: true };
3195
+ }
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";
3200
+ }
3201
+ //#endregion
3202
+ //#region src/lib/droplet-repair.ts
3203
+ /**
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).
3208
+ *
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.
3229
+ */
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;
3240
+ 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"
3246
+ };
3247
+ }
3248
+ /**
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.
3252
+ */
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;
3262
+ }
3263
+ } catch (err) {
3264
+ if (err instanceof MistApiError && err.status === 0) throw err;
3265
+ }
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;
3270
+ }
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;
3282
+ }
3283
+ }
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
3300
+ }
3301
+ };
3302
+ }
3303
+ /**
3304
+ * PATCH only the embed and lifecycle webhook URLs, then re-read the Droplet
3305
+ * to verify. A no-op when they already match.
3306
+ */
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
+ }
3322
+ return {
3323
+ summary: {
3324
+ ...planned.summary,
3325
+ status: verified ? "updated" : "error",
3326
+ verified,
3327
+ verifyStatus
3328
+ },
3329
+ isError: !verified
3330
+ };
3331
+ }
3332
+ const PLAN_TTL_MINUTES = REINSTALL_PLAN_TTL_MS / 6e4;
3333
+ /**
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.
3339
+ */
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;
3476
+ }
3477
+ }
3478
+ function reinstallCommand(slug, flags) {
3479
+ return [
3480
+ "fluid mist droplet reinstall",
3481
+ 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
+ }
3492
+ //#endregion
3493
+ //#region src/commands/droplet/reinstall.ts
3494
+ /**
3495
+ * `fluid mist droplet reinstall [slug] --dry-run | --confirm`
3496
+ * [--installation <uuid>] [--resume <operationId>] [--watch-timeout <s>]
3497
+ *
3498
+ * Port of Mist Desktop's `repair_and_reinstall_droplet`: an IRREVERSIBLE
3499
+ * repair plus backend-owned replacement of the Mist's own attached Droplet
3500
+ * installation. Same safety rules as Mist:
3501
+ *
3502
+ * - The Droplet is always the one attached to the Mist; there is no flag
3503
+ * to name another, and the backend rejects installations of any other
3504
+ * Droplet.
3505
+ * - `--dry-run` shows the exact plan and mutates nothing.
3506
+ * - `--confirm` executes, and only after a `--dry-run` plan for the same
3507
+ * Droplet and installation was shown within the last 15 minutes.
3508
+ * `--yes` never approves it.
3509
+ * - Execution repairs the lifecycle URLs, starts ONE durable replacement
3510
+ * operation, and watches it. It never DELETEs and re-creates an
3511
+ * installation itself and never reports success before Fluid does.
3512
+ * - A slow replacement returns "still_running" with the ids to resume:
3513
+ * `--resume <operationId> --installation <uuid>` (both required).
3514
+ * - `--installation <uuid>` alone recovers a source installation that a
3515
+ * failed attempt already terminalized.
3516
+ *
3517
+ * Output is always JSON; refusals and failures exit non-zero.
3518
+ */
3519
+ function parseSeconds(value) {
3520
+ const seconds = Number(value);
3521
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600) throw new InvalidArgumentError("Use a whole number of seconds (1-3600).");
3522
+ return seconds;
3523
+ }
3524
+ const dropletReinstallCommand = new Command("reinstall").description("Irreversibly repair and replace the Mist's attached Droplet installation (plan with --dry-run, then --confirm)").argument("[slug]", "Mist slug (defaults to the .mist file in CWD)").option("--dry-run", "Show the exact plan without changing anything").option("--confirm", "Execute the plan shown by the latest --dry-run (irreversible)").option("--installation <uuid>", "Recovery: the source installation uuid from an earlier plan or still_running result").option("--resume <operationId>", "Keep watching a replacement that already started (needs --installation)").option("--watch-timeout <seconds>", "How long to watch before returning still_running (default 120)", parseSeconds).addOption(new Option("-y, --yes", "Not accepted; use --confirm").hideHelp()).action((slug, opts) => runAction(async () => {
3525
+ let outcome;
3526
+ try {
3527
+ outcome = await runDropletReinstall({
3528
+ client: createMistClient(),
3529
+ cwd: process.cwd(),
3530
+ slug,
3531
+ planStore: createFileReinstallPlanStore(),
3532
+ newIdempotencyKey: () => `fluid-cli-repair-${randomUUID()}`
3533
+ }, {
3534
+ dryRun: opts.dryRun,
3535
+ confirm: opts.confirm,
3536
+ yes: opts.yes,
3537
+ installationUuid: opts.installation,
3538
+ resumeOperationId: opts.resume,
3539
+ watchTimeoutMs: opts.watchTimeout === void 0 ? void 0 : opts.watchTimeout * 1e3
3540
+ });
3541
+ } catch (err) {
3542
+ printJson({
3543
+ status: "error",
3544
+ message: err instanceof Error ? err.message : String(err),
3545
+ ...err instanceof MistApiError ? { httpStatus: err.status } : {}
3546
+ });
3547
+ process.exit(err instanceof MistApiError ? err.exitCode : 1);
3548
+ }
3549
+ printJson(outcome.output);
3550
+ if (outcome.exitCode !== 0) process.exit(outcome.exitCode);
3551
+ }));
3552
+ //#endregion
3553
+ //#region src/commands/droplet/repair.ts
3554
+ /**
3555
+ * `fluid mist droplet repair [slug] [--dry-run] [--yes] [--json]`
3556
+ *
3557
+ * Port of Mist Desktop's `repair_droplet_lifecycle_webhooks`: point the
3558
+ * Mist's attached Droplet's embed URL and install/uninstall webhook URLs at
3559
+ * the Mist's public URL. Only those three Droplet fields change, and the
3560
+ * Droplet is re-read to verify. This does not replay `droplet.installed`
3561
+ * for an existing installation — use `fluid mist droplet reinstall` when
3562
+ * the app needs a fresh install event.
3563
+ */
3564
+ const dropletRepairCommand = new Command("repair").description("Repair the attached Droplet's embed and lifecycle webhook URLs").argument("[slug]", "Mist slug (defaults to the .mist file in CWD)").option("--dry-run", "Show the planned change without updating the Droplet").option("-y, --yes", "Apply without a confirmation prompt").option("--json", "Emit JSON").action((slug, opts) => runAction(async () => {
3565
+ const client = createMistClient();
3566
+ let result;
3567
+ try {
3568
+ const target = await resolveRepairTarget(client, {
3569
+ slug,
3570
+ cwd: process.cwd()
3571
+ });
3572
+ const planned = await planDropletRepair(client, target, opts.dryRun === true || !opts.yes);
3573
+ if (opts.dryRun || planned.alreadyCorrect) result = {
3574
+ summary: planned.summary,
3575
+ isError: false
3576
+ };
3577
+ else {
3578
+ if (!opts.yes) {
3579
+ if (!(process.stdin.isTTY && !opts.json)) throw new DropletRepairRefusal("confirmation_required", "Refused to update the Droplet without confirmation. Rerun with --yes, or with --dry-run to preview.");
3580
+ printSummary(planned.summary, { preview: true });
3581
+ const { confirmed } = await prompts({
3582
+ type: "confirm",
3583
+ name: "confirmed",
3584
+ message: "Update these Droplet URLs?",
3585
+ initial: false
3586
+ }, { onCancel: () => process.exit(130) });
3587
+ if (!confirmed) {
3588
+ console.log(chalk.dim("Aborted."));
3589
+ return;
3590
+ }
3591
+ }
3592
+ result = await applyDropletRepair(client, target);
3593
+ }
3594
+ } catch (err) {
3595
+ if (opts.json && !(err instanceof DropletRepairRefusal)) {
3596
+ printJson({
3597
+ status: "error",
3598
+ message: err instanceof Error ? err.message : String(err),
3599
+ ...err instanceof MistApiError ? { httpStatus: err.status } : {}
3600
+ });
3601
+ process.exit(err instanceof MistApiError ? err.exitCode : 1);
3602
+ }
3603
+ if (!(err instanceof DropletRepairRefusal)) throw err;
3604
+ if (opts.json) printJson({
3605
+ status: "refused",
3606
+ code: err.code,
3607
+ message: err.message
3608
+ });
3609
+ else console.error(chalk.red(err.message));
3610
+ process.exit(1);
3611
+ }
3612
+ if (opts.json) printJson(result.summary);
3613
+ else printSummary(result.summary);
3614
+ if (result.isError) process.exit(1);
3615
+ }));
3616
+ function printSummary(summary, options = {}) {
3617
+ const label = {
3618
+ unchanged: chalk.green("Already correct; nothing to change."),
3619
+ planned: chalk.yellow("Planned (dry run); nothing was changed."),
3620
+ updated: chalk.green("Updated and verified."),
3621
+ error: chalk.red("Updated, but the Droplet did not verify.")
3622
+ };
3623
+ console.log();
3624
+ console.log(`${chalk.bold(summary.mistSlug ?? summary.mistId)} ${chalk.dim(summary.dropletUuid)}`);
3625
+ const row = (name, previous, next) => previous === next ? ` ${name.padEnd(10)} ${next}` : ` ${name.padEnd(10)} ${chalk.dim(previous ?? "(none)")} → ${chalk.cyan(next)}`;
3626
+ console.log(row("embed", summary.previous.embedUrl, summary.embedUrl));
3627
+ console.log(row("install", summary.previous.installWebhookUrl, summary.installWebhookUrl));
3628
+ console.log(row("uninstall", summary.previous.uninstallWebhookUrl, summary.uninstallWebhookUrl));
3629
+ if (summary.lifecyclePathsSource === "defaults") console.log(chalk.dim(" Webhook paths: defaults (no fluid.capabilities.json for this Mist in the current directory)."));
3630
+ if (!options.preview) console.log(` ${label[summary.status]}`);
3631
+ console.log(chalk.dim(` ${summary.note}`));
3632
+ }
3633
+ //#endregion
3634
+ //#region src/commands/droplet/index.ts
3635
+ /** `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);
3637
+ //#endregion
2612
3638
  //#region src/index.ts
2613
3639
  /**
2614
3640
  * @fluid-app/fluid-cli-mist
@@ -2621,7 +3647,7 @@ const envCommand = new Command("env").description("Manage a mist's environment v
2621
3647
  */
2622
3648
  const plugin = {
2623
3649
  name: "fluid-cli-mist",
2624
- version: "0.1.0",
3650
+ version: "0.1.32",
2625
3651
  async register(ctx) {
2626
3652
  const mist = new Command("mist").description("Create, manage, and deploy Mist hosted extensions");
2627
3653
  mist.addCommand(listCommand);
@@ -2639,6 +3665,7 @@ const plugin = {
2639
3665
  mist.addCommand(deploymentsCommand);
2640
3666
  mist.addCommand(logsCommand);
2641
3667
  mist.addCommand(envCommand);
3668
+ mist.addCommand(dropletCommand);
2642
3669
  ctx.program.addCommand(mist);
2643
3670
  }
2644
3671
  };