@fluid-app/fluid-cli-mist 0.1.81 → 0.1.83

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
@@ -2,12 +2,15 @@ import { Command, InvalidArgumentError, Option } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { getAuthToken, getConfigDir, gitSyncActorFromMe, gitSyncCommitSubject, parseNameStatusZ, resolveGitSyncActor, summarizeChanges } from "@fluid-app/fluid-cli";
4
4
  import { randomUUID } from "node:crypto";
5
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
6
  import path, { join, resolve } from "node:path";
7
7
  import os from "node:os";
8
8
  import { execa } from "execa";
9
9
  import ora from "ora";
10
10
  import prompts from "prompts";
11
+ import { execFile } from "node:child_process";
12
+ import { readFile } from "node:fs/promises";
13
+ import { promisify } from "node:util";
11
14
  import open from "open";
12
15
  //#region src/api/client.ts
13
16
  /**
@@ -547,6 +550,18 @@ async function ensureGitAvailable() {
547
550
  }
548
551
  }
549
552
  /**
553
+ * Abort a transfer that has moved under 1 KB/s for a full minute. Without
554
+ * this, git on a dropped or captive-portal Wi-Fi connection waits forever
555
+ * and the caller (Fluid Mist's clone/sync) shows a spinner that never ends;
556
+ * with it, the command fails and can be retried.
557
+ */
558
+ const GIT_STALL_GUARD_ARGS = [
559
+ "-c",
560
+ "http.lowSpeedLimit=1000",
561
+ "-c",
562
+ "http.lowSpeedTime=60"
563
+ ];
564
+ /**
550
565
  * `git clone <cleanUrl> <targetDir>`. The original remoteUrl carries a
551
566
  * token; gitAuthSpawn strips it from the URL and re-injects it via
552
567
  * GIT_ASKPASS so it never appears in argv. `credential.helper=` stops
@@ -557,6 +572,7 @@ async function gitClone(remoteUrl, targetDir) {
557
572
  const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
558
573
  try {
559
574
  await execa("git", [
575
+ ...GIT_STALL_GUARD_ARGS,
560
576
  "-c",
561
577
  "credential.helper=",
562
578
  "clone",
@@ -614,6 +630,7 @@ async function gitPull(cwd, remoteUrl, branch = "main") {
614
630
  const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
615
631
  try {
616
632
  await execa("git", [
633
+ ...GIT_STALL_GUARD_ARGS,
617
634
  "-c",
618
635
  "credential.helper=",
619
636
  "pull",
@@ -636,6 +653,20 @@ async function gitPull(cwd, remoteUrl, branch = "main") {
636
653
  throw new GitError(`git pull failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
637
654
  }
638
655
  }
656
+ /** True when `relativePath` is ignored by the repo at `cwd`. */
657
+ async function isGitIgnored(cwd, relativePath) {
658
+ try {
659
+ await execa("git", [
660
+ "check-ignore",
661
+ "--quiet",
662
+ "--",
663
+ relativePath
664
+ ], { cwd });
665
+ return true;
666
+ } catch {
667
+ return false;
668
+ }
669
+ }
639
670
  /**
640
671
  * Are there uncommitted changes (working tree or index) in this repo?
641
672
  * `git status --porcelain` is empty when everything is clean, non-empty
@@ -660,6 +691,7 @@ async function gitFetch(cwd, remoteUrl, branch = "main") {
660
691
  const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
661
692
  try {
662
693
  await execa("git", [
694
+ ...GIT_STALL_GUARD_ARGS,
663
695
  "-c",
664
696
  "credential.helper=",
665
697
  "fetch",
@@ -740,21 +772,126 @@ async function gitStash(cwd, message) {
740
772
  throw new GitError(`git stash push failed`, err.exitCode ?? 1);
741
773
  }
742
774
  }
775
+ /** Read the current HEAD sha, or null if there is no commit yet. */
776
+ async function getHeadSha(cwd) {
777
+ try {
778
+ const { stdout } = await execa("git", [
779
+ "rev-parse",
780
+ "--verify",
781
+ "HEAD"
782
+ ], { cwd });
783
+ return stdout.trim() || null;
784
+ } catch {
785
+ return null;
786
+ }
787
+ }
788
+ /** Shown when a teammate's pulled change collides with local edits. */
789
+ const STASH_CONFLICT_MESSAGE = "Your unpublished edits conflict with a teammate's change to the same files. Nothing was published and your edits are back exactly as they were. Merge the teammate's change first (ask Mist to, or run `git pull` in a terminal), then publish again.";
743
790
  /**
744
- * Pop the most recent stash. Throws on conflict so callers can surface
745
- * the situation (the working tree will have conflict markers; the user
746
- * needs to resolve manually).
791
+ * Pop the most recent stash.
792
+ *
793
+ * Without `restoreHead`, a failed pop throws and leaves the tree as git
794
+ * left it. With `restoreHead` (the HEAD the stash was taken on, captured
795
+ * BEFORE the pull), a failed pop never leaves conflict markers behind: the
796
+ * tree is hard-reset to `restoreHead`, any untracked files the failed pop
797
+ * already wrote are removed (the stash still holds them), and the stash is
798
+ * popped again onto the base it was taken from — which applies cleanly.
799
+ * The pulled commits are dropped from HEAD (they stay on the remote), and
800
+ * a GitError with {@link STASH_CONFLICT_MESSAGE} is thrown so the caller
801
+ * aborts the publish instead of committing `<<<<<<<` markers.
747
802
  */
