@fluid-app/fluid-cli-mist 0.1.10 → 0.1.12

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
  /**
@@ -131,28 +131,28 @@ async function resolveMistId(client, slugOrId) {
131
131
  }
132
132
  //#endregion
133
133
  //#region src/api/mists.ts
134
- const BASE$3 = "/api/v202604/mists";
134
+ const BASE$4 = "/api/v202604/mists";
135
135
  function listMists(client, params = {}) {
136
- return client.get(BASE$3, params);
136
+ return client.get(BASE$4, params);
137
137
  }
138
138
  async function showMist(client, slug) {
139
139
  const id = await resolveMistId(client, slug);
140
- return client.get(`${BASE$3}/${id}`);
140
+ return client.get(`${BASE$4}/${id}`);
141
141
  }
142
142
  function createMist(client, body) {
143
- return client.post(BASE$3, body);
143
+ return client.post(BASE$4, body);
144
144
  }
145
145
  async function updateMist(client, slug, body) {
146
146
  const id = await resolveMistId(client, slug);
147
- return client.patch(`${BASE$3}/${id}`, body);
147
+ return client.patch(`${BASE$4}/${id}`, body);
148
148
  }
149
149
  async function deleteMist(client, slug) {
150
150
  const id = await resolveMistId(client, slug);
151
- return client.delete(`${BASE$3}/${id}`);
151
+ return client.delete(`${BASE$4}/${id}`);
152
152
  }
153
153
  async function restoreMist(client, slug) {
154
154
  const id = await resolveMistId(client, slug);
155
- return client.post(`${BASE$3}/${id}/restore`);
155
+ return client.post(`${BASE$4}/${id}/restore`);
156
156
  }
157
157
  //#endregion
158
158
  //#region src/lib/hostable.ts
@@ -210,1519 +210,1752 @@ 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
- };
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
+ };
495
+ }
528
496
  }
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
- };
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
+ }
542
512
  }
543
- //#endregion
544
- //#region src/api/droplets.ts
545
- const BASE$2 = "/api/droplets";
546
513
  /**
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.
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.
551
517
  */
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
- };
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);
531
+ }
572
532
  }
573
533
  /**
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.
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).
576
537
  */
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));
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);
591
545
  }
592
- return input;
593
- }
594
- function createDroplet(client, body) {
595
- return client.post(BASE$2, body);
596
- }
597
- /** PATCH the droplet's embed URL (+ derived webhook URLs) once known. */
598
- function updateDroplet(client, uuid, input) {
599
- return client.patch(`${BASE$2}/${encodeURIComponent(uuid)}`, { droplet: input });
600
- }
601
- /** Convenience: set embed URL + derived webhook URLs in one PATCH. */
602
- function setDropletEmbedUrl(client, uuid, embedUrl) {
603
- return updateDroplet(client, uuid, {
604
- embed_url: embedUrl,
605
- ...dropletWebhookUrls(embedUrl)
606
- });
607
546
  }
608
- //#endregion
609
- //#region src/api/drop_zones.ts
610
- const BASE$1 = "/api/drop_zones";
611
547
  /**
612
- * Build a drop-zone create body. The slot id is `page__zone` (same split the
613
- * storefront preview uses, e.g. `checkout__below_total`) → `settings.page` +
614
- * `settings.zone`; height (px) → `settings.height`. Without a slot the zone
615
- * never renders, so the backend needs it to know WHERE to inject.
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.
616
564
  */
617
- function buildDropZoneCreateInput(opts) {
618
- const [page, zone] = (opts.zone ?? "").split("__");
619
- const settings = {};
620
- if (page) settings.page = page;
621
- if (zone) settings.zone = zone;
622
- if (opts.heightPx != null) settings.height = opts.heightPx;
623
- const input = { name: opts.name };
624
- if (opts.embedUrl) input.embed_url = opts.embedUrl;
625
- if (Object.keys(settings).length > 0) input.settings = settings;
626
- return input;
627
- }
628
- function createDropZone(client, body) {
629
- return client.post(BASE$1, body);
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 {}
630
580
  }
631
- //#endregion
632
- //#region src/api/mobile_widgets.ts
633
- const BASE = "/api/company/mobile_widgets";
634
581
  /**
635
- * Build a mobile-widget create body. `cover_image_url` and `height` are
636
- * REQUIRED by the API (422 without them); we fall back to the Fluid
637
- * placeholder + default height when the caller doesn't supply them.
582
+ * Append `.mist` to `.git/info/exclude` if it isn't already listed, so
583
+ * git treats the marker file (spec §3 — the local-only `.mist` file that
584
+ * `writeMistFile` drops after `fluid mist clone`) as ignored without ever
585
+ * touching the template's tracked `.gitignore`.
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`'s `git reset -- .mist`
597
+ * after staging is the second line of defense that keeps `.mist` out of
598
+ * the commit regardless.
638
599
  */
