@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
|
|
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-
|
|
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$
|
|
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$
|
|
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
|