@fluid-app/fluid-cli-mist 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -3,10 +3,10 @@ import chalk from "chalk";
3
3
  import { getAuthToken } from "@fluid-app/fluid-cli";
4
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import path, { join, resolve } from "node:path";
6
- import ora from "ora";
7
- import prompts from "prompts";
8
6
  import os from "node:os";
9
7
  import { execa } from "execa";
8
+ import ora from "ora";
9
+ import prompts from "prompts";
10
10
  import open from "open";
11
11
  //#region src/api/client.ts
12
12
  /**
@@ -210,1670 +210,1811 @@ function canonicalizeHostableType(typePart) {
210
210
  throw new HostableParseError(`Unknown hostable type "${typePart}". Use one of: ${SHORTHAND_LIST}.`);
211
211
  }
212
212
  //#endregion
213
- //#region src/lib/slug-resolver.ts
213
+ //#region src/lib/git.ts
214
214
  /**
215
- * Slug resolution — spec §3 conventions:
216
- * 1. If a slug arg is passed, use it.
217
- * 2. Otherwise look for a `.mist` file in CWD (written by `fluid mist
218
- * clone`) and use the slug from it.
219
- * 3. Otherwise error: tell the user to pass a slug or cd into a cloned dir.
215
+ * Git wrapper — shells out to system `git` via execa. Spec §11.1: we
216
+ * deliberately do NOT bundle git or use a pure-JS impl; users are expected
217
+ * to have it installed.
218
+ *
219
+ * Security: token-bearing `remote_url` from POST /git_credential must never
220
+ * be logged, persisted, *or* exposed in the process table. The token is
221
+ * stripped from the URL and handed to git through a GIT_ASKPASS script via
222
+ * env vars (MIST_GIT_USER / MIST_GIT_PASS) — so it never appears in argv
223
+ * (visible to `ps aux` on macOS, `/proc/<pid>/cmdline` on Linux) and is
224
+ * never written to `.git/config`.
220
225
  */