639
- function buildMobileWidgetCreateInput(opts) {
640
- return {
641
- name: opts.name,
642
- embed_url: opts.embedUrl,
643
- cover_image_url: opts.coverImageUrl || "https://ik.imagekit.io/fluid/s3/app/image-placeholder.svg",
644
- height: opts.heightPx ?? 600,
645
- scrollable: true
646
- };
647
- }
648
- function createMobileWidget(client, body) {
649
- return client.post(BASE, body);
650
- }
651
- //#endregion
652
- //#region src/api/env_vars.ts
653
- function path$1(id, key) {
654
- return key ? `/api/v202604/mists/${id}/env_vars/${encodeURIComponent(key)}` : `/api/v202604/mists/${id}/env_vars`;
655
- }
656
- async function listEnvVars(client, slug) {
657
- const id = await resolveMistId(client, slug);
658
- return client.get(path$1(id));
659
- }
660
- /** Create a new env var (the spec routes `set` to POST for new keys). */
661
- async function createEnvVar(client, slug, key, value, targets) {
662
- const id = await resolveMistId(client, slug);
663
- return client.post(path$1(id), { env_var: {
664
- key,
665
- value,
666
- targets
667
- } });
668
- }
669
- /** Update an existing env var (the spec routes `set` to PATCH for existing keys). */
670
- async function updateEnvVar(client, slug, key, value, targets) {
671
- const id = await resolveMistId(client, slug);
672
- return client.patch(path$1(id, key), { env_var: {
673
- value,
674
- targets
675
- } });
676
- }
677
- async function deleteEnvVar(client, slug, key) {
678
- const id = await resolveMistId(client, slug);
679
- return client.delete(path$1(id, key));
600
+ function excludeMistMarkerFromGit(cwd) {
601
+ try {
602
+ const gitDir = path.join(cwd, ".git");
603
+ if (!existsSync(gitDir)) return;
604
+ const infoDir = path.join(gitDir, "info");
605
+ if (!existsSync(infoDir)) mkdirSync(infoDir, { recursive: true });
606
+ const excludePath = path.join(infoDir, "exclude");
607
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
608
+ if (existing.split("\n").some((line) => line.trim() === ".mist")) return;
609
+ writeFileSync(excludePath, `${existing}${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}.mist\n`, "utf8");
610
+ } catch {}
680
611
  }
681
- //#endregion
682
- //#region src/types.ts
683
612
  /**
684
- * Managed env keys the backend writes onto a Mist once a droplet is
685
- * attached (backfill + redeploy). The CLI VERIFIES these appear with
686
- * `managed_by: "mist"` — it never sets them (the API 422s on write).
613
+ * `git add -A && git commit -m <message>` in `cwd`.
687
614
  *
688
- * Source: settled backend contract (#19685 auto-provisions the droplet
689
- * env on integration-point create).
690
- */
691
- const MANAGED_DROPLET_ENV_KEYS = [
692
- "FLUID_DROPLET_UUID",
693
- "FLUID_DROPLET_SECRET",
694
- "FLUID_WEBHOOK_AUTH_TOKEN"
695
- ];
696
- //#endregion
697
- //#region src/lib/surface-provision.ts
698
- /**
699
- * Surface provisioning engine — the one place the "create a Fluid surface and
700
- * wire it to a mist" flow lives, at parity with Mist Desktop's
701
- * mist-integration-points-api.service.ts. Exposed via `fluid mist create`
702
- * (create a bare mist first, then run this), `fluid mist attach` (any kind),
703
- * and `fluid mist attach-droplet` (droplet shortcut).
615
+ * Mist Desktop's "Publish" UX target is non-technical: the user expects
616
+ * "ship my changes" to ship the changes they made, not to know about
617
+ * staging or commit messages. This helper bundles working-tree edits
618
+ * into one commit per publish so push has something to send.
704
619
  *
705
- * Three surface kinds, each with its own create endpoint + attach identifier:
706
- * - droplet → POST /api/droplets → drp_ uuid → integratable_uuid
707
- * - drop-zone → POST /api/drop_zones → dz_ uuid → integratable_uuid
708
- * - mobile-embed→ POST /api/company/mobile_widgets→ numeric id → integratable_id
620
+ * The commit author is ALWAYS forced to the Mist GitHub App bot identity
621
+ * (MIST_GIT_AUTHOR_*), overriding whatever local/global git identity the
622
+ * machine has. This is what keeps Vercel from blocking the deploy: a commit
623
+ * authored by a user's personal GitHub account that isn't a we-commerce Vercel
624
+ * team member gets rejected. Passing `-c user.name=… -c user.email=…` beats
625
+ * even a `--local` config, so every publish is bot-authored regardless of how
626
+ * the repo landed on disk (fresh clone, pre-existing clone, or terminal CLI).
627
+ * The real person is still recorded in the commit *message* (see push.ts).
709
628
  *
710
- * Design tenets (repo philosophy — "server is source of truth"):
711
- * - No local progress file. State is re-derived from the API each run.
712
- * - Attach-existing is idempotent: if an integration point already links the
713
- * target surface (matched via the #19689 integratable_uuid echo, or the
714
- * numeric id for mobile), skip. A droplet is 1-per-mist, so creating a new
715
- * droplet also skips when one is already attached. Drop-zones/mobile
716
- * embeds are additive (a mist can have many).
717
- * - Only a droplet attach provisions managed env keys
718
- * (FLUID_DROPLET_UUID/_SECRET/_WEBHOOK_AUTH_TOKEN); this flow only
719
- * VERIFIES they landed — it never writes them (the API 422s on write).
629
+ * `.mist` marker handling: `writeMistFile` already writes `.mist` to
630
+ * `.git/info/exclude` at clone time, but this heals two other cases —
631
+ * a clone made before that existed, or a repo whose exclude write
632
+ * 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
+ * Note: a `.mist` file already committed in an existing repo's history
651
+ * is left alone — this only prevents *new* adds, it does not untrack
652
+ * anything or rewrite history.
720
653
  */
721
- const DEFAULT_TIMING = {
722
- publicUrlTimeoutMs: 9e4,
723
- publicUrlIntervalMs: 3e3,
724
- envTimeoutMs: 12e4,
725
- envIntervalMs: 5e3
726
- };
727
- const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
728
- const KIND_LABEL$1 = {
729
- droplet: "droplet",
730
- "drop-zone": "drop zone",
731
- "mobile-embed": "mobile embed"
654
+ async function gitAutoCommit(cwd, message) {
655
+ await ensureBotIdentity(cwd);
656
+ let repoRoot = cwd;
657
+ try {
658
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"], { cwd });
659
+ if (stdout.trim().length > 0) repoRoot = stdout.trim();
660
+ } catch {}
661
+ excludeMistMarkerFromGit(repoRoot);
662
+ try {
663
+ await execa("git", ["add", "-A"], { cwd });
664
+ } catch (err) {
665
+ const missing = gitMissingError(err);
666
+ if (missing) throw missing;
667
+ throw new GitError(`git add failed`, err.exitCode ?? 1);
668
+ }
669
+ try {
670
+ await execa("git", [
671
+ "reset",
672
+ "-q",
673
+ "--",
674
+ ":(top).mist",
675
+ ".mist"
676
+ ], { cwd });
677
+ } catch {}
678
+ const identityArgs = [
679
+ "-c",
680
+ `user.name=${MIST_GIT_AUTHOR_NAME}`,
681
+ "-c",
682
+ `user.email=${MIST_GIT_AUTHOR_EMAIL}`
683
+ ];
684
+ const identityEnv = {
685
+ GIT_AUTHOR_NAME: MIST_GIT_AUTHOR_NAME,
686
+ GIT_AUTHOR_EMAIL: MIST_GIT_AUTHOR_EMAIL,
687
+ GIT_COMMITTER_NAME: MIST_GIT_AUTHOR_NAME,
688
+ GIT_COMMITTER_EMAIL: MIST_GIT_AUTHOR_EMAIL
689
+ };
690
+ try {
691
+ await execa("git", [
692
+ ...identityArgs,
693
+ "commit",
694
+ "-m",
695
+ message
696
+ ], {
697
+ cwd,
698
+ env: {
699
+ ...process.env,
700
+ ...identityEnv
701
+ },
702
+ stdio: [
703
+ "ignore",
704
+ "inherit",
705
+ "inherit"
706
+ ]
707
+ });
708
+ } catch (err) {
709
+ const missing = gitMissingError(err);
710
+ if (missing) throw missing;
711
+ const e = err;
712
+ if (typeof e.stderr === "string" && /nothing to commit/i.test(e.stderr)) return;
713
+ throw new GitError(`git commit failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
714
+ }
715
+ }
716
+ //#endregion
717
+ //#region src/lib/slug-resolver.ts
718
+ /**
719
+ * Slug resolution — spec §3 conventions:
720
+ * 1. If a slug arg is passed, use it.
721
+ * 2. Otherwise look for a `.mist` file in CWD (written by `fluid mist
722
+ * clone`) and use the slug from it.
723
+ * 3. Otherwise error: tell the user to pass a slug or cd into a cloned dir.
724
+ */
725
+ const MIST_FILE = ".mist";
726
+ var SlugResolutionError = class extends Error {
727
+ constructor(message) {
728
+ super(message);
729
+ this.name = "SlugResolutionError";
730
+ }
732
731
  };
732
+ function readMistFile(dir) {
733
+ const filePath = join(dir, MIST_FILE);
734
+ if (!existsSync(filePath)) return null;
735
+ try {
736
+ const raw = readFileSync(filePath, "utf8");
737
+ const parsed = JSON.parse(raw);
738
+ if (!parsed || typeof parsed !== "object") return null;
739
+ const obj = parsed;
740
+ if (typeof obj["slug"] !== "string") return null;
741
+ return {
742
+ slug: obj["slug"],
743
+ vendor_name: typeof obj["vendor_name"] === "string" ? obj["vendor_name"] : "",
744
+ public_url: typeof obj["public_url"] === "string" ? obj["public_url"] : null
745
+ };
746
+ } catch {
747
+ return null;
748
+ }
749
+ }
750
+ /** Returns the slug from arg or `.mist` file; throws if neither is present. */
751
+ function resolveSlug(arg, cwd = process.cwd()) {
752
+ if (arg && arg.trim().length > 0) return arg.trim();
753
+ const file = readMistFile(cwd);
754
+ if (file) return file.slug;
755
+ 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)");
756
+ }
733
757
  /**
734
- * Create (or reuse) a surface of `kind` and wire it to the mist. Idempotent
735
- * for droplet + attach-existing; additive for new drop-zones/mobile embeds.
758
+ * Write a `.mist` file after `fluid mist clone`.
759
+ *
760
+ * Also excludes `.mist` via `.git/info/exclude` so it can never be swept
761
+ * into a publish commit by `gitAutoCommit`'s `git add -A` — a commit
762
+ * containing `.mist` that later gets `git revert`-ed deletes the marker
763
+ * and locks every subsequent `fluid mist <cmd>` out of slug resolution
764
+ * in that clone. Best-effort: if the exclude write fails (e.g. `.git`
765
+ * isn't set up yet), `writeFileSync` below still runs — this is a
766
+ * defense-in-depth step, not a precondition for the marker itself.
736
767
  */
737
- async function provisionSurface(client, options) {
738
- const { reporter, slugOrId, kind } = options;
739
- const timing = {
740
- ...DEFAULT_TIMING,
741
- ...options.timing
768
+ function writeMistFile(dir, mist) {
769
+ excludeMistMarkerFromGit(dir);
770
+ const payload = {
771
+ slug: mist.slug,
772
+ vendor_name: mist.vendor_name,
773
+ public_url: mist.public_url
742
774
  };
743
- const sleep = options.sleep ?? defaultSleep;
744
- const label = KIND_LABEL$1[kind];
745
- reporter.start("Resolving mist…");
746
- const mist = (await showMist(client, slugOrId)).mist;
747
- reporter.succeed(`Mist ${mist.slug} (${mist.state})`);
748
- reporter.start("Checking existing integrations…");
749
- const points = (await listIntegrationPoints(client, slugOrId)).integration_points;
750
- let surfaceUuid = null;
751
- let surfaceNumericId = null;
752
- let surfaceName = null;
753
- let surfaceCreated = false;
754
- let embedUrlSet = false;
755
- let integrationPoint;
756
- const attachingExisting = options.existingUuid != null || options.existingId != null;
757
- const alreadyLinked = attachingExisting ? findIntegrationPointBySurface(points, {
758
- kind,
759
- uuid: options.existingUuid,
760
- id: options.existingId
761
- }) : kind === "droplet" ? findDropletIntegrationPoint(points) : void 0;
762
- if (alreadyLinked) {
763
- reporter.skip(`${label} already attached (integration #${alreadyLinked.id}) — skipping`);
764
- surfaceUuid = alreadyLinked.integratable_uuid ?? options.existingUuid ?? null;
765
- surfaceNumericId = alreadyLinked.integratable_id ?? options.existingId ?? null;
766
- integrationPoint = {
767
- id: alreadyLinked.id,
768
- integratableUuid: alreadyLinked.integratable_uuid ?? null,
769
- created: false
770
- };
771
- } else {
772
- reporter.succeed("No matching integration yet");
773
- let attachUuid = options.existingUuid;
774
- let attachId = options.existingId;
775
- if (!attachingExisting) {
776
- const embedUrl = await resolvePublicUrl(client, {
777
- slugOrId,
778
- initial: mist.public_url,
779
- reporter,
780
- timing,
781
- sleep
782
- });
783
- const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name || mist.slug;
784
- reporter.start(`Creating ${label} "${name}"…`);
785
- if (kind === "droplet") {
786
- const created = await createDroplet(client, { droplet: buildDropletCreateInput({
787
- name,
788
- embedUrl,
789
- requestedScopes: options.requestedScopes
790
- }) });
791
- surfaceUuid = created.droplet.uuid;
792
- surfaceNumericId = created.droplet.id ?? null;
793
- surfaceName = created.droplet.name;
794
- attachUuid = created.droplet.uuid;
795
- embedUrlSet = Boolean(embedUrl);
796
- reporter.succeed(`Created droplet ${surfaceUuid}${embedUrl ? "" : " (embed URL pending)"}`);
797
- if (!embedUrl && surfaceUuid) {
798
- const laterUrl = await resolvePublicUrl(client, {
799
- slugOrId,
800
- initial: null,
801
- reporter,
802
- timing,
803
- sleep
804
- });
805
- if (laterUrl) {
806
- reporter.start("Backfilling droplet embed URL…");
807
- await setDropletEmbedUrl(client, surfaceUuid, laterUrl);
808
- embedUrlSet = true;
809
- reporter.succeed("Embed URL set");
810
- } else reporter.warn("Mist public_url not ready yet — re-run once the first deploy finishes to set the embed URL");
811
- }
812
- } else if (kind === "drop-zone") {
813
- if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the drop zone.");
814
- const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
815
- name,
816
- embedUrl,
817
- zone: options.zone,
818
- heightPx: options.heightPx
819
- }) });
820
- surfaceUuid = created.drop_zone.uuid;
821
- surfaceName = created.drop_zone.name;
822
- attachUuid = created.drop_zone.uuid;
823
- embedUrlSet = true;
824
- reporter.succeed(`Created drop zone ${surfaceUuid}`);
825
- } else {
826
- if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the mobile embed.");
827
- const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
828
- name,
829
- embedUrl,
830
- coverImageUrl: options.coverImageUrl,
831
- heightPx: options.heightPx
832
- }) });
833
- surfaceNumericId = created.mobile_widget.id;
834
- surfaceName = created.mobile_widget.name;
835
- attachId = created.mobile_widget.id;
836
- embedUrlSet = true;
837
- reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
838
- }
839
- surfaceCreated = true;
840
- } else {
841
- surfaceUuid = options.existingUuid ?? null;
842
- surfaceNumericId = options.existingId ?? null;
843
- embedUrlSet = true;
844
- }
845
- const attachInput = kind === "mobile-embed" ? {
846
- kind,
847
- integratable_id: attachId
848
- } : {
849
- kind,
850
- integratable_uuid: attachUuid
851
- };
852
- reporter.start(`Attaching ${label}…`);
853
- let ipResp;
854
- try {
855
- ipResp = await createIntegrationPoint(client, slugOrId, attachInput);
856
- } catch (err) {
857
- reporter.fail("Attach failed");
858
- throw normalizeAttachError(err, attachUuid ?? String(attachId), kind);
859
- }
860
- integrationPoint = {
861
- id: ipResp.integration_point.id,
862
- integratableUuid: ipResp.integration_point.integratable_uuid ?? null,
863
- created: true
864
- };
865
- if (!surfaceUuid) surfaceUuid = ipResp.integration_point.integratable_uuid ?? attachUuid ?? null;
866
- if (surfaceNumericId == null) surfaceNumericId = ipResp.integration_point.integratable_id ?? null;
867
- reporter.succeed(kind === "droplet" ? "Droplet attached — provisioning env + redeploy…" : `${label} attached`);
775
+ writeFileSync(join(dir, MIST_FILE), JSON.stringify(payload, null, 2) + "\n", "utf8");
776
+ }
777
+ //#endregion
778
+ //#region src/lib/error-handler.ts
779
+ /**
780
+ * Centralized error handling — maps thrown errors to spec'd exit codes
781
+ * and friendly messages. Imported by every command's `.action()` so each
782
+ * file doesn't repeat the same try/catch.
783
+ *
784
+ * Exit codes (spec §3 "Conventions"):
785
+ * 0 success | 1 user error | 2 API 4xx | 3 API 5xx / vendor | 4 network
786
+ */
787
+ /** Run an async action and translate failures into pretty output + exit. */
788
+ async function runAction(fn) {
789
+ try {
790
+ await fn();
791
+ } catch (err) {
792
+ handle(err);
868
793
  }
869
- const managedEnv = kind === "droplet" ? await verifyManagedEnv(client, {
870
- slugOrId,
871
- reporter,
872
- timing,
873
- sleep,
874
- enabled: options.verifyEnv !== false
875
- }) : null;
794
+ }
795
+ function handle(err) {
796
+ if (err instanceof SlugResolutionError) {
797
+ console.error(chalk.red(err.message));
798
+ process.exit(1);
799
+ }
800
+ if (err instanceof HostableParseError) {
801
+ console.error(chalk.red(err.message));
802
+ process.exit(1);
803
+ }
804
+ if (err instanceof MistApiError) {
805
+ if (err.status === 403) {
806
+ const action = err.details?.["permission"];
807
+ console.error(chalk.red(action ? `Your role doesn't include \`mist.${String(action)}\` — ask your Fluid admin to grant it.` : err.message));
808
+ } else if (err.status === 409) {
809
+ const date = err.details?.["scheduled_destroy_at"];
810
+ 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));
811
+ } else if (err.status === 422) {
812
+ const details = err.details ?? {};
813
+ const idErrors = details["integratable_id"] ?? details["hostable_id"];
814
+ const managedKey = typeof err.message === "string" && /managed_by|managed by mist|FLUID_DROPLET|FLUID_WEBHOOK/i.test(err.message);
815
+ 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."));
816
+ 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."));
817
+ else if (typeof err.message === "string" && /Droplet not found|integratable/i.test(err.message)) {
818
+ const id = details["integratable_id"] ?? details["hostable_id"];
819
+ console.error(chalk.yellow(`Couldn't find droplet${id != null ? ` #${String(id)}` : ""} in this company.`));
820
+ } else console.error(chalk.red(err.message));
821
+ } 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`."));
822
+ else console.error(chalk.red(err.message));
823
+ process.exit(err.exitCode);
824
+ }
825
+ if (err instanceof Error) console.error(chalk.red(err.message));
826
+ else console.error(chalk.red(String(err)));
827
+ process.exit(1);
828
+ }
829
+ //#endregion
830
+ //#region src/lib/output.ts
831
+ /**
832
+ * Output helpers — human-readable by default, `--json` emits raw API
833
+ * payload. NO_COLOR respected via chalk's built-in detection.
834
+ */
835
+ function printJson(payload) {
836
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
837
+ }
838
+ const STATE_COLOR$1 = {
839
+ pending: chalk.gray,
840
+ provisioning: chalk.yellow,
841
+ live: chalk.green,
842
+ failed: chalk.red,
843
+ pending_destroy: chalk.magenta,
844
+ archived: chalk.dim
845
+ };
846
+ function colorState(state) {
847
+ return STATE_COLOR$1[state](state);
848
+ }
849
+ /**
850
+ * Bold display label for a mist, falling back to a dim "(unnamed)"
851
+ * placeholder when both `name` and `vendorName` are missing. Never nests
852
+ * `chalk.bold(chalk.dim(...))` — most terminals render that as plain bold,
853
+ * losing the "this is a placeholder" muted intent.
854
+ */
855
+ function mistLabel(name, vendorName) {
856
+ const raw = name ?? vendorName;
857
+ return raw ? chalk.bold(raw) : chalk.dim("(unnamed)");
858
+ }
859
+ /**
860
+ * Pad columns to width — cheap manual table; avoids cli-table3 dep for v1.
861
+ * Cells are defensively normalized: API records can legitimately have every
862
+ * optional label field (`name`, `vendor_name`, `droplet.name`, ...) come back
863
+ * null/absent at once (e.g. a `pending_destroy` mist), so callers sometimes
864
+ * hand us `undefined`/`null` after chaining `??` — that must render as an
865
+ * empty cell, not crash the whole table.
866
+ */
867
+ function printTable(rows) {
868
+ if (rows.length === 0) return;
869
+ const widths = [];
870
+ for (const row of rows) row.forEach((cell, i) => {
871
+ const visible = stripAnsi(cell).length;
872
+ widths[i] = Math.max(widths[i] ?? 0, visible);
873
+ });
874
+ for (const row of rows) {
875
+ const padded = row.map((cell, i) => {
876
+ const w = widths[i] ?? 0;
877
+ const text = cell ?? "";
878
+ return text + " ".repeat(Math.max(0, w - stripAnsi(text).length));
879
+ });
880
+ process.stdout.write(padded.join(" ") + "\n");
881
+ }
882
+ }
883
+ function stripAnsi(s) {
884
+ return (s ?? "").replace(/\x1b\[[0-9;]*m/g, "");
885
+ }
886
+ function relativeDate(iso) {
887
+ if (!iso) return "—";
888
+ const diff = Date.now() - new Date(iso).getTime();
889
+ const minutes = Math.floor(diff / 6e4);
890
+ if (minutes < 1) return "just now";
891
+ if (minutes < 60) return `${minutes}m ago`;
892
+ const hours = Math.floor(minutes / 60);
893
+ if (hours < 24) return `${hours}h ago`;
894
+ const days = Math.floor(hours / 24);
895
+ if (days < 30) return `${days}d ago`;
896
+ return new Date(iso).toISOString().slice(0, 10);
897
+ }
898
+ /** Human-friendly absolute date for one-off prints (scheduled_destroy_at). */
899
+ function formatAbsolute(iso) {
900
+ return new Date(iso).toISOString().slice(0, 10);
901
+ }
902
+ //#endregion
903
+ //#region src/commands/list.ts
904
+ /** `fluid mist list` — GET /mists with optional filters. Spec §3. */
905
+ 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 () => {
906
+ const client = createMistClient();
907
+ const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
908
+ const response = await listMists(client, {
909
+ ...opts.state ? { state: opts.state } : {},
910
+ ...opts.kind ? { kind: opts.kind } : {},
911
+ ...limit && Number.isFinite(limit) ? { limit } : {}
912
+ });
913
+ if (opts.json) return printJson(response);
914
+ const mists = response.mists ?? [];
915
+ if (mists.length === 0) {
916
+ console.log(chalk.dim("No mists found."));
917
+ return;
918
+ }
919
+ printTable([[
920
+ chalk.bold("SLUG"),
921
+ chalk.bold("NAME"),
922
+ chalk.bold("STATE"),
923
+ chalk.bold("ATTACHED"),
924
+ chalk.bold("UPDATED")
925
+ ], ...mists.map((m) => [
926
+ m.slug,
927
+ m.name ?? m.droplet?.name ?? m.vendor_name ?? chalk.dim("(unnamed)"),
928
+ colorState(m.state),
929
+ m.hostable_type ?? chalk.dim("standalone"),
930
+ relativeDate(m.updated_at)
931
+ ])]);
932
+ }));
933
+ //#endregion
934
+ //#region src/api/integration_points.ts
935
+ function basePath(mistId) {
936
+ return `/api/v202604/mists/${mistId}/integration_points`;
937
+ }
938
+ async function listIntegrationPoints(client, slug) {
939
+ const id = await resolveMistId(client, slug);
940
+ return client.get(basePath(id));
941
+ }
942
+ async function createIntegrationPoint(client, slug, input) {
943
+ const id = await resolveMistId(client, slug);
944
+ return client.post(basePath(id), { integration_point: input });
945
+ }
946
+ async function deleteIntegrationPoint(client, slug, integrationPointId) {
947
+ const id = await resolveMistId(client, slug);
948
+ return client.delete(`${basePath(id)}/${integrationPointId}`);
949
+ }
950
+ /**
951
+ * First droplet-kind integration point in a list, or undefined. A droplet is
952
+ * 1-per-mist (it provisions the managed env), so this is the idempotency key
953
+ * for the droplet create+attach flow.
954
+ */
955
+ function findDropletIntegrationPoint(integrationPoints) {
956
+ return (integrationPoints ?? []).find((ip) => ip.kind === "droplet");
957
+ }
958
+ /**
959
+ * Find an integration point that already links a specific surface — matched
960
+ * by uuid (droplet/drop-zone) or numeric id (mobile-embed), optionally scoped
961
+ * to a kind. Used to make attach-existing idempotent (avoid the server's 422
962
+ * "already taken"). Uses the `integratable_uuid` echo (#19689) when present.
963
+ */
964
+ function findIntegrationPointBySurface(integrationPoints, target) {
965
+ return (integrationPoints ?? []).find((ip) => {
966
+ if (target.kind && ip.kind !== target.kind) return false;
967
+ if (target.uuid && ip.integratable_uuid === target.uuid) return true;
968
+ if (target.id != null && ip.integratable_id === target.id) return true;
969
+ return false;
970
+ });
971
+ }
972
+ //#endregion
973
+ //#region src/commands/show.ts
974
+ /** `fluid mist show <slug>` — single mist detail, including its attached
975
+ * surfaces (integration points). Surfaces scheduled_destroy_at when state is
976
+ * pending_destroy. */
977
+ /** Display ref for an attached surface: the uuid echo when present, else id. */
978
+ function surfaceRef(ip) {
979
+ if (ip.integratable_uuid) return ip.integratable_uuid;
980
+ if (ip.integratable_id != null) return `#${ip.integratable_id}`;
981
+ return "(unknown)";
982
+ }
983
+ 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 () => {
984
+ const resolved = resolveSlug(slug);
985
+ const client = createMistClient();
986
+ const { mist } = await showMist(client, resolved);
987
+ const integrationPoints = (await listIntegrationPoints(client, resolved)).integration_points;
988
+ if (opts.json) return printJson({
989
+ mist,
990
+ integration_points: integrationPoints
991
+ });
992
+ const lines = [];
993
+ const label = mistLabel(mist.name, mist.vendor_name);
994
+ lines.push(`${label} ${chalk.dim(mist.slug)}`);
995
+ if (mist.vendor_name && mist.name && mist.name !== mist.vendor_name) lines.push(` vendor ${chalk.dim(mist.vendor_name)}`);
996
+ lines.push(` state ${colorState(mist.state)}`);
997
+ if (mist.kind) lines.push(` kind ${mist.kind}`);
998
+ if (mist.public_url) lines.push(` url ${chalk.cyan(mist.public_url)}`);
999
+ if (integrationPoints.length > 0) {
1000
+ lines.push(` surfaces ${integrationPoints.length}`);
1001
+ for (const ip of integrationPoints) lines.push(` ${chalk.dim("·")} ${ip.kind} ${surfaceRef(ip)} ${chalk.dim(`integration ${ip.id}`)}`);
1002
+ }
1003
+ if (mist.provisioned_at) lines.push(` live ${relativeDate(mist.provisioned_at)}`);
1004
+ lines.push(` updated ${relativeDate(mist.updated_at)}`);
1005
+ if (mist.scheduled_destroy_at) lines.push(chalk.magenta(` destroy scheduled ${formatAbsolute(mist.scheduled_destroy_at)} (run \`fluid mist restore ${mist.slug}\` to undo)`));
1006
+ console.log(lines.join("\n"));
1007
+ }));
1008
+ //#endregion
1009
+ //#region src/lib/ora-reporter.ts
1010
+ /**
1011
+ * Spinner-backed `StepReporter` for interactive human runs. Split from
1012
+ * `step-reporter.ts` so tests can use the recording reporter without pulling
1013
+ * in `ora`'s ESM-only dependency chain.
1014
+ */
1015
+ function createOraReporter() {
1016
+ const spinner = ora();
876
1017
  return {
877
- mist: {
878
- id: mist.id,
879
- slug: mist.slug,
880
- name: mist.name,
881
- public_url: mist.public_url,
882
- state: mist.state
1018
+ start(text) {
1019
+ spinner.start(text);
883
1020
  },
884
- kind,
885
- surface: {
886
- uuid: surfaceUuid,
887
- id: surfaceNumericId,
888
- name: surfaceName
1021
+ update(text) {
1022
+ spinner.text = text;
889
1023
  },
890
- integrationPoint: integrationPoint ?? {
891
- id: "",
892
- integratableUuid: null,
893
- created: false
1024
+ succeed(text) {
1025
+ spinner.succeed(text);
894
1026
  },
895
- surfaceCreated,
896
- embedUrlSet,
897
- managedEnv
898
- };
899
- }
900
- /**
901
- * If the integration-points endpoint rejects the attach identifier (e.g. an
902
- * older API that predates #19688's attach-by-uuid support), surface an
903
- * actionable message noting the surface WAS created. Other errors pass through.
904
- */
905
- function normalizeAttachError(err, surfaceRef, kind) {
906
- if (err instanceof MistApiError && (err.status === 400 || err.status === 422)) {
907
- const blob = `${err.message} ${JSON.stringify(err.details ?? {})}`;
908
- 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);
909
- }
910
- return err instanceof Error ? err : new Error(String(err));
911
- }
912
- /**
913
- * Return the mist's public_url, polling briefly if it isn't ready yet
914
- * (async-first-deploy). Returns null if it never appears within the window.
915
- */
916
- async function resolvePublicUrl(client, args) {
917
- if (args.initial) return args.initial;
918
- args.reporter.start("Waiting for the mist's public URL…");
919
- const deadline = Date.now() + args.timing.publicUrlTimeoutMs;
920
- for (;;) {
921
- const url = (await showMist(client, args.slugOrId)).mist.public_url;
922
- if (url) {
923
- args.reporter.succeed(`Public URL ready: ${url}`);
924
- return url;
1027
+ skip(text) {
1028
+ spinner.info(text);
1029
+ },
1030
+ info(text) {
1031
+ spinner.stopAndPersist({
1032
+ symbol: "•",
1033
+ text
1034
+ });
1035
+ },
1036
+ warn(text) {
1037
+ spinner.warn(text);
1038
+ },
1039
+ fail(text) {
1040
+ spinner.fail(text);
925
1041
  }
926
- if (Date.now() >= deadline) break;
927
- await args.sleep(args.timing.publicUrlIntervalMs);
928
- }
929
- return null;
930
- }
931
- /**
932
- * Poll `GET …/env_vars` until the Mist-managed droplet keys appear with
933
- * `managed_by: "mist"`. Never fails the flow — a redeploy in progress is a
934
- * normal, transient state we simply report.
935
- */
936
- async function verifyManagedEnv(client, args) {
937
- const required = [...MANAGED_DROPLET_ENV_KEYS];
938
- if (!args.enabled) return {
939
- ready: false,
940
- present: [],
941
- missing: required
942
1042
  };
943
- args.reporter.start("Verifying managed environment…");
944
- const deadline = Date.now() + args.timing.envTimeoutMs;
945
- let present = [];
946
- for (;;) {
947
- const envVars = (await listEnvVars(client, args.slugOrId)).env_vars ?? [];
948
- const managed = new Set(envVars.filter((e) => e.managed_by === "mist").map((e) => e.key));
949
- present = required.filter((k) => managed.has(k));
950
- if (present.length === required.length) {
951
- args.reporter.succeed("Managed env keys present — droplet is wired up");
952
- return {
953
- ready: true,
954
- present,
955
- missing: []
956
- };
957
- }
958
- if (Date.now() >= deadline) break;
959
- args.reporter.update(`Verifying managed environment… (${present.length}/${required.length} keys, redeploy in progress)`);
960
- await args.sleep(args.timing.envIntervalMs);
961
- }
962
- const missing = required.filter((k) => !present.includes(k));
963
- 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(", ")}`);
1043
+ }
1044
+ //#endregion
1045
+ //#region src/lib/step-reporter.ts
1046
+ /** No-op reporter for `--json` mode (final payload printed by the command). */
1047
+ function createSilentReporter() {
964
1048
  return {
965
- ready: false,
966
- present,
967
- missing
1049
+ start() {},
1050
+ update() {},
1051
+ succeed() {},
1052
+ skip() {},
1053
+ info() {},
1054
+ warn() {},
1055
+ fail() {}
968
1056
  };
969
1057
  }
970
1058
  //#endregion
971
- //#region src/lib/surface-flags.ts
972
- const KIND_ALIASES = {
973
- droplet: "droplet",
974
- droplets: "droplet",
975
- "drop-zone": "drop-zone",
976
- drop_zone: "drop-zone",
977
- dropzone: "drop-zone",
978
- zone: "drop-zone",
979
- "mobile-embed": "mobile-embed",
980
- mobile_embed: "mobile-embed",
981
- mobile: "mobile-embed",
982
- "mobile-widget": "mobile-embed",
983
- mobilewidget: "mobile-embed"
984
- };
985
- const SURFACE_KIND_LIST = "droplet, drop-zone, mobile-embed";
986
- function parseSurfaceKind(raw) {
987
- const hit = KIND_ALIASES[raw.trim().toLowerCase()];
988
- if (!hit) throw new Error(`Unknown surface kind "${raw}". Use one of: ${SURFACE_KIND_LIST}.`);
989
- return hit;
990
- }
991
- function parseHeight(raw) {
992
- if (raw == null) return void 0;
993
- const n = Number(raw);
994
- if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid --height "${raw}". Must be a positive integer (px).`);
995
- return n;
996
- }
1059
+ //#region src/api/droplets.ts
1060
+ const BASE$3 = "/api/droplets";
997
1061
  /**
998
- * Map CLI flags to the kind-specific `provisionSurface` options, enforcing the
999
- * per-kind requirements the API needs:
1000
- * - drop-zone requires `--zone <page__zone>` (else it never renders).
1001
- * - mobile-embed requires `--cover-image <url>` (the API 422s without one; the
1002
- * CLI has no DAM picker, so the user must supply a url).
1062
+ * Full scope set from `Droplet::Installation::VALID_SCOPES`. We request
1063
+ * them all by default so a Mist-created droplet's installations carry write
1064
+ * access out of the box — matching what Mist Desktop sends. Callers can
1065
+ * narrow this via the flow's `requestedScopes` option.
1003
1066
  */
1004
- function surfaceProvisionOptionsFromFlags(kind, flags) {
1005
- const heightPx = parseHeight(flags.height);
1006
- if (kind === "drop-zone") {
1007
- if (!flags.zone?.trim()) throw new Error("A drop-zone needs a slot — pass `--zone <page__zone>` (e.g. --zone checkout__below_total).");
1008
- return {
1009
- zone: flags.zone.trim(),
1010
- heightPx
1011
- };
1012
- }
1013
- if (kind === "mobile-embed") {
1014
- 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).");
1015
- return {
1016
- coverImageUrl: flags.coverImage.trim(),
1017
- heightPx
1018
- };
1019
- }
1020
- return {};
1021
- }
1022
- //#endregion
1023
- //#region src/lib/surface-summary.ts
1024
- /** Shared human-readable summary for a surface provision result. */
1025
- const KIND_LABEL = {
1026
- droplet: "droplet",
1027
- "drop-zone": "drop zone",
1028
- "mobile-embed": "mobile embed"
1029
- };
1030
- function printSurfaceSummary(result) {
1031
- const ref = result.surface.uuid ?? (result.surface.id != null ? `#${result.surface.id}` : "(unknown)");
1032
- const state = result.surfaceCreated ? "created" : "existing";
1033
- console.log(chalk.dim(` ${KIND_LABEL[result.kind]}: ${ref} (${state}) · integration ${result.integrationPoint.id}`));
1034
- if (result.kind === "droplet" && result.managedEnv) if (result.managedEnv.ready) console.log(chalk.dim(" env: managed keys provisioned ✓"));
1035
- else console.log(chalk.yellow(" env: redeploy in progress — run `fluid mist env list` shortly to confirm managed keys"));
1067
+ const DEFAULT_DROPLET_SCOPES = [
1068
+ "main",
1069
+ "prospects",
1070
+ "members",
1071
+ "web",
1072
+ "settings",
1073
+ "products",
1074
+ "orders",
1075
+ "users",
1076
+ "website",
1077
+ "header_menus",
1078
+ "payments"
1079
+ ];
1080
+ /** Lifecycle webhook paths a Mist Next app serves (mirrors Mist Desktop). */
1081
+ function dropletWebhookUrls(embedUrl) {
1082
+ const base = embedUrl.replace(/\/+$/, "");
1083
+ return {
1084
+ install_webhook_url: `${base}/api/webhooks/installed`,
1085
+ uninstall_webhook_url: `${base}/api/webhooks/uninstalled`
1086
+ };
1036
1087
  }
1037
- //#endregion
1038
- //#region src/commands/create.ts
1039
1088
  /**
1040
- * `fluid mist create` — provision a new mist, optionally creating and attaching
1041
- * a surface in one step. All attach paths go through integration points
1042
- * (hostable is retired — #19684/#19687).
1043
- *
1044
- * --droplet [name] Create a NEW droplet and attach it. Bare →
1045
- * droplet name defaults to the mist name. The
1046
- * "one command that sets everything up" path.
1047
- * --kind <kind> [flags] Create + attach a surface of any kind:
1048
- * droplet | drop-zone | mobile-embed. Kind flags:
1049
- * drop-zone --zone <page__zone> --height <px>
1050
- * mobile-embed --cover-image <url> --height <px>
1051
- * --hostable droplet:<uuid> Attach an EXISTING droplet by its drp_… uuid.
1052
- *
1053
- * With none of these, creates a plain standalone mist.
1089
+ * Build a droplet create body from a name + (optional) embed URL. When the
1090
+ * embed URL is known we also derive the lifecycle webhook URLs from it.
1054
1091
  */
1055
- 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 () => {
1056
- const wantDroplet = opts.droplet !== void 0;
1057
- const wantKind = !!opts.kind;
1058
- const wantHostable = !!opts.hostable;
1059
- if ([
1060
- wantDroplet,
1061
- wantKind,
1062
- wantHostable
1063
- ].filter(Boolean).length > 1) throw new Error("Pass only one of `--droplet`, `--kind`, or `--hostable` — they're mutually exclusive.");
1064
- let kind;
1065
- let existingDropletUuid;
1066
- let nameOverride;
1067
- if (wantDroplet) {
1068
- kind = "droplet";
1069
- nameOverride = typeof opts.droplet === "string" ? opts.droplet.trim() : void 0;
1070
- } else if (wantKind) kind = parseSurfaceKind(opts.kind);
1071
- else if (wantHostable) {
1072
- kind = "droplet";
1073
- existingDropletUuid = parseDropletRef(opts.hostable);
1092
+ function buildDropletCreateInput(opts) {
1093
+ const scopes = [...opts.requestedScopes ?? DEFAULT_DROPLET_SCOPES];
1094
+ const summary = "Created by Fluid Mist";
1095
+ const input = {
1096
+ name: opts.name,
1097
+ requested_scopes: scopes,
1098
+ settings: {
1099
+ marketplace_page: { summary },
1100
+ details_page: { summary }
1101
+ }
1102
+ };
1103
+ if (opts.embedUrl) {
1104
+ input.embed_url = opts.embedUrl;
1105
+ Object.assign(input, dropletWebhookUrls(opts.embedUrl));
1074
1106
  }
1075
- const mistBody = { kind: opts.mistKind ?? "next_app" };
1076
- if (opts.name?.trim()) mistBody.name = opts.name.trim();
1077
- const client = createMistClient();
1078
- const reporter = opts.json ? createSilentReporter() : createOraReporter();
1079
- reporter.start("Setting up your Mist Cloud environment…");
1080
- let created;
1081
- try {
1082
- created = await createMist(client, { mist: mistBody });
1083
- } catch (err) {
1084
- reporter.fail("Provisioning failed");
1085
- throw err;
1107
+ return input;
1108
+ }
1109
+ function createDroplet(client, body) {
1110
+ return client.post(BASE$3, body);
1111
+ }
1112
+ /** Page size + runaway guard for walking the page-paginated droplets list. */
1113
+ const PER_PAGE$1 = 100;
1114
+ const MAX_PAGES$1 = 25;
1115
+ /**
1116
+ * Recover a droplet's drp_ uuid from its numeric id by paging the company's
1117
+ * droplets list (`GET /api/droplets?page=&per_page=` — the list rows carry
1118
+ * `id`, unlike the create/show responses). Needed for legacy integration
1119
+ * points that only echo `integratable_id`: the show endpoint resolves by
1120
+ * uuid, so a numeric id can't be fetched directly. Returns null when no
1121
+ * droplet matches within the page cap.
1122
+ */
1123
+ async function findDropletUuidByNumericId(client, id) {
1124
+ for (let page = 1; page <= MAX_PAGES$1; page++) {
1125
+ const resp = await client.get(`${BASE$3}?page=${page}&per_page=${PER_PAGE$1}`);
1126
+ const batch = resp.droplets ?? [];
1127
+ const match = batch.find((d) => d.id === id);
1128
+ if (match) return match.uuid ?? null;
1129
+ const totalPages = resp.meta?.pagination?.total_pages;
1130
+ if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE$1) break;
1086
1131
  }
1087
- const mist = created.mist;
1088
- reporter.succeed(`Provisioned ${mist.slug}`);
1089
- let provision;
1090
- if (kind) provision = await provisionSurface(client, {
1091
- slugOrId: mist.slug,
1092
- kind,
1093
- name: nameOverride ?? opts.name?.trim(),
1094
- existingUuid: existingDropletUuid,
1095
- verifyEnv: opts.wait !== false,
1096
- reporter,
1097
- ...surfaceProvisionOptionsFromFlags(kind, opts)
1132
+ return null;
1133
+ }
1134
+ /** PATCH the droplet's embed URL (+ derived webhook URLs) once known. */
1135
+ function updateDroplet(client, uuid, input) {
1136
+ return client.patch(`${BASE$3}/${encodeURIComponent(uuid)}`, { droplet: input });
1137
+ }
1138
+ /** Convenience: set embed URL + derived webhook URLs in one PATCH. */
1139
+ function setDropletEmbedUrl(client, uuid, embedUrl) {
1140
+ return updateDroplet(client, uuid, {
1141
+ embed_url: embedUrl,
1142
+ ...dropletWebhookUrls(embedUrl)
1098
1143
  });
1099
- if (opts.json) return printJson(provision ? {
1100
- mist,
1101
- provision
1102
- } : created);
1103
- const label = mistLabel(mist.name, mist.vendor_name);
1104
- console.log();
1105
- console.log(`${label} ${chalk.dim(mist.slug)} ${colorState(mist.state)}`);
1106
- if (mist.public_url) console.log(` ${chalk.cyan(mist.public_url)}`);
1107
- if (provision) printSurfaceSummary(provision);
1108
- console.log();
1109
- console.log(chalk.dim(`Clone with: fluid mist clone ${mist.slug}`));
1110
- }));
1144
+ }
1111
1145
  //#endregion
1112
- //#region src/commands/attach.ts
1146
+ //#region src/api/droplet_installations.ts
1147
+ const BASE$2 = "/api/droplet_installations";
1148
+ /** Page size + runaway guard for walking the page-paginated list. */
1149
+ const PER_PAGE = 100;
1150
+ const MAX_PAGES = 25;
1113
1151
  /**
1114
- * `fluid mist attach --kind <kind> [flags] [slug]` — create a surface and wire
1115
- * it to an existing mist. One command for all three kinds:
1116
- *
1117
- * --kind droplet (managed-env-provisioning droplet)
1118
- * --kind drop-zone --zone page__zone [--height <px>]
1119
- * --kind mobile-embed --cover-image <url> [--height <px>]
1120
- *
1121
- * Attach an EXISTING surface instead of creating one with `--existing <ref>`:
1122
- * droplet/drop-zone → droplet:drp_… / drop-zone:dz_… / bare uuid
1123
- * mobile-embed → mobile-embed:<numericId>
1124
- *
1125
- * Idempotent for droplet + attach-existing (skips when already linked).
1152
+ * List ALL of the current company's droplet installations, following the
1153
+ * endpoint's page-number pagination (`?page=&per_page=` — same shape as
1154
+ * fluid-admin's `getCompanyDroplets`). A single-page fetch would miss rows
1155
+ * beyond the first page, making the idempotency check (and the duplicate-
1156
+ * rejection confirmation) report a false "not installed" forever.
1126
1157
  */
1127
- /** Parse `--existing <ref>` into a uuid or numeric id for the given kind. */
1128
- function parseExisting(kind, raw) {
1129
- const trimmed = (raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw).trim();
1130
- if (!trimmed) throw new Error(`Empty --existing reference "${raw}".`);
1131
- if (kind === "mobile-embed") {
1132
- const id = Number(trimmed);
1133
- if (!Number.isInteger(id) || id <= 0) throw new Error(`mobile-embed attaches by numeric id — pass \`--existing <id>\` (got "${raw}").`);
1134
- return { existingId: id };
1158
+ async function listAllDropletInstallations(client) {
1159
+ const rows = [];
1160
+ for (let page = 1; page <= MAX_PAGES; page++) {
1161
+ const resp = await client.get(`${BASE$2}?page=${page}&per_page=${PER_PAGE}`);
1162
+ const batch = resp.droplet_installations ?? [];
1163
+ rows.push(...batch);
1164
+ const totalPages = resp.meta?.pagination?.total_pages;
1165
+ if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE) break;
1135
1166
  }
1136
- return { existingUuid: trimmed };
1167
+ return rows;
1168
+ }
1169
+ /** Install a droplet (by drp_ uuid) for the current company. */
1170
+ function createDropletInstallation(client, input) {
1171
+ return client.post(BASE$2, input);
1137
1172
  }
1138
- 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 () => {
1139
- const kind = parseSurfaceKind(opts.kind);
1140
- const resolved = resolveSlug(slug);
1141
- const client = createMistClient();
1142
- const reporter = opts.json ? createSilentReporter() : createOraReporter();
1143
- const existing = opts.existing ? parseExisting(kind, opts.existing) : void 0;
1144
- const result = await provisionSurface(client, {
1145
- slugOrId: resolved,
1146
- kind,
1147
- name: opts.name?.trim(),
1148
- verifyEnv: opts.wait !== false,
1149
- reporter,
1150
- ...existing,
1151
- ...existing ? {} : surfaceProvisionOptionsFromFlags(kind, opts)
1152
- });
1153
- if (opts.json) return printJson(result);
1154
- console.log();
1155
- console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1156
- printSurfaceSummary(result);
1157
- }));
1158
- //#endregion
1159
- //#region src/commands/attach-droplet.ts
1160
1173
  /**
1161
- * `fluid mist attach-droplet [name] [slug]` — droplet shortcut for
1162
- * `fluid mist attach --kind droplet`. Creates a droplet and wires it to an
1163
- * existing standalone mist (provisioning the managed env). Idempotent: if a
1164
- * droplet is already attached, it skips create + attach and just verifies env.
1165
- *
1166
- * name Optional droplet name (defaults to the mist's name).
1167
- * slug Optional mist slug (defaults to the `.mist` file in CWD).
1174
+ * The company's installation row for a droplet drp_ uuid, or undefined.
1175
+ * The idempotency key for the install step: an existing row means the
1176
+ * droplet is already connected (installed via a previous run, Mist Desktop,
1177
+ * or the admin marketplace).
1168
1178
  */
1169
- 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 () => {
1170
- const resolved = resolveSlug(slug);
1171
- const client = createMistClient();
1172
- const reporter = opts.json ? createSilentReporter() : createOraReporter();
1173
- const result = await provisionSurface(client, {
1174
- slugOrId: resolved,
1175
- kind: "droplet",
1176
- name: name?.trim() || void 0,
1177
- verifyEnv: opts.wait !== false,
1178
- reporter
1179
- });
1180
- if (opts.json) return printJson(result);
1181
- console.log();
1182
- console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1183
- printSurfaceSummary(result);
1184
- }));
1179
+ function findInstallationForDroplet(installations, dropletUuid) {
1180
+ return (installations ?? []).find((i) => i.droplet_uuid === dropletUuid);
1181
+ }
1185
1182
  //#endregion
