@fluid-app/fluid-cli-portal 0.1.38 → 0.1.39

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
@@ -1,4 +1,4 @@
1
- import { A as fluid_os_v0_create_fluid_osversion, B as fluid_os_v0_update_fluid_osnavigation_item, C as writeMappings, D as fluid_os_v0_create_fluid_osprofile, E as fluid_os_v0_create_fluid_osnavigation_item, F as fluid_os_v0_delete_fluid_osscreen, G as createFetchClient, H as fluid_os_v0_update_fluid_osscreen, I as fluid_os_v0_delete_fluid_ostheme, L as fluid_os_v0_list_fluid_osnavigation_items, M as fluid_os_v0_delete_fluid_osnavigation, N as fluid_os_v0_delete_fluid_osnavigation_item, O as fluid_os_v0_create_fluid_osscreen, P as fluid_os_v0_delete_fluid_osprofile, R as fluid_os_v0_list_fluid_osversions, S as updateMapping, T as fluid_os_v0_create_fluid_osnavigation, U as fluid_os_v0_update_fluid_ostheme, V as fluid_os_v0_update_fluid_osprofile, W as fluid_os_v0_update_fluid_osversion, _ as deriveSlug, a as buildThemeIdToSlugMap, b as resolveIdToSlug, c as transformNavigationItems, d as transformTheme, f as buildSnapshot, g as writeSnapshot, h as readSnapshot, i as buildNavigationIdToSlugMap, j as fluid_os_v0_create_widget_package_version, k as fluid_os_v0_create_fluid_ostheme, l as transformProfile, m as diffAgainstSnapshot, o as deriveScreenSlug, p as computeFileHash, r as buildIdToSlugMap, s as transformNavigation, t as pullCommand, u as transformScreen, v as readMappings, w as fluid_os_v0_complete_widget_package_version_upload, x as resolveSlugToId, y as removeMapping, z as fluid_os_v0_update_fluid_osnavigation } from "./pull-zrTaJuSb.mjs";
1
+ import { A as fluid_os_v0_create_fluid_osversion, B as fluid_os_v0_update_fluid_osnavigation, C as writeMappings, D as fluid_os_v0_create_fluid_osprofile, E as fluid_os_v0_create_fluid_osnavigation_item, F as fluid_os_v0_delete_fluid_osscreen, G as fluid_os_v0_update_fluid_osversion, H as fluid_os_v0_update_fluid_osprofile, I as fluid_os_v0_delete_fluid_ostheme, K as createFetchClient, L as fluid_os_v0_list_fluid_osdefinitions, M as fluid_os_v0_delete_fluid_osnavigation, N as fluid_os_v0_delete_fluid_osnavigation_item, O as fluid_os_v0_create_fluid_osscreen, P as fluid_os_v0_delete_fluid_osprofile, R as fluid_os_v0_list_fluid_osnavigation_items, S as updateMapping, T as fluid_os_v0_create_fluid_osnavigation, U as fluid_os_v0_update_fluid_osscreen, V as fluid_os_v0_update_fluid_osnavigation_item, W as fluid_os_v0_update_fluid_ostheme, _ as deriveSlug, a as buildThemeIdToSlugMap, b as resolveIdToSlug, c as transformNavigationItems, d as transformTheme, f as buildSnapshot, g as writeSnapshot, h as readSnapshot, i as buildNavigationIdToSlugMap, j as fluid_os_v0_create_widget_package_version, k as fluid_os_v0_create_fluid_ostheme, l as transformProfile, m as diffAgainstSnapshot, o as deriveScreenSlug, p as computeFileHash, r as buildIdToSlugMap, s as transformNavigation, t as pullCommand, u as transformScreen, v as readMappings, w as fluid_os_v0_complete_widget_package_version_upload, x as resolveSlugToId, y as removeMapping, z as fluid_os_v0_list_fluid_osversions } from "./pull-CG5dELOX.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { Command } from "commander";
4
4
  import chalk from "chalk";
@@ -575,6 +575,292 @@ async function gitInit(cwd) {
575
575
  return false;
576
576
  }
577
577
  }
