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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -579,10 +579,10 @@ async function ensureBotIdentity(cwd) {
579
579
  } catch {}
580
580
  }
581
581
  /**
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`.
582
+ * Append Mist's local-only state to `.git/info/exclude` when needed:
583
+ * `.mist` is the clone marker from spec §3, while `.mist-desktop/`
584
+ * contains private chat history, attachments, screenshots, and other
585
+ * machine-local Desktop state. Neither belongs in the project remote.
586
586
  *
587
587
  * Why local exclude and not `.gitignore`: `.gitignore` lives in the
588
588
  * template repo and would need a template release to reach existing
@@ -593,9 +593,8 @@ async function ensureBotIdentity(cwd) {
593
593
  *
594
594
  * Idempotent and best-effort: never throws. A repo with an unusual
595
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.
596
+ * etc.) just skips the exclude — `gitAutoCommit` also cleans both paths
597
+ * from the index after staging.
599
598
  */
600
599
  function excludeMistMarkerFromGit(cwd) {
601
600
  try {
@@ -605,8 +604,10 @@ function excludeMistMarkerFromGit(cwd) {
605
604
  if (!existsSync(infoDir)) mkdirSync(infoDir, { recursive: true });
606
605
  const excludePath = path.join(infoDir, "exclude");
607
606
  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");
607
+ const existingLines = new Set(existing.split("\n").map((line) => line.trim()).filter(Boolean));
608
+ const missing = [".mist", ".mist-desktop/"].filter((entry) => !existingLines.has(entry));
609
+ if (missing.length === 0) return;
610
+ writeFileSync(excludePath, `${existing}${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
610
611
  } catch {}
611
612
  }
612
613
  /**
@@ -626,10 +627,9 @@ function excludeMistMarkerFromGit(cwd) {
626
627
  * the repo landed on disk (fresh clone, pre-existing clone, or terminal CLI).
627
628
  * The real person is still recorded in the commit *message* (see push.ts).
628
629
  *
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:
630
+ * Local Mist state handling: clone writes `.mist` and `.mist-desktop/` to
631
+ * `.git/info/exclude`, but this heals a pre-existing clone or a repo whose
632
+ * exclude write silently failed. Belt AND suspenders:
633
633
  * 1. Re-run the exclude write here (idempotent) — heals pre-existing
634
634
  * clones on their very next push. Anchored to the repository root
635
635
  * (`git rev-parse --show-toplevel`), not cwd, so a push run from a
@@ -647,6 +647,10 @@ function excludeMistMarkerFromGit(cwd) {
647
647
  * case (exclude already written) into a hard failure. Add-then-
648
648
  * reset avoids that entirely and is a no-op when there's nothing
649
649
  * to unstage.
650
+ * 3. Run `git rm --cached -r --ignore-unmatch .mist-desktop` from the
651
+ * repository root. This keeps every local file on disk while staging
652
+ * their removal if an older publish already tracked them. Ignore rules
653
+ * alone cannot untrack an existing path.
650
654
  * Note: a `.mist` file already committed in an existing repo's history
651
655
  * is left alone — this only prevents *new* adds, it does not untrack
652
656
  * anything or rewrite history.
@@ -675,6 +679,17 @@ async function gitAutoCommit(cwd, message) {
675
679
  ".mist"
676
680
  ], { cwd });
677
681
  } catch {}
682
+ try {
683
+ await execa("git", [
684
+ "rm",
685
+ "-q",
686
+ "-r",
687
+ "--cached",
688
+ "--ignore-unmatch",
689
+ "--",
690
+ ".mist-desktop"
691
+ ], { cwd: repoRoot });
692
+ } catch {}
678
693
  const identityArgs = [
679
694
  "-c",
680
695
  `user.name=${MIST_GIT_AUTHOR_NAME}`,
@@ -846,6 +861,12 @@ const STATE_COLOR$1 = {
846
861
  function colorState(state) {
847
862
  return STATE_COLOR$1[state](state);
848
863
  }
864
+ /** Completion copy for `mist push --watch`. Deployment records expose an
865
+ * ephemeral provider URL that may require provider SSO; only the Mist's
866
+ * stable public URL is suitable to hand to a user. */
867
+ function readyDeploymentMessage(publicUrl, slug) {
868
+ return publicUrl ? `✔ Ready: ${chalk.cyan(publicUrl)}` : `✔ Deployment ready. The public URL is still provisioning; run \`fluid mist show ${slug}\` to check it.`;
869
+ }
849
870
  /**
850
871
  * Bold display label for a mist, falling back to a dim "(unnamed)"
851
872
  * placeholder when both `name` and `vendorName` are missing. Never nests
@@ -1056,7 +1077,41 @@ function createSilentReporter() {
1056
1077
  };
1057
1078
  }
1058
1079
  //#endregion
1080
+ //#region ../../mist/core/src/lifecycle-urls.ts
1081
+ function normalizeLifecycleOrigin(value) {
1082
+ let url;
1083
+ try {
1084
+ url = new URL(value);
1085
+ } catch {
1086
+ throw new Error("Lifecycle origin must be an absolute HTTP(S) URL");
1087
+ }
1088
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Lifecycle origin must be an absolute HTTP(S) URL");
1089
+ url.pathname = url.pathname.replace(/\/+$/, "");
1090
+ url.search = "";
1091
+ url.hash = "";
1092
+ return url.toString().replace(/\/$/, "");
1093
+ }
1094
+ function lifecycleUrls(publicUrl, paths = {}) {
1095
+ const origin = normalizeLifecycleOrigin(publicUrl);
1096
+ return {
1097
+ installWebhookUrl: `${origin}${normalizePath(paths.installPath ?? "/api/webhooks/installed")}`,
1098
+ uninstallWebhookUrl: `${origin}${normalizePath(paths.uninstallPath ?? "/api/webhooks/uninstalled")}`
1099
+ };
1100
+ }
1101
+ function normalizePath(value) {
1102
+ return `/${value.replace(/^\/+/, "")}`;
1103
+ }
1104
+ //#endregion
1059
1105
  //#region src/api/droplets.ts
1106
+ /**
1107
+ * Droplet CRUD over `/api/droplets`. Thin wrapper — the provisioning
1108
+ * orchestration (idempotency, embed-url readiness, attach) lives in
1109
+ * `lib/droplet-provision.ts`.
1110
+ *
1111
+ * Note: droplets are a company-level resource, so unlike the mist
1112
+ * sub-resources these paths take the droplet `uuid` (drp_…) directly and
1113
+ * don't go through `resolveMistId`.
1114
+ */
1060
1115
  const BASE$3 = "/api/droplets";
1061
1116
  /**
1062
1117
  * Full scope set from `Droplet::Installation::VALID_SCOPES`. We request
@@ -1077,12 +1132,14 @@ const DEFAULT_DROPLET_SCOPES = [
1077
1132
  "header_menus",
1078
1133
  "payments"
1079
1134
  ];
1080
- /** Lifecycle webhook paths a Mist Next app serves (mirrors Mist Desktop). */
1135
+ /** Lifecycle webhook paths a Mist Next app serves (mirrors Mist Desktop).
1136
+ * `embedUrl` must be an absolute HTTP(S) URL so malformed deployment metadata
1137
+ * fails before a Droplet is created with unusable callback URLs. */
1081
1138
  function dropletWebhookUrls(embedUrl) {
1082
- const base = embedUrl.replace(/\/+$/, "");
1139
+ const urls = lifecycleUrls(embedUrl);
1083
1140
  return {
1084
- install_webhook_url: `${base}/api/webhooks/installed`,
1085
- uninstall_webhook_url: `${base}/api/webhooks/uninstalled`
1141
+ install_webhook_url: urls.installWebhookUrl,
1142
+ uninstall_webhook_url: urls.uninstallWebhookUrl
1086
1143
  };
1087
1144
  }
1088
1145
  /**
@@ -1131,16 +1188,22 @@ async function findDropletUuidByNumericId(client, id) {
1131
1188
  }
1132
1189
  return null;
1133
1190
  }
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)
1143
- });
1191
+ /**
1192
+ * Recover a Droplet created by an interrupted surface-provisioning run. The
1193
+ * canonical Mist embed URL is stable and unique to that deployment, so a
1194
+ * matching unlinked Droplet can be attached instead of creating a duplicate.
1195
+ */
1196
+ async function findDropletByEmbedUrl(client, embedUrl) {
1197
+ const expectedUrl = embedUrl.replace(/\/$/, "");
1198
+ for (let page = 1; page <= MAX_PAGES$1; page++) {
1199
+ const resp = await client.get(`${BASE$3}?page=${page}&per_page=${PER_PAGE$1}`);
1200
+ const batch = resp.droplets ?? [];
1201
+ const match = batch.find((droplet) => droplet.embed_url?.replace(/\/$/, "") === expectedUrl);
1202
+ if (match) return match;
1203
+ const totalPages = resp.meta?.pagination?.total_pages;
1204
+ if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE$1) break;
1205
+ }
1206
+ return null;
1144
1207
  }
1145
1208
  //#endregion
1146
1209
  //#region src/api/droplet_installations.ts
@@ -1358,37 +1421,35 @@ async function provisionSurface(client, options) {
1358
1421
  timing,
1359
1422
  sleep
1360
1423
  });
1424
+ if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy before creating a surface.");
1361
1425
  const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name || mist.slug;
1362
- reporter.start(`Creating ${label} "${name}"…`);
1363
1426
  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");
1427
+ reporter.start("Checking for an interrupted Droplet create…");
1428
+ const recovered = await findDropletByEmbedUrl(client, embedUrl);
1429
+ if (recovered) {
1430
+ surfaceUuid = recovered.uuid;
1431
+ surfaceNumericId = recovered.id ?? null;
1432
+ surfaceName = recovered.name;
1433
+ attachUuid = recovered.uuid;
1434
+ embedUrlSet = true;
1435
+ reporter.skip(`Reusing droplet ${surfaceUuid} from an interrupted attach`);
1436
+ } else {
1437
+ reporter.start(`Creating ${label} "${name}"…`);
1438
+ const created = await createDroplet(client, { droplet: buildDropletCreateInput({
1439
+ name,
1440
+ embedUrl,
1441
+ requestedScopes: options.requestedScopes
1442
+ }) });
1443
+ surfaceUuid = created.droplet.uuid;
1444
+ surfaceNumericId = created.droplet.id ?? null;
1445
+ surfaceName = created.droplet.name;
1446
+ attachUuid = created.droplet.uuid;
1447
+ embedUrlSet = true;
1448
+ surfaceCreated = true;
1449
+ reporter.succeed(`Created droplet ${surfaceUuid}`);
1389
1450
  }
1390
1451
  } 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.");