1186
- //#region src/commands/update.ts
1183
+ //#region src/api/drop_zones.ts
1184
+ const BASE$1 = "/api/drop_zones";
1187
1185
  /**
1188
- * `fluid mist update` — mutate an existing mist. Hostable is retired
1189
- * (#19684/#19687), so attach/detach operate on integration points:
1190
- *
1191
- * --name <label> Rename the mist (PATCH /mists/:id).
1192
- * --hostable droplet:<uuid> Attach a droplet by uuid. No-ops if that same
1193
- * droplet is already attached; replaces it if a
1194
- * different droplet is attached (droplet is
1195
- * 1-per-mist — it provisions the managed env).
1196
- * --detach Remove the droplet integration point.
1197
- *
1198
- * The integration-point link is immutable (no update route, no `active`
1199
- * field) — enablement lives on the surface record, so there's no toggle here.
1200
- * All ops re-derive state from the integration-points index (which echoes
1201
- * `integratable_uuid` since #19689) and only make the calls that change it.
1186
+ * Build a drop-zone create body. The slot id is `page__zone` (same split the
1187
+ * storefront preview uses, e.g. `checkout__below_total`) → `settings.page` +
1188
+ * `settings.zone`; height (px) → `settings.height`. Without a slot the zone
1189
+ * never renders, so the backend needs it to know WHERE to inject.
1202
1190
  */
