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