1452
+ reporter.start(`Creating ${label} "${name}"…`);
1392
1453
  const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
1393
1454
  name,
1394
1455
  embedUrl,
@@ -1399,9 +1460,10 @@ async function provisionSurface(client, options) {
1399
1460
  surfaceName = created.drop_zone.name;
1400
1461
  attachUuid = created.drop_zone.uuid;
1401
1462
  embedUrlSet = true;
1463
+ surfaceCreated = true;
1402
1464
  reporter.succeed(`Created drop zone ${surfaceUuid}`);
1403
1465
  } else {
1404
- if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the mobile embed.");
1466
+ reporter.start(`Creating ${label} "${name}"…`);
1405
1467
  const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
1406
1468
  name,
1407
1469
  embedUrl,
@@ -1412,9 +1474,9 @@ async function provisionSurface(client, options) {
1412
1474
  surfaceName = created.mobile_widget.name;
1413
1475
  attachId = created.mobile_widget.id;
1414
1476
  embedUrlSet = true;
1477
+ surfaceCreated = true;
1415
1478
  reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
1416
1479
  }
1417
- surfaceCreated = true;
1418
1480
  } else {
1419
1481
  surfaceUuid = options.existingUuid ?? null;
1420
1482
  surfaceNumericId = options.existingId ?? null;
@@ -1523,7 +1585,7 @@ async function ensureDropletInstalled(client, args) {
1523
1585
  created: true
1524
1586
  };
1525
1587
  } catch (err) {
1526
- if (isAlreadyInstalledError(err)) {
1588
+ if (err instanceof MistApiError && (err.status === 409 || err.status === 422)) {
1527
1589
  const confirmed = await lookupInstallation(client, dropletUuid);
1528
1590
  if (confirmed) {
1529
1591
  reporter.skip("Droplet already installed — skipping");
@@ -1537,9 +1599,6 @@ async function ensureDropletInstalled(client, args) {
1537
1599
  throw normalizeInstallError(err, dropletUuid);
1538
1600
  }
1539
1601
  }
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
1602
  /** Best-effort installation lookup for a droplet; null when the row can't
1544
1603
  * be found or the list call fails. */
1545
1604
  async function lookupInstallation(client, dropletUuid) {
@@ -2038,10 +2097,21 @@ async function fetchCurrentUser(client) {
2038
2097
  const POLL_MS$1 = 2e3;
2039
2098
  const LOCATE_TIMEOUT_MS = 2 * 6e4;
2040
2099
  const READY_TIMEOUT_MS = 5 * 6e4;
2100
+ const PUBLIC_URL_TIMEOUT_MS = 5e3;
2041
2101
  function sleep$1(ms) {
2042
2102
  return new Promise((r) => setTimeout(r, ms));
2043
2103
  }
2044
- const pushCommand = new Command("push").description("Commit working-tree changes and push to the Mist-managed remote (triggers a Vercel deploy)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--watch", "Poll /deployments until ready or error").option("--branch <name>", "Branch to push (default: main)").option("--no-commit", "Skip the auto-commit step (push existing commits only)").option("-m, --message <message>", "Commit message for the auto-commit (default: timestamped Publish)").action((slug, opts) => runAction(async () => {
2104
+ async function withTimeout(promise, timeoutMs) {
2105
+ let timer = null;
2106
+ try {
2107
+ return await Promise.race([promise, new Promise((_, reject) => {
2108
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timed out after ${timeoutMs / 1e3}s`)), timeoutMs);
2109
+ })]);
2110
+ } finally {
2111
+ if (timer) clearTimeout(timer);
2112
+ }
2113
+ }
2114
+ const pushCommand = new Command("push").description("Commit working-tree changes and push to the Mist-managed remote (triggers a hosted deployment)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--watch", "Poll /deployments until ready or error").option("--branch <name>", "Branch to push (default: main)").option("--no-commit", "Skip the auto-commit step (push existing commits only)").option("-m, --message <message>", "Commit message for the auto-commit (default: timestamped Publish)").action((slug, opts) => runAction(async () => {
2045
2115
  await ensureGitAvailable();
2046
2116
  const resolved = resolveSlug(slug);
2047
2117
  const client = createMistClient();
@@ -2078,7 +2148,7 @@ const pushCommand = new Command("push").description("Commit working-tree changes
2078
2148
  let namePrefix = "";
2079
2149
  let idSuffix = "";
2080
2150
  try {
2081
- const me = await Promise.race([fetchCurrentUser(client), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("timed out after 5s")), 5e3))]);
2151
+ const me = await withTimeout(fetchCurrentUser(client), 5e3);
2082
2152
  const name = me.full_name && me.full_name.trim() || me.email || (Number.isFinite(me.id) ? `user-${me.id}` : null);
2083
2153
  const id = me.public_id && me.public_id.trim();
2084
2154
  if (name) namePrefix = `${name}: `;
@@ -2131,9 +2201,9 @@ const pushCommand = new Command("push").description("Commit working-tree changes
2131
2201
  await sleep$1(POLL_MS$1);
2132
2202
  }
2133
2203
  if (!deploymentId) {
2134
- console.error(`No deployment matching ${pushedSha?.slice(0, 7) ?? "the push"} appeared within ${LOCATE_TIMEOUT_MS / 1e3}s.`);
2135
- console.error("Vercel may still be queueing — run `fluid mist deployments` to check.");
2136
- process.exit(3);
2204
+ console.warn(chalk.yellow(`Push succeeded, but hosting has not started commit ${pushedSha?.slice(0, 7) ?? "(unknown)"} after ${LOCATE_TIMEOUT_MS / 1e3}s.`));
2205
+ console.warn(chalk.yellow("The deployment is still queued and may appear later. Run `fluid mist deployments` to check it — do not retry or rewrite the commit just because the hosting queue is slow."));
2206
+ return;
2137
2207
  }
2138
2208
  const readyStart = Date.now();
2139
2209
  let lastState = null;
@@ -2144,7 +2214,14 @@ const pushCommand = new Command("push").description("Commit working-tree changes
2144
2214
  lastState = deployment.state;
2145
2215
  }
2146
2216
  if (deployment.state === "ready") {
2147
- console.log(`✔ Ready: ${chalk.cyan(deployment.url ?? "(no url)")}`);
2217
+ let publicUrl = null;
2218
+ try {
2219
+ publicUrl = (await withTimeout(showMist(client, resolved), PUBLIC_URL_TIMEOUT_MS)).mist.public_url;
2220
+ } catch (err) {
2221
+ const reason = err instanceof Error ? err.message : String(err);
2222
+ console.warn(chalk.yellow(` Deployment is ready, but the public URL lookup failed: ${reason}`));
2223
+ }
2224
+ console.log(readyDeploymentMessage(publicUrl, resolved));
2148
2225
  return;
2149
2226
  }
2150
2227
  if (deployment.state === "error" || deployment.state === "canceled") {