1203
- 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 () => {
1204
- if (opts.hostable && opts.detach) throw new Error("Pass either `--hostable` OR `--detach`, not both — they're mutually exclusive.");
1205
- if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass `--name`, `--hostable`, or `--detach`.");
1206
- const resolved = resolveSlug(slug);
1207
- const client = createMistClient();
1208
- const notes = [];
1209
- let renameResp;
1210
- if (opts.name?.trim()) {
1211
- renameResp = await updateMist(client, resolved, { mist: { name: opts.name.trim() } });
1212
- if (!opts.json) {
1213
- const m = renameResp.mist;
1214
- console.log(`${mistLabel(m.name, m.vendor_name)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
1215
- }
1216
- }
1217
- if (opts.hostable || opts.detach) {
1218
- const existing = findDropletIntegrationPoint((await listIntegrationPoints(client, resolved)).integration_points);
1219
- if (opts.hostable) {
1220
- const dropletUuid = parseDropletRef(opts.hostable);
1221
- if (existing && existing.integratable_uuid === dropletUuid) notes.push(`droplet ${dropletUuid} already attached (integration ${existing.id})`);
1222
- else {
1223
- if (existing) {
1224
- await deleteIntegrationPoint(client, resolved, existing.id);
1225
- notes.push(`replaced integration ${existing.id}`);
1226
- }
1227
- const ip = await createIntegrationPoint(client, resolved, {
1228
- kind: "droplet",
1229
- integratable_uuid: dropletUuid
1230
- });
1231
- notes.push(`attached droplet ${dropletUuid} (integration ${ip.integration_point.id})`);
1232
- }
1233
- } else if (opts.detach) if (existing) {
1234
- await deleteIntegrationPoint(client, resolved, existing.id);
1235
- notes.push(`detached droplet (removed integration ${existing.id})`);
1236
- } else notes.push("already standalone (no droplet integration)");
1237
- }
1238
- if (opts.json) return printJson(renameResp ? {
1239
- ...renameResp,
1240
- slug: resolved,
1241
- notes
1242
- } : {
1243
- slug: resolved,
1244
- notes
1245
- });
1246
- for (const note of notes) console.log(chalk.dim(` ${note}`));
1247
- }));
1191
+ function buildDropZoneCreateInput(opts) {
1192
+ const [page, zone] = (opts.zone ?? "").split("__");
1193
+ const settings = {};
1194
+ if (page) settings.page = page;
1195
+ if (zone) settings.zone = zone;
1196
+ if (opts.heightPx != null) settings.height = opts.heightPx;
1197
+ const input = { name: opts.name };
1198
+ if (opts.embedUrl) input.embed_url = opts.embedUrl;
1199
+ if (Object.keys(settings).length > 0) input.settings = settings;
1200
+ return input;
1201
+ }
1202
+ function createDropZone(client, body) {
1203
+ return client.post(BASE$1, body);
1204
+ }
1248
1205
  //#endregion
1249
- //#region src/commands/delete.ts
1206
+ //#region src/api/mobile_widgets.ts
1207
+ const BASE = "/api/company/mobile_widgets";
1250
1208
  /**
1251
- * `fluid mist delete` — scheduled teardown 15 days out (spec §3 "15-day
1252
- * grace period"). DO NOT poll for completion.
1209
+ * Build a mobile-widget create body. `cover_image_url` and `height` are
1210
+ * REQUIRED by the API (422 without them); we fall back to the Fluid
1211
+ * placeholder + default height when the caller doesn't supply them.
1253
1212
  */
1254
- 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 () => {
1255
- const resolved = resolveSlug(slug);
1256
- const client = createMistClient();
1257
- if (!opts.yes) {
1258
- const { mist } = await showMist(client, resolved);
1259
- const fifteenDaysOut = new Date(Date.now() + 15 * 864e5).toISOString().slice(0, 10);
1260
- console.log();
1261
- console.log(`About to schedule ${chalk.bold(mist.name ?? mist.vendor_name ?? mist.slug)} (${chalk.dim(mist.slug)}) for destruction on ${chalk.yellow(fifteenDaysOut)}.`);
1262
- console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
1263
- const { confirmed } = await prompts({
1264
- type: "confirm",
1265
- name: "confirmed",
1266
- message: "Continue?",
1267
- initial: false
1268
- }, { onCancel: () => process.exit(130) });
1269
- if (!confirmed) {
1270
- console.log(chalk.dim("Aborted."));
1271
- process.exit(0);
1272
- }
1273
- }
1274
- const { mist } = await deleteMist(client, resolved);
1275
- if (opts.json) return printJson({ mist });
1276
- console.log(chalk.magenta(`Scheduled for destruction on ${mist.scheduled_destroy_at ? formatAbsolute(mist.scheduled_destroy_at) : "the 15-day mark"}.`));
1277
- console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
1278
- }));
1279
- //#endregion
1280
- //#region src/commands/restore.ts
1281
- /** `fluid mist restore` — un-delete within the 15-day window. Spec §3. */
1282
- 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 () => {
1283
- const resolved = resolveSlug(slug);
1284
- const { mist } = await restoreMist(createMistClient(), resolved);
1285
- if (opts.json) return printJson({ mist });
1286
- console.log(`Restored: ${mist.public_url ? chalk.cyan(mist.public_url) : mist.name ?? mist.vendor_name ?? mist.slug} (${colorState(mist.state)})`);
1287
- }));
1213
+ function buildMobileWidgetCreateInput(opts) {
1214
+ return {
1215
+ name: opts.name,
1216
+ embed_url: opts.embedUrl,
1217
+ cover_image_url: opts.coverImageUrl || "https://ik.imagekit.io/fluid/s3/app/image-placeholder.svg",
1218
+ height: opts.heightPx ?? 600,
1219
+ scrollable: true
1220
+ };
1221
+ }
1222
+ function createMobileWidget(client, body) {
1223
+ return client.post(BASE, body);
1224
+ }
1288
1225
  //#endregion
1289
- //#region src/api/git_credential.ts
1290
- async function createGitCredential(client, slug) {
1226
+ //#region src/api/env_vars.ts
1227
+ function path$1(id, key) {
1228
+ return key ? `/api/v202604/mists/${id}/env_vars/${encodeURIComponent(key)}` : `/api/v202604/mists/${id}/env_vars`;
1229
+ }
1230
+ async function listEnvVars(client, slug) {
1291
1231
  const id = await resolveMistId(client, slug);
1292
- return client.post(`/api/v202604/mists/${id}/git_credential`);
1232
+ return client.get(path$1(id));
1233
+ }
1234
+ /** Create a new env var (the spec routes `set` to POST for new keys). */
1235
+ async function createEnvVar(client, slug, key, value, targets) {
1236
+ const id = await resolveMistId(client, slug);
1237
+ return client.post(path$1(id), { env_var: {
1238
+ key,
1239
+ value,
1240
+ targets
1241
+ } });
1242
+ }
1243
+ /** Update an existing env var (the spec routes `set` to PATCH for existing keys). */
1244
+ async function updateEnvVar(client, slug, key, value, targets) {
1245
+ const id = await resolveMistId(client, slug);
1246
+ return client.patch(path$1(id, key), { env_var: {
1247
+ value,
1248
+ targets
1249
+ } });
1250
+ }
1251
+ async function deleteEnvVar(client, slug, key) {
1252
+ const id = await resolveMistId(client, slug);
1253
+ return client.delete(path$1(id, key));
1293
1254
  }
1294
1255
  //#endregion
1295
- //#region src/lib/git.ts
1256
+ //#region src/types.ts
1296
1257
  /**
1297
- * Git wrapper — shells out to system `git` via execa. Spec §11.1: we
1298
- * deliberately do NOT bundle git or use a pure-JS impl; users are expected
1299
- * to have it installed.
1258
+ * Managed env keys the backend writes onto a Mist once a droplet is
1259
+ * attached (backfill + redeploy). The CLI VERIFIES these appear with
1260
+ * `managed_by: "mist"` — it never sets them (the API 422s on write).
1300
1261
  *
1301
- * Security: token-bearing `remote_url` from POST /git_credential must never
1302
- * be logged, persisted, *or* exposed in the process table. The token is
1303
- * stripped from the URL and handed to git through a GIT_ASKPASS script via
1304
- * env vars (MIST_GIT_USER / MIST_GIT_PASS) — so it never appears in argv
1305
- * (visible to `ps aux` on macOS, `/proc/<pid>/cmdline` on Linux) and is
1306
- * never written to `.git/config`.
1262
+ * Source: settled backend contract (#19685 auto-provisions the droplet
1263
+ * env on integration-point create).
1307
1264
  */
1308
- var GitError = class extends Error {
1309
- exitCode;
1310
- constructor(message, exitCode = 1) {
1311
- super(message);
1312
- this.name = "GitError";
1313
- this.exitCode = exitCode;
1314
- }
1315
- };
1265
+ const MANAGED_DROPLET_ENV_KEYS = [
1266
+ "FLUID_DROPLET_UUID",
1267
+ "FLUID_DROPLET_SECRET",
1268
+ "FLUID_WEBHOOK_AUTH_TOKEN"
1269
+ ];
1270
+ //#endregion
1271
+ //#region src/lib/surface-provision.ts
1316
1272
  /**
1317
- * Git author for Mist publish commits. Mist app repos are connected to the
1318
- * we-commerce Vercel team via Vercel's GitHub integration, and Vercel
1319
- * attributes a git-triggered deploy to the *commit author's* GitHub account.
1320
- * If that author is a user's personal GitHub account (not a member of the
1321
- * Vercel team), Vercel blocks the deployment ("… does not have a Vercel
1322
- * account linked to their GitHub account"). Authoring every publish commit as
1323
- * the Mist GitHub App bot — which the Vercel integration recognizes — keeps
1324
- * deploys unblocked regardless of the user's local/global git identity. Human
1325
- * attribution is preserved in the commit *message* (see push.ts), not the
1326
- * author field.
1273
+ * Surface provisioning engine — the one place the "create a Fluid surface and
1274
+ * wire it to a mist" flow lives, at parity with Mist Desktop's
1275
+ * mist-integration-points-api.service.ts. Exposed via `fluid mist create`
1276
+ * (create a bare mist first, then run this), `fluid mist attach` (any kind),
1277
+ * and `fluid mist attach-droplet` (droplet shortcut).
1327
1278
  *
1328
- * These MUST match the identity set on cloned repos in Mist Desktop
1329
- * (apps/mist-desktop/src/main/ipc.ts) so both paths produce the same author.
1279
+ * Three surface kinds, each with its own create endpoint + attach identifier:
1280
+ * - droplet → POST /api/droplets → drp_ uuid → integratable_uuid
1281
+ * - drop-zone → POST /api/drop_zones → dz_ uuid → integratable_uuid
1282
+ * - mobile-embed→ POST /api/company/mobile_widgets→ numeric id → integratable_id
1283
+ *
1284
+ * Design tenets (repo philosophy — "server is source of truth"):
1285
+ * - No local progress file. State is re-derived from the API each run.
1286
+ * - Attach-existing is idempotent: if an integration point already links the
1287
+ * target surface (matched via the #19689 integratable_uuid echo, or the
1288
+ * numeric id for mobile), skip. A droplet is 1-per-mist, so creating a new
1289
+ * droplet also skips when one is already attached. Drop-zones/mobile
1290
+ * embeds are additive (a mist can have many).
1291
+ * - Only a droplet attach provisions managed env keys
1292
+ * (FLUID_DROPLET_UUID/_SECRET/_WEBHOOK_AUTH_TOKEN); this flow only
1293
+ * VERIFIES they landed — it never writes them (the API 422s on write).
1294
+ * - A droplet must also be INSTALLED (POST /api/droplet_installations) —
1295
+ * create + link alone leave it not-connected/inactive, so install-scoped
1296
+ * features (dit/dex exchange, admin embeds, install webhooks) don't work.
1297
+ * The install step is idempotent (skips when an installation exists).
1330
1298
  */
1331
- const MIST_GIT_AUTHOR_NAME = "mist-we-commerce[bot]";
1332
- const MIST_GIT_AUTHOR_EMAIL = "293596226+mist-we-commerce[bot]@users.noreply.github.com";
1333
- /** execa throws ENOENT when the git binary itself is missing — the
1334
- * single most common failure on non-developer machines. Translate it
1335
- * into an actionable message instead of "git <verb> failed (exit 1)".
1336
- * Returns null for every other failure so call sites keep their
1337
- * specific messages. */
1338
- function gitMissingError(err) {
1339
- if (err?.code !== "ENOENT") return null;
1340
- 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);
1341
- }
1299
+ const DEFAULT_TIMING = {
1300
+ publicUrlTimeoutMs: 9e4,
1301
+ publicUrlIntervalMs: 3e3,
1302
+ envTimeoutMs: 12e4,
1303
+ envIntervalMs: 5e3
1304
+ };
1305
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1306
+ const KIND_LABEL$1 = {
1307
+ droplet: "droplet",
1308
+ "drop-zone": "drop zone",
1309
+ "mobile-embed": "mobile embed"
1310
+ };
1342
1311
  /**
1343
- * Split a token-bearing remote URL into the userless URL + creds. Used
1344
- * to keep the token off git's argv: we pass `cleanUrl` to git and feed
1345
- * `username` / `password` back through GIT_ASKPASS env vars.
1346
- *
1347
- * If the URL has no embedded credentials, returns empty strings — fine
1348
- * for public repos where git won't even invoke askpass.
1312
+ * Create (or reuse) a surface of `kind` and wire it to the mist. Idempotent
1313
+ * for droplet + attach-existing; additive for new drop-zones/mobile embeds.
1349
1314
  */
1350
- function splitRemoteCredentials(remoteUrl) {
1351
- const u = new URL(remoteUrl);
1352
- const username = decodeURIComponent(u.username);
1353
- const password = decodeURIComponent(u.password);
1354
- u.username = "";
1355
- u.password = "";
1315
+ async function provisionSurface(client, options) {
1316
+ const { reporter, slugOrId, kind } = options;
1317
+ const timing = {
1318
+ ...DEFAULT_TIMING,
1319
+ ...options.timing
1320
+ };
1321
+ const sleep = options.sleep ?? defaultSleep;
1322
+ const label = KIND_LABEL$1[kind];
1323
+ reporter.start("Resolving mist…");
1324
+ const mist = (await showMist(client, slugOrId)).mist;
1325
+ reporter.succeed(`Mist ${mist.slug} (${mist.state})`);
1326
+ reporter.start("Checking existing integrations…");
1327
+ const points = (await listIntegrationPoints(client, slugOrId)).integration_points;
1328
+ let surfaceUuid = null;
1329
+ let surfaceNumericId = null;
1330
+ let surfaceName = null;
1331
+ let surfaceCreated = false;
1332
+ let embedUrlSet = false;
1333
+ let integrationPoint;
1334
+ const attachingExisting = options.existingUuid != null || options.existingId != null;
1335
+ const alreadyLinked = attachingExisting ? findIntegrationPointBySurface(points, {
1336
+ kind,
1337
+ uuid: options.existingUuid,
1338
+ id: options.existingId
1339
+ }) : kind === "droplet" ? findDropletIntegrationPoint(points) : void 0;
1340
+ if (alreadyLinked) {
1341
+ reporter.skip(`${label} already attached (integration #${alreadyLinked.id}) — skipping`);
1342
+ surfaceUuid = alreadyLinked.integratable_uuid ?? options.existingUuid ?? null;
1343
+ surfaceNumericId = alreadyLinked.integratable_id ?? options.existingId ?? null;
1344
+ integrationPoint = {
1345
+ id: alreadyLinked.id,
1346
+ integratableUuid: alreadyLinked.integratable_uuid ?? null,
1347
+ created: false
1348
+ };
1349
+ } else {
1350
+ reporter.succeed("No matching integration yet");
1351
+ let attachUuid = options.existingUuid;
1352
+ let attachId = options.existingId;
1353
+ if (!attachingExisting) {
1354
+ const embedUrl = await resolvePublicUrl(client, {
1355
+ slugOrId,
1356
+ initial: mist.public_url,
1357
+ reporter,
1358
+ timing,
1359
+ sleep
1360
+ });
1361
+ const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name || mist.slug;
1362
+ reporter.start(`Creating ${label} "${name}"…`);
1363
+ if (kind === "droplet") {
1364
+ const created = await createDroplet(client, { droplet: buildDropletCreateInput({
1365
+ name,
1366
+ embedUrl,
1367
+ requestedScopes: options.requestedScopes
1368
+ }) });
1369
+ surfaceUuid = created.droplet.uuid;
1370
+ surfaceNumericId = created.droplet.id ?? null;
1371
+ surfaceName = created.droplet.name;
1372
+ attachUuid = created.droplet.uuid;
1373
+ embedUrlSet = Boolean(embedUrl);
1374
+ reporter.succeed(`Created droplet ${surfaceUuid}${embedUrl ? "" : " (embed URL pending)"}`);
1375
+ if (!embedUrl && surfaceUuid) {
1376
+ const laterUrl = await resolvePublicUrl(client, {
1377
+ slugOrId,
1378
+ initial: null,
1379
+ reporter,
1380
+ timing,
1381
+ sleep
1382
+ });
1383
+ if (laterUrl) {
1384
+ reporter.start("Backfilling droplet embed URL…");
1385
+ await setDropletEmbedUrl(client, surfaceUuid, laterUrl);
1386
+ embedUrlSet = true;
1387
+ reporter.succeed("Embed URL set");
1388
+ } else reporter.warn("Mist public_url not ready yet — re-run once the first deploy finishes to set the embed URL");
1389
+ }
1390
+ } else if (kind === "drop-zone") {
1391
+ if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the drop zone.");
1392
+ const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
1393
+ name,
1394
+ embedUrl,
1395
+ zone: options.zone,
1396
+ heightPx: options.heightPx
1397
+ }) });
1398
+ surfaceUuid = created.drop_zone.uuid;
1399
+ surfaceName = created.drop_zone.name;
1400
+ attachUuid = created.drop_zone.uuid;
1401
+ embedUrlSet = true;
1402
+ reporter.succeed(`Created drop zone ${surfaceUuid}`);
1403
+ } else {
1404
+ if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the mobile embed.");
1405
+ const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
1406
+ name,
1407
+ embedUrl,
1408
+ coverImageUrl: options.coverImageUrl,
1409
+ heightPx: options.heightPx
1410
+ }) });
1411
+ surfaceNumericId = created.mobile_widget.id;
1412
+ surfaceName = created.mobile_widget.name;
1413
+ attachId = created.mobile_widget.id;
1414
+ embedUrlSet = true;
1415
+ reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
1416
+ }
1417
+ surfaceCreated = true;
1418
+ } else {
1419
+ surfaceUuid = options.existingUuid ?? null;
1420
+ surfaceNumericId = options.existingId ?? null;
1421
+ embedUrlSet = true;
1422
+ }
1423
+ const attachInput = kind === "mobile-embed" ? {
1424
+ kind,
1425
+ integratable_id: attachId
1426
+ } : {
1427
+ kind,
1428
+ integratable_uuid: attachUuid
1429
+ };
1430
+ reporter.start(`Attaching ${label}…`);
1431
+ let ipResp;
1432
+ try {
1433
+ ipResp = await createIntegrationPoint(client, slugOrId, attachInput);
1434
+ } catch (err) {
1435
+ reporter.fail("Attach failed");
1436
+ throw normalizeAttachError(err, attachUuid ?? String(attachId), kind);
1437
+ }
1438
+ integrationPoint = {
1439
+ id: ipResp.integration_point.id,
1440
+ integratableUuid: ipResp.integration_point.integratable_uuid ?? null,
1441
+ created: true
1442
+ };
1443
+ if (!surfaceUuid) surfaceUuid = ipResp.integration_point.integratable_uuid ?? attachUuid ?? null;
1444
+ if (surfaceNumericId == null) surfaceNumericId = ipResp.integration_point.integratable_id ?? null;
1445
+ reporter.succeed(kind === "droplet" ? "Droplet attached — provisioning env + redeploy…" : `${label} attached`);
1446
+ }
1447
+ let installation = null;
1448
+ if (kind === "droplet") {
1449
+ if (!surfaceUuid && surfaceNumericId != null) try {
1450
+ surfaceUuid = await findDropletUuidByNumericId(client, surfaceNumericId);
1451
+ } catch {}
1452
+ if (!surfaceUuid) {
1453
+ reporter.fail("Droplet uuid unknown — cannot install");
1454
+ 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>\"}.");
1455
+ }
1456
+ installation = await ensureDropletInstalled(client, {
1457
+ dropletUuid: surfaceUuid,
1458
+ scopes: options.requestedScopes ?? DEFAULT_DROPLET_SCOPES,
1459
+ reporter
1460
+ });
1461
+ }
1462
+ const managedEnv = kind === "droplet" ? await verifyManagedEnv(client, {
1463
+ slugOrId,
1464
+ reporter,
1465
+ timing,
1466
+ sleep,
1467
+ enabled: options.verifyEnv !== false
1468
+ }) : null;
1356
1469
  return {
1357
- cleanUrl: u.toString(),
1358
- username,
1359
- password
1470
+ mist: {
1471
+ id: mist.id,
1472
+ slug: mist.slug,
1473
+ name: mist.name,
1474
+ public_url: mist.public_url,
1475
+ state: mist.state
1476
+ },
1477
+ kind,
1478
+ surface: {
1479
+ uuid: surfaceUuid,
1480
+ id: surfaceNumericId,
1481
+ name: surfaceName
1482
+ },
1483
+ integrationPoint: integrationPoint ?? {
1484
+ id: "",
1485
+ integratableUuid: null,
1486
+ created: false
1487
+ },
1488
+ surfaceCreated,
1489
+ embedUrlSet,
1490
+ installation,
1491
+ managedEnv
1360
1492
  };