748
- async function gitStashPop(cwd) {
803
+ async function gitStashPop(cwd, restoreHead) {
804
+ try {
805
+ await execa("git", ["stash", "pop"], { cwd });
806
+ return;
807
+ } catch (err) {
808
+ const missing = gitMissingError(err);
809
+ if (missing) throw missing;
810
+ if (!restoreHead) 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);
811
+ }
812
+ await restoreStashOnto(cwd, restoreHead);
813
+ throw new GitError(STASH_CONFLICT_MESSAGE, 1);
814
+ }
815
+ async function restoreStashOnto(cwd, head) {
749
816
  try {
817
+ await execa("git", [
818
+ "reset",
819
+ "--hard",
820
+ "--quiet",
821
+ head
822
+ ], { cwd });
823
+ const untracked = await execa("git", [
824
+ "ls-tree",
825
+ "-r",
826
+ "-z",
827
+ "--name-only",
828
+ "stash@{0}^3"
829
+ ], {
830
+ cwd,
831
+ reject: false
832
+ });
833
+ if (untracked.exitCode === 0) {
834
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"], { cwd })).stdout.trim();
835
+ for (const rel of untracked.stdout.split("\0").filter(Boolean)) rmSync(path.join(root, rel), { force: true });
836
+ }
750
837
  await execa("git", ["stash", "pop"], { cwd });
751
838
  } catch (err) {
752
839
  const missing = gitMissingError(err);
753
840
  if (missing) throw missing;
754
- 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);
841
+ throw new GitError("Your edits conflict with a teammate's change and couldn't be restored automatically. Nothing was published; your edits are saved in `git stash list`.", 1);
755
842
  }
756
843
  }
