@fluid-app/fluid-cli-mist 0.1.30 → 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/README.md +69 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1034 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
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.
|
|
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
|
};
|