1361
1493
  }
1362
1494
  /**
1363
- * Write a stub GIT_ASKPASS shell script to a per-process tmpdir and
1364
- * return its path. The script itself contains no secret — it just
1365
- * echoes back whichever env var (MIST_GIT_USER / MIST_GIT_PASS)
1366
- * matches git's prompt ("Username for …" / "Password for …"). The
1367
- * credentials only live in the spawned git process's env for the
1368
- * duration of the call.
1369
- *
1370
- * One POSIX script for all platforms: Git for Windows executes
1371
- * GIT_ASKPASS through its bundled sh, so a .cmd askpass fails with
1372
- * "unable to read askpass response" (git 2.52.0.windows.1) while a
1373
- * plain sh script works.
1374
- *
1375
- * Idempotent: subsequent calls reuse the same script path.
1376
- */
1377
- let askpassPath = null;
1378
- function ensureAskpassScript() {
1379
- if (askpassPath && existsSync(askpassPath)) return askpassPath;
1380
- const dir = path.join(os.tmpdir(), `mist-cli-askpass-${process.pid}`);
1381
- if (!existsSync(dir)) mkdirSync(dir, {
1382
- recursive: true,
1383
- mode: 448
1384
- });
1385
- const p = path.join(dir, "askpass.sh");
1386
- writeFileSync(p, `#!/bin/sh\ncase "$1" in\n Username*) printf '%s' "$MIST_GIT_USER" ;;\n *) printf '%s' "$MIST_GIT_PASS" ;;\nesac\n`, { mode: 448 });
1387
- chmodSync(p, 448);
1388
- askpassPath = p;
1389
- return askpassPath;
1390
- }
1391
- /**
1392
- * Build the spawn env + cleaned URL for a credentialed git invocation.
1393
- * Returns:
1394
- * - `cleanUrl`: URL with userinfo stripped — safe to pass as argv.
1395
- * - `env`: parent env plus GIT_ASKPASS + MIST_GIT_USER / MIST_GIT_PASS.
1396
- * GIT_TERMINAL_PROMPT=0 prevents the TTY-prompt fallback if
1397
- * the credentials are empty (we want auth to fail fast, not
1398
- * hang the desktop waiting for interactive input).
1495
+ * Install the droplet for the current company (idempotent). Checks the
1496
+ * installations list first; treats the API's already-installed rejection as
1497
+ * success too, so re-runs and races both converge. A hard failure throws with
1498
+ * the droplet uuid + recovery steps — the created droplet and mist link stay
1499
+ * in place, so an idempotent re-run finishes the job.
1399
1500
  */