221
- const MIST_FILE = ".mist";
222
- var SlugResolutionError = class extends Error {
223
- constructor(message) {
226
+ var GitError = class extends Error {
227
+ exitCode;
228
+ constructor(message, exitCode = 1) {
224
229
  super(message);
225
- this.name = "SlugResolutionError";
230
+ this.name = "GitError";
231
+ this.exitCode = exitCode;
226
232
  }
227
233
  };
228
- function readMistFile(dir) {
229
- const filePath = join(dir, MIST_FILE);
230
- if (!existsSync(filePath)) return null;
231
- try {
232
- const raw = readFileSync(filePath, "utf8");
233
- const parsed = JSON.parse(raw);
234
- if (!parsed || typeof parsed !== "object") return null;
235
- const obj = parsed;
236
- if (typeof obj["slug"] !== "string") return null;
237
- return {
238
- slug: obj["slug"],
239
- vendor_name: typeof obj["vendor_name"] === "string" ? obj["vendor_name"] : "",
240
- public_url: typeof obj["public_url"] === "string" ? obj["public_url"] : null
241
- };
242
- } catch {
243
- return null;
244
- }
245
- }
246
- /** Returns the slug from arg or `.mist` file; throws if neither is present. */
247
- function resolveSlug(arg, cwd = process.cwd()) {
248
- if (arg && arg.trim().length > 0) return arg.trim();
249
- const file = readMistFile(cwd);
250
- if (file) return file.slug;
251
- throw new SlugResolutionError("No slug provided and no `.mist` file in this directory. Pass the slug as an argument or cd into a cloned mist.");
252
- }
253
- /** Write a `.mist` file after `fluid mist clone`. */
254
- function writeMistFile(dir, mist) {
255
- const payload = {
256
- slug: mist.slug,
257
- vendor_name: mist.vendor_name,
258
- public_url: mist.public_url
259
- };
260
- writeFileSync(join(dir, MIST_FILE), JSON.stringify(payload, null, 2) + "\n", "utf8");
261
- }
262
- //#endregion
263
- //#region src/lib/error-handler.ts
264
234
  /**
265
- * Centralized error handling — maps thrown errors to spec'd exit codes
266
- * and friendly messages. Imported by every command's `.action()` so each
267
- * file doesn't repeat the same try/catch.
235
+ * Git author for Mist publish commits. Mist app repos are connected to the
236
+ * we-commerce Vercel team via Vercel's GitHub integration, and Vercel
237
+ * attributes a git-triggered deploy to the *commit author's* GitHub account.
238
+ * If that author is a user's personal GitHub account (not a member of the
239
+ * Vercel team), Vercel blocks the deployment ("… does not have a Vercel
240
+ * account linked to their GitHub account"). Authoring every publish commit as
241
+ * the Mist GitHub App bot — which the Vercel integration recognizes — keeps
242
+ * deploys unblocked regardless of the user's local/global git identity. Human
243
+ * attribution is preserved in the commit *message* (see push.ts), not the
244
+ * author field.
268
245
  *
269
- * Exit codes (spec §3 "Conventions"):
270
- * 0 success | 1 user error | 2 API 4xx | 3 API 5xx / vendor | 4 network
246
+ * These MUST match the identity set on cloned repos in Mist Desktop
247
+ * (apps/mist-desktop/src/main/ipc.ts) so both paths produce the same author.
271
248
  */
272
- /** Run an async action and translate failures into pretty output + exit. */
273
- async function runAction(fn) {
274
- try {
275
- await fn();
276
- } catch (err) {
277
- handle(err);
278
- }
279
- }
280
- function handle(err) {
281
- if (err instanceof SlugResolutionError) {
282
- console.error(chalk.red(err.message));
283
- process.exit(1);
284
- }
285
- if (err instanceof HostableParseError) {
286
- console.error(chalk.red(err.message));
287
- process.exit(1);
288
- }
289
- if (err instanceof MistApiError) {
290
- if (err.status === 403) {
291
- const action = err.details?.["permission"];
292
- console.error(chalk.red(action ? `Your role doesn't include \`mist.${String(action)}\` — ask your Fluid admin to grant it.` : err.message));
293
- } else if (err.status === 409) {
294
- const date = err.details?.["scheduled_destroy_at"];
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));
296
- } else if (err.status === 422) {
297
- const details = err.details ?? {};
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.`));
305
- } else console.error(chalk.red(err.message));
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`."));
307
- else console.error(chalk.red(err.message));
308
- process.exit(err.exitCode);
309
- }
310
- if (err instanceof Error) console.error(chalk.red(err.message));
311
- else console.error(chalk.red(String(err)));
312
- process.exit(1);
249
+ const MIST_GIT_AUTHOR_NAME = "mist-we-commerce[bot]";
250
+ const MIST_GIT_AUTHOR_EMAIL = "293596226+mist-we-commerce[bot]@users.noreply.github.com";
251
+ /** execa throws ENOENT when the git binary itself is missing — the
252
+ * single most common failure on non-developer machines. Translate it
253
+ * into an actionable message instead of "git <verb> failed (exit 1)".
254
+ * Returns null for every other failure so call sites keep their
255
+ * specific messages. */
256
+ function gitMissingError(err) {
257
+ if (err?.code !== "ENOENT") return null;
258
+ return new GitError("Git isn't installed on this machine. In Mist Desktop, use the Install button in the yellow banner at the top of the app. From a terminal: macOS `xcode-select --install`, or download from https://git-scm.com/downloads.", 127);
313
259
  }
314
- //#endregion
315
- //#region src/lib/output.ts
316
260
  /**
317
- * Output helpers — human-readable by default, `--json` emits raw API
318
- * payload. NO_COLOR respected via chalk's built-in detection.
261
+ * Split a token-bearing remote URL into the userless URL + creds. Used
262
+ * to keep the token off git's argv: we pass `cleanUrl` to git and feed
263
+ * `username` / `password` back through GIT_ASKPASS env vars.
264
+ *
265
+ * If the URL has no embedded credentials, returns empty strings — fine
266
+ * for public repos where git won't even invoke askpass.
319
267
  */
320
- function printJson(payload) {
321
- process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
322
- }
323
- const STATE_COLOR$1 = {
324
- pending: chalk.gray,
325
- provisioning: chalk.yellow,
326
- live: chalk.green,
327
- failed: chalk.red,
328
- pending_destroy: chalk.magenta,
329
- archived: chalk.dim
330
- };
331
- function colorState(state) {
332
- return STATE_COLOR$1[state](state);
268
+ function splitRemoteCredentials(remoteUrl) {
269
+ const u = new URL(remoteUrl);
270
+ const username = decodeURIComponent(u.username);
271
+ const password = decodeURIComponent(u.password);
272
+ u.username = "";
273
+ u.password = "";
274
+ return {
275
+ cleanUrl: u.toString(),
276
+ username,
277
+ password
278
+ };
333
279
  }
334
280
  /**
335
- * Bold display label for a mist, falling back to a dim "(unnamed)"
336
- * placeholder when both `name` and `vendorName` are missing. Never nests
337
- * `chalk.bold(chalk.dim(...))` — most terminals render that as plain bold,
338
- * losing the "this is a placeholder" muted intent.
281
+ * Write a stub GIT_ASKPASS shell script to a per-process tmpdir and
282
+ * return its path. The script itself contains no secret — it just
283
+ * echoes back whichever env var (MIST_GIT_USER / MIST_GIT_PASS)
284
+ * matches git's prompt ("Username for …" / "Password for …"). The
285
+ * credentials only live in the spawned git process's env for the
286
+ * duration of the call.
287
+ *
288
+ * One POSIX script for all platforms: Git for Windows executes
289
+ * GIT_ASKPASS through its bundled sh, so a .cmd askpass fails with
290
+ * "unable to read askpass response" (git 2.52.0.windows.1) while a
291
+ * plain sh script works.
292
+ *
293
+ * Idempotent: subsequent calls reuse the same script path.
339
294
  */
340
- function mistLabel(name, vendorName) {
341
- const raw = name ?? vendorName;
342
- return raw ? chalk.bold(raw) : chalk.dim("(unnamed)");
295
+ let askpassPath = null;
296
+ function ensureAskpassScript() {
297
+ if (askpassPath && existsSync(askpassPath)) return askpassPath;
298
+ const dir = path.join(os.tmpdir(), `mist-cli-askpass-${process.pid}`);
299
+ if (!existsSync(dir)) mkdirSync(dir, {
300
+ recursive: true,
301
+ mode: 448
302
+ });
303
+ const p = path.join(dir, "askpass.sh");
304
+ writeFileSync(p, `#!/bin/sh\ncase "$1" in\n Username*) printf '%s' "$MIST_GIT_USER" ;;\n *) printf '%s' "$MIST_GIT_PASS" ;;\nesac\n`, { mode: 448 });
305
+ chmodSync(p, 448);
306
+ askpassPath = p;
307
+ return askpassPath;
343
308
  }
344
309
  /**
345
- * Pad columns to width — cheap manual table; avoids cli-table3 dep for v1.
346
- * Cells are defensively normalized: API records can legitimately have every
347
- * optional label field (`name`, `vendor_name`, `droplet.name`, ...) come back
348
- * null/absent at once (e.g. a `pending_destroy` mist), so callers sometimes
349
- * hand us `undefined`/`null` after chaining `??` — that must render as an
350
- * empty cell, not crash the whole table.
310
+ * Build the spawn env + cleaned URL for a credentialed git invocation.
311
+ * Returns:
312
+ * - `cleanUrl`: URL with userinfo stripped — safe to pass as argv.
313
+ * - `env`: parent env plus GIT_ASKPASS + MIST_GIT_USER / MIST_GIT_PASS.
314
+ * GIT_TERMINAL_PROMPT=0 prevents the TTY-prompt fallback if
315
+ * the credentials are empty (we want auth to fail fast, not
316
+ * hang the desktop waiting for interactive input).
351
317
  */
352
- function printTable(rows) {
353
- if (rows.length === 0) return;
354
- const widths = [];
355
- for (const row of rows) row.forEach((cell, i) => {
356
- const visible = stripAnsi(cell).length;
357
- widths[i] = Math.max(widths[i] ?? 0, visible);
358
- });
359
- for (const row of rows) {
360
- const padded = row.map((cell, i) => {
361
- const w = widths[i] ?? 0;
362
- const text = cell ?? "";
363
- return text + " ".repeat(Math.max(0, w - stripAnsi(text).length));
364
- });
365
- process.stdout.write(padded.join(" ") + "\n");
366
- }
318
+ function gitAuthSpawn(remoteUrl) {
319
+ const { cleanUrl, username, password } = splitRemoteCredentials(remoteUrl);
320
+ return {
321
+ cleanUrl,
322
+ env: {
323
+ ...process.env,
324
+ GIT_ASKPASS: ensureAskpassScript(),
325
+ GIT_TERMINAL_PROMPT: "0",
326
+ MIST_GIT_USER: username,
327
+ MIST_GIT_PASS: password
328
+ }
329
+ };
367
330
  }
368
- function stripAnsi(s) {
369
- return (s ?? "").replace(/\x1b\[[0-9;]*m/g, "");
331
+ /** Confirm git is installed. Cheap call once at startup. */
332
+ async function ensureGitAvailable() {
333
+ try {
334
+ await execa("git", ["--version"]);
335
+ } catch {
336
+ throw new GitError("`git` is required but not found on your PATH. Install it from https://git-scm.com/downloads or via `brew install git`.");
337
+ }
370
338
  }
371
- function relativeDate(iso) {
372
- if (!iso) return "—";
373
- const diff = Date.now() - new Date(iso).getTime();
374
- const minutes = Math.floor(diff / 6e4);
375
- if (minutes < 1) return "just now";
376
- if (minutes < 60) return `${minutes}m ago`;
377
- const hours = Math.floor(minutes / 60);
378
- if (hours < 24) return `${hours}h ago`;
379
- const days = Math.floor(hours / 24);
380
- if (days < 30) return `${days}d ago`;
381
- return new Date(iso).toISOString().slice(0, 10);
382
- }
383
- /** Human-friendly absolute date for one-off prints (scheduled_destroy_at). */
384
- function formatAbsolute(iso) {
385
- return new Date(iso).toISOString().slice(0, 10);
386
- }
387
- //#endregion
388
- //#region src/commands/list.ts
389
- /** `fluid mist list` — GET /mists with optional filters. Spec §3. */
390
- const listCommand = new Command("list").description("List the mists you have access to").option("--state <state>", "Filter by state: live | provisioning | failed | pending_destroy | archived").option("--kind <kind>", "Filter by kind (default: next_app)").option("--limit <n>", "Page size 1..100 (default 25)").option("--json", "Emit raw API JSON").action((opts) => runAction(async () => {
391
- const client = createMistClient();
392
- const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
393
- const response = await listMists(client, {
394
- ...opts.state ? { state: opts.state } : {},
395
- ...opts.kind ? { kind: opts.kind } : {},
396
- ...limit && Number.isFinite(limit) ? { limit } : {}
397
- });
398
- if (opts.json) return printJson(response);
399
- const mists = response.mists ?? [];
400
- if (mists.length === 0) {
401
- console.log(chalk.dim("No mists found."));
402
- return;
339
+ /**
340
+ * `git clone <cleanUrl> <targetDir>`. The original remoteUrl carries a
341
+ * token; gitAuthSpawn strips it from the URL and re-injects it via
342
+ * GIT_ASKPASS so it never appears in argv. `credential.helper=` stops
343
+ * git from caching what askpass returned to any system credential
344
+ * store.
345
+ */
346
+ async function gitClone(remoteUrl, targetDir) {
347
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
348
+ try {
349
+ await execa("git", [
350
+ "-c",
351
+ "credential.helper=",
352
+ "clone",
353
+ "--quiet",
354
+ cleanUrl,
355
+ targetDir
356
+ ], {
357
+ env,
358
+ stdio: [
359
+ "ignore",
360
+ "inherit",
361
+ "inherit"
362
+ ]
363
+ });
364
+ } catch (err) {
365
+ const missing = gitMissingError(err);
366
+ if (missing) throw missing;
367
+ const e = err;
368
+ throw new GitError(`git clone failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
403
369
  }
404
- printTable([[
405
- chalk.bold("SLUG"),
406
- chalk.bold("NAME"),
407
- chalk.bold("STATE"),
408
- chalk.bold("ATTACHED"),
409
- chalk.bold("UPDATED")
410
- ], ...mists.map((m) => [
411
- m.slug,
412
- m.name ?? m.droplet?.name ?? m.vendor_name ?? chalk.dim("(unnamed)"),
413
- colorState(m.state),
414
- m.hostable_type ?? chalk.dim("standalone"),
415
- relativeDate(m.updated_at)
416
- ])]);
417
- }));
418
- //#endregion
419
- //#region src/api/integration_points.ts
420
- function basePath(mistId) {
421
- return `/api/v202604/mists/${mistId}/integration_points`;
422
- }
423
- async function listIntegrationPoints(client, slug) {
424
- const id = await resolveMistId(client, slug);
425
- return client.get(basePath(id));
370
+ await ensureBotIdentity(targetDir);
426
371
  }
427
- async function createIntegrationPoint(client, slug, input) {
428
- const id = await resolveMistId(client, slug);
429
- return client.post(basePath(id), { integration_point: input });
372
+ /** `git push origin <branch>` — credentials supplied via GIT_ASKPASS,
373
+ * never in argv. The `.extraheader=` override clears any cached
374
+ * Authorization header that a CI-style git config might inject. */
375
+ async function gitPush(cwd, remoteUrl, branch = "main") {
376
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
377
+ try {
378
+ await execa("git", [
379
+ "-c",
380
+ "credential.helper=",
381
+ "-c",
382
+ `http.https://github.com/.extraheader=`,
383
+ "push",
384
+ cleanUrl,
385
+ `HEAD:${branch}`
386
+ ], {
387
+ cwd,
388
+ env,
389
+ stdio: [
390
+ "ignore",
391
+ "inherit",
392
+ "inherit"
393
+ ]
394
+ });
395
+ } catch (err) {
396
+ const missing = gitMissingError(err);
397
+ if (missing) throw missing;
398
+ const e = err;
399
+ throw new GitError(`git push failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
400
+ }
430
401
  }
431
- async function deleteIntegrationPoint(client, slug, integrationPointId) {
432
- const id = await resolveMistId(client, slug);
433
- return client.delete(`${basePath(id)}/${integrationPointId}`);
402
+ /** `git pull origin <branch>`. Token supplied via GIT_ASKPASS, same as push. */
403
+ async function gitPull(cwd, remoteUrl, branch = "main") {
404
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
405
+ try {
406
+ await execa("git", [
407
+ "-c",
408
+ "credential.helper=",
409
+ "pull",
410
+ "--ff-only",
411
+ cleanUrl,
412
+ branch
413
+ ], {
414
+ cwd,
415
+ env,
416
+ stdio: [
417
+ "ignore",
418
+ "inherit",
419
+ "inherit"
420
+ ]
421
+ });
422
+ } catch (err) {
423
+ const missing = gitMissingError(err);
424
+ if (missing) throw missing;
425
+ const e = err;
426
+ throw new GitError(`git pull failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
427
+ }
434
428
  }
435
429
  /**
436
- * First droplet-kind integration point in a list, or undefined. A droplet is
437
- * 1-per-mist (it provisions the managed env), so this is the idempotency key
438
- * for the droplet create+attach flow.
430
+ * Are there uncommitted changes (working tree or index) in this repo?
431
+ * `git status --porcelain` is empty when everything is clean, non-empty
432
+ * otherwise — so the count is just "is the output non-trivial".
439
433
  */
440
- function findDropletIntegrationPoint(integrationPoints) {
441
- return (integrationPoints ?? []).find((ip) => ip.kind === "droplet");
434
+ async function hasUncommittedChanges(cwd) {
435
+ try {
436
+ const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
437
+ return stdout.trim().length > 0;
438
+ } catch {
439
+ return false;
440
+ }
442
441
  }
443
442
  /**
444
- * Find an integration point that already links a specific surface — matched
445
- * by uuid (droplet/drop-zone) or numeric id (mobile-embed), optionally scoped
446
- * to a kind. Used to make attach-existing idempotent (avoid the server's 422
447
- * "already taken"). Uses the `integratable_uuid` echo (#19689) when present.
443
+ * Fetch the given branch from the token-bearing remote URL, writing
444
+ * `FETCH_HEAD` so `git rev-list HEAD...FETCH_HEAD` is meaningful.
445
+ *
446
+ * Same security stance as gitPush — installation token comes through
447
+ * GIT_ASKPASS, never argv or `.git/config`.
448
448
  */
449
- function findIntegrationPointBySurface(integrationPoints, target) {
450
- return (integrationPoints ?? []).find((ip) => {
451
- if (target.kind && ip.kind !== target.kind) return false;
452
- if (target.uuid && ip.integratable_uuid === target.uuid) return true;
453
- if (target.id != null && ip.integratable_id === target.id) return true;
454
- return false;
455
- });
456
- }
457
- //#endregion
458
- //#region src/commands/show.ts
459
- /** `fluid mist show <slug>` — single mist detail, including its attached
460
- * surfaces (integration points). Surfaces scheduled_destroy_at when state is
461
- * pending_destroy. */
462
- /** Display ref for an attached surface: the uuid echo when present, else id. */
463
- function surfaceRef(ip) {
464
- if (ip.integratable_uuid) return ip.integratable_uuid;
465
- if (ip.integratable_id != null) return `#${ip.integratable_id}`;
466
- return "(unknown)";
467
- }
468
- 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 () => {
469
- const resolved = resolveSlug(slug);
470
- const client = createMistClient();
471
- const { mist } = await showMist(client, resolved);
472
- const integrationPoints = (await listIntegrationPoints(client, resolved)).integration_points;
473
- if (opts.json) return printJson({
474
- mist,
475
- integration_points: integrationPoints
476
- });
477
- const lines = [];
478
- const label = mistLabel(mist.name, mist.vendor_name);
479
- lines.push(`${label} ${chalk.dim(mist.slug)}`);
480
- if (mist.vendor_name && mist.name && mist.name !== mist.vendor_name) lines.push(` vendor ${chalk.dim(mist.vendor_name)}`);
481
- lines.push(` state ${colorState(mist.state)}`);
482
- if (mist.kind) lines.push(` kind ${mist.kind}`);
483
- if (mist.public_url) lines.push(` url ${chalk.cyan(mist.public_url)}`);
484
- if (integrationPoints.length > 0) {
485
- lines.push(` surfaces ${integrationPoints.length}`);
486
- for (const ip of integrationPoints) lines.push(` ${chalk.dim("·")} ${ip.kind} ${surfaceRef(ip)} ${chalk.dim(`integration ${ip.id}`)}`);
449
+ async function gitFetch(cwd, remoteUrl, branch = "main") {
450
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
451
+ try {
452
+ await execa("git", [
453
+ "-c",
454
+ "credential.helper=",
455
+ "fetch",
456
+ "--quiet",
457
+ cleanUrl,
458
+ branch
459
+ ], {
460
+ cwd,
461
+ env
462
+ });
463
+ } catch (err) {
464
+ const missing = gitMissingError(err);
465
+ if (missing) throw missing;
466
+ const e = err;
467
+ throw new GitError(`git fetch failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
487
468
  }
488
- if (mist.provisioned_at) lines.push(` live ${relativeDate(mist.provisioned_at)}`);
489
- lines.push(` updated ${relativeDate(mist.updated_at)}`);
490
- if (mist.scheduled_destroy_at) lines.push(chalk.magenta(` destroy scheduled ${formatAbsolute(mist.scheduled_destroy_at)} (run \`fluid mist restore ${mist.slug}\` to undo)`));
491
- console.log(lines.join("\n"));
492
- }));
493
- //#endregion
494
- //#region src/lib/ora-reporter.ts
469
+ }
495
470
  /**
496
- * Spinner-backed `StepReporter` for interactive human runs. Split from
497
- * `step-reporter.ts` so tests can use the recording reporter without pulling
498
- * in `ora`'s ESM-only dependency chain.
471
+ * Count commits in local HEAD vs `FETCH_HEAD` (after `gitFetch`). Returns
472
+ * `{ahead, behind}`: how many commits HEAD has that the remote doesn't,
473
+ * and vice versa. A `0/0` result means "in sync".
499
474
  */
500
- function createOraReporter() {
501
- const spinner = ora();
502
- return {
503
- start(text) {
504
- spinner.start(text);
505
- },
506
- update(text) {
507
- spinner.text = text;
508
- },
509
- succeed(text) {
510
- spinner.succeed(text);
511
- },
512
- skip(text) {
513
- spinner.info(text);
514
- },
515
- info(text) {
516
- spinner.stopAndPersist({
517
- symbol: "•",
518
- text
519
- });
520
- },
521
- warn(text) {
522
- spinner.warn(text);
523
- },
524
- fail(text) {
525
- spinner.fail(text);
526
- }
527
- };
528
- }
529
- //#endregion
530
- //#region src/lib/step-reporter.ts
531
- /** No-op reporter for `--json` mode (final payload printed by the command). */
532
- function createSilentReporter() {
533
- return {
534
- start() {},
535
- update() {},
536
- succeed() {},
537
- skip() {},
538
- info() {},
539
- warn() {},
540
- fail() {}
541
- };
542
- }
543
- //#endregion
544
- //#region src/api/droplets.ts
545
- const BASE$3 = "/api/droplets";
546
- /**
547
- * Full scope set from `Droplet::Installation::VALID_SCOPES`. We request
548
- * them all by default so a Mist-created droplet's installations carry write
549
- * access out of the box — matching what Mist Desktop sends. Callers can
550
- * narrow this via the flow's `requestedScopes` option.
551
- */
552
- const DEFAULT_DROPLET_SCOPES = [
553
- "main",
554
- "prospects",
555
- "members",
556
- "web",
557
- "settings",
558
- "products",
559
- "orders",
560
- "users",
561
- "website",
562
- "header_menus",
563
- "payments"
564
- ];
565
- /** Lifecycle webhook paths a Mist Next app serves (mirrors Mist Desktop). */
566
- function dropletWebhookUrls(embedUrl) {
567
- const base = embedUrl.replace(/\/+$/, "");
568
- return {
569
- install_webhook_url: `${base}/api/webhooks/installed`,
570
- uninstall_webhook_url: `${base}/api/webhooks/uninstalled`
571
- };
572
- }
573
- /**
574
- * Build a droplet create body from a name + (optional) embed URL. When the
575
- * embed URL is known we also derive the lifecycle webhook URLs from it.
576
- */
577
- function buildDropletCreateInput(opts) {
578
- const scopes = [...opts.requestedScopes ?? DEFAULT_DROPLET_SCOPES];
579
- const summary = "Created by Fluid Mist";
580
- const input = {
581
- name: opts.name,
582
- requested_scopes: scopes,
583
- settings: {
584
- marketplace_page: { summary },
585
- details_page: { summary }
586
- }
587
- };
588
- if (opts.embedUrl) {
589
- input.embed_url = opts.embedUrl;
590
- Object.assign(input, dropletWebhookUrls(opts.embedUrl));
475
+ async function gitAheadBehindVsFetchHead(cwd) {
476
+ try {
477
+ const { stdout } = await execa("git", [
478
+ "rev-list",
479
+ "--left-right",
480
+ "--count",
481
+ "HEAD...FETCH_HEAD"
482
+ ], { cwd });
483
+ const [a, b] = stdout.trim().split(/\s+/);
484
+ const ahead = Number.parseInt(a ?? "0", 10);
485
+ const behind = Number.parseInt(b ?? "0", 10);
486
+ return {
487
+ ahead: Number.isFinite(ahead) ? ahead : 0,
488
+ behind: Number.isFinite(behind) ? behind : 0
489
+ };
490
+ } catch {
491
+ return {
492
+ ahead: 0,
493
+ behind: 0
494
+ };
591
495
  }
592
- return input;
593
496
  }
594
- function createDroplet(client, body) {
595
- return client.post(BASE$3, body);
497
+ /** Fast-forward `HEAD` to `FETCH_HEAD`. Fails (non-zero exit) if not
498
+ * fast-forwardable — caller must check ahead-count first. */
499
+ async function gitMergeFastForward(cwd) {
500
+ try {
501
+ await execa("git", [
502
+ "merge",
503
+ "--ff-only",
504
+ "FETCH_HEAD"
505
+ ], { cwd });
506
+ } catch (err) {
507
+ const missing = gitMissingError(err);
508
+ if (missing) throw missing;
509
+ const e = err;
510
+ throw new GitError(`git fast-forward merge failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
511
+ }
596
512
  }
597
- /** Page size + runaway guard for walking the page-paginated droplets list. */
598
- const PER_PAGE$1 = 100;
599
- const MAX_PAGES$1 = 25;
600
513
  /**
601
- * Recover a droplet's drp_ uuid from its numeric id by paging the company's
602
- * droplets list (`GET /api/droplets?page=&per_page=` — the list rows carry
603
- * `id`, unlike the create/show responses). Needed for legacy integration
604
- * points that only echo `integratable_id`: the show endpoint resolves by
605
- * uuid, so a numeric id can't be fetched directly. Returns null when no
606
- * droplet matches within the page cap.
514
+ * Stash working-tree + untracked changes with a Mist-tagged message.
515
+ * Returns the stash ref (`stash@{0}`) we created. Callers should pair
516
+ * this with `gitStashPop` after the dangerous step is done.
607
517
  */
608
- async function findDropletUuidByNumericId(client, id) {
609
- for (let page = 1; page <= MAX_PAGES$1; page++) {
610
- const resp = await client.get(`${BASE$3}?page=${page}&per_page=${PER_PAGE$1}`);
611
- const batch = resp.droplets ?? [];
612
- const match = batch.find((d) => d.id === id);
613
- if (match) return match.uuid ?? null;
614
- const totalPages = resp.meta?.pagination?.total_pages;
615
- if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE$1) break;
518
+ async function gitStash(cwd, message) {
519
+ try {
520
+ await execa("git", [
521
+ "stash",
522
+ "push",
523
+ "--include-untracked",
524
+ "--message",
525
+ message
526
+ ], { cwd });
527
+ } catch (err) {
528
+ const missing = gitMissingError(err);
529
+ if (missing) throw missing;
530
+ throw new GitError(`git stash push failed`, err.exitCode ?? 1);
616
531
  }
617
- return null;
618
532
  }
619
- /** PATCH the droplet's embed URL (+ derived webhook URLs) once known. */
620
- function updateDroplet(client, uuid, input) {
621
- return client.patch(`${BASE$3}/${encodeURIComponent(uuid)}`, { droplet: input });
622
- }
623
- /** Convenience: set embed URL + derived webhook URLs in one PATCH. */
624
- function setDropletEmbedUrl(client, uuid, embedUrl) {
625
- return updateDroplet(client, uuid, {
626
- embed_url: embedUrl,
627
- ...dropletWebhookUrls(embedUrl)
628
- });
629
- }
630
- //#endregion
631
- //#region src/api/droplet_installations.ts
632
- const BASE$2 = "/api/droplet_installations";
633
- /** Page size + runaway guard for walking the page-paginated list. */
634
- const PER_PAGE = 100;
635
- const MAX_PAGES = 25;
636
533
  /**
637
- * List ALL of the current company's droplet installations, following the
638
- * endpoint's page-number pagination (`?page=&per_page=` — same shape as
639
- * fluid-admin's `getCompanyDroplets`). A single-page fetch would miss rows
640
- * beyond the first page, making the idempotency check (and the duplicate-
641
- * rejection confirmation) report a false "not installed" forever.
534
+ * Pop the most recent stash. Throws on conflict so callers can surface
535
+ * the situation (the working tree will have conflict markers; the user
536
+ * needs to resolve manually).
642
537
  */
643
- async function listAllDropletInstallations(client) {
644
- const rows = [];
645
- for (let page = 1; page <= MAX_PAGES; page++) {
646
- const resp = await client.get(`${BASE$2}?page=${page}&per_page=${PER_PAGE}`);
647
- const batch = resp.droplet_installations ?? [];
648
- rows.push(...batch);
649
- const totalPages = resp.meta?.pagination?.total_pages;
650
- if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE) break;
538
+ async function gitStashPop(cwd) {
539
+ try {
540
+ await execa("git", ["stash", "pop"], { cwd });
541
+ } catch (err) {
542
+ const missing = gitMissingError(err);
543
+ if (missing) throw missing;
544
+ throw new GitError(`Couldn't restore your local changes after pulling — the file edits collided with a teammate's. Run \`git stash list\` and resolve manually.`, err.exitCode ?? 1);
651
545
  }
652
- return rows;
653
- }
654
- /** Install a droplet (by drp_ uuid) for the current company. */
655
- function createDropletInstallation(client, input) {
656
- return client.post(BASE$2, input);
657
546
  }
658
547
  /**
659
- * The company's installation row for a droplet drp_ uuid, or undefined.
660
- * The idempotency key for the install step: an existing row means the
661
- * droplet is already connected (installed via a previous run, Mist Desktop,
662
- * or the admin marketplace).
548
+ * Persist the Mist bot as the repo's LOCAL git identity
549
+ * (`git config --local user.{name,email}`).
550
+ *
551
+ * `gitAutoCommit` already forces the bot author on the publish commit
552
+ * via `-c`/env, but that only covers commits WE make. Commits made any
553
+ * other way against the same checkout — the Mist chat agent shelling out
554
+ * `git commit` via run_cli, or the user in a terminal — inherit the
555
+ * repo's configured identity, which is the person's personal GitHub
556
+ * account. Vercel rejects a deploy whose commit author isn't a
557
+ * we-commerce team member, so those personal-authored commits silently
558
+ * block the deploy (exactly the www-* "stuck project" case).
559
+ *
560
+ * Setting the identity at the repo level makes EVERY commit path
561
+ * bot-authored, and heals repos cloned before this was in place on their
562
+ * next publish. Best-effort: a config failure must never abort a push —
563
+ * the publish commit is still bot-forced by gitAutoCommit regardless.
663
564
  */
664
- function findInstallationForDroplet(installations, dropletUuid) {
665
- return (installations ?? []).find((i) => i.droplet_uuid === dropletUuid);
565
+ async function ensureBotIdentity(cwd) {
566
+ try {
567
+ await execa("git", [
568
+ "config",
569
+ "--local",
570
+ "user.name",
571
+ MIST_GIT_AUTHOR_NAME
572
+ ], { cwd });
573
+ await execa("git", [
574
+ "config",
575
+ "--local",
576
+ "user.email",
577
+ MIST_GIT_AUTHOR_EMAIL
578
+ ], { cwd });
579
+ } catch {}
666
580
  }
667
- //#endregion
668
- //#region src/api/drop_zones.ts
669
- const BASE$1 = "/api/drop_zones";
670
581
  /**
671
- * Build a drop-zone create body. The slot id is `page__zone` (same split the
672
- * storefront preview uses, e.g. `checkout__below_total`) → `settings.page` +
673
- * `settings.zone`; height (px) → `settings.height`. Without a slot the zone
674
- * never renders, so the backend needs it to know WHERE to inject.
582
+ * Append Mist's local-only state to `.git/info/exclude` when needed:
583
+ * `.mist` is the clone marker from spec §3, while `.mist-desktop/`
584
+ * contains private chat history, attachments, screenshots, and other
585
+ * machine-local Desktop state. Neither belongs in the project remote.
586
+ *
587
+ * Why local exclude and not `.gitignore`: `.gitignore` lives in the
588
+ * template repo and would need a template release to reach existing
589
+ * clones. `.git/info/exclude` is per-checkout, so writing it here (at
590
+ * clone time) AND defensively at the top of every `gitAutoCommit` (at
591
+ * push time) heals pre-existing clones on their very next push, with no
592
+ * template change required.
593
+ *
594
+ * Idempotent and best-effort: never throws. A repo with an unusual
595
+ * `.git` layout (missing `info/`, `.git` as a worktree pointer file,
596
+ * etc.) just skips the exclude — `gitAutoCommit` also cleans both paths
597
+ * from the index after staging.
675
598
  */
676
- function buildDropZoneCreateInput(opts) {
677
- const [page, zone] = (opts.zone ?? "").split("__");
678
- const settings = {};
679
- if (page) settings.page = page;
680
- if (zone) settings.zone = zone;
681
- if (opts.heightPx != null) settings.height = opts.heightPx;
682
- const input = { name: opts.name };
683
- if (opts.embedUrl) input.embed_url = opts.embedUrl;
684
- if (Object.keys(settings).length > 0) input.settings = settings;
685
- return input;
686
- }
687
- function createDropZone(client, body) {
688
- return client.post(BASE$1, body);
599
+ function excludeMistMarkerFromGit(cwd) {
600
+ try {
601
+ const gitDir = path.join(cwd, ".git");
602
+ if (!existsSync(gitDir)) return;
603
+ const infoDir = path.join(gitDir, "info");
604
+ if (!existsSync(infoDir)) mkdirSync(infoDir, { recursive: true });
605
+ const excludePath = path.join(infoDir, "exclude");
606
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
607
+ const existingLines = new Set(existing.split("\n").map((line) => line.trim()).filter(Boolean));
608
+ const missing = [".mist", ".mist-desktop/"].filter((entry) => !existingLines.has(entry));
609
+ if (missing.length === 0) return;
610
+ writeFileSync(excludePath, `${existing}${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
611
+ } catch {}
689
612
  }
690
- //#endregion
691
- //#region src/api/mobile_widgets.ts
692
- const BASE = "/api/company/mobile_widgets";
693
613
  /**
694
- * Build a mobile-widget create body. `cover_image_url` and `height` are
695
- * REQUIRED by the API (422 without them); we fall back to the Fluid
696
- * placeholder + default height when the caller doesn't supply them.
614
+ * `git add -A && git commit -m <message>` in `cwd`.
615
+ *
616
+ * Mist Desktop's "Publish" UX target is non-technical: the user expects
617
+ * "ship my changes" to ship the changes they made, not to know about
618
+ * staging or commit messages. This helper bundles working-tree edits
619
+ * into one commit per publish so push has something to send.
620
+ *
621
+ * The commit author is ALWAYS forced to the Mist GitHub App bot identity
622
+ * (MIST_GIT_AUTHOR_*), overriding whatever local/global git identity the
623
+ * machine has. This is what keeps Vercel from blocking the deploy: a commit
624
+ * authored by a user's personal GitHub account that isn't a we-commerce Vercel
625
+ * team member gets rejected. Passing `-c user.name=… -c user.email=…` beats
626
+ * even a `--local` config, so every publish is bot-authored regardless of how
627
+ * the repo landed on disk (fresh clone, pre-existing clone, or terminal CLI).
628
+ * The real person is still recorded in the commit *message* (see push.ts).
629
+ *
630
+ * Local Mist state handling: clone writes `.mist` and `.mist-desktop/` to
631
+ * `.git/info/exclude`, but this heals a pre-existing clone or a repo whose
632
+ * exclude write silently failed. Belt AND suspenders:
633
+ * 1. Re-run the exclude write here (idempotent) — heals pre-existing
634
+ * clones on their very next push. Anchored to the repository root
635
+ * (`git rev-parse --show-toplevel`), not cwd, so a push run from a
636
+ * repo subdirectory heals the real `.git/info/exclude`.
637
+ * 2. After `git add -A`, run `git reset -- ':(top).mist' .mist`
638
+ * (root-anchored + cwd-relative pathspecs; best-effort,
639
+ * ignored if it fails or matches nothing) to unstage the marker in
640
+ * case the exclude write above failed and `.mist` got swept in
641
+ * anyway. We deliberately do NOT ask `git add` to exclude `.mist`
642
+ * via a `:(exclude)` pathspec — when a path is *also* already
643
+ * listed in `.git/info/exclude`, combining `-A` with an explicit
644
+ * exclude pathspec on that same path makes git exit 1 with
645
+ * "paths are ignored by one of your .gitignore files" (a real git
646
+ * quirk, confirmed against git 2.41), which would turn the normal
647
+ * case (exclude already written) into a hard failure. Add-then-
648
+ * reset avoids that entirely and is a no-op when there's nothing
649
+ * to unstage.
650
+ * 3. Run `git rm --cached -r --ignore-unmatch .mist-desktop` from the
651
+ * repository root. This keeps every local file on disk while staging
652
+ * their removal if an older publish already tracked them. Ignore rules
653
+ * alone cannot untrack an existing path.
654
+ * Note: a `.mist` file already committed in an existing repo's history
655
+ * is left alone — this only prevents *new* adds, it does not untrack
656
+ * anything or rewrite history.
697
657
  */
698
- function buildMobileWidgetCreateInput(opts) {
699
- return {
700
- name: opts.name,
701
- embed_url: opts.embedUrl,
702
- cover_image_url: opts.coverImageUrl || "https://ik.imagekit.io/fluid/s3/app/image-placeholder.svg",
703
- height: opts.heightPx ?? 600,
704
- scrollable: true
658
+ async function gitAutoCommit(cwd, message) {
659
+ await ensureBotIdentity(cwd);
660
+ let repoRoot = cwd;
661
+ try {
662
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"], { cwd });
663
+ if (stdout.trim().length > 0) repoRoot = stdout.trim();
664
+ } catch {}
665
+ excludeMistMarkerFromGit(repoRoot);
666
+ try {
667
+ await execa("git", ["add", "-A"], { cwd });
668
+ } catch (err) {
669
+ const missing = gitMissingError(err);
670
+ if (missing) throw missing;
671
+ throw new GitError(`git add failed`, err.exitCode ?? 1);
672
+ }
673
+ try {
674
+ await execa("git", [
675
+ "reset",
676
+ "-q",
677
+ "--",
678
+ ":(top).mist",
679
+ ".mist"
680
+ ], { cwd });
681
+ } catch {}
682
+ try {
683
+ await execa("git", [
684
+ "rm",
685
+ "-q",
686
+ "-r",
687
+ "--cached",
688
+ "--ignore-unmatch",
689
+ "--",
690
+ ".mist-desktop"
691
+ ], { cwd: repoRoot });
692
+ } catch {}
693
+ const identityArgs = [
694
+ "-c",
695
+ `user.name=${MIST_GIT_AUTHOR_NAME}`,
696
+ "-c",
697
+ `user.email=${MIST_GIT_AUTHOR_EMAIL}`
698
+ ];
699
+ const identityEnv = {
700
+ GIT_AUTHOR_NAME: MIST_GIT_AUTHOR_NAME,
701
+ GIT_AUTHOR_EMAIL: MIST_GIT_AUTHOR_EMAIL,
702
+ GIT_COMMITTER_NAME: MIST_GIT_AUTHOR_NAME,
703
+ GIT_COMMITTER_EMAIL: MIST_GIT_AUTHOR_EMAIL
705
704
  };
706
- }
707
- function createMobileWidget(client, body) {
708
- return client.post(BASE, body);
705
+ try {
706
+ await execa("git", [
707
+ ...identityArgs,
708
+ "commit",
709
+ "-m",
710
+ message
711
+ ], {
712
+ cwd,
713
+ env: {
714
+ ...process.env,
715
+ ...identityEnv
716
+ },
717
+ stdio: [
718
+ "ignore",
719
+ "inherit",
720
+ "inherit"
721
+ ]
722
+ });
723
+ } catch (err) {
724
+ const missing = gitMissingError(err);
725
+ if (missing) throw missing;
726
+ const e = err;
727
+ if (typeof e.stderr === "string" && /nothing to commit/i.test(e.stderr)) return;
728
+ throw new GitError(`git commit failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
729
+ }
709
730
  }
710
731
  //#endregion
711
- //#region src/api/env_vars.ts
712
- function path$1(id, key) {
713
- return key ? `/api/v202604/mists/${id}/env_vars/${encodeURIComponent(key)}` : `/api/v202604/mists/${id}/env_vars`;
714
- }
715
- async function listEnvVars(client, slug) {
716
- const id = await resolveMistId(client, slug);
717
- return client.get(path$1(id));
718
- }
719
- /** Create a new env var (the spec routes `set` to POST for new keys). */
720
- async function createEnvVar(client, slug, key, value, targets) {
721
- const id = await resolveMistId(client, slug);
722
- return client.post(path$1(id), { env_var: {
723
- key,
724
- value,
725
- targets
726
- } });
732
+ //#region src/lib/slug-resolver.ts
733
+ /**
734
+ * Slug resolution — spec §3 conventions:
735
+ * 1. If a slug arg is passed, use it.
736
+ * 2. Otherwise look for a `.mist` file in CWD (written by `fluid mist
737
+ * clone`) and use the slug from it.
738
+ * 3. Otherwise error: tell the user to pass a slug or cd into a cloned dir.
739
+ */
740
+ const MIST_FILE = ".mist";
741
+ var SlugResolutionError = class extends Error {
742
+ constructor(message) {
743
+ super(message);
744
+ this.name = "SlugResolutionError";
745
+ }
746
+ };
747
+ function readMistFile(dir) {
748
+ const filePath = join(dir, MIST_FILE);
749
+ if (!existsSync(filePath)) return null;
750
+ try {
751
+ const raw = readFileSync(filePath, "utf8");
752
+ const parsed = JSON.parse(raw);
753
+ if (!parsed || typeof parsed !== "object") return null;
754
+ const obj = parsed;
755
+ if (typeof obj["slug"] !== "string") return null;
756
+ return {
757
+ slug: obj["slug"],
758
+ vendor_name: typeof obj["vendor_name"] === "string" ? obj["vendor_name"] : "",
759
+ public_url: typeof obj["public_url"] === "string" ? obj["public_url"] : null
760
+ };
761
+ } catch {
762
+ return null;
763
+ }
727
764
  }
728
- /** Update an existing env var (the spec routes `set` to PATCH for existing keys). */
729
- async function updateEnvVar(client, slug, key, value, targets) {
730
- const id = await resolveMistId(client, slug);
731
- return client.patch(path$1(id, key), { env_var: {
732
- value,
733
- targets
734
- } });
765
+ /** Returns the slug from arg or `.mist` file; throws if neither is present. */
766
+ function resolveSlug(arg, cwd = process.cwd()) {
767
+ if (arg && arg.trim().length > 0) return arg.trim();
768
+ const file = readMistFile(cwd);
769
+ if (file) return file.slug;
770
+ throw new SlugResolutionError("No slug provided and no `.mist` file in this directory. Pass the slug as an argument or cd into a cloned mist. (if you reverted a commit that removed .mist, pass the slug explicitly or re-run fluid mist clone)");
735
771
  }
736
- async function deleteEnvVar(client, slug, key) {
737
- const id = await resolveMistId(client, slug);
738
- return client.delete(path$1(id, key));
772
+ /**
773
+ * Write a `.mist` file after `fluid mist clone`.
774
+ *
775
+ * Also excludes `.mist` via `.git/info/exclude` so it can never be swept
776
+ * into a publish commit by `gitAutoCommit`'s `git add -A` — a commit
777
+ * containing `.mist` that later gets `git revert`-ed deletes the marker
778
+ * and locks every subsequent `fluid mist <cmd>` out of slug resolution
779
+ * in that clone. Best-effort: if the exclude write fails (e.g. `.git`
780
+ * isn't set up yet), `writeFileSync` below still runs — this is a
781
+ * defense-in-depth step, not a precondition for the marker itself.
782
+ */
783
+ function writeMistFile(dir, mist) {
784
+ excludeMistMarkerFromGit(dir);
785
+ const payload = {
786
+ slug: mist.slug,
787
+ vendor_name: mist.vendor_name,
788
+ public_url: mist.public_url
789
+ };
790
+ writeFileSync(join(dir, MIST_FILE), JSON.stringify(payload, null, 2) + "\n", "utf8");
739
791
  }
740
792
  //#endregion
741
- //#region src/types.ts
793
+ //#region src/lib/error-handler.ts
742
794
  /**
743
- * Managed env keys the backend writes onto a Mist once a droplet is
744
- * attached (backfill + redeploy). The CLI VERIFIES these appear with
745
- * `managed_by: "mist"` — it never sets them (the API 422s on write).
795
+ * Centralized error handling — maps thrown errors to spec'd exit codes
796
+ * and friendly messages. Imported by every command's `.action()` so each
797
+ * file doesn't repeat the same try/catch.
746
798
  *
747
- * Source: settled backend contract (#19685 auto-provisions the droplet
748
- * env on integration-point create).
799
+ * Exit codes (spec §3 "Conventions"):
800
+ * 0 success | 1 user error | 2 API 4xx | 3 API 5xx / vendor | 4 network
749
801
  */
750
- const MANAGED_DROPLET_ENV_KEYS = [
751
- "FLUID_DROPLET_UUID",
752
- "FLUID_DROPLET_SECRET",
753
- "FLUID_WEBHOOK_AUTH_TOKEN"
754
- ];
802
+ /** Run an async action and translate failures into pretty output + exit. */
803
+ async function runAction(fn) {
804
+ try {
805
+ await fn();
806
+ } catch (err) {
807
+ handle(err);
808
+ }
809
+ }
810
+ function handle(err) {
811
+ if (err instanceof SlugResolutionError) {
812
+ console.error(chalk.red(err.message));
813
+ process.exit(1);
814
+ }
815
+ if (err instanceof HostableParseError) {
816
+ console.error(chalk.red(err.message));
817
+ process.exit(1);
818
+ }
819
+ if (err instanceof MistApiError) {
820
+ if (err.status === 403) {
821
+ const action = err.details?.["permission"];
822
+ console.error(chalk.red(action ? `Your role doesn't include \`mist.${String(action)}\` — ask your Fluid admin to grant it.` : err.message));
823
+ } else if (err.status === 409) {
824
+ const date = err.details?.["scheduled_destroy_at"];
825
+ 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));
826
+ } else if (err.status === 422) {
827
+ const details = err.details ?? {};
828
+ const idErrors = details["integratable_id"] ?? details["hostable_id"];
829
+ const managedKey = typeof err.message === "string" && /managed_by|managed by mist|FLUID_DROPLET|FLUID_WEBHOOK/i.test(err.message);
830
+ 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."));
831
+ 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."));
832
+ else if (typeof err.message === "string" && /Droplet not found|integratable/i.test(err.message)) {
833
+ const id = details["integratable_id"] ?? details["hostable_id"];
834
+ console.error(chalk.yellow(`Couldn't find droplet${id != null ? ` #${String(id)}` : ""} in this company.`));
835
+ } else console.error(chalk.red(err.message));
836
+ } 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`."));
837
+ else console.error(chalk.red(err.message));
838
+ process.exit(err.exitCode);
839
+ }
840
+ if (err instanceof Error) console.error(chalk.red(err.message));
841
+ else console.error(chalk.red(String(err)));
842
+ process.exit(1);
843
+ }
755
844
  //#endregion
756
- //#region src/lib/surface-provision.ts
845
+ //#region src/lib/output.ts
757
846
  /**
758
- * Surface provisioning engine — the one place the "create a Fluid surface and
759
- * wire it to a mist" flow lives, at parity with Mist Desktop's
760
- * mist-integration-points-api.service.ts. Exposed via `fluid mist create`
761
- * (create a bare mist first, then run this), `fluid mist attach` (any kind),
762
- * and `fluid mist attach-droplet` (droplet shortcut).
763
- *
764
- * Three surface kinds, each with its own create endpoint + attach identifier:
765
- * - droplet → POST /api/droplets → drp_ uuid → integratable_uuid
766
- * - drop-zone → POST /api/drop_zones → dz_ uuid → integratable_uuid
767
- * - mobile-embed→ POST /api/company/mobile_widgets→ numeric id → integratable_id
768
- *
769
- * Design tenets (repo philosophy — "server is source of truth"):
770
- * - No local progress file. State is re-derived from the API each run.
771
- * - Attach-existing is idempotent: if an integration point already links the
772
- * target surface (matched via the #19689 integratable_uuid echo, or the
773
- * numeric id for mobile), skip. A droplet is 1-per-mist, so creating a new
774
- * droplet also skips when one is already attached. Drop-zones/mobile
775
- * embeds are additive (a mist can have many).
776
- * - Only a droplet attach provisions managed env keys
777
- * (FLUID_DROPLET_UUID/_SECRET/_WEBHOOK_AUTH_TOKEN); this flow only
778
- * VERIFIES they landed — it never writes them (the API 422s on write).
779
- * - A droplet must also be INSTALLED (POST /api/droplet_installations) —
780
- * create + link alone leave it not-connected/inactive, so install-scoped
781
- * features (dit/dex exchange, admin embeds, install webhooks) don't work.
782
- * The install step is idempotent (skips when an installation exists).
847
+ * Output helpers — human-readable by default, `--json` emits raw API
848
+ * payload. NO_COLOR respected via chalk's built-in detection.
783
849
  */
784
- const DEFAULT_TIMING = {
785
- publicUrlTimeoutMs: 9e4,
786
- publicUrlIntervalMs: 3e3,
787
- envTimeoutMs: 12e4,
788
- envIntervalMs: 5e3
789
- };
790
- const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
791
- const KIND_LABEL$1 = {
792
- droplet: "droplet",
793
- "drop-zone": "drop zone",
794
- "mobile-embed": "mobile embed"
850
+ function printJson(payload) {
851
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
852
+ }
853
+ const STATE_COLOR$1 = {
854
+ pending: chalk.gray,
855
+ provisioning: chalk.yellow,
856
+ live: chalk.green,
857
+ failed: chalk.red,
858
+ pending_destroy: chalk.magenta,
859
+ archived: chalk.dim
795
860
  };
861
+ function colorState(state) {
862
+ return STATE_COLOR$1[state](state);
863
+ }
864
+ /** Completion copy for `mist push --watch`. Deployment records expose an
865
+ * ephemeral provider URL that may require provider SSO; only the Mist's
866
+ * stable public URL is suitable to hand to a user. */
867
+ function readyDeploymentMessage(publicUrl, slug) {
868
+ return publicUrl ? `✔ Ready: ${chalk.cyan(publicUrl)}` : `✔ Deployment ready. The public URL is still provisioning; run \`fluid mist show ${slug}\` to check it.`;
869
+ }
796
870
  /**
797
- * Create (or reuse) a surface of `kind` and wire it to the mist. Idempotent
798
- * for droplet + attach-existing; additive for new drop-zones/mobile embeds.
871
+ * Bold display label for a mist, falling back to a dim "(unnamed)"
872
+ * placeholder when both `name` and `vendorName` are missing. Never nests
873
+ * `chalk.bold(chalk.dim(...))` — most terminals render that as plain bold,
874
+ * losing the "this is a placeholder" muted intent.
799
875
  */
800
- async function provisionSurface(client, options) {
801
- const { reporter, slugOrId, kind } = options;
802
- const timing = {
803
- ...DEFAULT_TIMING,
804
- ...options.timing
805
- };
806
- const sleep = options.sleep ?? defaultSleep;
807
- const label = KIND_LABEL$1[kind];
808
- reporter.start("Resolving mist…");
809
- const mist = (await showMist(client, slugOrId)).mist;
810
- reporter.succeed(`Mist ${mist.slug} (${mist.state})`);
811
- reporter.start("Checking existing integrations…");
812
- const points = (await listIntegrationPoints(client, slugOrId)).integration_points;
813
- let surfaceUuid = null;
814
- let surfaceNumericId = null;
815
- let surfaceName = null;
816
- let surfaceCreated = false;
817
- let embedUrlSet = false;
818
- let integrationPoint;
819
- const attachingExisting = options.existingUuid != null || options.existingId != null;
820
- const alreadyLinked = attachingExisting ? findIntegrationPointBySurface(points, {
821
- kind,
822
- uuid: options.existingUuid,
823
- id: options.existingId
824
- }) : kind === "droplet" ? findDropletIntegrationPoint(points) : void 0;
825
- if (alreadyLinked) {
826
- reporter.skip(`${label} already attached (integration #${alreadyLinked.id}) — skipping`);
827
- surfaceUuid = alreadyLinked.integratable_uuid ?? options.existingUuid ?? null;
828
- surfaceNumericId = alreadyLinked.integratable_id ?? options.existingId ?? null;
829
- integrationPoint = {
830
- id: alreadyLinked.id,
831
- integratableUuid: alreadyLinked.integratable_uuid ?? null,
832
- created: false
833
- };
834
- } else {
835
- reporter.succeed("No matching integration yet");
836
- let attachUuid = options.existingUuid;
837
- let attachId = options.existingId;
838
- if (!attachingExisting) {
839
- const embedUrl = await resolvePublicUrl(client, {
840
- slugOrId,
841
- initial: mist.public_url,
842
- reporter,
843
- timing,
844
- sleep
845
- });
846
- const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name || mist.slug;
847
- reporter.start(`Creating ${label} "${name}"…`);
848
- if (kind === "droplet") {
849
- const created = await createDroplet(client, { droplet: buildDropletCreateInput({
850
- name,
851
- embedUrl,
852
- requestedScopes: options.requestedScopes
853
- }) });
854
- surfaceUuid = created.droplet.uuid;
855
- surfaceNumericId = created.droplet.id ?? null;
856
- surfaceName = created.droplet.name;
857
- attachUuid = created.droplet.uuid;
858
- embedUrlSet = Boolean(embedUrl);
859
- reporter.succeed(`Created droplet ${surfaceUuid}${embedUrl ? "" : " (embed URL pending)"}`);
860
- if (!embedUrl && surfaceUuid) {
861
- const laterUrl = await resolvePublicUrl(client, {
862
- slugOrId,
863
- initial: null,
864
- reporter,
865
- timing,
866
- sleep
867
- });
868
- if (laterUrl) {
869
- reporter.start("Backfilling droplet embed URL…");
870
- await setDropletEmbedUrl(client, surfaceUuid, laterUrl);
871
- embedUrlSet = true;
872
- reporter.succeed("Embed URL set");
873
- } else reporter.warn("Mist public_url not ready yet — re-run once the first deploy finishes to set the embed URL");
874
- }
875
- } else if (kind === "drop-zone") {
876
- if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the drop zone.");
877
- const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
878
- name,
879
- embedUrl,
880
- zone: options.zone,
881
- heightPx: options.heightPx
882
- }) });
883
- surfaceUuid = created.drop_zone.uuid;
884
- surfaceName = created.drop_zone.name;
885
- attachUuid = created.drop_zone.uuid;
886
- embedUrlSet = true;
887
- reporter.succeed(`Created drop zone ${surfaceUuid}`);
888
- } else {
889
- if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the mobile embed.");
890
- const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
891
- name,
892
- embedUrl,
893
- coverImageUrl: options.coverImageUrl,
894
- heightPx: options.heightPx
895
- }) });
896
- surfaceNumericId = created.mobile_widget.id;
897
- surfaceName = created.mobile_widget.name;
898
- attachId = created.mobile_widget.id;
899
- embedUrlSet = true;
900
- reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
901
- }
902
- surfaceCreated = true;
903
- } else {
904
- surfaceUuid = options.existingUuid ?? null;
905
- surfaceNumericId = options.existingId ?? null;
906
- embedUrlSet = true;
907
- }
908
- const attachInput = kind === "mobile-embed" ? {
909
- kind,
910
- integratable_id: attachId
911
- } : {
912
- kind,
913
- integratable_uuid: attachUuid
914
- };
915
- reporter.start(`Attaching ${label}…`);
916
- let ipResp;
917
- try {
918
- ipResp = await createIntegrationPoint(client, slugOrId, attachInput);
919
- } catch (err) {
920
- reporter.fail("Attach failed");
921
- throw normalizeAttachError(err, attachUuid ?? String(attachId), kind);
922
- }
923
- integrationPoint = {
924
- id: ipResp.integration_point.id,
925
- integratableUuid: ipResp.integration_point.integratable_uuid ?? null,
926
- created: true
927
- };
928
- if (!surfaceUuid) surfaceUuid = ipResp.integration_point.integratable_uuid ?? attachUuid ?? null;
929
- if (surfaceNumericId == null) surfaceNumericId = ipResp.integration_point.integratable_id ?? null;
930
- reporter.succeed(kind === "droplet" ? "Droplet attached — provisioning env + redeploy…" : `${label} attached`);
931
- }
932
- let installation = null;
933
- if (kind === "droplet") {
934
- if (!surfaceUuid && surfaceNumericId != null) try {
935
- surfaceUuid = await findDropletUuidByNumericId(client, surfaceNumericId);
936
- } catch {}
937
- if (!surfaceUuid) {
938
- reporter.fail("Droplet uuid unknown — cannot install");
939
- 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>\"}.");
940
- }
941
- installation = await ensureDropletInstalled(client, {
942
- dropletUuid: surfaceUuid,
943
- scopes: options.requestedScopes ?? DEFAULT_DROPLET_SCOPES,
944
- reporter
945
- });
946
- }
947
- const managedEnv = kind === "droplet" ? await verifyManagedEnv(client, {
948
- slugOrId,
949
- reporter,
950
- timing,
951
- sleep,
952
- enabled: options.verifyEnv !== false
953
- }) : null;
954
- return {
955
- mist: {
956
- id: mist.id,
957
- slug: mist.slug,
958
- name: mist.name,
959
- public_url: mist.public_url,
960
- state: mist.state
961
- },
962
- kind,
963
- surface: {
964
- uuid: surfaceUuid,
965
- id: surfaceNumericId,
966
- name: surfaceName
967
- },
968
- integrationPoint: integrationPoint ?? {
969
- id: "",
970
- integratableUuid: null,
971
- created: false
972
- },
973
- surfaceCreated,
974
- embedUrlSet,
975
- installation,
976
- managedEnv
977
- };
876
+ function mistLabel(name, vendorName) {
877
+ const raw = name ?? vendorName;
878
+ return raw ? chalk.bold(raw) : chalk.dim("(unnamed)");
978
879
  }
979
880
  /**
980
- * Install the droplet for the current company (idempotent). Checks the
981
- * installations list first; treats the API's already-installed rejection as
982
- * success too, so re-runs and races both converge. A hard failure throws with
983
- * the droplet uuid + recovery steps — the created droplet and mist link stay
984
- * in place, so an idempotent re-run finishes the job.
881
+ * Pad columns to width — cheap manual table; avoids cli-table3 dep for v1.
882
+ * Cells are defensively normalized: API records can legitimately have every
883
+ * optional label field (`name`, `vendor_name`, `droplet.name`, ...) come back
884
+ * null/absent at once (e.g. a `pending_destroy` mist), so callers sometimes
885
+ * hand us `undefined`/`null` after chaining `??` — that must render as an
886
+ * empty cell, not crash the whole table.
985
887
  */
986
- async function ensureDropletInstalled(client, args) {
987
- const { reporter, dropletUuid } = args;
988
- reporter.start("Installing droplet…");
989
- let existing;
990
- try {
991
- existing = findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid);
992
- } catch {}
993
- if (existing) {
994
- reporter.skip(`Droplet already installed${existing.uuid ? ` (${existing.uuid})` : ""} — skipping`);
995
- return {
996
- uuid: existing.uuid ?? null,
997
- created: false
998
- };
999
- }
1000
- try {
1001
- const uuid = (await createDropletInstallation(client, {
1002
- droplet_uuid: dropletUuid,
1003
- scopes: [...args.scopes]
1004
- })).droplet_installation?.uuid ?? null;
1005
- reporter.succeed(`Droplet installed${uuid ? ` (${uuid})` : ""}`);
1006
- return {
1007
- uuid,
1008
- created: true
1009
- };
1010
- } catch (err) {
1011
- if (isAlreadyInstalledError(err)) {
1012
- const confirmed = await lookupInstallation(client, dropletUuid);
1013
- if (confirmed) {
1014
- reporter.skip("Droplet already installed — skipping");
1015
- return {
1016
- uuid: confirmed.uuid ?? null,
1017
- created: false
1018
- };
1019
- }
1020
- }
1021
- reporter.fail("Droplet install failed");
1022
- throw normalizeInstallError(err, dropletUuid);
888
+ function printTable(rows) {
889
+ if (rows.length === 0) return;
890
+ const widths = [];
891
+ for (const row of rows) row.forEach((cell, i) => {
892
+ const visible = stripAnsi(cell).length;
893
+ widths[i] = Math.max(widths[i] ?? 0, visible);
894
+ });
895
+ for (const row of rows) {
896
+ const padded = row.map((cell, i) => {
897
+ const w = widths[i] ?? 0;
898
+ const text = cell ?? "";
899
+ return text + " ".repeat(Math.max(0, w - stripAnsi(text).length));
900
+ });
901
+ process.stdout.write(padded.join(" ") + "\n");
1023
902
  }
1024
903
  }
1025
- function isAlreadyInstalledError(err) {
1026
- return err instanceof MistApiError && (err.status === 409 || err.status === 422) && /already|taken|installed|exists/i.test(`${err.message} ${JSON.stringify(err.details ?? {})}`);
904
+ function stripAnsi(s) {
905
+ return (s ?? "").replace(/\x1b\[[0-9;]*m/g, "");
1027
906
  }
1028
- /** Best-effort installation lookup for a droplet; null when the row can't
1029
- * be found or the list call fails. */
1030
- async function lookupInstallation(client, dropletUuid) {
1031
- try {
1032
- return findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid) ?? null;
1033
- } catch {
1034
- return null;
1035
- }
907
+ function relativeDate(iso) {
908
+ if (!iso) return "—";
909
+ const diff = Date.now() - new Date(iso).getTime();
910
+ const minutes = Math.floor(diff / 6e4);
911
+ if (minutes < 1) return "just now";
912
+ if (minutes < 60) return `${minutes}m ago`;
913
+ const hours = Math.floor(minutes / 60);
914
+ if (hours < 24) return `${hours}h ago`;
915
+ const days = Math.floor(hours / 24);
916
+ if (days < 30) return `${days}d ago`;
917
+ return new Date(iso).toISOString().slice(0, 10);
1036
918
  }
1037
- /**
1038
- * Install failures must NOT read as "wired up" — an uninstalled droplet means
1039
- * dit/dex token exchange, admin embeds, and install webhooks silently don't
1040
- * work. Surface the droplet uuid + recovery path; keep the MistApiError
1041
- * status so the error handler exits with the spec'd non-zero code.
1042
- */
1043
- function normalizeInstallError(err, dropletUuid) {
1044
- 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}"}.`;
1045
- return err instanceof MistApiError ? new MistApiError(message, err.status, err.details) : new Error(message);
919
+ /** Human-friendly absolute date for one-off prints (scheduled_destroy_at). */
920
+ function formatAbsolute(iso) {
921
+ return new Date(iso).toISOString().slice(0, 10);
1046
922
  }
1047
- /**
1048
- * If the integration-points endpoint rejects the attach identifier (e.g. an
1049
- * older API that predates #19688's attach-by-uuid support), surface an
1050
- * actionable message noting the surface WAS created. Other errors pass through.
1051
- */
1052
- function normalizeAttachError(err, surfaceRef, kind) {
1053
- if (err instanceof MistApiError && (err.status === 400 || err.status === 422)) {
1054
- const blob = `${err.message} ${JSON.stringify(err.details ?? {})}`;
1055
- 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);
923
+ //#endregion
924
+ //#region src/commands/list.ts
925
+ /** `fluid mist list` — GET /mists with optional filters. Spec §3. */
926
+ const listCommand = new Command("list").description("List the mists you have access to").option("--state <state>", "Filter by state: live | provisioning | failed | pending_destroy | archived").option("--kind <kind>", "Filter by kind (default: next_app)").option("--limit <n>", "Page size 1..100 (default 25)").option("--json", "Emit raw API JSON").action((opts) => runAction(async () => {
927
+ const client = createMistClient();
928
+ const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
929
+ const response = await listMists(client, {
930
+ ...opts.state ? { state: opts.state } : {},
931
+ ...opts.kind ? { kind: opts.kind } : {},
932
+ ...limit && Number.isFinite(limit) ? { limit } : {}
933
+ });
934
+ if (opts.json) return printJson(response);
935
+ const mists = response.mists ?? [];
936
+ if (mists.length === 0) {
937
+ console.log(chalk.dim("No mists found."));
938
+ return;
1056
939
  }
1057
- return err instanceof Error ? err : new Error(String(err));
940
+ printTable([[
941
+ chalk.bold("SLUG"),
942
+ chalk.bold("NAME"),
943
+ chalk.bold("STATE"),
944
+ chalk.bold("ATTACHED"),
945
+ chalk.bold("UPDATED")
946
+ ], ...mists.map((m) => [
947
+ m.slug,
948
+ m.name ?? m.droplet?.name ?? m.vendor_name ?? chalk.dim("(unnamed)"),
949
+ colorState(m.state),
950
+ m.hostable_type ?? chalk.dim("standalone"),
951
+ relativeDate(m.updated_at)
952
+ ])]);
953
+ }));
954
+ //#endregion
955
+ //#region src/api/integration_points.ts
956
+ function basePath(mistId) {
957
+ return `/api/v202604/mists/${mistId}/integration_points`;
958
+ }
959
+ async function listIntegrationPoints(client, slug) {
960
+ const id = await resolveMistId(client, slug);
961
+ return client.get(basePath(id));
962
+ }
963
+ async function createIntegrationPoint(client, slug, input) {
964
+ const id = await resolveMistId(client, slug);
965
+ return client.post(basePath(id), { integration_point: input });
966
+ }
967
+ async function deleteIntegrationPoint(client, slug, integrationPointId) {
968
+ const id = await resolveMistId(client, slug);
969
+ return client.delete(`${basePath(id)}/${integrationPointId}`);
1058
970
  }
1059
971
  /**
1060
- * Return the mist's public_url, polling briefly if it isn't ready yet
1061
- * (async-first-deploy). Returns null if it never appears within the window.
972
+ * First droplet-kind integration point in a list, or undefined. A droplet is
973
+ * 1-per-mist (it provisions the managed env), so this is the idempotency key
974
+ * for the droplet create+attach flow.
1062
975
  */
1063
- async function resolvePublicUrl(client, args) {
1064
- if (args.initial) return args.initial;
1065
- args.reporter.start("Waiting for the mist's public URL…");
1066
- const deadline = Date.now() + args.timing.publicUrlTimeoutMs;
1067
- for (;;) {
1068
- const url = (await showMist(client, args.slugOrId)).mist.public_url;
1069
- if (url) {
1070
- args.reporter.succeed(`Public URL ready: ${url}`);
1071
- return url;
1072
- }
1073
- if (Date.now() >= deadline) break;
1074
- await args.sleep(args.timing.publicUrlIntervalMs);
1075
- }
1076
- return null;
976
+ function findDropletIntegrationPoint(integrationPoints) {
977
+ return (integrationPoints ?? []).find((ip) => ip.kind === "droplet");
1077
978
  }
1078
979
  /**
1079
- * Poll `GET …/env_vars` until the Mist-managed droplet keys appear with
1080
- * `managed_by: "mist"`. Never fails the flow — a redeploy in progress is a
1081
- * normal, transient state we simply report.
980
+ * Find an integration point that already links a specific surface — matched
981
+ * by uuid (droplet/drop-zone) or numeric id (mobile-embed), optionally scoped
982
+ * to a kind. Used to make attach-existing idempotent (avoid the server's 422
983
+ * "already taken"). Uses the `integratable_uuid` echo (#19689) when present.
1082
984
  */
1083
- async function verifyManagedEnv(client, args) {
1084
- const required = [...MANAGED_DROPLET_ENV_KEYS];
1085
- if (!args.enabled) return {
1086
- ready: false,
1087
- present: [],
1088
- missing: required
1089
- };
1090
- args.reporter.start("Verifying managed environment…");
1091
- const deadline = Date.now() + args.timing.envTimeoutMs;
1092
- let present = [];
1093
- for (;;) {
1094
- const envVars = (await listEnvVars(client, args.slugOrId)).env_vars ?? [];
1095
- const managed = new Set(envVars.filter((e) => e.managed_by === "mist").map((e) => e.key));
1096
- present = required.filter((k) => managed.has(k));
1097
- if (present.length === required.length) {
1098
- args.reporter.succeed("Managed env keys present — droplet is wired up");
1099
- return {
1100
- ready: true,
1101
- present,
1102
- missing: []
1103
- };
1104
- }
1105
- if (Date.now() >= deadline) break;
1106
- args.reporter.update(`Verifying managed environment… (${present.length}/${required.length} keys, redeploy in progress)`);
1107
- await args.sleep(args.timing.envIntervalMs);
985
+ function findIntegrationPointBySurface(integrationPoints, target) {
986
+ return (integrationPoints ?? []).find((ip) => {
987
+ if (target.kind && ip.kind !== target.kind) return false;
988
+ if (target.uuid && ip.integratable_uuid === target.uuid) return true;
989
+ if (target.id != null && ip.integratable_id === target.id) return true;
990
+ return false;
991
+ });
992
+ }
993
+ //#endregion
994
+ //#region src/commands/show.ts
995
+ /** `fluid mist show <slug>` — single mist detail, including its attached
996
+ * surfaces (integration points). Surfaces scheduled_destroy_at when state is
997
+ * pending_destroy. */
998
+ /** Display ref for an attached surface: the uuid echo when present, else id. */
999
+ function surfaceRef(ip) {
1000
+ if (ip.integratable_uuid) return ip.integratable_uuid;
1001
+ if (ip.integratable_id != null) return `#${ip.integratable_id}`;
1002
+ return "(unknown)";
1003
+ }
1004
+ 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 () => {
1005
+ const resolved = resolveSlug(slug);
1006
+ const client = createMistClient();
1007
+ const { mist } = await showMist(client, resolved);
1008
+ const integrationPoints = (await listIntegrationPoints(client, resolved)).integration_points;
1009
+ if (opts.json) return printJson({
1010
+ mist,
1011
+ integration_points: integrationPoints
1012
+ });
1013
+ const lines = [];
1014
+ const label = mistLabel(mist.name, mist.vendor_name);
1015
+ lines.push(`${label} ${chalk.dim(mist.slug)}`);
1016
+ if (mist.vendor_name && mist.name && mist.name !== mist.vendor_name) lines.push(` vendor ${chalk.dim(mist.vendor_name)}`);
1017
+ lines.push(` state ${colorState(mist.state)}`);
1018
+ if (mist.kind) lines.push(` kind ${mist.kind}`);
1019
+ if (mist.public_url) lines.push(` url ${chalk.cyan(mist.public_url)}`);
1020
+ if (integrationPoints.length > 0) {
1021
+ lines.push(` surfaces ${integrationPoints.length}`);
1022
+ for (const ip of integrationPoints) lines.push(` ${chalk.dim("·")} ${ip.kind} ${surfaceRef(ip)} ${chalk.dim(`integration ${ip.id}`)}`);
1108
1023
  }
1109
- const missing = required.filter((k) => !present.includes(k));
1110
- 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(", ")}`);
1024
+ if (mist.provisioned_at) lines.push(` live ${relativeDate(mist.provisioned_at)}`);
1025
+ lines.push(` updated ${relativeDate(mist.updated_at)}`);
1026
+ if (mist.scheduled_destroy_at) lines.push(chalk.magenta(` destroy scheduled ${formatAbsolute(mist.scheduled_destroy_at)} (run \`fluid mist restore ${mist.slug}\` to undo)`));
1027
+ console.log(lines.join("\n"));
1028
+ }));
1029
+ //#endregion
1030
+ //#region src/lib/ora-reporter.ts
1031
+ /**
1032
+ * Spinner-backed `StepReporter` for interactive human runs. Split from
1033
+ * `step-reporter.ts` so tests can use the recording reporter without pulling
1034
+ * in `ora`'s ESM-only dependency chain.
1035
+ */
1036
+ function createOraReporter() {
1037
+ const spinner = ora();
1111
1038
  return {
1112
- ready: false,
1113
- present,
1114
- missing
1039
+ start(text) {
1040
+ spinner.start(text);
1041
+ },
1042
+ update(text) {
1043
+ spinner.text = text;
1044
+ },
1045
+ succeed(text) {
1046
+ spinner.succeed(text);
1047
+ },
1048
+ skip(text) {
1049
+ spinner.info(text);
1050
+ },
1051
+ info(text) {
1052
+ spinner.stopAndPersist({
1053
+ symbol: "•",
1054
+ text
1055
+ });
1056
+ },
1057
+ warn(text) {
1058
+ spinner.warn(text);
1059
+ },
1060
+ fail(text) {
1061
+ spinner.fail(text);
1062
+ }
1115
1063
  };
1116
1064
  }
1117
1065
  //#endregion
1118
- //#region src/lib/surface-flags.ts
1119
- const KIND_ALIASES = {
1120
- droplet: "droplet",
1121
- droplets: "droplet",
1122
- "drop-zone": "drop-zone",
1123
- drop_zone: "drop-zone",
1124
- dropzone: "drop-zone",
1125
- zone: "drop-zone",
1126
- "mobile-embed": "mobile-embed",
1127
- mobile_embed: "mobile-embed",
1128
- mobile: "mobile-embed",
1129
- "mobile-widget": "mobile-embed",
1130
- mobilewidget: "mobile-embed"
1131
- };
1132
- const SURFACE_KIND_LIST = "droplet, drop-zone, mobile-embed";
1133
- function parseSurfaceKind(raw) {
1134
- const hit = KIND_ALIASES[raw.trim().toLowerCase()];
1135
- if (!hit) throw new Error(`Unknown surface kind "${raw}". Use one of: ${SURFACE_KIND_LIST}.`);
1136
- return hit;
1137
- }
1138
- function parseHeight(raw) {
1139
- if (raw == null) return void 0;
1140
- const n = Number(raw);
1141
- if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid --height "${raw}". Must be a positive integer (px).`);
1142
- return n;
1143
- }
1144
- /**
1145
- * Map CLI flags to the kind-specific `provisionSurface` options, enforcing the
1146
- * per-kind requirements the API needs:
1147
- * - drop-zone requires `--zone <page__zone>` (else it never renders).
1148
- * - mobile-embed requires `--cover-image <url>` (the API 422s without one; the
1149
- * CLI has no DAM picker, so the user must supply a url).
1150
- */
1151
- function surfaceProvisionOptionsFromFlags(kind, flags) {
1152
- const heightPx = parseHeight(flags.height);
1153
- if (kind === "drop-zone") {
1154
- if (!flags.zone?.trim()) throw new Error("A drop-zone needs a slot — pass `--zone <page__zone>` (e.g. --zone checkout__below_total).");
1155
- return {
1156
- zone: flags.zone.trim(),
1157
- heightPx
1158
- };
1159
- }
1160
- if (kind === "mobile-embed") {
1161
- 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).");
1162
- return {
1163
- coverImageUrl: flags.coverImage.trim(),
1164
- heightPx
1165
- };
1166
- }
1167
- return {};
1066
+ //#region src/lib/step-reporter.ts
1067
+ /** No-op reporter for `--json` mode (final payload printed by the command). */
1068
+ function createSilentReporter() {
1069
+ return {
1070
+ start() {},
1071
+ update() {},
1072
+ succeed() {},
1073
+ skip() {},
1074
+ info() {},
1075
+ warn() {},
1076
+ fail() {}
1077
+ };
1168
1078
  }
1169
1079
  //#endregion
1170
- //#region src/lib/surface-summary.ts
1171
- /** Shared human-readable summary for a surface provision result. */
1172
- const KIND_LABEL = {
1173
- droplet: "droplet",
1174
- "drop-zone": "drop zone",
1175
- "mobile-embed": "mobile embed"
1176
- };
1177
- function printSurfaceSummary(result) {
1178
- const ref = result.surface.uuid ?? (result.surface.id != null ? `#${result.surface.id}` : "(unknown)");
1179
- const state = result.surfaceCreated ? "created" : "existing";
1180
- console.log(chalk.dim(` ${KIND_LABEL[result.kind]}: ${ref} (${state}) · integration ${result.integrationPoint.id}`));
1181
- if (result.kind === "droplet" && result.installation) {
1182
- const ref = result.installation.uuid ?? "(uuid unknown)";
1183
- console.log(chalk.dim(` installation: ${ref} (${result.installation.created ? "installed" : "already installed"})`));
1080
+ //#region ../../mist/core/src/lifecycle-urls.ts
1081
+ function normalizeLifecycleOrigin(value) {
1082
+ let url;
1083
+ try {
1084
+ url = new URL(value);
1085
+ } catch {
1086
+ throw new Error("Lifecycle origin must be an absolute HTTP(S) URL");
1184
1087
  }
1185
- if (result.kind === "droplet" && result.managedEnv) if (result.managedEnv.ready) console.log(chalk.dim(" env: managed keys provisioned ✓"));
1186
- else console.log(chalk.yellow(" env: redeploy in progress — run `fluid mist env list` shortly to confirm managed keys"));
1088
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Lifecycle origin must be an absolute HTTP(S) URL");
1089
+ url.pathname = url.pathname.replace(/\/+$/, "");
1090
+ url.search = "";
1091
+ url.hash = "";
1092
+ return url.toString().replace(/\/$/, "");
1093
+ }
1094
+ function lifecycleUrls(publicUrl, paths = {}) {
1095
+ const origin = normalizeLifecycleOrigin(publicUrl);
1096
+ return {
1097
+ installWebhookUrl: `${origin}${normalizePath(paths.installPath ?? "/api/webhooks/installed")}`,
1098
+ uninstallWebhookUrl: `${origin}${normalizePath(paths.uninstallPath ?? "/api/webhooks/uninstalled")}`
1099
+ };
1100
+ }
1101
+ function normalizePath(value) {
1102
+ return `/${value.replace(/^\/+/, "")}`;
1187
1103
  }
1188
1104
  //#endregion
1189
- //#region src/commands/create.ts
1105
+ //#region src/api/droplets.ts
1190
1106
  /**
1191
- * `fluid mist create` — provision a new mist, optionally creating and attaching
1192
- * a surface in one step. All attach paths go through integration points
1193
- * (hostable is retired — #19684/#19687).
1194
- *
1195
- * --droplet [name] Create a NEW droplet and attach it. Bare →
1196
- * droplet name defaults to the mist name. The
1197
- * "one command that sets everything up" path.
1198
- * --kind <kind> [flags] Create + attach a surface of any kind:
1199
- * droplet | drop-zone | mobile-embed. Kind flags:
1200
- * drop-zone --zone <page__zone> --height <px>
1201
- * mobile-embed --cover-image <url> --height <px>
1202
- * --hostable droplet:<uuid> Attach an EXISTING droplet by its drp_… uuid.
1107
+ * Droplet CRUD over `/api/droplets`. Thin wrapper — the provisioning
1108
+ * orchestration (idempotency, embed-url readiness, attach) lives in
1109
+ * `lib/droplet-provision.ts`.
1203
1110
  *
1204
- * With none of these, creates a plain standalone mist.
1111
+ * Note: droplets are a company-level resource, so unlike the mist
1112
+ * sub-resources these paths take the droplet `uuid` (drp_…) directly and
1113
+ * don't go through `resolveMistId`.
1205
1114
  */
1206
- 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 () => {
1207
- const wantDroplet = opts.droplet !== void 0;
1208
- const wantKind = !!opts.kind;
1209
- const wantHostable = !!opts.hostable;
1210
- if ([
1211
- wantDroplet,
1212
- wantKind,
1213
- wantHostable
1214
- ].filter(Boolean).length > 1) throw new Error("Pass only one of `--droplet`, `--kind`, or `--hostable` — they're mutually exclusive.");
1215
- let kind;
1216
- let existingDropletUuid;
1217
- let nameOverride;
1218
- if (wantDroplet) {
1219
- kind = "droplet";
1220
- nameOverride = typeof opts.droplet === "string" ? opts.droplet.trim() : void 0;
1221
- } else if (wantKind) kind = parseSurfaceKind(opts.kind);
1222
- else if (wantHostable) {
1223
- kind = "droplet";
1224
- existingDropletUuid = parseDropletRef(opts.hostable);
1225
- }
1226
- const mistBody = { kind: opts.mistKind ?? "next_app" };
1227
- if (opts.name?.trim()) mistBody.name = opts.name.trim();
1228
- const client = createMistClient();
1229
- const reporter = opts.json ? createSilentReporter() : createOraReporter();
1230
- reporter.start("Setting up your Mist Cloud environment…");
1231
- let created;
1232
- try {
1233
- created = await createMist(client, { mist: mistBody });
1234
- } catch (err) {
1235
- reporter.fail("Provisioning failed");
1236
- throw err;
1237
- }
1238
- const mist = created.mist;
1239
- reporter.succeed(`Provisioned ${mist.slug}`);
1240
- let provision;
1241
- if (kind) provision = await provisionSurface(client, {
1242
- slugOrId: mist.slug,
1243
- kind,
1244
- name: nameOverride ?? opts.name?.trim(),
1245
- existingUuid: existingDropletUuid,
1246
- verifyEnv: opts.wait !== false,
1247
- reporter,
1248
- ...surfaceProvisionOptionsFromFlags(kind, opts)
1249
- });
1250
- if (opts.json) return printJson(provision ? {
1251
- mist,
1252
- provision
1253
- } : created);
1254
- const label = mistLabel(mist.name, mist.vendor_name);
1255
- console.log();
1256
- console.log(`${label} ${chalk.dim(mist.slug)} ${colorState(mist.state)}`);
1257
- if (mist.public_url) console.log(` ${chalk.cyan(mist.public_url)}`);
1258
- if (provision) printSurfaceSummary(provision);
1259
- console.log();
1260
- console.log(chalk.dim(`Clone with: fluid mist clone ${mist.slug}`));
1261
- }));
1262
- //#endregion
1263
- //#region src/commands/attach.ts
1115
+ const BASE$3 = "/api/droplets";
1264
1116
  /**
1265
- * `fluid mist attach --kind <kind> [flags] [slug]` — create a surface and wire
1266
- * it to an existing mist. One command for all three kinds:
1267
- *
1268
- * --kind droplet (managed-env-provisioning droplet)
1269
- * --kind drop-zone --zone page__zone [--height <px>]
1270
- * --kind mobile-embed --cover-image <url> [--height <px>]
1271
- *
1272
- * Attach an EXISTING surface instead of creating one with `--existing <ref>`:
1273
- * droplet/drop-zone → droplet:drp_… / drop-zone:dz_… / bare uuid
1274
- * mobile-embed → mobile-embed:<numericId>
1275
- *
1276
- * Idempotent for droplet + attach-existing (skips when already linked).
1117
+ * Full scope set from `Droplet::Installation::VALID_SCOPES`. We request
1118
+ * them all by default so a Mist-created droplet's installations carry write
1119
+ * access out of the box — matching what Mist Desktop sends. Callers can
1120
+ * narrow this via the flow's `requestedScopes` option.
1121
+ */
1122
+ const DEFAULT_DROPLET_SCOPES = [
1123
+ "main",
1124
+ "prospects",
1125
+ "members",
1126
+ "web",
1127
+ "settings",
1128
+ "products",
1129
+ "orders",
1130
+ "users",
1131
+ "website",
1132
+ "header_menus",
1133
+ "payments"
1134
+ ];
1135
+ /** Lifecycle webhook paths a Mist Next app serves (mirrors Mist Desktop).
1136
+ * `embedUrl` must be an absolute HTTP(S) URL so malformed deployment metadata
1137
+ * fails before a Droplet is created with unusable callback URLs. */
1138
+ function dropletWebhookUrls(embedUrl) {
1139
+ const urls = lifecycleUrls(embedUrl);
1140
+ return {
1141
+ install_webhook_url: urls.installWebhookUrl,
1142
+ uninstall_webhook_url: urls.uninstallWebhookUrl
1143
+ };
1144
+ }
1145
+ /**
1146
+ * Build a droplet create body from a name + (optional) embed URL. When the
1147
+ * embed URL is known we also derive the lifecycle webhook URLs from it.
1148
+ */
1149
+ function buildDropletCreateInput(opts) {
1150
+ const scopes = [...opts.requestedScopes ?? DEFAULT_DROPLET_SCOPES];
1151
+ const summary = "Created by Fluid Mist";
1152
+ const input = {
1153
+ name: opts.name,
1154
+ requested_scopes: scopes,
1155
+ settings: {
1156
+ marketplace_page: { summary },
1157
+ details_page: { summary }
1158
+ }
1159
+ };
1160
+ if (opts.embedUrl) {
1161
+ input.embed_url = opts.embedUrl;
1162
+ Object.assign(input, dropletWebhookUrls(opts.embedUrl));
1163
+ }
1164
+ return input;
1165
+ }
1166
+ function createDroplet(client, body) {
1167
+ return client.post(BASE$3, body);
1168
+ }
1169
+ /** Page size + runaway guard for walking the page-paginated droplets list. */
1170
+ const PER_PAGE$1 = 100;
1171
+ const MAX_PAGES$1 = 25;
1172
+ /**
1173
+ * Recover a droplet's drp_ uuid from its numeric id by paging the company's
1174
+ * droplets list (`GET /api/droplets?page=&per_page=` — the list rows carry
1175
+ * `id`, unlike the create/show responses). Needed for legacy integration
1176
+ * points that only echo `integratable_id`: the show endpoint resolves by
1177
+ * uuid, so a numeric id can't be fetched directly. Returns null when no
1178
+ * droplet matches within the page cap.
1277
1179
  */
1278
- /** Parse `--existing <ref>` into a uuid or numeric id for the given kind. */
1279
- function parseExisting(kind, raw) {
1280
- const trimmed = (raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw).trim();
1281
- if (!trimmed) throw new Error(`Empty --existing reference "${raw}".`);
1282
- if (kind === "mobile-embed") {
1283
- const id = Number(trimmed);
1284
- if (!Number.isInteger(id) || id <= 0) throw new Error(`mobile-embed attaches by numeric id — pass \`--existing <id>\` (got "${raw}").`);
1285
- return { existingId: id };
1180
+ async function findDropletUuidByNumericId(client, id) {
1181
+ for (let page = 1; page <= MAX_PAGES$1; page++) {
1182
+ const resp = await client.get(`${BASE$3}?page=${page}&per_page=${PER_PAGE$1}`);
1183
+ const batch = resp.droplets ?? [];
1184
+ const match = batch.find((d) => d.id === id);
1185
+ if (match) return match.uuid ?? null;
1186
+ const totalPages = resp.meta?.pagination?.total_pages;
1187
+ if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE$1) break;
1286
1188
  }
1287
- return { existingUuid: trimmed };
1189
+ return null;
1288
1190
  }
1289
- 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 () => {
1290
- const kind = parseSurfaceKind(opts.kind);
1291
- const resolved = resolveSlug(slug);
1292
- const client = createMistClient();
1293
- const reporter = opts.json ? createSilentReporter() : createOraReporter();
1294
- const existing = opts.existing ? parseExisting(kind, opts.existing) : void 0;
1295
- const result = await provisionSurface(client, {
1296
- slugOrId: resolved,
1297
- kind,
1298
- name: opts.name?.trim(),
1299
- verifyEnv: opts.wait !== false,
1300
- reporter,
1301
- ...existing,
1302
- ...existing ? {} : surfaceProvisionOptionsFromFlags(kind, opts)
1303
- });
1304
- if (opts.json) return printJson(result);
1305
- console.log();
1306
- console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1307
- printSurfaceSummary(result);
1308
- }));
1309
- //#endregion
1310
- //#region src/commands/attach-droplet.ts
1311
1191
  /**
1312
- * `fluid mist attach-droplet [name] [slug]` — droplet shortcut for
1313
- * `fluid mist attach --kind droplet`. Creates a droplet and wires it to an
1314
- * existing standalone mist (provisioning the managed env). Idempotent: if a
1315
- * droplet is already attached, it skips create + attach and just verifies env.
1316
- *
1317
- * name Optional droplet name (defaults to the mist's name).
1318
- * slug Optional mist slug (defaults to the `.mist` file in CWD).
1192
+ * Recover a Droplet created by an interrupted surface-provisioning run. The
1193
+ * canonical Mist embed URL is stable and unique to that deployment, so a
1194
+ * matching unlinked Droplet can be attached instead of creating a duplicate.
1319
1195
  */
1320
- 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 () => {
1321
- const resolved = resolveSlug(slug);
1322
- const client = createMistClient();
1323
- const reporter = opts.json ? createSilentReporter() : createOraReporter();
1324
- const result = await provisionSurface(client, {
1325
- slugOrId: resolved,
1326
- kind: "droplet",
1327
- name: name?.trim() || void 0,
1328
- verifyEnv: opts.wait !== false,
1329
- reporter
1330
- });
1331
- if (opts.json) return printJson(result);
1332
- console.log();
1333
- console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1334
- printSurfaceSummary(result);
1335
- }));
1196
+ async function findDropletByEmbedUrl(client, embedUrl) {
1197
+ const expectedUrl = embedUrl.replace(/\/$/, "");
1198
+ for (let page = 1; page <= MAX_PAGES$1; page++) {
1199
+ const resp = await client.get(`${BASE$3}?page=${page}&per_page=${PER_PAGE$1}`);
1200
+ const batch = resp.droplets ?? [];
1201
+ const match = batch.find((droplet) => droplet.embed_url?.replace(/\/$/, "") === expectedUrl);
1202
+ if (match) return match;
1203
+ const totalPages = resp.meta?.pagination?.total_pages;
1204
+ if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE$1) break;
1205
+ }
1206
+ return null;
1207
+ }
1336
1208
  //#endregion
1337
- //#region src/commands/update.ts
1209
+ //#region src/api/droplet_installations.ts
1210
+ const BASE$2 = "/api/droplet_installations";
1211
+ /** Page size + runaway guard for walking the page-paginated list. */
1212
+ const PER_PAGE = 100;
1213
+ const MAX_PAGES = 25;
1338
1214
  /**
1339
- * `fluid mist update` — mutate an existing mist. Hostable is retired
1340
- * (#19684/#19687), so attach/detach operate on integration points:
1341
- *
1342
- * --name <label> Rename the mist (PATCH /mists/:id).
1343
- * --hostable droplet:<uuid> Attach a droplet by uuid. No-ops if that same
1344
- * droplet is already attached; replaces it if a
1345
- * different droplet is attached (droplet is
1346
- * 1-per-mist — it provisions the managed env).
1347
- * --detach Remove the droplet integration point.
1348
- *
1349
- * The integration-point link is immutable (no update route, no `active`
1350
- * field) — enablement lives on the surface record, so there's no toggle here.
1351
- * All ops re-derive state from the integration-points index (which echoes
1352
- * `integratable_uuid` since #19689) and only make the calls that change it.
1215
+ * List ALL of the current company's droplet installations, following the
1216
+ * endpoint's page-number pagination (`?page=&per_page=` — same shape as
1217
+ * fluid-admin's `getCompanyDroplets`). A single-page fetch would miss rows
1218
+ * beyond the first page, making the idempotency check (and the duplicate-
1219
+ * rejection confirmation) report a false "not installed" forever.
1353
1220
  */
1354
- 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 () => {
1355
- if (opts.hostable && opts.detach) throw new Error("Pass either `--hostable` OR `--detach`, not both — they're mutually exclusive.");
1356
- if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass `--name`, `--hostable`, or `--detach`.");
1357
- const resolved = resolveSlug(slug);
1358
- const client = createMistClient();
1359
- const notes = [];
1360
- let renameResp;
1361
- if (opts.name?.trim()) {
1362
- renameResp = await updateMist(client, resolved, { mist: { name: opts.name.trim() } });
1363
- if (!opts.json) {
1364
- const m = renameResp.mist;
1365
- console.log(`${mistLabel(m.name, m.vendor_name)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
1366
- }
1367
- }
1368
- if (opts.hostable || opts.detach) {
1369
- const existing = findDropletIntegrationPoint((await listIntegrationPoints(client, resolved)).integration_points);
1370
- if (opts.hostable) {
1371
- const dropletUuid = parseDropletRef(opts.hostable);
1372
- if (existing && existing.integratable_uuid === dropletUuid) notes.push(`droplet ${dropletUuid} already attached (integration ${existing.id})`);
1373
- else {
1374
- if (existing) {
1375
- await deleteIntegrationPoint(client, resolved, existing.id);
1376
- notes.push(`replaced integration ${existing.id}`);
1377
- }
1378
- const ip = await createIntegrationPoint(client, resolved, {
1379
- kind: "droplet",
1380
- integratable_uuid: dropletUuid
1381
- });
1382
- notes.push(`attached droplet ${dropletUuid} (integration ${ip.integration_point.id})`);
1383
- }
1384
- } else if (opts.detach) if (existing) {
1385
- await deleteIntegrationPoint(client, resolved, existing.id);
1386
- notes.push(`detached droplet (removed integration ${existing.id})`);
1387
- } else notes.push("already standalone (no droplet integration)");
1221
+ async function listAllDropletInstallations(client) {
1222
+ const rows = [];
1223
+ for (let page = 1; page <= MAX_PAGES; page++) {
1224
+ const resp = await client.get(`${BASE$2}?page=${page}&per_page=${PER_PAGE}`);
1225
+ const batch = resp.droplet_installations ?? [];
1226
+ rows.push(...batch);
1227
+ const totalPages = resp.meta?.pagination?.total_pages;
1228
+ if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE) break;
1388
1229
  }
1389
- if (opts.json) return printJson(renameResp ? {
1390
- ...renameResp,
1391
- slug: resolved,
1392
- notes
1393
- } : {
1394
- slug: resolved,
1395
- notes
1396
- });
1397
- for (const note of notes) console.log(chalk.dim(` ${note}`));
1398
- }));
1230
+ return rows;
1231
+ }
1232
+ /** Install a droplet (by drp_ uuid) for the current company. */
1233
+ function createDropletInstallation(client, input) {
1234
+ return client.post(BASE$2, input);
1235
+ }
1236
+ /**
1237
+ * The company's installation row for a droplet drp_ uuid, or undefined.
1238
+ * The idempotency key for the install step: an existing row means the
1239
+ * droplet is already connected (installed via a previous run, Mist Desktop,
1240
+ * or the admin marketplace).
1241
+ */
1242
+ function findInstallationForDroplet(installations, dropletUuid) {
1243
+ return (installations ?? []).find((i) => i.droplet_uuid === dropletUuid);
1244
+ }
1399
1245
  //#endregion
1400
- //#region src/commands/delete.ts
1246
+ //#region src/api/drop_zones.ts
1247
+ const BASE$1 = "/api/drop_zones";
1401
1248
  /**
1402
- * `fluid mist delete` — scheduled teardown 15 days out (spec §3 "15-day
1403
- * grace period"). DO NOT poll for completion.
1249
+ * Build a drop-zone create body. The slot id is `page__zone` (same split the
1250
+ * storefront preview uses, e.g. `checkout__below_total`) → `settings.page` +
1251
+ * `settings.zone`; height (px) → `settings.height`. Without a slot the zone
1252
+ * never renders, so the backend needs it to know WHERE to inject.
1404
1253
  */
1405
- 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 () => {
1406
- const resolved = resolveSlug(slug);
1407
- const client = createMistClient();
1408
- if (!opts.yes) {
1409
- const { mist } = await showMist(client, resolved);
1410
- const fifteenDaysOut = new Date(Date.now() + 15 * 864e5).toISOString().slice(0, 10);
1411
- console.log();
1412
- console.log(`About to schedule ${chalk.bold(mist.name ?? mist.vendor_name ?? mist.slug)} (${chalk.dim(mist.slug)}) for destruction on ${chalk.yellow(fifteenDaysOut)}.`);
1413
- console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
1414
- const { confirmed } = await prompts({
1415
- type: "confirm",
1416
- name: "confirmed",
1417
- message: "Continue?",
1418
- initial: false
1419
- }, { onCancel: () => process.exit(130) });
1420
- if (!confirmed) {
1421
- console.log(chalk.dim("Aborted."));
1422
- process.exit(0);
1423
- }
1424
- }
1425
- const { mist } = await deleteMist(client, resolved);
1426
- if (opts.json) return printJson({ mist });
1427
- console.log(chalk.magenta(`Scheduled for destruction on ${mist.scheduled_destroy_at ? formatAbsolute(mist.scheduled_destroy_at) : "the 15-day mark"}.`));
1428
- console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
1429
- }));
1254
+ function buildDropZoneCreateInput(opts) {
1255
+ const [page, zone] = (opts.zone ?? "").split("__");
1256
+ const settings = {};
1257
+ if (page) settings.page = page;
1258
+ if (zone) settings.zone = zone;
1259
+ if (opts.heightPx != null) settings.height = opts.heightPx;
1260
+ const input = { name: opts.name };
1261
+ if (opts.embedUrl) input.embed_url = opts.embedUrl;
1262
+ if (Object.keys(settings).length > 0) input.settings = settings;
1263
+ return input;
1264
+ }
1265
+ function createDropZone(client, body) {
1266
+ return client.post(BASE$1, body);
1267
+ }
1430
1268
  //#endregion
1431
- //#region src/commands/restore.ts
1432
- /** `fluid mist restore` — un-delete within the 15-day window. Spec §3. */
1433
- 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 () => {
1434
- const resolved = resolveSlug(slug);
1435
- const { mist } = await restoreMist(createMistClient(), resolved);
1436
- if (opts.json) return printJson({ mist });
1437
- console.log(`Restored: ${mist.public_url ? chalk.cyan(mist.public_url) : mist.name ?? mist.vendor_name ?? mist.slug} (${colorState(mist.state)})`);
1438
- }));
1269
+ //#region src/api/mobile_widgets.ts
1270
+ const BASE = "/api/company/mobile_widgets";
1271
+ /**
1272
+ * Build a mobile-widget create body. `cover_image_url` and `height` are
1273
+ * REQUIRED by the API (422 without them); we fall back to the Fluid
1274
+ * placeholder + default height when the caller doesn't supply them.
1275
+ */
1276
+ function buildMobileWidgetCreateInput(opts) {
1277
+ return {
1278
+ name: opts.name,
1279
+ embed_url: opts.embedUrl,
1280
+ cover_image_url: opts.coverImageUrl || "https://ik.imagekit.io/fluid/s3/app/image-placeholder.svg",
1281
+ height: opts.heightPx ?? 600,
1282
+ scrollable: true
1283
+ };
1284
+ }
1285
+ function createMobileWidget(client, body) {
1286
+ return client.post(BASE, body);
1287
+ }
1439
1288
  //#endregion
1440
- //#region src/api/git_credential.ts
1441
- async function createGitCredential(client, slug) {
1289
+ //#region src/api/env_vars.ts
1290
+ function path$1(id, key) {
1291
+ return key ? `/api/v202604/mists/${id}/env_vars/${encodeURIComponent(key)}` : `/api/v202604/mists/${id}/env_vars`;
1292
+ }
1293
+ async function listEnvVars(client, slug) {
1442
1294
  const id = await resolveMistId(client, slug);
1443
- return client.post(`/api/v202604/mists/${id}/git_credential`);
1295
+ return client.get(path$1(id));
1296
+ }
1297
+ /** Create a new env var (the spec routes `set` to POST for new keys). */
1298
+ async function createEnvVar(client, slug, key, value, targets) {
1299
+ const id = await resolveMistId(client, slug);
1300
+ return client.post(path$1(id), { env_var: {
1301
+ key,
1302
+ value,
1303
+ targets
1304
+ } });
1305
+ }
1306
+ /** Update an existing env var (the spec routes `set` to PATCH for existing keys). */
1307
+ async function updateEnvVar(client, slug, key, value, targets) {
1308
+ const id = await resolveMistId(client, slug);
1309
+ return client.patch(path$1(id, key), { env_var: {
1310
+ value,
1311
+ targets
1312
+ } });
1313
+ }
1314
+ async function deleteEnvVar(client, slug, key) {
1315
+ const id = await resolveMistId(client, slug);
1316
+ return client.delete(path$1(id, key));
1444
1317
  }
1445
1318
  //#endregion
1446
- //#region src/lib/git.ts
1319
+ //#region src/types.ts
1447
1320
  /**
1448
- * Git wrapper — shells out to system `git` via execa. Spec §11.1: we
1449
- * deliberately do NOT bundle git or use a pure-JS impl; users are expected
1450
- * to have it installed.
1321
+ * Managed env keys the backend writes onto a Mist once a droplet is
1322
+ * attached (backfill + redeploy). The CLI VERIFIES these appear with
1323
+ * `managed_by: "mist"` — it never sets them (the API 422s on write).
1451
1324
  *
1452
- * Security: token-bearing `remote_url` from POST /git_credential must never
1453
- * be logged, persisted, *or* exposed in the process table. The token is
1454
- * stripped from the URL and handed to git through a GIT_ASKPASS script via
1455
- * env vars (MIST_GIT_USER / MIST_GIT_PASS) — so it never appears in argv
1456
- * (visible to `ps aux` on macOS, `/proc/<pid>/cmdline` on Linux) and is
1457
- * never written to `.git/config`.
1325
+ * Source: settled backend contract (#19685 auto-provisions the droplet
1326
+ * env on integration-point create).
1458
1327
  */
1459
- var GitError = class extends Error {
1460
- exitCode;
1461
- constructor(message, exitCode = 1) {
1462
- super(message);
1463
- this.name = "GitError";
1464
- this.exitCode = exitCode;
1465
- }
1466
- };
1328
+ const MANAGED_DROPLET_ENV_KEYS = [
1329
+ "FLUID_DROPLET_UUID",
1330
+ "FLUID_DROPLET_SECRET",
1331
+ "FLUID_WEBHOOK_AUTH_TOKEN"
1332
+ ];
1333
+ //#endregion
1334
+ //#region src/lib/surface-provision.ts
1467
1335
  /**
1468
- * Git author for Mist publish commits. Mist app repos are connected to the
1469
- * we-commerce Vercel team via Vercel's GitHub integration, and Vercel
1470
- * attributes a git-triggered deploy to the *commit author's* GitHub account.
1471
- * If that author is a user's personal GitHub account (not a member of the
1472
- * Vercel team), Vercel blocks the deployment ("… does not have a Vercel
1473
- * account linked to their GitHub account"). Authoring every publish commit as
1474
- * the Mist GitHub App bot — which the Vercel integration recognizes — keeps
1475
- * deploys unblocked regardless of the user's local/global git identity. Human
1476
- * attribution is preserved in the commit *message* (see push.ts), not the
1477
- * author field.
1336
+ * Surface provisioning engine — the one place the "create a Fluid surface and
1337
+ * wire it to a mist" flow lives, at parity with Mist Desktop's
1338
+ * mist-integration-points-api.service.ts. Exposed via `fluid mist create`
1339
+ * (create a bare mist first, then run this), `fluid mist attach` (any kind),
1340
+ * and `fluid mist attach-droplet` (droplet shortcut).
1478
1341
  *
1479
- * These MUST match the identity set on cloned repos in Mist Desktop
1480
- * (apps/mist-desktop/src/main/ipc.ts) so both paths produce the same author.
1342
+ * Three surface kinds, each with its own create endpoint + attach identifier:
1343
+ * - droplet → POST /api/droplets → drp_ uuid → integratable_uuid
1344
+ * - drop-zone → POST /api/drop_zones → dz_ uuid → integratable_uuid
1345
+ * - mobile-embed→ POST /api/company/mobile_widgets→ numeric id → integratable_id
1346
+ *
1347
+ * Design tenets (repo philosophy — "server is source of truth"):
1348
+ * - No local progress file. State is re-derived from the API each run.
1349
+ * - Attach-existing is idempotent: if an integration point already links the
1350
+ * target surface (matched via the #19689 integratable_uuid echo, or the
1351
+ * numeric id for mobile), skip. A droplet is 1-per-mist, so creating a new
1352
+ * droplet also skips when one is already attached. Drop-zones/mobile
1353
+ * embeds are additive (a mist can have many).
1354
+ * - Only a droplet attach provisions managed env keys
1355
+ * (FLUID_DROPLET_UUID/_SECRET/_WEBHOOK_AUTH_TOKEN); this flow only
1356
+ * VERIFIES they landed — it never writes them (the API 422s on write).
1357
+ * - A droplet must also be INSTALLED (POST /api/droplet_installations) —
1358
+ * create + link alone leave it not-connected/inactive, so install-scoped
1359
+ * features (dit/dex exchange, admin embeds, install webhooks) don't work.
1360
+ * The install step is idempotent (skips when an installation exists).
1481
1361
  */
1482
- const MIST_GIT_AUTHOR_NAME = "mist-we-commerce[bot]";
1483
- const MIST_GIT_AUTHOR_EMAIL = "293596226+mist-we-commerce[bot]@users.noreply.github.com";
1484
- /** execa throws ENOENT when the git binary itself is missing — the
1485
- * single most common failure on non-developer machines. Translate it
1486
- * into an actionable message instead of "git <verb> failed (exit 1)".
1487
- * Returns null for every other failure so call sites keep their
1488
- * specific messages. */
1489
- function gitMissingError(err) {
1490
- if (err?.code !== "ENOENT") return null;
1491
- return new GitError("Git isn't installed on this machine. In Mist Desktop, use the Install button in the yellow banner at the top of the app. From a terminal: macOS `xcode-select --install`, or download from https://git-scm.com/downloads.", 127);
1492
- }
1362
+ const DEFAULT_TIMING = {
1363
+ publicUrlTimeoutMs: 9e4,
1364
+ publicUrlIntervalMs: 3e3,
1365
+ envTimeoutMs: 12e4,
1366
+ envIntervalMs: 5e3
1367
+ };
1368
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1369
+ const KIND_LABEL$1 = {
1370
+ droplet: "droplet",
1371
+ "drop-zone": "drop zone",
1372
+ "mobile-embed": "mobile embed"
1373
+ };
1493
1374
  /**
1494
- * Split a token-bearing remote URL into the userless URL + creds. Used
1495
- * to keep the token off git's argv: we pass `cleanUrl` to git and feed
1496
- * `username` / `password` back through GIT_ASKPASS env vars.
1497
- *
1498
- * If the URL has no embedded credentials, returns empty strings — fine
1499
- * for public repos where git won't even invoke askpass.
1375
+ * Create (or reuse) a surface of `kind` and wire it to the mist. Idempotent
1376
+ * for droplet + attach-existing; additive for new drop-zones/mobile embeds.
1500
1377
  */
1501
- function splitRemoteCredentials(remoteUrl) {
1502
- const u = new URL(remoteUrl);
1503
- const username = decodeURIComponent(u.username);
1504
- const password = decodeURIComponent(u.password);
1505
- u.username = "";
1506
- u.password = "";
1378
+ async function provisionSurface(client, options) {
1379
+ const { reporter, slugOrId, kind } = options;
1380
+ const timing = {
1381
+ ...DEFAULT_TIMING,
1382
+ ...options.timing
1383
+ };
1384
+ const sleep = options.sleep ?? defaultSleep;
1385
+ const label = KIND_LABEL$1[kind];
1386
+ reporter.start("Resolving mist…");
1387
+ const mist = (await showMist(client, slugOrId)).mist;
1388
+ reporter.succeed(`Mist ${mist.slug} (${mist.state})`);
1389
+ reporter.start("Checking existing integrations…");
1390
+ const points = (await listIntegrationPoints(client, slugOrId)).integration_points;
1391
+ let surfaceUuid = null;
1392
+ let surfaceNumericId = null;
1393
+ let surfaceName = null;
1394
+ let surfaceCreated = false;
1395
+ let embedUrlSet = false;
1396
+ let integrationPoint;
1397
+ const attachingExisting = options.existingUuid != null || options.existingId != null;
1398
+ const alreadyLinked = attachingExisting ? findIntegrationPointBySurface(points, {
1399
+ kind,
1400
+ uuid: options.existingUuid,
1401
+ id: options.existingId
1402
+ }) : kind === "droplet" ? findDropletIntegrationPoint(points) : void 0;
1403
+ if (alreadyLinked) {
1404
+ reporter.skip(`${label} already attached (integration #${alreadyLinked.id}) — skipping`);
1405
+ surfaceUuid = alreadyLinked.integratable_uuid ?? options.existingUuid ?? null;
1406
+ surfaceNumericId = alreadyLinked.integratable_id ?? options.existingId ?? null;
1407
+ integrationPoint = {
1408
+ id: alreadyLinked.id,
1409
+ integratableUuid: alreadyLinked.integratable_uuid ?? null,
1410
+ created: false
1411
+ };
1412
+ } else {
1413
+ reporter.succeed("No matching integration yet");
1414
+ let attachUuid = options.existingUuid;
1415
+ let attachId = options.existingId;
1416
+ if (!attachingExisting) {
1417
+ const embedUrl = await resolvePublicUrl(client, {
1418
+ slugOrId,
1419
+ initial: mist.public_url,
1420
+ reporter,
1421
+ timing,
1422
+ sleep
1423
+ });
1424
+ if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy before creating a surface.");
1425
+ const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name || mist.slug;
1426
+ if (kind === "droplet") {
1427
+ reporter.start("Checking for an interrupted Droplet create…");
1428
+ const recovered = await findDropletByEmbedUrl(client, embedUrl);
1429
+ if (recovered) {
1430
+ surfaceUuid = recovered.uuid;
1431
+ surfaceNumericId = recovered.id ?? null;
1432
+ surfaceName = recovered.name;
1433
+ attachUuid = recovered.uuid;
1434
+ embedUrlSet = true;
1435
+ reporter.skip(`Reusing droplet ${surfaceUuid} from an interrupted attach`);
1436
+ } else {
1437
+ reporter.start(`Creating ${label} "${name}"…`);
1438
+ const created = await createDroplet(client, { droplet: buildDropletCreateInput({
1439
+ name,
1440
+ embedUrl,
1441
+ requestedScopes: options.requestedScopes
1442
+ }) });
1443
+ surfaceUuid = created.droplet.uuid;
1444
+ surfaceNumericId = created.droplet.id ?? null;
1445
+ surfaceName = created.droplet.name;
1446
+ attachUuid = created.droplet.uuid;
1447
+ embedUrlSet = true;
1448
+ surfaceCreated = true;
1449
+ reporter.succeed(`Created droplet ${surfaceUuid}`);
1450
+ }
1451
+ } else if (kind === "drop-zone") {
1452
+ reporter.start(`Creating ${label} "${name}"…`);
1453
+ const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
1454
+ name,
1455
+ embedUrl,
1456
+ zone: options.zone,
1457
+ heightPx: options.heightPx
1458
+ }) });
1459
+ surfaceUuid = created.drop_zone.uuid;
1460
+ surfaceName = created.drop_zone.name;
1461
+ attachUuid = created.drop_zone.uuid;
1462
+ embedUrlSet = true;
1463
+ surfaceCreated = true;
1464
+ reporter.succeed(`Created drop zone ${surfaceUuid}`);
1465
+ } else {
1466
+ reporter.start(`Creating ${label} "${name}"…`);
1467
+ const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
1468
+ name,
1469
+ embedUrl,
1470
+ coverImageUrl: options.coverImageUrl,
1471
+ heightPx: options.heightPx
1472
+ }) });
1473
+ surfaceNumericId = created.mobile_widget.id;
1474
+ surfaceName = created.mobile_widget.name;
1475
+ attachId = created.mobile_widget.id;
1476
+ embedUrlSet = true;
1477
+ surfaceCreated = true;
1478
+ reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
1479
+ }
1480
+ } else {
1481
+ surfaceUuid = options.existingUuid ?? null;
1482
+ surfaceNumericId = options.existingId ?? null;
1483
+ embedUrlSet = true;
1484
+ }
1485
+ const attachInput = kind === "mobile-embed" ? {
1486
+ kind,
1487
+ integratable_id: attachId
1488
+ } : {
1489
+ kind,
1490
+ integratable_uuid: attachUuid
1491
+ };
1492
+ reporter.start(`Attaching ${label}…`);
1493
+ let ipResp;
1494
+ try {
1495
+ ipResp = await createIntegrationPoint(client, slugOrId, attachInput);
1496
+ } catch (err) {
1497
+ reporter.fail("Attach failed");
1498
+ throw normalizeAttachError(err, attachUuid ?? String(attachId), kind);
1499
+ }
1500
+ integrationPoint = {
1501
+ id: ipResp.integration_point.id,
1502
+ integratableUuid: ipResp.integration_point.integratable_uuid ?? null,
1503
+ created: true
1504
+ };
1505
+ if (!surfaceUuid) surfaceUuid = ipResp.integration_point.integratable_uuid ?? attachUuid ?? null;
1506
+ if (surfaceNumericId == null) surfaceNumericId = ipResp.integration_point.integratable_id ?? null;
1507
+ reporter.succeed(kind === "droplet" ? "Droplet attached — provisioning env + redeploy…" : `${label} attached`);
1508
+ }
1509
+ let installation = null;
1510
+ if (kind === "droplet") {
1511
+ if (!surfaceUuid && surfaceNumericId != null) try {
1512
+ surfaceUuid = await findDropletUuidByNumericId(client, surfaceNumericId);
1513
+ } catch {}
1514
+ if (!surfaceUuid) {
1515
+ reporter.fail("Droplet uuid unknown — cannot install");
1516
+ 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>\"}.");
1517
+ }
1518
+ installation = await ensureDropletInstalled(client, {
1519
+ dropletUuid: surfaceUuid,
1520
+ scopes: options.requestedScopes ?? DEFAULT_DROPLET_SCOPES,
1521
+ reporter
1522
+ });
1523
+ }
1524
+ const managedEnv = kind === "droplet" ? await verifyManagedEnv(client, {
1525
+ slugOrId,
1526
+ reporter,
1527
+ timing,
1528
+ sleep,
1529
+ enabled: options.verifyEnv !== false
1530
+ }) : null;
1507
1531
  return {
1508
- cleanUrl: u.toString(),
1509
- username,
1510
- password
1532
+ mist: {
1533
+ id: mist.id,
1534
+ slug: mist.slug,
1535
+ name: mist.name,
1536
+ public_url: mist.public_url,
1537
+ state: mist.state
1538
+ },
1539
+ kind,
1540
+ surface: {
1541
+ uuid: surfaceUuid,
1542
+ id: surfaceNumericId,
1543
+ name: surfaceName
1544
+ },
1545
+ integrationPoint: integrationPoint ?? {
1546
+ id: "",
1547
+ integratableUuid: null,
1548
+ created: false
1549
+ },
1550
+ surfaceCreated,
1551
+ embedUrlSet,
1552
+ installation,
1553
+ managedEnv
1511
1554
  };
1512
1555
  }
1513
1556
  /**
1514
- * Write a stub GIT_ASKPASS shell script to a per-process tmpdir and
1515
- * return its path. The script itself contains no secret — it just
1516
- * echoes back whichever env var (MIST_GIT_USER / MIST_GIT_PASS)
1517
- * matches git's prompt ("Username for …" / "Password for …"). The
1518
- * credentials only live in the spawned git process's env for the
1519
- * duration of the call.
1520
- *
1521
- * One POSIX script for all platforms: Git for Windows executes
1522
- * GIT_ASKPASS through its bundled sh, so a .cmd askpass fails with
1523
- * "unable to read askpass response" (git 2.52.0.windows.1) while a
1524
- * plain sh script works.
1525
- *
1526
- * Idempotent: subsequent calls reuse the same script path.
1527
- */
1528
- let askpassPath = null;
1529
- function ensureAskpassScript() {
1530
- if (askpassPath && existsSync(askpassPath)) return askpassPath;
1531
- const dir = path.join(os.tmpdir(), `mist-cli-askpass-${process.pid}`);
1532
- if (!existsSync(dir)) mkdirSync(dir, {
1533
- recursive: true,
1534
- mode: 448
1535
- });
1536
- const p = path.join(dir, "askpass.sh");
1537
- writeFileSync(p, `#!/bin/sh\ncase "$1" in\n Username*) printf '%s' "$MIST_GIT_USER" ;;\n *) printf '%s' "$MIST_GIT_PASS" ;;\nesac\n`, { mode: 448 });
1538
- chmodSync(p, 448);
1539
- askpassPath = p;
1540
- return askpassPath;
1541
- }
1542
- /**
1543
- * Build the spawn env + cleaned URL for a credentialed git invocation.
1544
- * Returns:
1545
- * - `cleanUrl`: URL with userinfo stripped — safe to pass as argv.
1546
- * - `env`: parent env plus GIT_ASKPASS + MIST_GIT_USER / MIST_GIT_PASS.
1547
- * GIT_TERMINAL_PROMPT=0 prevents the TTY-prompt fallback if
1548
- * the credentials are empty (we want auth to fail fast, not
1549
- * hang the desktop waiting for interactive input).
1557
+ * Install the droplet for the current company (idempotent). Checks the
1558
+ * installations list first; treats the API's already-installed rejection as
1559
+ * success too, so re-runs and races both converge. A hard failure throws with
1560
+ * the droplet uuid + recovery steps — the created droplet and mist link stay
1561
+ * in place, so an idempotent re-run finishes the job.
1550
1562
  */
1551
- function gitAuthSpawn(remoteUrl) {
1552
- const { cleanUrl, username, password } = splitRemoteCredentials(remoteUrl);
1553
- return {
1554
- cleanUrl,
1555
- env: {
1556
- ...process.env,
1557
- GIT_ASKPASS: ensureAskpassScript(),
1558
- GIT_TERMINAL_PROMPT: "0",
1559
- MIST_GIT_USER: username,
1560
- MIST_GIT_PASS: password
1563
+ async function ensureDropletInstalled(client, args) {
1564
+ const { reporter, dropletUuid } = args;
1565
+ reporter.start("Installing droplet…");
1566
+ let existing;
1567
+ try {
1568
+ existing = findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid);
1569
+ } catch {}
1570
+ if (existing) {
1571
+ reporter.skip(`Droplet already installed${existing.uuid ? ` (${existing.uuid})` : ""} — skipping`);
1572
+ return {
1573
+ uuid: existing.uuid ?? null,
1574
+ created: false
1575
+ };
1576
+ }
1577
+ try {
1578
+ const uuid = (await createDropletInstallation(client, {
1579
+ droplet_uuid: dropletUuid,
1580
+ scopes: [...args.scopes]
1581
+ })).droplet_installation?.uuid ?? null;
1582
+ reporter.succeed(`Droplet installed${uuid ? ` (${uuid})` : ""}`);
1583
+ return {
1584
+ uuid,
1585
+ created: true
1586
+ };
1587
+ } catch (err) {
1588
+ if (err instanceof MistApiError && (err.status === 409 || err.status === 422)) {
1589
+ const confirmed = await lookupInstallation(client, dropletUuid);
1590
+ if (confirmed) {
1591
+ reporter.skip("Droplet already installed — skipping");
1592
+ return {
1593
+ uuid: confirmed.uuid ?? null,
1594
+ created: false
1595
+ };
1596
+ }
1561
1597
  }
1562
- };
1598
+ reporter.fail("Droplet install failed");
1599
+ throw normalizeInstallError(err, dropletUuid);
1600
+ }
1563
1601
  }
1564
- /** Confirm git is installed. Cheap call once at startup. */
1565
- async function ensureGitAvailable() {
1602
+ /** Best-effort installation lookup for a droplet; null when the row can't
1603
+ * be found or the list call fails. */
1604
+ async function lookupInstallation(client, dropletUuid) {
1566
1605
  try {
1567
- await execa("git", ["--version"]);
1606
+ return findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid) ?? null;
1568
1607
  } catch {
1569
- throw new GitError("`git` is required but not found on your PATH. Install it from https://git-scm.com/downloads or via `brew install git`.");
1608
+ return null;
1570
1609
  }
1571
1610
  }
1572
1611
  /**
1573
- * `git clone <cleanUrl> <targetDir>`. The original remoteUrl carries a
1574
- * token; gitAuthSpawn strips it from the URL and re-injects it via
1575
- * GIT_ASKPASS so it never appears in argv. `credential.helper=` stops
1576
- * git from caching what askpass returned to any system credential
1577
- * store.
1612
+ * Install failures must NOT read as "wired up" — an uninstalled droplet means
1613
+ * dit/dex token exchange, admin embeds, and install webhooks silently don't
1614
+ * work. Surface the droplet uuid + recovery path; keep the MistApiError
1615
+ * status so the error handler exits with the spec'd non-zero code.
1578
1616
  */
1579
- async function gitClone(remoteUrl, targetDir) {
1580
- const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
1581
- try {
1582
- await execa("git", [
1583
- "-c",
1584
- "credential.helper=",
1585
- "clone",
1586
- "--quiet",
1587
- cleanUrl,
1588
- targetDir
1589
- ], {
1590
- env,
1591
- stdio: [
1592
- "ignore",
1593
- "inherit",
1594
- "inherit"
1595
- ]
1596
- });
1597
- } catch (err) {
1598
- const missing = gitMissingError(err);
1599
- if (missing) throw missing;
1600
- const e = err;
1601
- throw new GitError(`git clone failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1617
+ function normalizeInstallError(err, dropletUuid) {
1618
+ 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}"}.`;
1619
+ return err instanceof MistApiError ? new MistApiError(message, err.status, err.details) : new Error(message);
1620
+ }
1621
+ /**
1622
+ * If the integration-points endpoint rejects the attach identifier (e.g. an
1623
+ * older API that predates #19688's attach-by-uuid support), surface an
1624
+ * actionable message noting the surface WAS created. Other errors pass through.
1625
+ */
1626
+ function normalizeAttachError(err, surfaceRef, kind) {
1627
+ if (err instanceof MistApiError && (err.status === 400 || err.status === 422)) {
1628
+ const blob = `${err.message} ${JSON.stringify(err.details ?? {})}`;
1629
+ 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);
1602
1630
  }
1603
- await ensureBotIdentity(targetDir);
1631
+ return err instanceof Error ? err : new Error(String(err));
1604
1632
  }
1605
- /** `git push origin <branch>` — credentials supplied via GIT_ASKPASS,
1606
- * never in argv. The `.extraheader=` override clears any cached
1607
- * Authorization header that a CI-style git config might inject. */
1608
- async function gitPush(cwd, remoteUrl, branch = "main") {
1609
- const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
1610
- try {
1611
- await execa("git", [
1612
- "-c",
1613
- "credential.helper=",
1614
- "-c",
1615
- `http.https://github.com/.extraheader=`,
1616
- "push",
1617
- cleanUrl,
1618
- `HEAD:${branch}`
1619
- ], {
1620
- cwd,
1621
- env,
1622
- stdio: [
1623
- "ignore",
1624
- "inherit",
1625
- "inherit"
1626
- ]
1627
- });
1628
- } catch (err) {
1629
- const missing = gitMissingError(err);
1630
- if (missing) throw missing;
1631
- const e = err;
1632
- throw new GitError(`git push failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1633
+ /**
1634
+ * Return the mist's public_url, polling briefly if it isn't ready yet
1635
+ * (async-first-deploy). Returns null if it never appears within the window.
1636
+ */
1637
+ async function resolvePublicUrl(client, args) {
1638
+ if (args.initial) return args.initial;
1639
+ args.reporter.start("Waiting for the mist's public URL…");
1640
+ const deadline = Date.now() + args.timing.publicUrlTimeoutMs;
1641
+ for (;;) {
1642
+ const url = (await showMist(client, args.slugOrId)).mist.public_url;
1643
+ if (url) {
1644
+ args.reporter.succeed(`Public URL ready: ${url}`);
1645
+ return url;
1646
+ }
1647
+ if (Date.now() >= deadline) break;
1648
+ await args.sleep(args.timing.publicUrlIntervalMs);
1633
1649
  }
1650
+ return null;
1634
1651
  }
1635
- /** `git pull origin <branch>`. Token supplied via GIT_ASKPASS, same as push. */
1636
- async function gitPull(cwd, remoteUrl, branch = "main") {
1637
- const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
1638
- try {
1639
- await execa("git", [
1640
- "-c",
1641
- "credential.helper=",
1642
- "pull",
1643
- "--ff-only",
1644
- cleanUrl,
1645
- branch
1646
- ], {
1647
- cwd,
1648
- env,
1649
- stdio: [
1650
- "ignore",
1651
- "inherit",
1652
- "inherit"
1653
- ]
1654
- });
1655
- } catch (err) {
1656
- const missing = gitMissingError(err);
1657
- if (missing) throw missing;
1658
- const e = err;
1659
- throw new GitError(`git pull failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1652
+ /**
1653
+ * Poll `GET …/env_vars` until the Mist-managed droplet keys appear with
1654
+ * `managed_by: "mist"`. Never fails the flow — a redeploy in progress is a
1655
+ * normal, transient state we simply report.
1656
+ */
1657
+ async function verifyManagedEnv(client, args) {
1658
+ const required = [...MANAGED_DROPLET_ENV_KEYS];
1659
+ if (!args.enabled) return {
1660
+ ready: false,
1661
+ present: [],
1662
+ missing: required
1663
+ };
1664
+ args.reporter.start("Verifying managed environment…");
1665
+ const deadline = Date.now() + args.timing.envTimeoutMs;
1666
+ let present = [];
1667
+ for (;;) {
1668
+ const envVars = (await listEnvVars(client, args.slugOrId)).env_vars ?? [];
1669
+ const managed = new Set(envVars.filter((e) => e.managed_by === "mist").map((e) => e.key));
1670
+ present = required.filter((k) => managed.has(k));
1671
+ if (present.length === required.length) {
1672
+ args.reporter.succeed("Managed env keys present — droplet is wired up");
1673
+ return {
1674
+ ready: true,
1675
+ present,
1676
+ missing: []
1677
+ };
1678
+ }
1679
+ if (Date.now() >= deadline) break;
1680
+ args.reporter.update(`Verifying managed environment… (${present.length}/${required.length} keys, redeploy in progress)`);
1681
+ await args.sleep(args.timing.envIntervalMs);
1660
1682
  }
1683
+ const missing = required.filter((k) => !present.includes(k));
1684
+ 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(", ")}`);
1685
+ return {
1686
+ ready: false,
1687
+ present,
1688
+ missing
1689
+ };
1661
1690
  }
1662
- /**
1663
- * Are there uncommitted changes (working tree or index) in this repo?
1664
- * `git status --porcelain` is empty when everything is clean, non-empty
1665
- * otherwise — so the count is just "is the output non-trivial".
1666
- */
1667
- async function hasUncommittedChanges(cwd) {
1668
- try {
1669
- const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
1670
- return stdout.trim().length > 0;
1671
- } catch {
1672
- return false;
1673
- }
1691
+ //#endregion
1692
+ //#region src/lib/surface-flags.ts
1693
+ const KIND_ALIASES = {
1694
+ droplet: "droplet",
1695
+ droplets: "droplet",
1696
+ "drop-zone": "drop-zone",
1697
+ drop_zone: "drop-zone",
1698
+ dropzone: "drop-zone",
1699
+ zone: "drop-zone",
1700
+ "mobile-embed": "mobile-embed",
1701
+ mobile_embed: "mobile-embed",
1702
+ mobile: "mobile-embed",
1703
+ "mobile-widget": "mobile-embed",
1704
+ mobilewidget: "mobile-embed"
1705
+ };
1706
+ const SURFACE_KIND_LIST = "droplet, drop-zone, mobile-embed";
1707
+ function parseSurfaceKind(raw) {
1708
+ const hit = KIND_ALIASES[raw.trim().toLowerCase()];
1709
+ if (!hit) throw new Error(`Unknown surface kind "${raw}". Use one of: ${SURFACE_KIND_LIST}.`);
1710
+ return hit;
1674
1711
  }
1675
- /**
1676
- * Fetch the given branch from the token-bearing remote URL, writing
1677
- * `FETCH_HEAD` so `git rev-list HEAD...FETCH_HEAD` is meaningful.
1678
- *
1679
- * Same security stance as gitPush — installation token comes through
1680
- * GIT_ASKPASS, never argv or `.git/config`.
1681
- */
1682
- async function gitFetch(cwd, remoteUrl, branch = "main") {
1683
- const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
1684
- try {
1685
- await execa("git", [
1686
- "-c",
1687
- "credential.helper=",
1688
- "fetch",
1689
- "--quiet",
1690
- cleanUrl,
1691
- branch
1692
- ], {
1693
- cwd,
1694
- env
1695
- });
1696
- } catch (err) {
1697
- const missing = gitMissingError(err);
1698
- if (missing) throw missing;
1699
- const e = err;
1700
- throw new GitError(`git fetch failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1701
- }
1712
+ function parseHeight(raw) {
1713
+ if (raw == null) return void 0;
1714
+ const n = Number(raw);
1715
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid --height "${raw}". Must be a positive integer (px).`);
1716
+ return n;
1702
1717
  }
1703
1718
  /**
1704
- * Count commits in local HEAD vs `FETCH_HEAD` (after `gitFetch`). Returns
1705
- * `{ahead, behind}`: how many commits HEAD has that the remote doesn't,
1706
- * and vice versa. A `0/0` result means "in sync".
1719
+ * Map CLI flags to the kind-specific `provisionSurface` options, enforcing the
1720
+ * per-kind requirements the API needs:
1721
+ * - drop-zone requires `--zone <page__zone>` (else it never renders).
1722
+ * - mobile-embed requires `--cover-image <url>` (the API 422s without one; the
1723
+ * CLI has no DAM picker, so the user must supply a url).
1707
1724
  */
1708
- async function gitAheadBehindVsFetchHead(cwd) {
1709
- try {
1710
- const { stdout } = await execa("git", [
1711
- "rev-list",
1712
- "--left-right",
1713
- "--count",
1714
- "HEAD...FETCH_HEAD"
1715
- ], { cwd });
1716
- const [a, b] = stdout.trim().split(/\s+/);
1717
- const ahead = Number.parseInt(a ?? "0", 10);
1718
- const behind = Number.parseInt(b ?? "0", 10);
1725
+ function surfaceProvisionOptionsFromFlags(kind, flags) {
1726
+ const heightPx = parseHeight(flags.height);
1727
+ if (kind === "drop-zone") {
1728
+ if (!flags.zone?.trim()) throw new Error("A drop-zone needs a slot — pass `--zone <page__zone>` (e.g. --zone checkout__below_total).");
1719
1729
  return {
1720
- ahead: Number.isFinite(ahead) ? ahead : 0,
1721
- behind: Number.isFinite(behind) ? behind : 0
1730
+ zone: flags.zone.trim(),
1731
+ heightPx
1722
1732
  };
1723
- } catch {
1733
+ }
1734
+ if (kind === "mobile-embed") {
1735
+ 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).");
1724
1736
  return {
1725
- ahead: 0,
1726
- behind: 0
1737
+ coverImageUrl: flags.coverImage.trim(),
1738
+ heightPx
1727
1739
  };
1728
1740
  }
1741
+ return {};
1729
1742
  }
1730
- /** Fast-forward `HEAD` to `FETCH_HEAD`. Fails (non-zero exit) if not
1731
- * fast-forwardable — caller must check ahead-count first. */
1732
- async function gitMergeFastForward(cwd) {
1733
- try {
1734
- await execa("git", [
1735
- "merge",
1736
- "--ff-only",
1737
- "FETCH_HEAD"
1738
- ], { cwd });
1739
- } catch (err) {
1740
- const missing = gitMissingError(err);
1741
- if (missing) throw missing;
1742
- const e = err;
1743
- throw new GitError(`git fast-forward merge failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1743
+ //#endregion
1744
+ //#region src/lib/surface-summary.ts
1745
+ /** Shared human-readable summary for a surface provision result. */
1746
+ const KIND_LABEL = {
1747
+ droplet: "droplet",
1748
+ "drop-zone": "drop zone",
1749
+ "mobile-embed": "mobile embed"
1750
+ };
1751
+ function printSurfaceSummary(result) {
1752
+ const ref = result.surface.uuid ?? (result.surface.id != null ? `#${result.surface.id}` : "(unknown)");
1753
+ const state = result.surfaceCreated ? "created" : "existing";
1754
+ console.log(chalk.dim(` ${KIND_LABEL[result.kind]}: ${ref} (${state}) · integration ${result.integrationPoint.id}`));
1755
+ if (result.kind === "droplet" && result.installation) {
1756
+ const ref = result.installation.uuid ?? "(uuid unknown)";
1757
+ console.log(chalk.dim(` installation: ${ref} (${result.installation.created ? "installed" : "already installed"})`));
1744
1758
  }
1759
+ if (result.kind === "droplet" && result.managedEnv) if (result.managedEnv.ready) console.log(chalk.dim(" env: managed keys provisioned ✓"));
1760
+ else console.log(chalk.yellow(" env: redeploy in progress — run `fluid mist env list` shortly to confirm managed keys"));
1745
1761
  }
1762
+ //#endregion
1763
+ //#region src/commands/create.ts
1746
1764
  /**
1747
- * Stash working-tree + untracked changes with a Mist-tagged message.
1748
- * Returns the stash ref (`stash@{0}`) we created. Callers should pair
1749
- * this with `gitStashPop` after the dangerous step is done.
1765
+ * `fluid mist create` — provision a new mist, optionally creating and attaching
1766
+ * a surface in one step. All attach paths go through integration points
1767
+ * (hostable is retired — #19684/#19687).
1768
+ *
1769
+ * --droplet [name] Create a NEW droplet and attach it. Bare →
1770
+ * droplet name defaults to the mist name. The
1771
+ * "one command that sets everything up" path.
1772
+ * --kind <kind> [flags] Create + attach a surface of any kind:
1773
+ * droplet | drop-zone | mobile-embed. Kind flags:
1774
+ * drop-zone --zone <page__zone> --height <px>
1775
+ * mobile-embed --cover-image <url> --height <px>
1776
+ * --hostable droplet:<uuid> Attach an EXISTING droplet by its drp_… uuid.
1777
+ *
1778
+ * With none of these, creates a plain standalone mist.
1750
1779
  */
1751
- async function gitStash(cwd, message) {
1780
+ 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 () => {
1781
+ const wantDroplet = opts.droplet !== void 0;
1782
+ const wantKind = !!opts.kind;
1783
+ const wantHostable = !!opts.hostable;
1784
+ if ([
1785
+ wantDroplet,
1786
+ wantKind,
1787
+ wantHostable
1788
+ ].filter(Boolean).length > 1) throw new Error("Pass only one of `--droplet`, `--kind`, or `--hostable` — they're mutually exclusive.");
1789
+ let kind;
1790
+ let existingDropletUuid;
1791
+ let nameOverride;
1792
+ if (wantDroplet) {
1793
+ kind = "droplet";
1794
+ nameOverride = typeof opts.droplet === "string" ? opts.droplet.trim() : void 0;
1795
+ } else if (wantKind) kind = parseSurfaceKind(opts.kind);
1796
+ else if (wantHostable) {
1797
+ kind = "droplet";
1798
+ existingDropletUuid = parseDropletRef(opts.hostable);
1799
+ }
1800
+ const mistBody = { kind: opts.mistKind ?? "next_app" };
1801
+ if (opts.name?.trim()) mistBody.name = opts.name.trim();
1802
+ const client = createMistClient();
1803
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
1804
+ reporter.start("Setting up your Mist Cloud environment…");
1805
+ let created;
1752
1806
  try {
1753
- await execa("git", [
1754
- "stash",
1755
- "push",
1756
- "--include-untracked",
1757
- "--message",
1758
- message
1759
- ], { cwd });
1807
+ created = await createMist(client, { mist: mistBody });
1760
1808
  } catch (err) {
1761
- const missing = gitMissingError(err);
1762
- if (missing) throw missing;
1763
- throw new GitError(`git stash push failed`, err.exitCode ?? 1);
1809
+ reporter.fail("Provisioning failed");
1810
+ throw err;
1764
1811
  }
1765
- }
1812
+ const mist = created.mist;
1813
+ reporter.succeed(`Provisioned ${mist.slug}`);
1814
+ let provision;
1815
+ if (kind) provision = await provisionSurface(client, {
1816
+ slugOrId: mist.slug,
1817
+ kind,
1818
+ name: nameOverride ?? opts.name?.trim(),
1819
+ existingUuid: existingDropletUuid,
1820
+ verifyEnv: opts.wait !== false,
1821
+ reporter,
1822
+ ...surfaceProvisionOptionsFromFlags(kind, opts)
1823
+ });
1824
+ if (opts.json) return printJson(provision ? {
1825
+ mist,
1826
+ provision
1827
+ } : created);
1828
+ const label = mistLabel(mist.name, mist.vendor_name);
1829
+ console.log();
1830
+ console.log(`${label} ${chalk.dim(mist.slug)} ${colorState(mist.state)}`);
1831
+ if (mist.public_url) console.log(` ${chalk.cyan(mist.public_url)}`);
1832
+ if (provision) printSurfaceSummary(provision);
1833
+ console.log();
1834
+ console.log(chalk.dim(`Clone with: fluid mist clone ${mist.slug}`));
1835
+ }));
1836
+ //#endregion
1837
+ //#region src/commands/attach.ts
1766
1838
  /**
1767
- * Pop the most recent stash. Throws on conflict so callers can surface
1768
- * the situation (the working tree will have conflict markers; the user
1769
- * needs to resolve manually).
1770
- */
1771
- async function gitStashPop(cwd) {
1772
- try {
1773
- await execa("git", ["stash", "pop"], { cwd });
1774
- } catch (err) {
1775
- const missing = gitMissingError(err);
1776
- if (missing) throw missing;
1777
- throw new GitError(`Couldn't restore your local changes after pulling — the file edits collided with a teammate's. Run \`git stash list\` and resolve manually.`, err.exitCode ?? 1);
1839
+ * `fluid mist attach --kind <kind> [flags] [slug]` — create a surface and wire
1840
+ * it to an existing mist. One command for all three kinds:
1841
+ *
1842
+ * --kind droplet (managed-env-provisioning droplet)
1843
+ * --kind drop-zone --zone page__zone [--height <px>]
1844
+ * --kind mobile-embed --cover-image <url> [--height <px>]
1845
+ *
1846
+ * Attach an EXISTING surface instead of creating one with `--existing <ref>`:
1847
+ * droplet/drop-zone → droplet:drp_… / drop-zone:dz_… / bare uuid
1848
+ * mobile-embed → mobile-embed:<numericId>
1849
+ *
1850
+ * Idempotent for droplet + attach-existing (skips when already linked).
1851
+ */
1852
+ /** Parse `--existing <ref>` into a uuid or numeric id for the given kind. */
1853
+ function parseExisting(kind, raw) {
1854
+ const trimmed = (raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw).trim();
1855
+ if (!trimmed) throw new Error(`Empty --existing reference "${raw}".`);
1856
+ if (kind === "mobile-embed") {
1857
+ const id = Number(trimmed);
1858
+ if (!Number.isInteger(id) || id <= 0) throw new Error(`mobile-embed attaches by numeric id — pass \`--existing <id>\` (got "${raw}").`);
1859
+ return { existingId: id };
1778
1860
  }
1861
+ return { existingUuid: trimmed };
1779
1862
  }
1863
+ 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 () => {
1864
+ const kind = parseSurfaceKind(opts.kind);
1865
+ const resolved = resolveSlug(slug);
1866
+ const client = createMistClient();
1867
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
1868
+ const existing = opts.existing ? parseExisting(kind, opts.existing) : void 0;
1869
+ const result = await provisionSurface(client, {
1870
+ slugOrId: resolved,
1871
+ kind,
1872
+ name: opts.name?.trim(),
1873
+ verifyEnv: opts.wait !== false,
1874
+ reporter,
1875
+ ...existing,
1876
+ ...existing ? {} : surfaceProvisionOptionsFromFlags(kind, opts)
1877
+ });
1878
+ if (opts.json) return printJson(result);
1879
+ console.log();
1880
+ console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1881
+ printSurfaceSummary(result);
1882
+ }));
1883
+ //#endregion
1884
+ //#region src/commands/attach-droplet.ts
1780
1885
  /**
1781
- * Persist the Mist bot as the repo's LOCAL git identity
1782
- * (`git config --local user.{name,email}`).
1783
- *
1784
- * `gitAutoCommit` already forces the bot author on the publish commit
1785
- * via `-c`/env, but that only covers commits WE make. Commits made any
1786
- * other way against the same checkout — the Mist chat agent shelling out
1787
- * `git commit` via run_cli, or the user in a terminal — inherit the
1788
- * repo's configured identity, which is the person's personal GitHub
1789
- * account. Vercel rejects a deploy whose commit author isn't a
1790
- * we-commerce team member, so those personal-authored commits silently
1791
- * block the deploy (exactly the www-* "stuck project" case).
1886
+ * `fluid mist attach-droplet [name] [slug]` — droplet shortcut for
1887
+ * `fluid mist attach --kind droplet`. Creates a droplet and wires it to an
1888
+ * existing standalone mist (provisioning the managed env). Idempotent: if a
1889
+ * droplet is already attached, it skips create + attach and just verifies env.
1792
1890
  *
1793
- * Setting the identity at the repo level makes EVERY commit path
1794
- * bot-authored, and heals repos cloned before this was in place on their
1795
- * next publish. Best-effort: a config failure must never abort a push —
1796
- * the publish commit is still bot-forced by gitAutoCommit regardless.
1891
+ * name Optional droplet name (defaults to the mist's name).
1892
+ * slug Optional mist slug (defaults to the `.mist` file in CWD).
1797
1893
  */
1798
- async function ensureBotIdentity(cwd) {
1799
- try {
1800
- await execa("git", [
1801
- "config",
1802
- "--local",
1803
- "user.name",
1804
- MIST_GIT_AUTHOR_NAME
1805
- ], { cwd });
1806
- await execa("git", [
1807
- "config",
1808
- "--local",
1809
- "user.email",
1810
- MIST_GIT_AUTHOR_EMAIL
1811
- ], { cwd });
1812
- } catch {}
1813
- }
1894
+ 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 () => {
1895
+ const resolved = resolveSlug(slug);
1896
+ const client = createMistClient();
1897
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
1898
+ const result = await provisionSurface(client, {
1899
+ slugOrId: resolved,
1900
+ kind: "droplet",
1901
+ name: name?.trim() || void 0,
1902
+ verifyEnv: opts.wait !== false,
1903
+ reporter
1904
+ });
1905
+ if (opts.json) return printJson(result);
1906
+ console.log();
1907
+ console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1908
+ printSurfaceSummary(result);
1909
+ }));
1910
+ //#endregion
1911
+ //#region src/commands/update.ts
1814
1912
  /**
1815
- * `git add -A && git commit -m <message>` in `cwd`.
1913
+ * `fluid mist update` — mutate an existing mist. Hostable is retired
1914
+ * (#19684/#19687), so attach/detach operate on integration points:
1816
1915
  *
1817
- * Mist Desktop's "Publish" UX target is non-technical: the user expects
1818
- * "ship my changes" to ship the changes they made, not to know about
1819
- * staging or commit messages. This helper bundles working-tree edits
1820
- * into one commit per publish so push has something to send.
1916
+ * --name <label> Rename the mist (PATCH /mists/:id).
1917
+ * --hostable droplet:<uuid> Attach a droplet by uuid. No-ops if that same
1918
+ * droplet is already attached; replaces it if a
1919
+ * different droplet is attached (droplet is
1920
+ * 1-per-mist — it provisions the managed env).
1921
+ * --detach Remove the droplet integration point.
1821
1922
  *
1822
- * The commit author is ALWAYS forced to the Mist GitHub App bot identity
1823
- * (MIST_GIT_AUTHOR_*), overriding whatever local/global git identity the
1824
- * machine has. This is what keeps Vercel from blocking the deploy: a commit
1825
- * authored by a user's personal GitHub account that isn't a we-commerce Vercel
1826
- * team member gets rejected. Passing `-c user.name=… -c user.email=…` beats
1827
- * even a `--local` config, so every publish is bot-authored regardless of how
1828
- * the repo landed on disk (fresh clone, pre-existing clone, or terminal CLI).
1829
- * The real person is still recorded in the commit *message* (see push.ts).
1923
+ * The integration-point link is immutable (no update route, no `active`
1924
+ * field) — enablement lives on the surface record, so there's no toggle here.
1925
+ * All ops re-derive state from the integration-points index (which echoes
1926
+ * `integratable_uuid` since #19689) and only make the calls that change it.
1830
1927
  */
1831
- async function gitAutoCommit(cwd, message) {
1832
- await ensureBotIdentity(cwd);
1833
- try {
1834
- await execa("git", ["add", "-A"], { cwd });
1835
- } catch (err) {
1836
- const missing = gitMissingError(err);
1837
- if (missing) throw missing;
1838
- throw new GitError(`git add failed`, err.exitCode ?? 1);
1928
+ 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 () => {
1929
+ if (opts.hostable && opts.detach) throw new Error("Pass either `--hostable` OR `--detach`, not both — they're mutually exclusive.");
1930
+ if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass `--name`, `--hostable`, or `--detach`.");
1931
+ const resolved = resolveSlug(slug);
1932
+ const client = createMistClient();
1933
+ const notes = [];
1934
+ let renameResp;
1935
+ if (opts.name?.trim()) {
1936
+ renameResp = await updateMist(client, resolved, { mist: { name: opts.name.trim() } });
1937
+ if (!opts.json) {
1938
+ const m = renameResp.mist;
1939
+ console.log(`${mistLabel(m.name, m.vendor_name)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
1940
+ }
1839
1941
  }
1840
- const identityArgs = [
1841
- "-c",
1842
- `user.name=${MIST_GIT_AUTHOR_NAME}`,
1843
- "-c",
1844
- `user.email=${MIST_GIT_AUTHOR_EMAIL}`
1845
- ];
1846
- const identityEnv = {
1847
- GIT_AUTHOR_NAME: MIST_GIT_AUTHOR_NAME,
1848
- GIT_AUTHOR_EMAIL: MIST_GIT_AUTHOR_EMAIL,
1849
- GIT_COMMITTER_NAME: MIST_GIT_AUTHOR_NAME,
1850
- GIT_COMMITTER_EMAIL: MIST_GIT_AUTHOR_EMAIL
1851
- };
1852
- try {
1853
- await execa("git", [
1854
- ...identityArgs,
1855
- "commit",
1856
- "-m",
1857
- message
1858
- ], {
1859
- cwd,
1860
- env: {
1861
- ...process.env,
1862
- ...identityEnv
1863
- },
1864
- stdio: [
1865
- "ignore",
1866
- "inherit",
1867
- "inherit"
1868
- ]
1869
- });
1870
- } catch (err) {
1871
- const missing = gitMissingError(err);
1872
- if (missing) throw missing;
1873
- const e = err;
1874
- if (typeof e.stderr === "string" && /nothing to commit/i.test(e.stderr)) return;
1875
- throw new GitError(`git commit failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1942
+ if (opts.hostable || opts.detach) {
1943
+ const existing = findDropletIntegrationPoint((await listIntegrationPoints(client, resolved)).integration_points);
1944
+ if (opts.hostable) {
1945
+ const dropletUuid = parseDropletRef(opts.hostable);
1946
+ if (existing && existing.integratable_uuid === dropletUuid) notes.push(`droplet ${dropletUuid} already attached (integration ${existing.id})`);
1947
+ else {
1948
+ if (existing) {
1949
+ await deleteIntegrationPoint(client, resolved, existing.id);
1950
+ notes.push(`replaced integration ${existing.id}`);
1951
+ }
1952
+ const ip = await createIntegrationPoint(client, resolved, {
1953
+ kind: "droplet",
1954
+ integratable_uuid: dropletUuid
1955
+ });
1956
+ notes.push(`attached droplet ${dropletUuid} (integration ${ip.integration_point.id})`);
1957
+ }
1958
+ } else if (opts.detach) if (existing) {
1959
+ await deleteIntegrationPoint(client, resolved, existing.id);
1960
+ notes.push(`detached droplet (removed integration ${existing.id})`);
1961
+ } else notes.push("already standalone (no droplet integration)");
1962
+ }
1963
+ if (opts.json) return printJson(renameResp ? {
1964
+ ...renameResp,
1965
+ slug: resolved,
1966
+ notes
1967
+ } : {
1968
+ slug: resolved,
1969
+ notes
1970
+ });
1971
+ for (const note of notes) console.log(chalk.dim(` ${note}`));
1972
+ }));
1973
+ //#endregion
1974
+ //#region src/commands/delete.ts
1975
+ /**
1976
+ * `fluid mist delete` — scheduled teardown 15 days out (spec §3 "15-day
1977
+ * grace period"). DO NOT poll for completion.
1978
+ */
1979
+ 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 () => {
1980
+ const resolved = resolveSlug(slug);
1981
+ const client = createMistClient();
1982
+ if (!opts.yes) {
1983
+ const { mist } = await showMist(client, resolved);
1984
+ const fifteenDaysOut = new Date(Date.now() + 15 * 864e5).toISOString().slice(0, 10);
1985
+ console.log();
1986
+ console.log(`About to schedule ${chalk.bold(mist.name ?? mist.vendor_name ?? mist.slug)} (${chalk.dim(mist.slug)}) for destruction on ${chalk.yellow(fifteenDaysOut)}.`);
1987
+ console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
1988
+ const { confirmed } = await prompts({
1989
+ type: "confirm",
1990
+ name: "confirmed",
1991
+ message: "Continue?",
1992
+ initial: false
1993
+ }, { onCancel: () => process.exit(130) });
1994
+ if (!confirmed) {
1995
+ console.log(chalk.dim("Aborted."));
1996
+ process.exit(0);
1997
+ }
1876
1998
  }
1999
+ const { mist } = await deleteMist(client, resolved);
2000
+ if (opts.json) return printJson({ mist });
2001
+ console.log(chalk.magenta(`Scheduled for destruction on ${mist.scheduled_destroy_at ? formatAbsolute(mist.scheduled_destroy_at) : "the 15-day mark"}.`));
2002
+ console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
2003
+ }));
2004
+ //#endregion
2005
+ //#region src/commands/restore.ts
2006
+ /** `fluid mist restore` — un-delete within the 15-day window. Spec §3. */
2007
+ 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 () => {
2008
+ const resolved = resolveSlug(slug);
2009
+ const { mist } = await restoreMist(createMistClient(), resolved);
2010
+ if (opts.json) return printJson({ mist });
2011
+ console.log(`Restored: ${mist.public_url ? chalk.cyan(mist.public_url) : mist.name ?? mist.vendor_name ?? mist.slug} (${colorState(mist.state)})`);
2012
+ }));
2013
+ //#endregion
2014
+ //#region src/api/git_credential.ts
2015
+ async function createGitCredential(client, slug) {
2016
+ const id = await resolveMistId(client, slug);
2017
+ return client.post(`/api/v202604/mists/${id}/git_credential`);
1877
2018
  }
1878
2019
  //#endregion
1879
2020
  //#region src/commands/clone.ts
@@ -1956,10 +2097,21 @@ async function fetchCurrentUser(client) {
1956
2097
  const POLL_MS$1 = 2e3;
1957
2098
  const LOCATE_TIMEOUT_MS = 2 * 6e4;
1958
2099
  const READY_TIMEOUT_MS = 5 * 6e4;
2100
+ const PUBLIC_URL_TIMEOUT_MS = 5e3;
1959
2101
  function sleep$1(ms) {
1960
2102
  return new Promise((r) => setTimeout(r, ms));
1961
2103
  }
1962
- const pushCommand = new Command("push").description("Commit working-tree changes and push to the Mist-managed remote (triggers a Vercel deploy)").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 () => {
2104
+ async function withTimeout(promise, timeoutMs) {
2105
+ let timer = null;
2106
+ try {
2107
+ return await Promise.race([promise, new Promise((_, reject) => {
2108
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timed out after ${timeoutMs / 1e3}s`)), timeoutMs);
2109
+ })]);
2110
+ } finally {
2111
+ if (timer) clearTimeout(timer);
2112
+ }
2113
+ }
2114
+ 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 () => {
1963
2115
  await ensureGitAvailable();
1964
2116
  const resolved = resolveSlug(slug);
1965
2117
  const client = createMistClient();
@@ -1996,7 +2148,7 @@ const pushCommand = new Command("push").description("Commit working-tree changes
1996
2148
  let namePrefix = "";
1997
2149
  let idSuffix = "";
1998
2150
  try {
1999
- const me = await Promise.race([fetchCurrentUser(client), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("timed out after 5s")), 5e3))]);
2151
+ const me = await withTimeout(fetchCurrentUser(client), 5e3);
2000
2152
  const name = me.full_name && me.full_name.trim() || me.email || (Number.isFinite(me.id) ? `user-${me.id}` : null);
2001
2153
  const id = me.public_id && me.public_id.trim();
2002
2154
  if (name) namePrefix = `${name}: `;
@@ -2049,9 +2201,9 @@ const pushCommand = new Command("push").description("Commit working-tree changes
2049
2201
  await sleep$1(POLL_MS$1);
2050
2202
  }
2051
2203
  if (!deploymentId) {
2052
- console.error(`No deployment matching ${pushedSha?.slice(0, 7) ?? "the push"} appeared within ${LOCATE_TIMEOUT_MS / 1e3}s.`);
2053
- console.error("Vercel may still be queueing — run `fluid mist deployments` to check.");
2054
- process.exit(3);
2204
+ console.warn(chalk.yellow(`Push succeeded, but hosting has not started commit ${pushedSha?.slice(0, 7) ?? "(unknown)"} after ${LOCATE_TIMEOUT_MS / 1e3}s.`));
2205
+ 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."));
2206
+ return;
2055
2207
  }
2056
2208
  const readyStart = Date.now();
2057
2209
  let lastState = null;
@@ -2062,7 +2214,14 @@ const pushCommand = new Command("push").description("Commit working-tree changes
2062
2214
  lastState = deployment.state;
2063
2215
  }
2064
2216
  if (deployment.state === "ready") {
2065
- console.log(`✔ Ready: ${chalk.cyan(deployment.url ?? "(no url)")}`);
2217
+ let publicUrl = null;
2218
+ try {
2219
+ publicUrl = (await withTimeout(showMist(client, resolved), PUBLIC_URL_TIMEOUT_MS)).mist.public_url;
2220
+ } catch (err) {
2221
+ const reason = err instanceof Error ? err.message : String(err);
2222
+ console.warn(chalk.yellow(` Deployment is ready, but the public URL lookup failed: ${reason}`));
2223
+ }
2224
+ console.log(readyDeploymentMessage(publicUrl, resolved));
2066
2225
  return;
2067
2226
  }
2068
2227
  if (deployment.state === "error" || deployment.state === "canceled") {