578
+ /** Is `cwd` inside a git work tree? Cheap, never throws. */
579
+ async function isGitRepo(cwd) {
580
+ try {
581
+ const { stdout } = await execa("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
582
+ return stdout.trim() === "true";
583
+ } catch {
584
+ return false;
585
+ }
586
+ }
587
+ /** Read `git remote get-url origin` (or null if no origin / not a repo). */
588
+ async function getOriginUrl(cwd) {
589
+ try {
590
+ const { stdout } = await execa("git", [
591
+ "remote",
592
+ "get-url",
593
+ "origin"
594
+ ], { cwd });
595
+ return stdout.trim();
596
+ } catch {
597
+ return null;
598
+ }
599
+ }
600
+ /**
601
+ * Are there uncommitted changes (working tree or index)? `git status
602
+ * --porcelain` is empty when clean. Returns false (never throws) when
603
+ * `cwd` isn't a repo.
604
+ */
605
+ async function hasUncommittedChanges(cwd) {
606
+ try {
607
+ const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
608
+ return stdout.trim().length > 0;
609
+ } catch {
610
+ return false;
611
+ }
612
+ }
613
+ /**
614
+ * Fetch `branch` from a token-bearing remote URL, writing FETCH_HEAD so
615
+ * `gitAheadBehindVsFetchHead` is meaningful. Token comes through
616
+ * GIT_ASKPASS, never argv or `.git/config`.
617
+ */
618
+ async function gitFetch(cwd, remoteUrl, branch = "main") {
619
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
620
+ try {
621
+ await execa("git", [
622
+ "-c",
623
+ "credential.helper=",
624
+ "fetch",
625
+ "--quiet",
626
+ cleanUrl,
627
+ branch
628
+ ], {
629
+ cwd,
630
+ env
631
+ });
632
+ } catch (err) {
633
+ throw toGitError(err, "fetch");
634
+ }
635
+ }
636
+ /**
637
+ * Count commits in local HEAD vs FETCH_HEAD (after `gitFetch`). Returns
638
+ * `{ahead, behind}`. `0/0` means "in sync" (or no FETCH_HEAD yet).
639
+ */
640
+ async function gitAheadBehindVsFetchHead(cwd) {
641
+ try {
642
+ const { stdout } = await execa("git", [
643
+ "rev-list",
644
+ "--left-right",
645
+ "--count",
646
+ "HEAD...FETCH_HEAD"
647
+ ], { cwd });
648
+ const [a, b] = stdout.trim().split(/\s+/);
649
+ const ahead = Number.parseInt(a ?? "0", 10);
650
+ const behind = Number.parseInt(b ?? "0", 10);
651
+ return {
652
+ ahead: Number.isFinite(ahead) ? ahead : 0,
653
+ behind: Number.isFinite(behind) ? behind : 0
654
+ };
655
+ } catch {
656
+ return {
657
+ ahead: 0,
658
+ behind: 0
659
+ };
660
+ }
661
+ }
662
+ /** Does `cwd` have any commits yet? (false right after `git init`.) */
663
+ async function hasCommits(cwd) {
664
+ try {
665
+ await execa("git", [
666
+ "rev-parse",
667
+ "--verify",
668
+ "HEAD"
669
+ ], { cwd });
670
+ return true;
671
+ } catch {
672
+ return false;
673
+ }
674
+ }
675
+ /**
676
+ * Does `branch` exist on the remote? Throws on transport/auth failure so
677
+ * a network blip is never mistaken for an empty remote (which would skip
678
+ * drift detection). Token via GIT_ASKPASS, never argv or .git/config.
679
+ */
680
+ async function gitRemoteBranchExists(cwd, remoteUrl, branch = "main") {
681
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
682
+ try {
683
+ const { stdout } = await execa("git", [
684
+ "-c",
685
+ "credential.helper=",
686
+ "ls-remote",
687
+ "--heads",
688
+ cleanUrl,
689
+ `refs/heads/${branch}`
690
+ ], {
691
+ cwd,
692
+ env
693
+ });
694
+ return stdout.trim().length > 0;
695
+ } catch (err) {
696
+ throw toGitError(err, "ls-remote");
697
+ }
698
+ }
699
+ /**
700
+ * Adopt `FETCH_HEAD` as the current (unborn) branch's base: point the
701
+ * branch ref at it and load its tree into the index, leaving the working
702
+ * tree untouched. The next auto-commit then records the local project
703
+ * tree as a child of the remote history — local files win, remote-only
704
+ * files are recorded as deletions — so the push fast-forwards instead of
705
+ * being rejected for unrelated history. Only call when `hasCommits` is
706
+ * false; with local commits it would silently rewrite the branch ref.
707
+ * Mirrors the widget CLI's first-push adoption (the backend provisions
708
+ * repos with an auto-init/template commit, so a fresh root commit is
709
+ * always unrelated history).
710
+ *
711
+ * Returns the number of remote commits adopted.
712
+ */
713
+ async function gitAdoptFetchHead(cwd) {
714
+ try {
715
+ await execa("git", [
716
+ "reset",
717
+ "--mixed",
718
+ "FETCH_HEAD"
719
+ ], { cwd });
720
+ const { stdout } = await execa("git", [
721
+ "rev-list",
722
+ "--count",
723
+ "FETCH_HEAD"
724
+ ], { cwd });
725
+ const count = Number.parseInt(stdout.trim(), 10);
726
+ return Number.isFinite(count) ? count : 0;
727
+ } catch (err) {
728
+ throw toGitError(err, "adopt remote history");
729
+ }
730
+ }
731
+ /**
732
+ * Normalize a git remote URL to `host/owner/repo` for identity
733
+ * comparison — strips credentials, protocol, `.git`, and trailing
734
+ * slashes so a token-bearing minted URL compares equal to the stored
735
+ * origin. Returns null when the URL doesn't parse.
736
+ */
737
+ function normalizedRepoIdentity(remoteUrl) {
738
+ const scp = remoteUrl.match(/^(?:[^@/\s]+@)?([^:/\s]+):([^/].*)$/);
739
+ if (scp?.[1] && scp[2] && !remoteUrl.includes("://")) {
740
+ const path = scp[2].replace(/\.git$/, "").replace(/\/+$/, "");
741
+ return `${scp[1]}/${path}`.toLowerCase();
742
+ }
743
+ if (remoteUrl.startsWith("/") || remoteUrl.startsWith(".")) return remoteUrl.replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
744
+ try {
745
+ const u = new URL(remoteUrl);
746
+ const path = u.pathname.replace(/\.git$/, "").replace(/\/+$/, "").replace(/^\/+/, "");
747
+ return `${u.host}/${path}`.toLowerCase();
748
+ } catch {
749
+ return null;
750
+ }
751
+ }
752
+ /**
753
+ * Do HEAD and FETCH_HEAD share any common ancestor? False for unrelated
754
+ * histories — the guard that keeps a whole-tree auto-commit of the WRONG
755
+ * repository from being pushed to a portal's remote. Never throws.
756
+ */
757
+ async function hasCommonAncestorWithFetchHead(cwd) {
758
+ try {
759
+ await execa("git", [
760
+ "merge-base",
761
+ "HEAD",
762
+ "FETCH_HEAD"
763
+ ], { cwd });
764
+ return true;
765
+ } catch {
766
+ return false;
767
+ }
768
+ }
769
+ /** Fast-forward HEAD to FETCH_HEAD. Fails (non-zero exit) if not
770
+ * fast-forwardable — callers check the ahead-count first. */
771
+ async function gitMergeFastForward(cwd) {
772
+ try {
773
+ await execa("git", [
774
+ "merge",
775
+ "--ff-only",
776
+ "FETCH_HEAD"
777
+ ], { cwd });
778
+ } catch (err) {
779
+ throw toGitError(err, "fast-forward merge");
780
+ }
781
+ }
782
+ /**
783
+ * `git add -A && git commit -m <message>`.
784
+ *
785
+ * `git add -A` intentionally stages the whole work tree: a portal's
786
+ * provisioned repo tracks the entire project (it's seeded from the
787
+ * starter template), and the "publish my changes" UX expects every edit
788
+ * to ship — the same stance as `fluid mist push`. The exclusion boundary
789
+ * is the project's `.gitignore` (the starter ignores `.env`,
790
+ * `node_modules/`, `dist/`, `.portal-sync/`, logs, `*.tsbuildinfo`), so
791
+ * generated output and local secrets stay out of the commit.
792
+ *
793
+ * Uses the repo's configured identity, else a synthetic Fluid bot
794
+ * identity so the commit isn't rejected. "nothing to commit" is treated
795
+ * as a no-op, not an error.
796
+ */
797
+ async function gitAutoCommit(cwd, message) {
798
+ try {
799
+ await execa("git", ["add", "-A"], { cwd });
800
+ } catch (err) {
801
+ throw toGitError(err, "add");
802
+ }
803
+ let hasName = false;
804
+ let hasEmail = false;
805
+ try {
806
+ hasName = (await execa("git", ["config", "user.name"], { cwd })).stdout.trim().length > 0;
807
+ } catch {}
808
+ try {
809
+ hasEmail = (await execa("git", ["config", "user.email"], { cwd })).stdout.trim().length > 0;
810
+ } catch {}
811
+ const identityArgs = [...hasName ? [] : ["-c", "user.name=Fluid Portal CLI"], ...hasEmail ? [] : ["-c", "user.email=portal@fluid.app"]];
812
+ try {
813
+ await execa("git", [
814
+ ...identityArgs,
815
+ "commit",
816
+ "-m",
817
+ message
818
+ ], {
819
+ cwd,
820
+ stdio: [
821
+ "ignore",
822
+ "inherit",
823
+ "pipe"
824
+ ]
825
+ });
826
+ } catch (err) {
827
+ const missing = gitMissingError(err);
828
+ if (missing) throw missing;
829
+ const e = err;
830
+ const streams = `${typeof e.stdout === "string" ? e.stdout : ""}\n${typeof e.stderr === "string" ? e.stderr : ""}`;
831
+ if (/nothing to commit/i.test(streams)) return;
832
+ throw toGitError(err, "commit");
833
+ }
834
+ }
835
+ /**
836
+ * `git push <cleanUrl> HEAD:<branch>` — credentials supplied via
837
+ * GIT_ASKPASS, never argv. `http.…/.extraheader=` clears any cached
838
+ * Authorization header a CI-style config might inject.
839
+ */
840
+ async function gitPush(cwd, remoteUrl, branch = "main") {
841
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
842
+ try {
843
+ await execa("git", [
844
+ "-c",
845
+ "credential.helper=",
846
+ "-c",
847
+ "http.https://github.com/.extraheader=",
848
+ "push",
849
+ cleanUrl,
850
+ `HEAD:${branch}`
851
+ ], {
852
+ cwd,
853
+ env,
854
+ stdio: [
855
+ "ignore",
856
+ "inherit",
857
+ "inherit"
858
+ ]
859
+ });
860
+ } catch (err) {
861
+ throw toGitError(err, "push");
862
+ }
863
+ }
578
864
  //#endregion
579
865
  //#region src/utils/scaffold-github.ts
580
866
  /**
@@ -863,7 +1149,7 @@ async function autoPull(cwd) {
863
1149
  console.log(chalk.yellow("No portal/ directory found.") + " Attempting to pull content...");
864
1150
  console.log();
865
1151
  try {
866
- const { pullCommand } = await import("./pull-zrTaJuSb.mjs").then((n) => n.n);
1152
+ const { pullCommand } = await import("./pull-CG5dELOX.mjs").then((n) => n.n);
867
1153
  await pullCommand.parseAsync([], { from: "user" });
868
1154
  return hasPortalContent(cwd);
869
1155
  } catch (err) {
@@ -1541,6 +1827,181 @@ function validateNavigationItems(items, file, validScreenSlugs, errors) {
1541
1827
  }
1542
1828
  }
1543
1829
  //#endregion
1830
+ //#region src/api/git-credential.ts
1831
+ /**
1832
+ * Build the portal git-credential path for a definition id.
1833
+ *
1834
+ * Matches GitSync Phase 006 (fluid-commerce/fluid #19596): a member
1835
+ * route on the company-scoped definitions resource —
1836
+ * `POST /api/company/fluid_os/definitions/:id/git_credential`.
1837
+ */
1838
+ function portalGitCredentialPath(definitionId) {
1839
+ return `/api/company/fluid_os/definitions/${definitionId}/git_credential`;
1840
+ }
1841
+ function extractErrorReason(body, fallback) {
1842
+ if (body && typeof body === "object" && "error" in body) {
1843
+ const error = body.error;
1844
+ if (error && typeof error === "object" && "message" in error) {
1845
+ const message = error.message;
1846
+ if (typeof message === "string" && message.length > 0) return message;
1847
+ }
1848
+ }
1849
+ return fallback;
1850
+ }
1851
+ /**
1852
+ * Request a git credential for the portal definition's provisioned repo.
1853
+ *
1854
+ * Never throws — every failure maps to an `unavailable`/`error` outcome
1855
+ * so the invisible-git sync is strictly best-effort.
1856
+ */
1857
+ async function createPortalGitCredential(options) {
1858
+ const { baseUrl, token, definitionId } = options;
1859
+ const fetchImpl = options.fetchImpl ?? fetch;
1860
+ const url = `${baseUrl.replace(/\/$/, "")}${portalGitCredentialPath(definitionId)}`;
1861
+ let resp;
1862
+ try {
1863
+ resp = await fetchImpl(url, {
1864
+ method: "POST",
1865
+ headers: {
1866
+ Authorization: `Bearer ${token}`,
1867
+ Accept: "application/json"
1868
+ }
1869
+ });
1870
+ } catch (err) {
1871
+ return {
1872
+ status: "error",
1873
+ reason: err instanceof Error ? err.message : String(err)
1874
+ };
1875
+ }
1876
+ if (resp.status === 404) return {
1877
+ status: "unavailable",
1878
+ reason: "git sync isn't available for portals on this Fluid API yet"
1879
+ };
1880
+ const body = await resp.json().catch(() => null);
1881
+ if (!resp.ok) {
1882
+ const reason = extractErrorReason(body, `git credential request failed (HTTP ${resp.status})`);
1883
+ if (resp.status === 409) return {
1884
+ status: "unavailable",
1885
+ reason
1886
+ };
1887
+ return {
1888
+ status: "error",
1889
+ reason
1890
+ };
1891
+ }
1892
+ const credential = body?.git_credential;
1893
+ if (!credential?.remote_url) return {
1894
+ status: "error",
1895
+ reason: "git credential response missing remote_url"
1896
+ };
1897
+ return {
1898
+ status: "ok",
1899
+ credential
1900
+ };
1901
+ }
1902
+ //#endregion
1903
+ //#region src/lib/git-sync.ts
1904
+ /**
1905
+ * Invisible-git sync for `fluid portal push`.
1906
+ *
1907
+ * Before the normal content push, we best-effort mirror the local portal
1908
+ * project to its provisioned GitHub repo: fetch → fast-forward-pull if a
1909
+ * teammate pushed → auto-commit working-tree changes → push HEAD:main.
1910
+ * This mirrors `fluid mist push`, but is STRICTLY non-fatal: any problem
1911
+ * (no git, no repo, credential endpoint missing, diverged history,
1912
+ * network blip) degrades to a skip so the content push always proceeds.
1913
+ */
1914
+ /**
1915
+ * Run the best-effort git sync. Returns a result describing what
1916
+ * happened; never throws. Callers surface `skipped` reasons as a dim
1917
+ * note and continue.
1918
+ */
1919
+ async function runPortalGitSync(options) {
1920
+ const { cwd, baseUrl, token, definitionId, commitMessage } = options;
1921
+ const branch = options.branch ?? "main";
1922
+ if (!await isGitRepo(cwd) && !await gitInit(cwd)) return {
1923
+ status: "skipped",
1924
+ reason: "not a git repository"
1925
+ };
1926
+ const outcome = await createPortalGitCredential({
1927
+ baseUrl,
1928
+ token,
1929
+ definitionId,
1930
+ fetchImpl: options.fetchImpl
1931
+ });
1932
+ if (outcome.status !== "ok") return {
1933
+ status: "skipped",
1934
+ reason: outcome.reason
1935
+ };
1936
+ const remoteUrl = outcome.credential.remote_url;
1937
+ const origin = await getOriginUrl(cwd);
1938
+ if (origin) {
1939
+ const originId = normalizedRepoIdentity(origin);
1940
+ const mintedId = normalizedRepoIdentity(remoteUrl);
1941
+ if (mintedId && originId !== mintedId) return {
1942
+ status: "skipped",
1943
+ reason: `this directory's git origin (${originId ?? origin}) is not the portal's provisioned repository (${mintedId}) — are you in the right project directory?`
1944
+ };
1945
+ }
1946
+ try {
1947
+ if (!await hasCommits(cwd)) {
1948
+ if (await gitRemoteBranchExists(cwd, remoteUrl, branch)) {
1949
+ await gitFetch(cwd, remoteUrl, branch);
1950
+ await gitAdoptFetchHead(cwd);
1951
+ }
1952
+ if (await hasUncommittedChanges(cwd)) await gitAutoCommit(cwd, commitMessage);
1953
+ return await pushCommitted(cwd, remoteUrl, branch);
1954
+ }
1955
+ await gitFetch(cwd, remoteUrl, branch);
1956
+ if (!await hasCommonAncestorWithFetchHead(cwd)) return {
1957
+ status: "skipped",
1958
+ reason: "this repository's history is unrelated to the portal's provisioned repository — are you in the right project directory?"
1959
+ };
1960
+ const { ahead, behind } = await gitAheadBehindVsFetchHead(cwd);
1961
+ if (behind > 0 && ahead > 0) return {
1962
+ status: "skipped",
1963
+ reason: "local branch and origin have diverged — run `git pull --rebase` and retry"
1964
+ };
1965
+ if (behind > 0) {
1966
+ if (await hasUncommittedChanges(cwd)) return {
1967
+ status: "skipped",
1968
+ reason: "origin has newer commits and you have uncommitted changes — run `git pull` and retry"
1969
+ };
1970
+ await gitMergeFastForward(cwd);
1971
+ }
1972
+ if (await hasUncommittedChanges(cwd)) await gitAutoCommit(cwd, commitMessage);
1973
+ return await pushCommitted(cwd, remoteUrl, branch);
1974
+ } catch (err) {
1975
+ return {
1976
+ status: "skipped",
1977
+ reason: err instanceof Error ? err.message : String(err)
1978
+ };
1979
+ }
1980
+ }
1981
+ /**
1982
+ * The push gets its own failure boundary: by this point the auto-commit
1983
+ * (and any fast-forward) has already mutated local git state. A push
1984
+ * failure here is FATAL to the content push (matching `fluid widget
1985
+ * push`): publishing artifacts whose source never reached the repo is
1986
+ * exactly the drift the invisible-git model exists to prevent. The
1987
+ * local commit is durable — rerunning push finds the branch ahead and
1988
+ * retries the sync.
1989
+ */
1990
+ async function pushCommitted(cwd, remoteUrl, branch) {
1991
+ try {
1992
+ await gitPush(cwd, remoteUrl, branch);
1993
+ } catch (err) {
1994
+ return {
1995
+ status: "failed",
1996
+ reason: `your changes are committed locally but couldn't be pushed (${err instanceof Error ? err.message : String(err)}) — nothing was published; rerun \`fluid portal push\` to retry`
1997
+ };
1998
+ }
1999
+ return {
2000
+ status: "synced",
2001
+ pushed: true
2002
+ };
2003
+ }
2004
+ //#endregion
1544
2005
  //#region src/commands/push.ts
1545
2006
  /**
1546
2007
  * `fluid portal push` command
@@ -1564,7 +2025,7 @@ function toApiComponentTree(tree) {
1564
2025
  /**
1565
2026
  * Create an authenticated FetchClient using the stored CLI profile.
1566
2027
  */
1567
- function createClient$1() {
2028
+ function createClient$2() {
1568
2029
  const token = getAuthToken();
1569
2030
  if (!token) {
1570
2031
  const profile = getActiveProfile();
@@ -1586,6 +2047,32 @@ function enrichedErrorMessage(err) {
1586
2047
  return msg;
1587
2048
  }
1588
2049
  /**
2050
+ * Best-effort "invisible git" sync: commit the local project and push it
2051
+ * to the portal's provisioned GitHub repo before the content push. Never
2052
+ * throws — every failure is reported as a dim note and swallowed so the
2053
+ * content push always runs.
2054
+ */
2055
+ async function runInvisibleGitSync(cwd, definitionId, definitionName) {
2056
+ const token = getAuthToken();
2057
+ if (!token) return;
2058
+ const baseUrl = process.env["FLUID_API_BASE"] ?? "https://api.fluid.app";
2059
+ const commitMessage = `Portal push: ${definitionName} · ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace("T", " ")}`;
2060
+ const spinner = ora("Syncing project to git…").start();
2061
+ const result = await runPortalGitSync({
2062
+ cwd,
2063
+ baseUrl,
2064
+ token,
2065
+ definitionId,
2066
+ commitMessage
2067
+ });
2068
+ if (result.status === "synced") spinner.succeed("Synced project to git");
2069
+ else if (result.status === "failed") {
2070
+ spinner.fail("Git sync failed");
2071
+ throw new Error(result.reason);
2072
+ } else spinner.info(chalk.gray(`Skipped git sync — ${result.reason}`));
2073
+ console.log();
2074
+ }
2075
+ /**
1589
2076
  * Read and parse a JSON file from the portal directory.
1590
2077
  */
1591
2078
  async function readPortalFile(portalDir, relativePath) {
@@ -2193,6 +2680,7 @@ const pushCommand = new Command("push").description("Push local portal content c
2193
2680
  const definitionName = snapshot.definition;
2194
2681
  console.log(chalk.gray("Definition: ") + chalk.white(definitionName) + chalk.gray(` (ID: ${definitionId})`));
2195
2682
  console.log();
2683
+ await runInvisibleGitSync(cwd, definitionId, definitionName);
2196
2684
  const spinner = ora();
2197
2685
  spinner.start("Detecting changes...");
2198
2686
  const diff = await diffAgainstSnapshot(portalDir, snapshot);
@@ -2234,7 +2722,7 @@ const pushCommand = new Command("push").description("Push local portal content c
2234
2722
  spinner.start("Authenticating...");
2235
2723
  let client;
2236
2724
  try {
2237
- client = createClient$1();
2725
+ client = createClient$2();
2238
2726
  spinner.succeed("Authenticated");
2239
2727
  } catch (err) {
2240
2728
  spinner.fail("Authentication failed");
@@ -2322,6 +2810,114 @@ const pushCommand = new Command("push").description("Push local portal content c
2322
2810
  console.log();
2323
2811
  });
2324
2812
  //#endregion
2813
+ //#region src/commands/clone.ts
2814
+ /**
2815
+ * `fluid portal clone [app]` — mints a fresh git credential for a Fluid
2816
+ * OS portal definition and clones its provisioned GitHub repo into
2817
+ * `./<slug>/`. Mirrors `fluid mist clone` (credential → clone), but
2818
+ * against the portal git-credential endpoint.
2819
+ *
2820
+ * The credential endpoint is part of GitSync phase 006; until it ships
2821
+ * the request 404s and we print a friendly "not available yet" message
2822
+ * instead of a raw git error.
2823
+ */
2824
+ function getBaseUrl() {
2825
+ return process.env["FLUID_API_BASE"] ?? "https://api.fluid.app";
2826
+ }
2827
+ function createClient$1(token) {
2828
+ return createFetchClient({
2829
+ baseUrl: getBaseUrl(),
2830
+ getAuthToken: () => token
2831
+ });
2832
+ }
2833
+ /** Select a definition by --app name, else interactively. */
2834
+ async function selectDefinition(client, appName) {
2835
+ const definitions = [];
2836
+ let page = 1;
2837
+ while (true) {
2838
+ const response = await fluid_os_v0_list_fluid_osdefinitions(client, {
2839
+ page,
2840
+ per_page: 500
2841
+ });
2842
+ const batch = response.definitions ?? [];
2843
+ definitions.push(...batch);
2844
+ const totalPages = response.meta?.total_pages ?? 1;
2845
+ if (page >= totalPages || batch.length === 0) break;
2846
+ page++;
2847
+ }
2848
+ if (definitions.length === 0) throw new Error("No Fluid OS definitions found. Create one in the admin dashboard first.");
2849
+ if (appName) {
2850
+ const match = definitions.find((d) => d.name?.toLowerCase() === appName.toLowerCase());
2851
+ if (!match || match.id == null) {
2852
+ const available = definitions.map((d) => chalk.cyan(d.name ?? "(unnamed)")).join(", ");
2853
+ throw new Error(`No definition found matching "${appName}". Available: ${available}`);
2854
+ }
2855
+ return {
2856
+ id: match.id,
2857
+ name: match.name ?? `portal-${match.id}`
2858
+ };
2859
+ }
2860
+ const { definitionId } = await prompts({
2861
+ type: "select",
2862
+ name: "definitionId",
2863
+ message: "Select a Fluid OS definition to clone",
2864
+ choices: definitions.filter((d) => d.id != null).map((d) => ({
2865
+ title: d.name ?? "(unnamed)",
2866
+ value: d.id
2867
+ }))
2868
+ });
2869
+ if (definitionId == null) throw new Error("No definition selected.");
2870
+ const selected = definitions.find((d) => d.id === definitionId);
2871
+ if (!selected || selected.id == null) throw new Error("Selected definition not found.");
2872
+ return {
2873
+ id: selected.id,
2874
+ name: selected.name ?? `portal-${selected.id}`
2875
+ };
2876
+ }
2877
+ const cloneCommand = new Command("clone").description("Clone a portal definition's provisioned GitHub repo (mints a fresh token per call)").argument("[app]", "Definition name (prompts to select when omitted)").option("--app <name>", "Definition name (same as the positional argument)").option("--dir <dir>", "Target directory (defaults to ./<slug>/)").action(async (appArg, options) => {
2878
+ try {
2879
+ await ensureGitAvailable();
2880
+ const token = getAuthToken();
2881
+ if (!token) {
2882
+ const profile = getActiveProfile();
2883
+ throw new Error(profile ? `No auth token for profile ${chalk.cyan(profile.name)}. Run ${chalk.cyan("fluid login")}.` : `Not logged in. Run ${chalk.cyan("fluid login")} first.`);
2884
+ }
2885
+ const client = createClient$1(token);
2886
+ const spinner = ora("Resolving definition…").start();
2887
+ const definition = await selectDefinition(client, options.app ?? appArg);
2888
+ spinner.text = "Minting git credential…";
2889
+ const outcome = await createPortalGitCredential({
2890
+ baseUrl: getBaseUrl(),
2891
+ token,
2892
+ definitionId: definition.id
2893
+ });
2894
+ if (outcome.status !== "ok") {
2895
+ spinner.warn(`Can't clone — ${outcome.reason}`);
2896
+ process.exit(1);
2897
+ }
2898
+ const slug = deriveSlug(definition.name);
2899
+ const targetDir = resolve(options.dir ?? slug);
2900
+ if (existsSync(targetDir)) {
2901
+ spinner.fail(`Directory ${targetDir} already exists. Pass --dir or remove it first.`);
2902
+ process.exit(1);
2903
+ }
2904
+ spinner.text = `Cloning into ${slug}/…`;
2905
+ try {
2906
+ await gitClone(outcome.credential.remote_url, targetDir);
2907
+ } catch (err) {
2908
+ spinner.fail("git clone failed");
2909
+ throw err;
2910
+ }
2911
+ spinner.succeed(`Cloned into ${chalk.cyan(targetDir)}`);
2912
+ console.log(chalk.dim(` cd ${slug}`));
2913
+ console.log(chalk.dim(` ${chalk.cyan("fluid portal pull")} to sync content`));
2914
+ } catch (error) {
2915
+ console.log();
2916
+ console.log(chalk.red("Error:") + " " + (error instanceof Error ? error.message : String(error)));
2917
+ process.exit(1);
2918
+ }
2919
+ });
2920
+ //#endregion
2325
2921
  //#region src/utils/widget-helpers.ts
2326
2922
  /**
2327
2923
  * Pure helper functions for widget scaffolding.
@@ -5620,6 +6216,7 @@ const plugin = {
5620
6216
  portal.addCommand(buildCommand);
5621
6217
  portal.addCommand(pullCommand);
5622
6218
  portal.addCommand(pushCommand);
6219
+ portal.addCommand(cloneCommand);
5623
6220
  portal.addCommand(deployCommand);
5624
6221
  portal.addCommand(widgetCommand);
5625
6222
  portal.addCommand(doctorCommand);
@@ -5628,6 +6225,6 @@ const plugin = {
5628
6225
  }
5629
6226
  };
5630
6227
  //#endregion
5631
- export { CANONICAL_PROJECT_NAME, FILE_SYSTEM_ERRORS, GitError, TEMPLATES, TEMPLATE_REPO_URL, WIDGET_PACKAGE_BUILDER_VERSION, WidgetPackageUploadError, buildIdToSlugMap, buildNavigationIdToSlugMap, buildSharedWidgetPackage, buildSnapshot, buildThemeIdToSlugMap, buildWidgetPackageCompleteUploadPayload, buildWidgetPackageDescriptor, buildWidgetPackageUploadSessionPayload, categorizeChanges, collectWidgetPackageArtifacts, completeWidgetPackageUpload, computeArtifactMetadata, computeFileHash, copyTemplate, copyTemplateSafe, createAuthenticatedWidgetPackageClient, createCommand, createDirectory, createDirectorySafe, createPublishManifestMetadata, createWidgetPackageEntrySource, createWidgetPackagePublishRequest, plugin as default, deployCommand, deriveScreenSlug, deriveSlug, diffAgainstSnapshot, directoryExists, doctorCommand, ensureGitAvailable, fileExists, getArtifactContentType, getInstallCommand, getRunCommand, getSdkVersion, getSdkVersionSafe, getTemplatePaths, getWidgetPackageCompleteUploadRoute, getWidgetPackageUploadSessionRoute, gitClone, gitInit, gitMissingError, installDependencies, isGitAvailable, loadSourceWidgetPackages, normalizeWidgetPackageCompleteResponse, normalizeWidgetPackageUploadSession, pathExists, printDryRunSessionPayload, printRuntimeOnlyNotice, printWidgetPublishSummary, promptProjectConfig, publishWidgetPackageVersion, publishWidgetRuntimeArtifacts, pullCommand, pushCommand, readFileSafe, readMappings, readSnapshot, removeMapping, requestWidgetPackageUploadSession, resolveIdToSlug, resolvePortalWidgetSourceConfig, resolveSlugToId, rewriteProjectName, runPackageManager, scaffoldFromGitHub, sha256, slugFromPath, topLevelWidgetCommand, transformNavigation, transformNavigationItems, transformProfile, transformScreen, transformTheme, updateMapping, uploadWidgetPackageArtifacts, validateCrossReferences, validateSingleSourceWidgetPackage, validateWidgetPublishOutDir, versionCommand, widgetCommand, writeFileSafe, writeFluidrcProfile, writeMappings, writeSnapshot };
6228
+ export { CANONICAL_PROJECT_NAME, FILE_SYSTEM_ERRORS, GitError, TEMPLATES, TEMPLATE_REPO_URL, WIDGET_PACKAGE_BUILDER_VERSION, WidgetPackageUploadError, buildIdToSlugMap, buildNavigationIdToSlugMap, buildSharedWidgetPackage, buildSnapshot, buildThemeIdToSlugMap, buildWidgetPackageCompleteUploadPayload, buildWidgetPackageDescriptor, buildWidgetPackageUploadSessionPayload, categorizeChanges, cloneCommand, collectWidgetPackageArtifacts, completeWidgetPackageUpload, computeArtifactMetadata, computeFileHash, copyTemplate, copyTemplateSafe, createAuthenticatedWidgetPackageClient, createCommand, createDirectory, createDirectorySafe, createPortalGitCredential, createPublishManifestMetadata, createWidgetPackageEntrySource, createWidgetPackagePublishRequest, plugin as default, deployCommand, deriveScreenSlug, deriveSlug, diffAgainstSnapshot, directoryExists, doctorCommand, ensureGitAvailable, fileExists, getArtifactContentType, getInstallCommand, getOriginUrl, getRunCommand, getSdkVersion, getSdkVersionSafe, getTemplatePaths, getWidgetPackageCompleteUploadRoute, getWidgetPackageUploadSessionRoute, gitAheadBehindVsFetchHead, gitAutoCommit, gitClone, gitFetch, gitInit, gitMergeFastForward, gitMissingError, gitPush, hasUncommittedChanges, installDependencies, isGitAvailable, isGitRepo, loadSourceWidgetPackages, normalizeWidgetPackageCompleteResponse, normalizeWidgetPackageUploadSession, pathExists, portalGitCredentialPath, printDryRunSessionPayload, printRuntimeOnlyNotice, printWidgetPublishSummary, promptProjectConfig, publishWidgetPackageVersion, publishWidgetRuntimeArtifacts, pullCommand, pushCommand, readFileSafe, readMappings, readSnapshot, removeMapping, requestWidgetPackageUploadSession, resolveIdToSlug, resolvePortalWidgetSourceConfig, resolveSlugToId, rewriteProjectName, runPackageManager, runPortalGitSync, scaffoldFromGitHub, sha256, slugFromPath, topLevelWidgetCommand, transformNavigation, transformNavigationItems, transformProfile, transformScreen, transformTheme, updateMapping, uploadWidgetPackageArtifacts, validateCrossReferences, validateSingleSourceWidgetPackage, validateWidgetPublishOutDir, versionCommand, widgetCommand, writeFileSafe, writeFluidrcProfile, writeMappings, writeSnapshot };
5632
6229
 
5633
6230
  //# sourceMappingURL=index.mjs.map