757
844
  /**
845
+ * Refuse to commit a tree that still carries merge-conflict state:
846
+ * unmerged index entries (`git ls-files -u`), or — checked after staging —
847
+ * a staged file with a line starting `<<<<<<< `. Publishing either would
848
+ * deploy a broken site.
849
+ */
850
+ async function assertNoUnmergedPaths(cwd) {
851
+ const { stdout } = await execa("git", [
852
+ "ls-files",
853
+ "-u",
854
+ "-z"
855
+ ], {
856
+ cwd,
857
+ reject: false
858
+ });
859
+ if (stdout.trim().length > 0) throw new GitError(CONFLICT_REFUSAL_MESSAGE, 1);
860
+ }
861
+ const CONFLICT_REFUSAL_MESSAGE = "Some files still have unresolved merge conflicts (lines starting `<<<<<<<`). Nothing was published. Resolve the conflicts, then publish again.";
862
+ async function assertNoStagedConflictMarkers(cwd) {
863
+ const staged = await execa("git", [
864
+ "diff",
865
+ "--cached",
866
+ "--name-only",
867
+ "--diff-filter=ACMR",
868
+ "-z"
869
+ ], {
870
+ cwd,
871
+ reject: false
872
+ });
873
+ const stagedPaths = new Set(staged.stdout.split("\0").filter(Boolean));
874
+ if (stagedPaths.size === 0) return;
875
+ const hits = await execa("git", [
876
+ "grep",
877
+ "--cached",
878
+ "-I",
879
+ "-l",
880
+ "-z",
881
+ "--full-name",
882
+ "-E",
883
+ "^<<<<<<< ",
884
+ "--",
885
+ ":(top)"
886
+ ], {
887
+ cwd,
888
+ reject: false
889
+ });
890
+ if (hits.exitCode !== 0) return;
891
+ const conflicted = hits.stdout.split("\0").filter((p) => p && stagedPaths.has(p));
892
+ if (conflicted.length > 0) throw new GitError(`${CONFLICT_REFUSAL_MESSAGE} Conflicted: ${conflicted.slice(0, 5).join(", ")}`, 1);
893
+ }
894
+ /**
758
895
  * Persist the Mist bot as the repo's LOCAL git identity
759
896
  * (`git config --local user.{name,email}`).
760
897
  *
@@ -898,6 +1035,7 @@ async function gitAutoCommit(cwd, message) {
898
1035
  if (stdout.trim().length > 0) repoRoot = stdout.trim();
899
1036
  } catch {}
900
1037
  excludeMistMarkerFromGit(repoRoot);
1038
+ await assertNoUnmergedPaths(cwd);
901
1039
  try {
902
1040
  await execa("git", ["add", "-A"], { cwd });
903
1041
  } catch (err) {
@@ -925,6 +1063,7 @@ async function gitAutoCommit(cwd, message) {
925
1063
  ".mist-desktop"
926
1064
  ], { cwd: repoRoot });
927
1065
  } catch {}
1066
+ await assertNoStagedConflictMarkers(cwd);
928
1067
  const subject = typeof message === "string" ? message : await message(await stagedChanges(cwd));
929
1068
  const identityArgs = [
930
1069
  "-c",
@@ -3416,6 +3555,65 @@ async function createGitCredential(client, slug) {
3416
3555
  return client.post(`/api/v202604/mists/${id}/git_credential`);
3417
3556
  }
3418
3557
  //#endregion
3558
+ //#region src/lib/env-placeholders.ts
3559
+ /**
3560
+ * After `fluid mist clone`, list the production env keys the checkout has no
3561
+ * local value for, as placeholders in `.env.local`.
3562
+ *
3563
+ * A fresh clone has no env file, so the app fails at the first missing key
3564
+ * with an error that rarely names it. Writing the keys (never the values —
3565
+ * Vercel returns ciphertext, and secrets don't belong on disk unasked) tells
3566
+ * the user exactly what to fill in. Mist-managed keys (DATABASE_URL,
3567
+ * FLUID_*) are left out: local dev provides its own.
3568
+ *
3569
+ * The placeholders are commented out on purpose. An uncommented `KEY=`
3570
+ * defines the variable as an empty string, which `process.env.KEY ?? default`
3571
+ * and Next.js's NEXT_PUBLIC_ inlining treat differently from unset — so an
3572
+ * empty placeholder could break an app that works with the key absent.
3573
+ */
3574
+ /** Env files Next.js and Vite load in local development. */
3575
+ const LOCAL_ENV_FILES = [
3576
+ ".env",
3577
+ ".env.local",
3578
+ ".env.development",
3579
+ ".env.development.local"
3580
+ ];
3581
+ const ENV_ASSIGNMENT$1 = /^\s*#?\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
3582
+ /** Keys assigned (or already listed as a placeholder) in the local env files. */
3583
+ function localEnvKeys(dir) {
3584
+ const keys = /* @__PURE__ */ new Set();
3585
+ for (const name of LOCAL_ENV_FILES) {
3586
+ const file = join(dir, name);
3587
+ if (!existsSync(file)) continue;
3588
+ for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
3589
+ const key = ENV_ASSIGNMENT$1.exec(line)?.[1];
3590
+ if (key) keys.add(key);
3591
+ }
3592
+ }
3593
+ return keys;
3594
+ }
3595
+ /** Production keys a user set that the checkout doesn't define locally. */
3596
+ function missingProductionKeys(envVars, localKeys) {
3597
+ const missing = /* @__PURE__ */ new Set();
3598
+ for (const v of envVars) {
3599
+ if (v.managed_by === "mist") continue;
3600
+ if (!v.targets.includes("production")) continue;
3601
+ if (localKeys.has(v.key)) continue;
3602
+ missing.add(v.key);
3603
+ }
3604
+ return [...missing].sort();
3605
+ }
3606
+ /** The block appended to `.env.local`, or "" when nothing is missing. */
3607
+ function renderEnvPlaceholders(keys) {
3608
+ if (keys.length === 0) return "";
3609
+ return [
3610
+ "# Set in production but not here. Uncomment and fill in the ones this",
3611
+ "# app needs locally (values are never copied — see `fluid mist env pull`).",
3612
+ ...keys.map((key) => `# ${key}=`),
3613
+ ""
3614
+ ].join("\n");
3615
+ }
3616
+ //#endregion
3419
3617
  //#region src/commands/clone.ts