1400
- function gitAuthSpawn(remoteUrl) {
1401
- const { cleanUrl, username, password } = splitRemoteCredentials(remoteUrl);
1402
- return {
1403
- cleanUrl,
1404
- env: {
1405
- ...process.env,
1406
- GIT_ASKPASS: ensureAskpassScript(),
1407
- GIT_TERMINAL_PROMPT: "0",
1408
- MIST_GIT_USER: username,
1409
- MIST_GIT_PASS: password
1501
+ async function ensureDropletInstalled(client, args) {
1502
+ const { reporter, dropletUuid } = args;
1503
+ reporter.start("Installing droplet…");
1504
+ let existing;
1505
+ try {
1506
+ existing = findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid);
1507
+ } catch {}
1508
+ if (existing) {
1509
+ reporter.skip(`Droplet already installed${existing.uuid ? ` (${existing.uuid})` : ""} — skipping`);
1510
+ return {
1511
+ uuid: existing.uuid ?? null,
1512
+ created: false
1513
+ };
1514
+ }
1515
+ try {
1516
+ const uuid = (await createDropletInstallation(client, {
1517
+ droplet_uuid: dropletUuid,
1518
+ scopes: [...args.scopes]
1519
+ })).droplet_installation?.uuid ?? null;
1520
+ reporter.succeed(`Droplet installed${uuid ? ` (${uuid})` : ""}`);
1521
+ return {
1522
+ uuid,
1523
+ created: true
1524
+ };
1525
+ } catch (err) {
1526
+ if (isAlreadyInstalledError(err)) {
1527
+ const confirmed = await lookupInstallation(client, dropletUuid);
1528
+ if (confirmed) {
1529
+ reporter.skip("Droplet already installed — skipping");
1530
+ return {
1531
+ uuid: confirmed.uuid ?? null,
1532
+ created: false
1533
+ };
1534
+ }
1410
1535
  }
1411
- };
1536
+ reporter.fail("Droplet install failed");
1537
+ throw normalizeInstallError(err, dropletUuid);
1538
+ }
1412
1539
  }
1413
- /** Confirm git is installed. Cheap call once at startup. */
1414
- async function ensureGitAvailable() {
1540
+ function isAlreadyInstalledError(err) {
1541
+ return err instanceof MistApiError && (err.status === 409 || err.status === 422) && /already|taken|installed|exists/i.test(`${err.message} ${JSON.stringify(err.details ?? {})}`);
1542
+ }
1543
+ /** Best-effort installation lookup for a droplet; null when the row can't
1544
+ * be found or the list call fails. */
1545
+ async function lookupInstallation(client, dropletUuid) {
1415
1546
  try {
1416
- await execa("git", ["--version"]);
1547
+ return findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid) ?? null;
1417
1548
  } catch {
1418
- 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`.");
1549
+ return null;
1419
1550
  }
1420
1551
  }
1421
1552
  /**
1422
- * `git clone <cleanUrl> <targetDir>`. The original remoteUrl carries a
1423
- * token; gitAuthSpawn strips it from the URL and re-injects it via
1424
- * GIT_ASKPASS so it never appears in argv. `credential.helper=` stops
1425
- * git from caching what askpass returned to any system credential
1426
- * store.
1553
+ * Install failures must NOT read as "wired up" — an uninstalled droplet means
1554
+ * dit/dex token exchange, admin embeds, and install webhooks silently don't
1555
+ * work. Surface the droplet uuid + recovery path; keep the MistApiError
1556
+ * status so the error handler exits with the spec'd non-zero code.
1427
1557
  */
1428
- async function gitClone(remoteUrl, targetDir) {
1429
- const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
1430
- try {
1431
- await execa("git", [
1432
- "-c",
1433
- "credential.helper=",
1434
- "clone",
1435
- "--quiet",
1436
- cleanUrl,
1437
- targetDir
1438
- ], {
1439
- env,
1440
- stdio: [
1441
- "ignore",
1442
- "inherit",
1443
- "inherit"
1444
- ]
1445
- });
1446
- } catch (err) {
1447
- const missing = gitMissingError(err);
1448
- if (missing) throw missing;
1449
- const e = err;
1450
- throw new GitError(`git clone failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1451
- }
1452
- await ensureBotIdentity(targetDir);
1558
+ function normalizeInstallError(err, dropletUuid) {
1559
+ 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}"}.`;
1560
+ return err instanceof MistApiError ? new MistApiError(message, err.status, err.details) : new Error(message);
1453
1561
  }
1454
- /** `git push origin <branch>` — credentials supplied via GIT_ASKPASS,
1455
- * never in argv. The `.extraheader=` override clears any cached
1456
- * Authorization header that a CI-style git config might inject. */
1457
- async function gitPush(cwd, remoteUrl, branch = "main") {
1458
- const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
1459
- try {
1460
- await execa("git", [
1461
- "-c",
1462
- "credential.helper=",
1463
- "-c",
1464
- `http.https://github.com/.extraheader=`,
1465
- "push",
1466
- cleanUrl,
1467
- `HEAD:${branch}`
1468
- ], {
1469
- cwd,
1470
- env,
1471
- stdio: [
1472
- "ignore",
1473
- "inherit",
1474
- "inherit"
1475
- ]
1476
- });
1477
- } catch (err) {
1478
- const missing = gitMissingError(err);
1479
- if (missing) throw missing;
1480
- const e = err;
1481
- throw new GitError(`git push failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1562
+ /**
1563
+ * If the integration-points endpoint rejects the attach identifier (e.g. an
1564
+ * older API that predates #19688's attach-by-uuid support), surface an
1565
+ * actionable message noting the surface WAS created. Other errors pass through.
1566
+ */
1567
+ function normalizeAttachError(err, surfaceRef, kind) {
1568
+ if (err instanceof MistApiError && (err.status === 400 || err.status === 422)) {
1569
+ const blob = `${err.message} ${JSON.stringify(err.details ?? {})}`;
1570
+ 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);
1482
1571
  }
1572
+ return err instanceof Error ? err : new Error(String(err));
1483
1573
  }
1484
- /** `git pull origin <branch>`. Token supplied via GIT_ASKPASS, same as push. */
1485
- async function gitPull(cwd, remoteUrl, branch = "main") {
1486
- const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
1487
- try {
1488
- await execa("git", [
1489
- "-c",
1490
- "credential.helper=",
1491
- "pull",
1492
- "--ff-only",
1493
- cleanUrl,
1494
- branch
1495
- ], {
1496
- cwd,
1497
- env,
1498
- stdio: [
1499
- "ignore",
1500
- "inherit",
1501
- "inherit"
1502
- ]
1503
- });
1504
- } catch (err) {
1505
- const missing = gitMissingError(err);
1506
- if (missing) throw missing;
1507
- const e = err;
1508
- throw new GitError(`git pull failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1574
+ /**
1575
+ * Return the mist's public_url, polling briefly if it isn't ready yet
1576
+ * (async-first-deploy). Returns null if it never appears within the window.
1577
+ */
1578
+ async function resolvePublicUrl(client, args) {
1579
+ if (args.initial) return args.initial;
1580
+ args.reporter.start("Waiting for the mist's public URL…");
1581
+ const deadline = Date.now() + args.timing.publicUrlTimeoutMs;
1582
+ for (;;) {
1583
+ const url = (await showMist(client, args.slugOrId)).mist.public_url;
1584
+ if (url) {
1585
+ args.reporter.succeed(`Public URL ready: ${url}`);
1586
+ return url;
1587
+ }
1588
+ if (Date.now() >= deadline) break;
1589
+ await args.sleep(args.timing.publicUrlIntervalMs);
1509
1590
  }
1591
+ return null;
1510
1592
  }
1511
1593
  /**
1512
- * Are there uncommitted changes (working tree or index) in this repo?
1513
- * `git status --porcelain` is empty when everything is clean, non-empty
1514
- * otherwise — so the count is just "is the output non-trivial".
1594
+ * Poll `GET …/env_vars` until the Mist-managed droplet keys appear with
1595
+ * `managed_by: "mist"`. Never fails the flow — a redeploy in progress is a
1596
+ * normal, transient state we simply report.
1515
1597
  */
1516
- async function hasUncommittedChanges(cwd) {
1517
- try {
1518
- const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
1519
- return stdout.trim().length > 0;
1520
- } catch {
1521
- return false;
1598
+ async function verifyManagedEnv(client, args) {
1599
+ const required = [...MANAGED_DROPLET_ENV_KEYS];
1600
+ if (!args.enabled) return {
1601
+ ready: false,
1602
+ present: [],
1603
+ missing: required
1604
+ };
1605
+ args.reporter.start("Verifying managed environment…");
1606
+ const deadline = Date.now() + args.timing.envTimeoutMs;
1607
+ let present = [];
1608
+ for (;;) {
1609
+ const envVars = (await listEnvVars(client, args.slugOrId)).env_vars ?? [];
1610
+ const managed = new Set(envVars.filter((e) => e.managed_by === "mist").map((e) => e.key));
1611
+ present = required.filter((k) => managed.has(k));
1612
+ if (present.length === required.length) {
1613
+ args.reporter.succeed("Managed env keys present — droplet is wired up");
1614
+ return {
1615
+ ready: true,
1616
+ present,
1617
+ missing: []
1618
+ };
1619
+ }
1620
+ if (Date.now() >= deadline) break;
1621
+ args.reporter.update(`Verifying managed environment… (${present.length}/${required.length} keys, redeploy in progress)`);
1622
+ await args.sleep(args.timing.envIntervalMs);
1522
1623
  }
1624
+ const missing = required.filter((k) => !present.includes(k));
1625
+ 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(", ")}`);
1626
+ return {
1627
+ ready: false,
1628
+ present,
1629
+ missing
1630
+ };
1523
1631
  }
1524
- /**
1525
- * Fetch the given branch from the token-bearing remote URL, writing
1526
- * `FETCH_HEAD` so `git rev-list HEAD...FETCH_HEAD` is meaningful.
1527
- *
1528
- * Same security stance as gitPush — installation token comes through
1529
- * GIT_ASKPASS, never argv or `.git/config`.
1530
- */
1531
- async function gitFetch(cwd, remoteUrl, branch = "main") {
1532
- const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
1533
- try {
1534
- await execa("git", [
1535
- "-c",
1536
- "credential.helper=",
1537
- "fetch",
1538
- "--quiet",
1539
- cleanUrl,
1540
- branch
1541
- ], {
1542
- cwd,
1543
- env
1544
- });
1545
- } catch (err) {
1546
- const missing = gitMissingError(err);
1547
- if (missing) throw missing;
1548
- const e = err;
1549
- throw new GitError(`git fetch failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1550
- }
1632
+ //#endregion
1633
+ //#region src/lib/surface-flags.ts
1634
+ const KIND_ALIASES = {
1635
+ droplet: "droplet",
1636
+ droplets: "droplet",
1637
+ "drop-zone": "drop-zone",
1638
+ drop_zone: "drop-zone",
1639
+ dropzone: "drop-zone",
1640
+ zone: "drop-zone",
1641
+ "mobile-embed": "mobile-embed",
1642
+ mobile_embed: "mobile-embed",
1643
+ mobile: "mobile-embed",
1644
+ "mobile-widget": "mobile-embed",
1645
+ mobilewidget: "mobile-embed"
1646
+ };
1647
+ const SURFACE_KIND_LIST = "droplet, drop-zone, mobile-embed";
1648
+ function parseSurfaceKind(raw) {
1649
+ const hit = KIND_ALIASES[raw.trim().toLowerCase()];
1650
+ if (!hit) throw new Error(`Unknown surface kind "${raw}". Use one of: ${SURFACE_KIND_LIST}.`);
1651
+ return hit;
1652
+ }
1653
+ function parseHeight(raw) {
1654
+ if (raw == null) return void 0;
1655
+ const n = Number(raw);
1656
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid --height "${raw}". Must be a positive integer (px).`);
1657
+ return n;
1551
1658
  }
1552
1659
  /**
1553
- * Count commits in local HEAD vs `FETCH_HEAD` (after `gitFetch`). Returns
1554
- * `{ahead, behind}`: how many commits HEAD has that the remote doesn't,
1555
- * and vice versa. A `0/0` result means "in sync".
1660
+ * Map CLI flags to the kind-specific `provisionSurface` options, enforcing the
1661
+ * per-kind requirements the API needs:
1662
+ * - drop-zone requires `--zone <page__zone>` (else it never renders).
1663
+ * - mobile-embed requires `--cover-image <url>` (the API 422s without one; the
1664
+ * CLI has no DAM picker, so the user must supply a url).
1556
1665
  */
1557
- async function gitAheadBehindVsFetchHead(cwd) {
1558
- try {
1559
- const { stdout } = await execa("git", [
1560
- "rev-list",
1561
- "--left-right",
1562
- "--count",
1563
- "HEAD...FETCH_HEAD"
1564
- ], { cwd });
1565
- const [a, b] = stdout.trim().split(/\s+/);
1566
- const ahead = Number.parseInt(a ?? "0", 10);
1567
- const behind = Number.parseInt(b ?? "0", 10);
1666
+ function surfaceProvisionOptionsFromFlags(kind, flags) {
1667
+ const heightPx = parseHeight(flags.height);
1668
+ if (kind === "drop-zone") {
1669
+ if (!flags.zone?.trim()) throw new Error("A drop-zone needs a slot — pass `--zone <page__zone>` (e.g. --zone checkout__below_total).");
1568
1670
  return {
1569
- ahead: Number.isFinite(ahead) ? ahead : 0,
1570
- behind: Number.isFinite(behind) ? behind : 0
1671
+ zone: flags.zone.trim(),
1672
+ heightPx
1571
1673
  };
1572
- } catch {
1674
+ }
1675
+ if (kind === "mobile-embed") {
1676
+ 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).");
1573
1677
  return {
1574
- ahead: 0,
1575
- behind: 0
1678
+ coverImageUrl: flags.coverImage.trim(),
1679
+ heightPx
1576
1680
  };
1577
1681
  }
1682
+ return {};
1578
1683
  }
1579
- /** Fast-forward `HEAD` to `FETCH_HEAD`. Fails (non-zero exit) if not
1580
- * fast-forwardable — caller must check ahead-count first. */
1581
- async function gitMergeFastForward(cwd) {
1582
- try {
1583
- await execa("git", [
1584
- "merge",
1585
- "--ff-only",
1586
- "FETCH_HEAD"
1587
- ], { cwd });
1588
- } catch (err) {
1589
- const missing = gitMissingError(err);
1590
- if (missing) throw missing;
1591
- const e = err;
1592
- throw new GitError(`git fast-forward merge failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1684
+ //#endregion
1685
+ //#region src/lib/surface-summary.ts
1686
+ /** Shared human-readable summary for a surface provision result. */
1687
+ const KIND_LABEL = {
1688
+ droplet: "droplet",
1689
+ "drop-zone": "drop zone",
1690
+ "mobile-embed": "mobile embed"
1691
+ };
1692
+ function printSurfaceSummary(result) {
1693
+ const ref = result.surface.uuid ?? (result.surface.id != null ? `#${result.surface.id}` : "(unknown)");
1694
+ const state = result.surfaceCreated ? "created" : "existing";
1695
+ console.log(chalk.dim(` ${KIND_LABEL[result.kind]}: ${ref} (${state}) · integration ${result.integrationPoint.id}`));
1696
+ if (result.kind === "droplet" && result.installation) {
1697
+ const ref = result.installation.uuid ?? "(uuid unknown)";
1698
+ console.log(chalk.dim(` installation: ${ref} (${result.installation.created ? "installed" : "already installed"})`));
1593
1699
  }
1700
+ if (result.kind === "droplet" && result.managedEnv) if (result.managedEnv.ready) console.log(chalk.dim(" env: managed keys provisioned ✓"));
1701
+ else console.log(chalk.yellow(" env: redeploy in progress — run `fluid mist env list` shortly to confirm managed keys"));
1594
1702
  }
1703
+ //#endregion
1704
+ //#region src/commands/create.ts
1595
1705
  /**
1596
- * Stash working-tree + untracked changes with a Mist-tagged message.
1597
- * Returns the stash ref (`stash@{0}`) we created. Callers should pair
1598
- * this with `gitStashPop` after the dangerous step is done.
1706
+ * `fluid mist create` — provision a new mist, optionally creating and attaching
1707
+ * a surface in one step. All attach paths go through integration points
1708
+ * (hostable is retired — #19684/#19687).
1709
+ *
1710
+ * --droplet [name] Create a NEW droplet and attach it. Bare →
1711
+ * droplet name defaults to the mist name. The
1712
+ * "one command that sets everything up" path.
1713
+ * --kind <kind> [flags] Create + attach a surface of any kind:
1714
+ * droplet | drop-zone | mobile-embed. Kind flags:
1715
+ * drop-zone --zone <page__zone> --height <px>
1716
+ * mobile-embed --cover-image <url> --height <px>
1717
+ * --hostable droplet:<uuid> Attach an EXISTING droplet by its drp_… uuid.
1718
+ *
1719
+ * With none of these, creates a plain standalone mist.
1599
1720
  */
1600
- async function gitStash(cwd, message) {
1721
+ 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 () => {
1722
+ const wantDroplet = opts.droplet !== void 0;
1723
+ const wantKind = !!opts.kind;
1724
+ const wantHostable = !!opts.hostable;
1725
+ if ([
1726
+ wantDroplet,
1727
+ wantKind,
1728
+ wantHostable
1729
+ ].filter(Boolean).length > 1) throw new Error("Pass only one of `--droplet`, `--kind`, or `--hostable` — they're mutually exclusive.");
1730
+ let kind;
1731
+ let existingDropletUuid;
1732
+ let nameOverride;
1733
+ if (wantDroplet) {
1734
+ kind = "droplet";
1735
+ nameOverride = typeof opts.droplet === "string" ? opts.droplet.trim() : void 0;
1736
+ } else if (wantKind) kind = parseSurfaceKind(opts.kind);
1737
+ else if (wantHostable) {
1738
+ kind = "droplet";
1739
+ existingDropletUuid = parseDropletRef(opts.hostable);
1740
+ }
1741
+ const mistBody = { kind: opts.mistKind ?? "next_app" };
1742
+ if (opts.name?.trim()) mistBody.name = opts.name.trim();
1743
+ const client = createMistClient();
1744
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
1745
+ reporter.start("Setting up your Mist Cloud environment…");
1746
+ let created;
1601
1747
  try {
1602
- await execa("git", [
1603
- "stash",
1604
- "push",
1605
- "--include-untracked",
1606
- "--message",
1607
- message
1608
- ], { cwd });
1748
+ created = await createMist(client, { mist: mistBody });
1609
1749
  } catch (err) {
1610
- const missing = gitMissingError(err);
1611
- if (missing) throw missing;
1612
- throw new GitError(`git stash push failed`, err.exitCode ?? 1);
1750
+ reporter.fail("Provisioning failed");
1751
+ throw err;
1613
1752
  }
1614
- }
1753
+ const mist = created.mist;
1754
+ reporter.succeed(`Provisioned ${mist.slug}`);
1755
+ let provision;
1756
+ if (kind) provision = await provisionSurface(client, {
1757
+ slugOrId: mist.slug,
1758
+ kind,
1759
+ name: nameOverride ?? opts.name?.trim(),
1760
+ existingUuid: existingDropletUuid,
1761
+ verifyEnv: opts.wait !== false,
1762
+ reporter,
1763
+ ...surfaceProvisionOptionsFromFlags(kind, opts)
1764
+ });
1765
+ if (opts.json) return printJson(provision ? {
1766
+ mist,
1767
+ provision
1768
+ } : created);
1769
+ const label = mistLabel(mist.name, mist.vendor_name);
1770
+ console.log();
1771
+ console.log(`${label} ${chalk.dim(mist.slug)} ${colorState(mist.state)}`);
1772
+ if (mist.public_url) console.log(` ${chalk.cyan(mist.public_url)}`);
1773
+ if (provision) printSurfaceSummary(provision);
1774
+ console.log();
1775
+ console.log(chalk.dim(`Clone with: fluid mist clone ${mist.slug}`));
1776
+ }));
1777
+ //#endregion
1778
+ //#region src/commands/attach.ts
1615
1779
  /**
1616
- * Pop the most recent stash. Throws on conflict so callers can surface
1617
- * the situation (the working tree will have conflict markers; the user
1618
- * needs to resolve manually).
1780
+ * `fluid mist attach --kind <kind> [flags] [slug]` — create a surface and wire
1781
+ * it to an existing mist. One command for all three kinds:
1782
+ *
1783
+ * --kind droplet (managed-env-provisioning droplet)
1784
+ * --kind drop-zone --zone page__zone [--height <px>]
1785
+ * --kind mobile-embed --cover-image <url> [--height <px>]
1786
+ *
1787
+ * Attach an EXISTING surface instead of creating one with `--existing <ref>`:
1788
+ * droplet/drop-zone → droplet:drp_… / drop-zone:dz_… / bare uuid
1789
+ * mobile-embed → mobile-embed:<numericId>
1790
+ *
1791
+ * Idempotent for droplet + attach-existing (skips when already linked).
1619
1792
  */
1620
- async function gitStashPop(cwd) {
1621
- try {
1622
- await execa("git", ["stash", "pop"], { cwd });
1623
- } catch (err) {
1624
- const missing = gitMissingError(err);
1625
- if (missing) throw missing;
1626
- 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);
1793
+ /** Parse `--existing <ref>` into a uuid or numeric id for the given kind. */
1794
+ function parseExisting(kind, raw) {
1795
+ const trimmed = (raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw).trim();
1796
+ if (!trimmed) throw new Error(`Empty --existing reference "${raw}".`);
1797
+ if (kind === "mobile-embed") {
1798
+ const id = Number(trimmed);
1799
+ if (!Number.isInteger(id) || id <= 0) throw new Error(`mobile-embed attaches by numeric id — pass \`--existing <id>\` (got "${raw}").`);
1800
+ return { existingId: id };
1627
1801
  }
1802
+ return { existingUuid: trimmed };
1628
1803
  }
1804
+ 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 () => {
1805
+ const kind = parseSurfaceKind(opts.kind);
1806
+ const resolved = resolveSlug(slug);
1807
+ const client = createMistClient();
1808
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
1809
+ const existing = opts.existing ? parseExisting(kind, opts.existing) : void 0;
1810
+ const result = await provisionSurface(client, {
1811
+ slugOrId: resolved,
1812
+ kind,
1813
+ name: opts.name?.trim(),
1814
+ verifyEnv: opts.wait !== false,
1815
+ reporter,
1816
+ ...existing,
1817
+ ...existing ? {} : surfaceProvisionOptionsFromFlags(kind, opts)
1818
+ });
1819
+ if (opts.json) return printJson(result);
1820
+ console.log();
1821
+ console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1822
+ printSurfaceSummary(result);
1823
+ }));
1824
+ //#endregion
1825
+ //#region src/commands/attach-droplet.ts
1629
1826
  /**
1630
- * Persist the Mist bot as the repo's LOCAL git identity
1631
- * (`git config --local user.{name,email}`).
1632
- *
1633
- * `gitAutoCommit` already forces the bot author on the publish commit
1634
- * via `-c`/env, but that only covers commits WE make. Commits made any
1635
- * other way against the same checkout — the Mist chat agent shelling out
1636
- * `git commit` via run_cli, or the user in a terminal — inherit the
1637
- * repo's configured identity, which is the person's personal GitHub
1638
- * account. Vercel rejects a deploy whose commit author isn't a
1639
- * we-commerce team member, so those personal-authored commits silently
1640
- * block the deploy (exactly the www-* "stuck project" case).
1827
+ * `fluid mist attach-droplet [name] [slug]` — droplet shortcut for
1828
+ * `fluid mist attach --kind droplet`. Creates a droplet and wires it to an
1829
+ * existing standalone mist (provisioning the managed env). Idempotent: if a
1830
+ * droplet is already attached, it skips create + attach and just verifies env.
1641
1831
  *
1642
- * Setting the identity at the repo level makes EVERY commit path
1643
- * bot-authored, and heals repos cloned before this was in place on their
1644
- * next publish. Best-effort: a config failure must never abort a push —
1645
- * the publish commit is still bot-forced by gitAutoCommit regardless.
1832
+ * name Optional droplet name (defaults to the mist's name).
1833
+ * slug Optional mist slug (defaults to the `.mist` file in CWD).
1646
1834
  */
1647
- async function ensureBotIdentity(cwd) {
1648
- try {
1649
- await execa("git", [
1650
- "config",
1651
- "--local",
1652
- "user.name",
1653
- MIST_GIT_AUTHOR_NAME
1654
- ], { cwd });
1655
- await execa("git", [
1656
- "config",
1657
- "--local",
1658
- "user.email",
1659
- MIST_GIT_AUTHOR_EMAIL
1660
- ], { cwd });
1661
- } catch {}
1662
- }
1835
+ 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 () => {
1836
+ const resolved = resolveSlug(slug);
1837
+ const client = createMistClient();
1838
+ const reporter = opts.json ? createSilentReporter() : createOraReporter();
1839
+ const result = await provisionSurface(client, {
1840
+ slugOrId: resolved,
1841
+ kind: "droplet",
1842
+ name: name?.trim() || void 0,
1843
+ verifyEnv: opts.wait !== false,
1844
+ reporter
1845
+ });
1846
+ if (opts.json) return printJson(result);
1847
+ console.log();
1848
+ console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
1849
+ printSurfaceSummary(result);
1850
+ }));
1851
+ //#endregion
1852
+ //#region src/commands/update.ts
1663
1853
  /**
1664
- * `git add -A && git commit -m <message>` in `cwd`.
1854
+ * `fluid mist update` — mutate an existing mist. Hostable is retired
1855
+ * (#19684/#19687), so attach/detach operate on integration points:
1665
1856
  *
1666
- * Mist Desktop's "Publish" UX target is non-technical: the user expects
1667
- * "ship my changes" to ship the changes they made, not to know about
1668
- * staging or commit messages. This helper bundles working-tree edits
1669
- * into one commit per publish so push has something to send.
1857
+ * --name <label> Rename the mist (PATCH /mists/:id).
1858
+ * --hostable droplet:<uuid> Attach a droplet by uuid. No-ops if that same
1859
+ * droplet is already attached; replaces it if a
1860
+ * different droplet is attached (droplet is
1861
+ * 1-per-mist — it provisions the managed env).
1862
+ * --detach Remove the droplet integration point.
1670
1863
  *
1671
- * The commit author is ALWAYS forced to the Mist GitHub App bot identity
1672
- * (MIST_GIT_AUTHOR_*), overriding whatever local/global git identity the
1673
- * machine has. This is what keeps Vercel from blocking the deploy: a commit
1674
- * authored by a user's personal GitHub account that isn't a we-commerce Vercel
1675
- * team member gets rejected. Passing `-c user.name=… -c user.email=…` beats
1676
- * even a `--local` config, so every publish is bot-authored regardless of how
1677
- * the repo landed on disk (fresh clone, pre-existing clone, or terminal CLI).
1678
- * The real person is still recorded in the commit *message* (see push.ts).
1864
+ * The integration-point link is immutable (no update route, no `active`
1865
+ * field) — enablement lives on the surface record, so there's no toggle here.
1866
+ * All ops re-derive state from the integration-points index (which echoes
1867
+ * `integratable_uuid` since #19689) and only make the calls that change it.
1679
1868
  */
1680
- async function gitAutoCommit(cwd, message) {
1681
- await ensureBotIdentity(cwd);
1682
- try {
1683
- await execa("git", ["add", "-A"], { cwd });
1684
- } catch (err) {
1685
- const missing = gitMissingError(err);
1686
- if (missing) throw missing;
1687
- throw new GitError(`git add failed`, err.exitCode ?? 1);
1869
+ 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 () => {
1870
+ if (opts.hostable && opts.detach) throw new Error("Pass either `--hostable` OR `--detach`, not both — they're mutually exclusive.");
1871
+ if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass `--name`, `--hostable`, or `--detach`.");
1872
+ const resolved = resolveSlug(slug);
1873
+ const client = createMistClient();
1874
+ const notes = [];
1875
+ let renameResp;
1876
+ if (opts.name?.trim()) {
1877
+ renameResp = await updateMist(client, resolved, { mist: { name: opts.name.trim() } });
1878
+ if (!opts.json) {
1879
+ const m = renameResp.mist;
1880
+ console.log(`${mistLabel(m.name, m.vendor_name)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
1881
+ }
1688
1882
  }
1689
- const identityArgs = [
1690
- "-c",
1691
- `user.name=${MIST_GIT_AUTHOR_NAME}`,
1692
- "-c",
1693
- `user.email=${MIST_GIT_AUTHOR_EMAIL}`
1694
- ];
1695
- const identityEnv = {
1696
- GIT_AUTHOR_NAME: MIST_GIT_AUTHOR_NAME,
1697
- GIT_AUTHOR_EMAIL: MIST_GIT_AUTHOR_EMAIL,
1698
- GIT_COMMITTER_NAME: MIST_GIT_AUTHOR_NAME,
1699
- GIT_COMMITTER_EMAIL: MIST_GIT_AUTHOR_EMAIL
1700
- };
1701
- try {
1702
- await execa("git", [
1703
- ...identityArgs,
1704
- "commit",
1705
- "-m",
1706
- message
1707
- ], {
1708
- cwd,
1709
- env: {
1710
- ...process.env,
1711
- ...identityEnv
1712
- },
1713
- stdio: [
1714
- "ignore",
1715
- "inherit",
1716
- "inherit"
1717
- ]
1718
- });
1719
- } catch (err) {
1720
- const missing = gitMissingError(err);
1721
- if (missing) throw missing;
1722
- const e = err;
1723
- if (typeof e.stderr === "string" && /nothing to commit/i.test(e.stderr)) return;
1724
- throw new GitError(`git commit failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
1883
+ if (opts.hostable || opts.detach) {
1884
+ const existing = findDropletIntegrationPoint((await listIntegrationPoints(client, resolved)).integration_points);
1885
+ if (opts.hostable) {
1886
+ const dropletUuid = parseDropletRef(opts.hostable);
1887
+ if (existing && existing.integratable_uuid === dropletUuid) notes.push(`droplet ${dropletUuid} already attached (integration ${existing.id})`);
1888
+ else {
1889
+ if (existing) {
1890
+ await deleteIntegrationPoint(client, resolved, existing.id);
1891
+ notes.push(`replaced integration ${existing.id}`);
1892
+ }
1893
+ const ip = await createIntegrationPoint(client, resolved, {
1894
+ kind: "droplet",
1895
+ integratable_uuid: dropletUuid
1896
+ });
1897
+ notes.push(`attached droplet ${dropletUuid} (integration ${ip.integration_point.id})`);
1898
+ }
1899
+ } else if (opts.detach) if (existing) {
1900
+ await deleteIntegrationPoint(client, resolved, existing.id);
1901
+ notes.push(`detached droplet (removed integration ${existing.id})`);
1902
+ } else notes.push("already standalone (no droplet integration)");
1903
+ }
1904
+ if (opts.json) return printJson(renameResp ? {
1905
+ ...renameResp,
1906
+ slug: resolved,
1907
+ notes
1908
+ } : {
1909
+ slug: resolved,
1910
+ notes
1911
+ });
1912
+ for (const note of notes) console.log(chalk.dim(` ${note}`));
1913
+ }));
1914
+ //#endregion
1915
+ //#region src/commands/delete.ts
1916
+ /**
1917
+ * `fluid mist delete` — scheduled teardown 15 days out (spec §3 "15-day
1918
+ * grace period"). DO NOT poll for completion.
1919
+ */
1920
+ 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 () => {
1921
+ const resolved = resolveSlug(slug);
1922
+ const client = createMistClient();
1923
+ if (!opts.yes) {
1924
+ const { mist } = await showMist(client, resolved);
1925
+ const fifteenDaysOut = new Date(Date.now() + 15 * 864e5).toISOString().slice(0, 10);
1926
+ console.log();
1927
+ console.log(`About to schedule ${chalk.bold(mist.name ?? mist.vendor_name ?? mist.slug)} (${chalk.dim(mist.slug)}) for destruction on ${chalk.yellow(fifteenDaysOut)}.`);
1928
+ console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
1929
+ const { confirmed } = await prompts({
1930
+ type: "confirm",
1931
+ name: "confirmed",
1932
+ message: "Continue?",
1933
+ initial: false
1934
+ }, { onCancel: () => process.exit(130) });
1935
+ if (!confirmed) {
1936
+ console.log(chalk.dim("Aborted."));
1937
+ process.exit(0);
1938
+ }
1725
1939
  }
1940
+ const { mist } = await deleteMist(client, resolved);
1941
+ if (opts.json) return printJson({ mist });
1942
+ console.log(chalk.magenta(`Scheduled for destruction on ${mist.scheduled_destroy_at ? formatAbsolute(mist.scheduled_destroy_at) : "the 15-day mark"}.`));
1943
+ console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
1944
+ }));
1945
+ //#endregion
1946
+ //#region src/commands/restore.ts
1947
+ /** `fluid mist restore` — un-delete within the 15-day window. Spec §3. */
1948
+ 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 () => {
1949
+ const resolved = resolveSlug(slug);
1950
+ const { mist } = await restoreMist(createMistClient(), resolved);
1951
+ if (opts.json) return printJson({ mist });
1952
+ console.log(`Restored: ${mist.public_url ? chalk.cyan(mist.public_url) : mist.name ?? mist.vendor_name ?? mist.slug} (${colorState(mist.state)})`);
1953
+ }));
1954
+ //#endregion
1955
+ //#region src/api/git_credential.ts
1956
+ async function createGitCredential(client, slug) {
1957
+ const id = await resolveMistId(client, slug);
1958
+ return client.post(`/api/v202604/mists/${id}/git_credential`);
1726
1959
  }
