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