3420
3618
  /**
3421
3619
  * `fluid mist clone <slug>` — mints a fresh git credential and clones
@@ -3458,10 +3656,33 @@ const cloneCommand = new Command("clone").description("Clone the mist's GitHub r
3458
3656
  }
3459
3657
  writeMistFile(targetDir, mist);
3460
3658
  spinner.succeed(`Cloned into ${chalk.cyan(targetDir)}`);
3659
+ await writeEnvPlaceholders(client, mist.id || mist.slug, targetDir);
3461
3660
  const displayPath = opts.dir ?? join(".", dirName);
3462
3661
  console.log(chalk.dim(` cd ${quotePathIfNeeded(displayPath)}`));
3463
3662
  if (mist.public_url) console.log(chalk.dim(` ${chalk.cyan(mist.public_url)}`));
3464
3663
  }));
3664
+ /**
3665
+ * List production env keys this checkout has no local value for as
3666
+ * commented placeholders in `.env.local` — keys only, never values. Only
3667
+ * when `.env.local` is git-ignored, so the file can't be committed.
3668
+ * Best-effort: a clone never fails over it.
3669
+ */
3670
+ async function writeEnvPlaceholders(client, slug, targetDir) {
3671
+ try {
3672
+ if (!await isGitIgnored(targetDir, ".env.local")) return 0;
3673
+ const { env_vars } = await listEnvVars(client, slug);
3674
+ const keys = missingProductionKeys(env_vars ?? [], localEnvKeys(targetDir));
3675
+ const block = renderEnvPlaceholders(keys);
3676
+ if (!block) return 0;
3677
+ const target = join(targetDir, ".env.local");
3678
+ const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
3679
+ appendFileSync(target, `${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${existing ? "\n" : ""}${block}`, "utf8");
3680
+ console.log(chalk.dim(` Listed ${keys.length} production env key(s) in .env.local to fill in: ${keys.join(", ")}`));
3681
+ return keys.length;
3682
+ } catch {
3683
+ return 0;
3684
+ }
3685
+ }
3465
3686
  //#endregion
3466
3687
  //#region src/api/deployments.ts
3467
3688
  async function listDeployments(client, slug, limit) {
@@ -3488,6 +3709,57 @@ async function fetchCurrentUser(client) {
3488
3709
  };
3489
3710
  }
3490
3711
  //#endregion