1727
1960
  //#endregion
1728
1961
  //#region src/commands/clone.ts
@@ -2035,12 +2268,67 @@ const logsCommand = new Command("logs").description("Fetch deployment logs (use
2035
2268
  }
2036
2269
  }));
2037
2270
  //#endregion
2271
+ //#region src/lib/encrypted-env.ts
2272
+ /**
2273
+ * Detects Vercel-encrypted env var ciphertext.
2274
+ *
2275
+ * `GET /api/v202604/mists/:id/env_vars` is a thin proxy to the Mist's
2276
+ * Vercel project env — it returns values ENCRYPTED, never plaintext (see
2277
+ * apps/mist-desktop/src/main/services/mist-env-vars.service.ts:25-28,
2278
+ * which documents the same server behavior for the desktop app's copy of
2279
+ * this API). The ciphertext is a base64-encoded JSON envelope of shape
2280
+ * `{"v":"v2","c":"<base64 ciphertext>","k":[<byte array>]}`.
2281
+ *
2282
+ * `fluid mist env pull` uses this to avoid writing that ciphertext into
2283
+ * `.env.local` as if it were a usable value.
2284
+ */
2285
+ const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
2286
+ function looksLikeBase64(value) {
2287
+ return value.length > 0 && value.length % 4 === 0 && BASE64_RE.test(value);
2288
+ }
2289
+ function hasEncryptedEnvelopeShape(value) {
2290
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "v" in value && "c" in value && "k" in value;
2291
+ }
2292
+ /**
2293
+ * True when `value` is the Vercel encrypted-env envelope: a base64 string
2294
+ * that decodes to a JSON object with the `{ v, c, k }` shape. Returns
2295
+ * false for empty strings, non-base64 strings, and base64 strings that
2296
+ * decode to plaintext or to JSON that doesn't match the envelope shape —
2297
+ * so real plaintext secrets that happen to look like base64 are never
2298
+ * misclassified as ciphertext.
2299
+ */
2300
+ function isVercelEncryptedEnvValue(value) {
2301
+ if (!looksLikeBase64(value)) return false;
2302
+ let decoded;
2303
+ try {
2304
+ decoded = Buffer.from(value, "base64").toString("utf8");
2305
+ } catch {
2306
+ return false;
2307
+ }
2308
+ let parsed;
2309
+ try {
2310
+ parsed = JSON.parse(decoded);
2311
+ } catch {
2312
+ return false;
2313
+ }
2314
+ return hasEncryptedEnvelopeShape(parsed);
2315
+ }
2316
+ //#endregion
2038
2317
  //#region src/commands/env/list.ts