3712
+ //#region src/lib/rollback-subject.ts
3713
+ const execFileAsync = promisify(execFile);
3714
+ /** The marker Mist Desktop writes when it rolls a checkout back. */
3715
+ const ROLLBACK_MARKER = ".mist-rollback.json";
3716
+ /**
3717
+ * "Roll back to 492c088 — Document reviews env vars" for a push that carries
3718
+ * a fresh rollback marker, so History names what was restored instead of
3719
+ * "Delete 23 files, update 8, add 1". Null when there is no readable marker;
3720
+ * the caller then describes the diff as before.
3721
+ */
3722
+ async function rollbackActionFromMarker(cwd) {
3723
+ let restored;
3724
+ try {
3725
+ const raw = await readFile(path.join(cwd, ROLLBACK_MARKER), "utf8");
3726
+ restored = JSON.parse(raw).restored_commit;
3727
+ } catch {
3728
+ return null;
3729
+ }
3730
+ if (typeof restored !== "string" || !/^[0-9a-f]{7,40}$/i.test(restored)) return null;
3731
+ let subject = "";
3732
+ try {
3733
+ const { stdout } = await execFileAsync("git", [
3734
+ "log",
3735
+ "-1",
3736
+ "--format=%s",
3737
+ restored
3738
+ ], {
3739
+ cwd,
3740
+ windowsHide: true
3741
+ });
3742
+ subject = stdout.trim();
3743
+ } catch {}
3744
+ return rollbackAction(restored, subject);
3745
+ }
3746
+ /** Pure part of {@link rollbackActionFromMarker}, for tests. */
3747
+ function rollbackAction(sha, subject) {
3748
+ const short = sha.slice(0, 7);
3749
+ const title = subjectAction(subject);
3750
+ const base = `Roll back to ${short}`;
3751
+ if (!title) return base;
3752
+ const full = `${base} — ${title}`;
3753
+ return full.length > 72 ? `${full.slice(0, 71).trimEnd()}…` : full;
3754
+ }
3755
+ /** A Git Sync stamp: "Name: action · YYYY-MM-DD HH:MM:SS (public id)". */
3756
+ const STAMPED_SUBJECT_RE = /^.+?: (.+) · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: \([^)]*\))?$/;
3757
+ /** A stamped subject contributes only its action; others pass through. */
3758
+ function subjectAction(subject) {
3759
+ const trimmed = subject.trim();
3760
+ return trimmed.match(STAMPED_SUBJECT_RE)?.[1]?.trim() ?? trimmed;
3761
+ }
3762
+ //#endregion
3491
3763
  //#region src/commands/push.ts
3492
3764
  /**
3493
3765
  * `fluid mist push [--watch]` — mints a credential, pushes to origin, and
@@ -3526,11 +3798,12 @@ const pushCommand = new Command("push").description("Commit working-tree changes
3526
3798
  if (behind > 0 && ahead === 0) {
3527
3799
  const wasDirty = await hasUncommittedChanges(cwd);
3528
3800
  driftSpinner.text = `Pulling ${behind} commit${behind === 1 ? "" : "s"} from origin/main…`;
3801
+ const preHead = await getHeadSha(cwd);
3529
3802
  if (wasDirty) await gitStash(cwd, "mist-push: pre-pull stash");
3530
3803
  try {
3531
3804
  await gitMergeFastForward(cwd);
3532
3805
  } finally {
3533
- if (wasDirty) await gitStashPop(cwd);
3806
+ if (wasDirty) await gitStashPop(cwd, preHead);
3534
3807
  }
3535
3808
  driftSpinner.succeed(`Pulled ${behind} commit${behind === 1 ? "" : "s"} from origin/main`);
3536
3809
  } else if (behind > 0 && ahead > 0) {
@@ -3546,6 +3819,7 @@ const pushCommand = new Command("push").description("Commit working-tree changes
3546
3819
  if (opts.commit !== false) {
3547
3820
  if (await hasUncommittedChanges(cwd)) {
3548
3821
  const explicit = opts.message?.trim();
3822
+ const rollbackAction = explicit ? null : await rollbackActionFromMarker(cwd);
3549
3823
  const actor = await resolveGitSyncActor(async () => {
3550
3824
  try {
3551
3825
  return gitSyncActorFromMe(await withTimeout(fetchCurrentUser(client), 5e3));
@@ -3559,7 +3833,7 @@ const pushCommand = new Command("push").description("Commit working-tree changes
3559
3833
  const commitSpinner = ora("Committing changes…").start();
3560
3834
  try {
3561
3835
  await gitAutoCommit(cwd, (changes) => {
3562
- subject = gitSyncCommitSubject(explicit || summarizeChanges(changes) || FALLBACK_ACTION, actor);
3836
+ subject = gitSyncCommitSubject(explicit || (rollbackAction && changes.some((change) => change.path === ".mist-rollback.json") ? rollbackAction : null) || summarizeChanges(changes) || FALLBACK_ACTION, actor);
3563
3837
  return subject;
3564
3838
  });
3565
3839
  commitSpinner.succeed(subject ? `Committed — ${subject}` : "Committed");