2039
2318
  /** `fluid mist env list` — list env vars; mark managed_by entries. Spec §4. */
2040
2319
  function maskValue(value) {
2041
2320
  if (value.length <= 8) return "•".repeat(value.length);
2042
2321
  return `${value.slice(0, 4)}${"•".repeat(8)}${value.slice(-2)}`;
2043
2322
  }
2323
+ /**
2324
+ * Values come back Vercel-encrypted ciphertext (see
2325
+ * `../../lib/encrypted-env.ts`) — never show that raw blob, masked or not,
2326
+ * since it isn't the real secret and can run to ~1KB of base64.
2327
+ */
2328
+ function displayValue(v, showValues) {
2329
+ if (isVercelEncryptedEnvValue(v.value)) return chalk.dim("<encrypted>");
2330
+ return showValues ? v.value : maskValue(v.value);
2331
+ }
2044
2332
  const envListCommand = new Command("list").description("List a mist's env vars").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--show-values", "Print full values instead of masking them").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
2045
2333
  const resolved = resolveSlug(slug);
2046
2334
  const response = await listEnvVars(createMistClient(), resolved);
@@ -2057,7 +2345,7 @@ const envListCommand = new Command("list").description("List a mist's env vars")
2057
2345
  chalk.bold("MANAGED")
2058
2346
  ], ...vars.map((v) => [
2059
2347
  v.key,
2060
- opts.showValues ? v.value : maskValue(v.value),
2348
+ displayValue(v, opts.showValues),
2061
2349
  v.targets.join(","),
2062
2350
  v.managed_by === "mist" ? chalk.magenta("mist") : chalk.dim("user")
2063
2351
  ])]);
@@ -2137,21 +2425,79 @@ const envUnsetCommand = new Command("unset").description("Delete one or more env
2137
2425
  * `fluid mist env pull` — writes `.env.local` with user-managed vars only.
2138
2426
  * Skips `managed_by: "mist"` entries (DATABASE_URL, FLUID_*) since local
2139
2427
  * dev uses PGlite, not the production database. Spec §4 + §5.
2428
+ *
2429
+ * Also skips user-managed vars whose value comes back as Vercel-encrypted
2430
+ * ciphertext (`GET .../env_vars` is a proxy to Vercel's project env, which
2431
+ * never returns plaintext — see `../../lib/encrypted-env.ts`). Writing that
2432
+ * ciphertext into `.env.local` verbatim would silently produce an unusable
2433
+ * file, so those keys are collected and surfaced as a warning instead.
2434
+ *
2435
+ * The output file is MERGED, not replaced: pulled plaintext values update
2436
+ * their existing lines (or are appended), while every other existing line
2437
+ * — comments, locally maintained secrets for encrypted/managed keys, and
2438
+ * local-only vars — is preserved verbatim. Replacing the file wholesale
2439
+ * would delete manually set values for skipped keys, and an all-encrypted
2440
+ * response would truncate the file to zero bytes right before telling the
2441
+ * user to set those same values locally.
2140
2442
  */
2141
2443
  function escapeValue(v) {
2142
2444
  if (/[\s#"']/.test(v)) return `"${v.replace(/(["\\$`])/g, "\\$1")}"`;
2143
2445
  return v;
2144
2446
  }
2447
+ /**
2448
+ * Splits the raw env-var list into what `env pull` can safely write vs.
2449
+ * what it must skip, and why. Pure — no I/O — so it's unit-testable
2450
+ * without mocking the API client or the filesystem.
2451
+ */
2452
+ function partitionPullEnvVars(envVars) {
2453
+ const plaintext = [];
2454
+ const encrypted = [];
2455
+ const managed = [];
2456
+ for (const v of envVars) if (v.managed_by === "mist") managed.push(v);
2457
+ else if (isVercelEncryptedEnvValue(v.value)) encrypted.push(v);
2458
+ else plaintext.push(v);
2459
+ return {
2460
+ plaintext,
2461
+ encrypted,
2462
+ managed
2463
+ };
2464
+ }
2465
+ /** Matches a `KEY=` / `export KEY=` assignment line and captures the key. */
2466
+ const ENV_ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
2467
+ /**
2468
+ * Merges pulled plaintext vars into the existing env-file contents.
2469
+ * Existing assignment lines for pulled keys are updated in place; every
2470
+ * other line (comments, blank lines, locally maintained values for keys
2471
+ * the pull skipped) is preserved verbatim; new keys are appended. Pure —
2472
+ * no I/O — so it's unit-testable without touching the filesystem.
2473
+ */
2474
+ function mergeEnvContents(existing, plaintext) {
2475
+ const updates = new Map(plaintext.map((v) => [v.key, `${v.key}=${escapeValue(v.value)}`]));
2476
+ const lines = existing.length > 0 ? existing.split("\n") : [];
2477
+ if (lines.at(-1) === "") lines.pop();
2478
+ const out = [];
2479
+ for (const line of lines) {
2480
+ const key = ENV_ASSIGNMENT.exec(line)?.[1];
2481
+ const replacement = key === void 0 ? void 0 : updates.get(key);
2482
+ if (key !== void 0 && replacement !== void 0) {
2483
+ out.push(replacement);
2484
+ updates.delete(key);
2485
+ } else out.push(line);
2486
+ }
2487
+ for (const rendered of updates.values()) out.push(rendered);
2488
+ return out.length > 0 ? out.join("\n") + "\n" : "";
2489
+ }
2145
2490
  const envPullCommand = new Command("pull").description("Write user-managed env vars to .env.local in the CWD").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--out <path>", "Output path (default: .env.local)").action((slug, opts) => runAction(async () => {
2146
2491
  const resolved = resolveSlug(slug);
2147
2492
  const { env_vars } = await listEnvVars(createMistClient(), resolved);
2148
- const user = env_vars.filter((v) => v.managed_by === "user");
2149
- const skipped = env_vars.filter((v) => v.managed_by === "mist");
2150
- const target = join(process.cwd(), opts.out ?? ".env.local");
2151
- const body = user.map((v) => `${v.key}=${escapeValue(v.value)}`).join("\n");
2152
- writeFileSync(target, body + (body.length ? "\n" : ""), "utf8");
2153
- console.log(chalk.green(`Wrote ${user.length} var(s) to ${target.replace(process.cwd() + "/", "")}.`));
2154
- if (skipped.length > 0) console.log(chalk.dim(`Skipped ${skipped.length} Mist-managed var(s): ${skipped.map((v) => v.key).join(", ")} (local dev uses PGlite)`));
2493
+ const { plaintext, encrypted, managed } = partitionPullEnvVars(env_vars);
2494
+ const outName = opts.out ?? ".env.local";
2495
+ const target = join(process.cwd(), outName);
2496
+ const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
2497
+ writeFileSync(target, mergeEnvContents(existing, plaintext), "utf8");
2498
+ console.log(chalk.green(`Wrote ${plaintext.length} var(s) to ${target.replace(process.cwd() + "/", "")}${existing.length > 0 ? " (existing entries for other keys preserved)" : ""}.`));
2499
+ if (managed.length > 0) console.log(chalk.dim(`Skipped ${managed.length} Mist-managed var(s): ${managed.map((v) => v.key).join(", ")} (local dev uses PGlite)`));
2500
+ if (encrypted.length > 0) console.log(chalk.yellow(`Skipped ${encrypted.length} var(s) whose values are stored encrypted and can't be pulled: ${encrypted.map((v) => v.key).join(", ")} — set them locally in ${outName} yourself (values are visible in the Vercel dashboard) or re-set them via \`fluid mist env set\`.`));
2155
2501
  }));
2156
2502
  //#endregion
2157
2503
  //#region src/commands/env/index.ts