@fluid-app/fluid-cli-portal 0.1.37 → 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,16 +1,17 @@
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";
5
5
  import ora from "ora";
6
6
  import path, { basename, dirname, join, relative, resolve } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
- import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
8
+ import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
9
9
  import { failure, findProjectConfig, getActiveProfile, getAuthToken, listProfileNames, readConfig, success } from "@fluid-app/fluid-cli";
10
10
  import prompts from "prompts";
11
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
11
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
12
12
  import Handlebars from "handlebars";
13
13
  import { execa } from "execa";
14
+ import os from "node:os";
14
15
  import fs from "fs-extra";
15
16
  import path$1 from "path";
16
17
  import { createHash } from "node:crypto";
@@ -404,8 +405,583 @@ async function installDependencies(cwd) {
404
405
  await runPackageManager(["install"], cwd);
405
406
  }
406
407
  //#endregion
408
+ //#region src/lib/git.ts
409
+ /**
410
+ * Git wrapper for the portal CLI — shells out to the system `git` via
411
+ * execa. We deliberately do NOT bundle git or use a pure-JS impl; users
412
+ * are expected to have it installed (the same stance as `fluid mist`).
413
+ *
414
+ * Security: a token-bearing `remote_url` (minted by the portal
415
+ * `git_credential` endpoint) must never be logged, persisted, *or*
416
+ * exposed in the process table. The token is stripped from the URL and
417
+ * handed to git through a GIT_ASKPASS script via env vars
418
+ * (FLUID_PORTAL_GIT_USER / FLUID_PORTAL_GIT_PASS) — so it never appears
419
+ * in argv (visible to `ps aux` / `/proc/<pid>/cmdline`) and is never
420
+ * written to `.git/config`.
421
+ *
422
+ * This mirrors `packages/cli/mist/src/lib/git.ts`. Kept portal-local for
423
+ * now; if the widget/mist tracks extract a shared git lib we should
424
+ * consume it instead (see overnight/track2-portal.md).
425
+ */
426
+ var GitError = class extends Error {
427
+ exitCode;
428
+ constructor(message, exitCode = 1) {
429
+ super(message);
430
+ this.name = "GitError";
431
+ this.exitCode = exitCode;
432
+ }
433
+ };
434
+ /**
435
+ * execa throws ENOENT when the git binary itself is missing — the single
436
+ * most common failure on non-developer machines. Translate it into an
437
+ * actionable message. Returns null for every other failure so call sites
438
+ * keep their specific messages.
439
+ */
440
+ function gitMissingError(err) {
441
+ if (err?.code !== "ENOENT") return null;
442
+ return new GitError("Git isn't installed (or isn't on your PATH), so this command can't run.\n Install it, then try again:\n macOS: xcode-select --install (or `brew install git`)\n Windows: https://git-scm.com/download/win\n Linux: use your package manager, e.g. `apt install git`", 127);
443
+ }
444
+ /**
445
+ * Is `git` available on this machine? Cheap, never throws — used to
446
+ * decide whether to take the GitHub-template path or fall back to the
447
+ * bundled scaffold.
448
+ */
449
+ async function isGitAvailable() {
450
+ try {
451
+ await execa("git", ["--version"]);
452
+ return true;
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+ /** Confirm git is installed, throwing the friendly message otherwise. */
458
+ async function ensureGitAvailable() {
459
+ if (!await isGitAvailable()) throw new GitError("Git isn't installed (or isn't on your PATH), so this command can't run.\n Install it from https://git-scm.com/downloads (or `brew install git`), then try again.");
460
+ }
461
+ /**
462
+ * Split a token-bearing remote URL into the userless URL + creds. Keeps
463
+ * the token off git's argv: we pass `cleanUrl` to git and feed
464
+ * `username` / `password` back through GIT_ASKPASS env vars.
465
+ *
466
+ * If the URL has no embedded credentials, returns empty strings — fine
467
+ * for public repos where git won't invoke askpass at all.
468
+ */
469
+ function splitRemoteCredentials(remoteUrl) {
470
+ const u = new URL(remoteUrl);
471
+ const username = decodeURIComponent(u.username);
472
+ const password = decodeURIComponent(u.password);
473
+ u.username = "";
474
+ u.password = "";
475
+ return {
476
+ cleanUrl: u.toString(),
477
+ username,
478
+ password
479
+ };
480
+ }
481
+ /**
482
+ * Write a stub GIT_ASKPASS shell script to a per-process tmpdir and
483
+ * return its path. The script contains no secret — it echoes back
484
+ * whichever env var (FLUID_PORTAL_GIT_USER / FLUID_PORTAL_GIT_PASS)
485
+ * matches git's prompt. The credentials only live in the spawned git
486
+ * process's env for the duration of the call.
487
+ *
488
+ * One POSIX script for all platforms: Git for Windows executes
489
+ * GIT_ASKPASS through its bundled sh, so a plain sh script works
490
+ * everywhere. Idempotent: subsequent calls reuse the same path.
491
+ */
492
+ let askpassPath = null;
493
+ function ensureAskpassScript() {
494
+ if (askpassPath && existsSync(askpassPath)) return askpassPath;
495
+ const dir = path.join(os.tmpdir(), `fluid-portal-askpass-${process.pid}`);
496
+ if (!existsSync(dir)) mkdirSync(dir, {
497
+ recursive: true,
498
+ mode: 448
499
+ });
500
+ const p = path.join(dir, "askpass.sh");
501
+ writeFileSync(p, `#!/bin/sh\ncase "$1" in\n Username*) printf '%s' "$FLUID_PORTAL_GIT_USER" ;;\n *) printf '%s' "$FLUID_PORTAL_GIT_PASS" ;;\nesac\n`, { mode: 448 });
502
+ chmodSync(p, 448);
503
+ askpassPath = p;
504
+ return askpassPath;
505
+ }
506
+ /**
507
+ * Build the spawn env + cleaned URL for a credentialed git invocation.
508
+ * - `cleanUrl`: URL with userinfo stripped — safe to pass as argv.
509
+ * - `env`: parent env plus GIT_ASKPASS + FLUID_PORTAL_GIT_USER/PASS.
510
+ * GIT_TERMINAL_PROMPT=0 prevents the TTY-prompt fallback if the
511
+ * credentials are empty (auth should fail fast, not hang).
512
+ */
513
+ function gitAuthSpawn(remoteUrl) {
514
+ const { cleanUrl, username, password } = splitRemoteCredentials(remoteUrl);
515
+ return {
516
+ cleanUrl,
517
+ env: {
518
+ ...process.env,
519
+ GIT_ASKPASS: ensureAskpassScript(),
520
+ GIT_TERMINAL_PROMPT: "0",
521
+ FLUID_PORTAL_GIT_USER: username,
522
+ FLUID_PORTAL_GIT_PASS: password
523
+ }
524
+ };
525
+ }
526
+ /** Map an execa failure to a GitError, preferring the friendly
527
+ * git-missing message when the binary itself is absent. */
528
+ function toGitError(err, verb) {
529
+ const missing = gitMissingError(err);
530
+ if (missing) return missing;
531
+ const e = err;
532
+ return new GitError(`git ${verb} failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
533
+ }
534
+ /**
535
+ * `git clone <cleanUrl> <targetDir>`. When `remoteUrl` carries a token,
536
+ * gitAuthSpawn strips it from the URL and re-injects it via GIT_ASKPASS
537
+ * so it never appears in argv. `credential.helper=` stops git from
538
+ * caching what askpass returned to any system credential store.
539
+ *
540
+ * `depth` shallow-clones (used by the create flow, which discards `.git`
541
+ * afterwards anyway).
542
+ */
543
+ async function gitClone(remoteUrl, targetDir, opts = {}) {
544
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
545
+ const depthArgs = opts.depth ? ["--depth", String(opts.depth)] : [];
546
+ try {
547
+ await execa("git", [
548
+ "-c",
549
+ "credential.helper=",
550
+ "clone",
551
+ "--quiet",
552
+ ...depthArgs,
553
+ cleanUrl,
554
+ targetDir
555
+ ], {
556
+ env,
557
+ stdio: [
558
+ "ignore",
559
+ "inherit",
560
+ "inherit"
561
+ ]
562
+ });
563
+ } catch (err) {
564
+ throw toGitError(err, "clone");
565
+ }
566
+ }
567
+ /** Initialize a fresh repo (used after scaffolding so the project is
568
+ * git-ready). Best-effort: never throws — a scaffold without a repo is
569
+ * still usable, and a missing git binary is handled by the caller. */
570
+ async function gitInit(cwd) {
571
+ try {
572
+ await execa("git", ["init", "--quiet"], { cwd });
573
+ return true;
574
+ } catch {
575
+ return false;
576
+ }
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
+ }
864
+ //#endregion
865
+ //#region src/utils/scaffold-github.ts
866
+ /**
867
+ * GitHub-template scaffolding for `fluid portal create`.
868
+ *
869
+ * The canonical starter lives at github.com/Fluid-WeCommerce/portal-template
870
+ * as a **fully rendered, runnable** project (GitHub's generate-from-template
871
+ * copies files verbatim, so it can't contain `{{handlebars}}` placeholders).
872
+ * The project name is baked in as a distinctive token (CANONICAL_PROJECT_NAME);
873
+ * after cloning we re-substitute it with the user's chosen app name.
874
+ *
875
+ * This is the primary create path. When git or the network is unavailable
876
+ * the command falls back to the bundled template (see create.ts), so
877
+ * scaffolding never hard-fails offline.
878
+ */
879
+ /** Public HTTPS URL of the portal starter template repo. */
880
+ const TEMPLATE_REPO_URL = "https://github.com/Fluid-WeCommerce/portal-template.git";
881
+ /**
882
+ * The project name baked into the rendered template repo. `fluid portal
883
+ * create` replaces every occurrence with the user's app name after
884
+ * cloning. Must match the name the template was rendered with — keep in
885
+ * sync with the render used to publish the repo.
886
+ */
887
+ const CANONICAL_PROJECT_NAME = "fluid-portal-starter";
888
+ /**
889
+ * Files/dirs we never rewrite when re-substituting the project name.
890
+ * The lockfile keys packages by import path (`.`), not the root name, so
891
+ * touching it would only risk corruption for zero benefit.
892
+ */
893
+ const SUBSTITUTION_SKIP = new Set([
894
+ ".git",
895
+ "node_modules",
896
+ "pnpm-lock.yaml"
897
+ ]);
898
+ /** Extensions we treat as text and safe to rewrite. Everything else is
899
+ * left byte-for-byte (avoids mangling any binary asset a future template
900
+ * might carry). */
901
+ const TEXT_EXTENSIONS = new Set([
902
+ ".ts",
903
+ ".tsx",
904
+ ".js",
905
+ ".jsx",
906
+ ".mjs",
907
+ ".cjs",
908
+ ".json",
909
+ ".md",
910
+ ".yml",
911
+ ".yaml",
912
+ ".css",
913
+ ".html",
914
+ ".txt"
915
+ ]);
916
+ function hasTextExtension(file) {
917
+ const dot = file.lastIndexOf(".");
918
+ if (dot < 0) return false;
919
+ return TEXT_EXTENSIONS.has(file.slice(dot));
920
+ }
921
+ /** Recursively list absolute file paths, skipping SUBSTITUTION_SKIP dirs. */
922
+ async function listFiles(dir) {
923
+ const entries = await readdir(dir, { withFileTypes: true });
924
+ const files = [];
925
+ for (const entry of entries) {
926
+ if (SUBSTITUTION_SKIP.has(entry.name)) continue;
927
+ const full = join(dir, entry.name);
928
+ if (entry.isDirectory()) files.push(...await listFiles(full));
929
+ else files.push(full);
930
+ }
931
+ return files;
932
+ }
933
+ /** Escape a string for use in a RegExp. */
934
+ function escapeRegExp(value) {
935
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
936
+ }
937
+ /**
938
+ * Replace every occurrence of the canonical project name with `appName`
939
+ * across the cloned tree's text files. Only files that actually contain
940
+ * the token are rewritten.
941
+ */
942
+ async function rewriteProjectName(targetPath, appName) {
943
+ if (appName === "fluid-portal-starter") return;
944
+ const token = new RegExp(escapeRegExp(CANONICAL_PROJECT_NAME), "g");
945
+ const files = await listFiles(targetPath);
946
+ for (const file of files) {
947
+ if (!hasTextExtension(file)) continue;
948
+ const content = await readFile(file, "utf-8");
949
+ if (!content.includes("fluid-portal-starter")) continue;
950
+ await writeFile(file, content.replace(token, () => appName), "utf-8");
951
+ }
952
+ }
953
+ /** Rewrite `.fluidrc` with the chosen CLI profile (the template ships an
954
+ * empty profile). No-op when the file is absent. */
955
+ async function writeFluidrcProfile(targetPath, profileName) {
956
+ const fluidrcPath = join(targetPath, ".fluidrc");
957
+ if (!existsSync(fluidrcPath)) return;
958
+ await writeFile(fluidrcPath, JSON.stringify({ profile: profileName }, null, 2) + "\n", "utf-8");
959
+ }
960
+ /**
961
+ * Clone the GitHub starter template into `targetPath`, discard its git
962
+ * history, re-substitute the project name, wire `.fluidrc`, and copy
963
+ * `.env.example` → `.env`.
964
+ *
965
+ * Throws (GitError or otherwise) on any failure — the caller decides
966
+ * whether to fall back to the bundled template.
967
+ */
968
+ async function scaffoldFromGitHub(options) {
969
+ const { targetPath, appName, profileName } = options;
970
+ await gitClone(options.repoUrl ?? "https://github.com/Fluid-WeCommerce/portal-template.git", targetPath, { depth: 1 });
971
+ await rm(join(targetPath, ".git"), {
972
+ recursive: true,
973
+ force: true
974
+ });
975
+ await rewriteProjectName(targetPath, appName);
976
+ await writeFluidrcProfile(targetPath, profileName);
977
+ const envExample = join(targetPath, ".env.example");
978
+ try {
979
+ if ((await stat(envExample)).isFile()) await copyFile(envExample, join(targetPath, ".env"));
980
+ } catch {}
981
+ }
982
+ //#endregion
407
983
  //#region src/commands/create.ts
408
- const createCommand = new Command("create").description("Create a new Fluid portal application").argument("<app-name>", "Name of the application to create").option("--skip-install", "Skip dependency installation").option("-o, --output-dir <dir>", "Directory to create the project in (defaults to cwd)").option("--local", "Use local monorepo packages via file: links (for development testing)").action(async (appName, options) => {
984
+ const createCommand = new Command("create").description("Create a new Fluid portal application").argument("<app-name>", "Name of the application to create").option("--skip-install", "Skip dependency installation").option("-o, --output-dir <dir>", "Directory to create the project in (defaults to cwd)").option("--local", "Use local monorepo packages via file: links (for development testing)").option("--no-template", "Skip the GitHub starter template; scaffold from the bundled template").action(async (appName, options) => {
409
985
  try {
410
986
  console.log();
411
987
  console.log(chalk.bold("Creating a new Fluid portal application"));
@@ -426,65 +1002,39 @@ const createCommand = new Command("create").description("Create a new Fluid port
426
1002
  process.exit(0);
427
1003
  }
428
1004
  console.log();
429
- const templatePaths = getTemplatePaths("starter");
430
- if (!await directoryExists(templatePaths.base)) {
431
- console.error(chalk.red("Error: Base template not found"));
432
- process.exit(1);
433
- }
434
- if (!await directoryExists(templatePaths.overlay)) {
435
- console.error(chalk.red("Error: Starter template not found"));
436
- process.exit(1);
437
- }
438
- let sdkVersion;
439
- let localCoreVersion;
440
- const cliVersion = await getCliVersion();
441
- if (!!options.local) {
442
- const monorepoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
443
- const sdkPath = join(monorepoRoot, "packages", "portal", "sdk");
444
- const corePath = join(monorepoRoot, "packages", "portal", "core");
445
- if (!await directoryExists(sdkPath) || !await directoryExists(corePath)) {
446
- console.error(chalk.red("Error: --local requires running from within the fluid-mono monorepo\n Could not find packages/portal/sdk or packages/portal/core"));
447
- process.exit(1);
1005
+ const isLocal = !!options.local;
1006
+ const gitReady = !(isLocal || options.template === false || config.selectedPages.length > 0) && await isGitAvailable();
1007
+ let scaffolded = false;
1008
+ if (gitReady) {
1009
+ const spinner = ora("Fetching starter template from GitHub...").start();
1010
+ try {
1011
+ await scaffoldFromGitHub({
1012
+ targetPath,
1013
+ appName: config.name,
1014
+ profileName: config.profileName
1015
+ });
1016
+ spinner.succeed("Fetched starter template");
1017
+ scaffolded = true;
1018
+ } catch (error) {
1019
+ spinner.warn("Couldn't fetch the GitHub template — using the bundled starter");
1020
+ const reason = error instanceof GitError ? error.message.split("\n")[0] : error instanceof Error ? error.message : String(error);
1021
+ console.log(chalk.dim(` (${reason}; source: ${TEMPLATE_REPO_URL})`));
1022
+ try {
1023
+ await rm(targetPath, {
1024
+ recursive: true,
1025
+ force: true
1026
+ });
1027
+ } catch {}
448
1028
  }
449
- sdkVersion = getLocalPackageLinkVersion(targetPath, sdkPath);
450
- localCoreVersion = getLocalPackageLinkVersion(targetPath, corePath);
451
- console.log(chalk.cyan(" Using local packages (--local mode)"));
452
- } else sdkVersion = await getSdkVersion();
453
- const spinner = ora("Creating project directory...").start();
454
- try {
455
- await createDirectory(targetPath);
456
- spinner.succeed("Created project directory");
457
- } catch (error) {
458
- spinner.fail("Failed to create project directory");
459
- throw error;
460
- }
461
- const templateVariables = {
462
- projectName: config.name,
463
- sdkVersion,
464
- localCoreVersion,
465
- cliVersion,
466
- selectedPages: config.selectedPages,
467
- hasSelectedPages: config.selectedPages.length > 0,
468
- profileName: config.profileName
469
- };
470
- spinner.start("Copying template files...");
471
- try {
472
- await copyTemplate(templatePaths.base, targetPath, templateVariables);
473
- await copyTemplate(templatePaths.overlay, targetPath, templateVariables);
474
- const envExamplePath = join(targetPath, ".env.example");
475
- if (await fileExists(envExamplePath)) await copyFile(envExamplePath, join(targetPath, ".env"));
476
- spinner.succeed("Copied template files");
477
- } catch (error) {
478
- spinner.fail("Failed to copy template files");
479
- throw error;
480
1029
  }
1030
+ if (!scaffolded) await scaffoldFromBundled(targetPath, config, isLocal);
481
1031
  if (config.installDeps) {
482
- spinner.start("Installing dependencies with pnpm...");
1032
+ const installSpinner = ora("Installing dependencies with pnpm...").start();
483
1033
  try {
484
1034
  await installDependencies(targetPath);
485
- spinner.succeed("Installed dependencies");
1035
+ installSpinner.succeed("Installed dependencies");
486
1036
  } catch {
487
- spinner.fail("Failed to install dependencies");
1037
+ installSpinner.fail("Failed to install dependencies");
488
1038
  console.log();
489
1039
  console.log(chalk.yellow("You can try installing dependencies manually:"));
490
1040
  console.log(chalk.cyan(` cd ${appName}`));
@@ -517,6 +1067,59 @@ const createCommand = new Command("create").description("Create a new Fluid port
517
1067
  process.exit(1);
518
1068
  }
519
1069
  });
1070
+ /**
1071
+ * Scaffold from the bundled base + overlay templates (Handlebars). Used
1072
+ * when the GitHub template is skipped/unavailable, or with `--local`
1073
+ * (which rewrites SDK/core deps to workspace `file:`/`link:` paths so the
1074
+ * generated project resolves unpublished monorepo source).
1075
+ *
1076
+ * Throws on failure; the caller reports and exits.
1077
+ */
1078
+ async function scaffoldFromBundled(targetPath, config, isLocal) {
1079
+ const templatePaths = getTemplatePaths("starter");
1080
+ if (!await directoryExists(templatePaths.base)) throw new Error("Base template not found");
1081
+ if (!await directoryExists(templatePaths.overlay)) throw new Error("Starter template not found");
1082
+ let sdkVersion;
1083
+ let localCoreVersion;
1084
+ const cliVersion = await getCliVersion();
1085
+ if (isLocal) {
1086
+ const monorepoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
1087
+ const sdkPath = join(monorepoRoot, "packages", "portal", "sdk");
1088
+ const corePath = join(monorepoRoot, "packages", "portal", "core");
1089
+ if (!await directoryExists(sdkPath) || !await directoryExists(corePath)) throw new Error("--local requires running from within the fluid-mono monorepo\n Could not find packages/portal/sdk or packages/portal/core");
1090
+ sdkVersion = getLocalPackageLinkVersion(targetPath, sdkPath);
1091
+ localCoreVersion = getLocalPackageLinkVersion(targetPath, corePath);
1092
+ console.log(chalk.cyan(" Using local packages (--local mode)"));
1093
+ } else sdkVersion = await getSdkVersion();
1094
+ const spinner = ora("Creating project directory...").start();
1095
+ try {
1096
+ await createDirectory(targetPath);
1097
+ spinner.succeed("Created project directory");
1098
+ } catch (error) {
1099
+ spinner.fail("Failed to create project directory");
1100
+ throw error;
1101
+ }
1102
+ const templateVariables = {
1103
+ projectName: config.name,
1104
+ sdkVersion,
1105
+ localCoreVersion,
1106
+ cliVersion,
1107
+ selectedPages: config.selectedPages,
1108
+ hasSelectedPages: config.selectedPages.length > 0,
1109
+ profileName: config.profileName
1110
+ };
1111
+ spinner.start("Copying template files...");
1112
+ try {
1113
+ await copyTemplate(templatePaths.base, targetPath, templateVariables);
1114
+ await copyTemplate(templatePaths.overlay, targetPath, templateVariables);
1115
+ const envExamplePath = join(targetPath, ".env.example");
1116
+ if (await fileExists(envExamplePath)) await copyFile(envExamplePath, join(targetPath, ".env"));
1117
+ spinner.succeed("Copied template files");
1118
+ } catch (error) {
1119
+ spinner.fail("Failed to copy template files");
1120
+ throw error;
1121
+ }
1122
+ }
520
1123
  //#endregion
521
1124
  //#region src/commands/dev.ts
522
1125
  /**
@@ -546,7 +1149,7 @@ async function autoPull(cwd) {
546
1149
  console.log(chalk.yellow("No portal/ directory found.") + " Attempting to pull content...");
547
1150
  console.log();
548
1151
  try {
549
- const { pullCommand } = await import("./pull-zrTaJuSb.mjs").then((n) => n.n);
1152
+ const { pullCommand } = await import("./pull-CG5dELOX.mjs").then((n) => n.n);
550
1153
  await pullCommand.parseAsync([], { from: "user" });
551
1154
  return hasPortalContent(cwd);
552
1155
  } catch (err) {
@@ -1224,6 +1827,181 @@ function validateNavigationItems(items, file, validScreenSlugs, errors) {
1224
1827
  }
1225
1828
  }
1226
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
1227
2005
  //#region src/commands/push.ts
1228
2006
  /**
1229
2007
  * `fluid portal push` command
@@ -1247,7 +2025,7 @@ function toApiComponentTree(tree) {
1247
2025
  /**
1248
2026
  * Create an authenticated FetchClient using the stored CLI profile.
1249
2027
  */
1250
- function createClient$1() {
2028
+ function createClient$2() {
1251
2029
  const token = getAuthToken();
1252
2030
  if (!token) {
1253
2031
  const profile = getActiveProfile();
@@ -1269,6 +2047,32 @@ function enrichedErrorMessage(err) {
1269
2047
  return msg;
1270
2048
  }
1271
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
+ /**
1272
2076
  * Read and parse a JSON file from the portal directory.
1273
2077
  */
1274
2078
  async function readPortalFile(portalDir, relativePath) {
@@ -1876,6 +2680,7 @@ const pushCommand = new Command("push").description("Push local portal content c
1876
2680
  const definitionName = snapshot.definition;
1877
2681
  console.log(chalk.gray("Definition: ") + chalk.white(definitionName) + chalk.gray(` (ID: ${definitionId})`));
1878
2682
  console.log();
2683
+ await runInvisibleGitSync(cwd, definitionId, definitionName);
1879
2684
  const spinner = ora();
1880
2685
  spinner.start("Detecting changes...");
1881
2686
  const diff = await diffAgainstSnapshot(portalDir, snapshot);
@@ -1917,7 +2722,7 @@ const pushCommand = new Command("push").description("Push local portal content c
1917
2722
  spinner.start("Authenticating...");
1918
2723
  let client;
1919
2724
  try {
1920
- client = createClient$1();
2725
+ client = createClient$2();
1921
2726
  spinner.succeed("Authenticated");
1922
2727
  } catch (err) {
1923
2728
  spinner.fail("Authentication failed");
@@ -2005,6 +2810,114 @@ const pushCommand = new Command("push").description("Push local portal content c
2005
2810
  console.log();
2006
2811
  });
2007
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
2008
2921
  //#region src/utils/widget-helpers.ts
2009
2922
  /**
2010
2923
  * Pure helper functions for widget scaffolding.
@@ -5303,6 +6216,7 @@ const plugin = {
5303
6216
  portal.addCommand(buildCommand);
5304
6217
  portal.addCommand(pullCommand);
5305
6218
  portal.addCommand(pushCommand);
6219
+ portal.addCommand(cloneCommand);
5306
6220
  portal.addCommand(deployCommand);
5307
6221
  portal.addCommand(widgetCommand);
5308
6222
  portal.addCommand(doctorCommand);
@@ -5311,6 +6225,6 @@ const plugin = {
5311
6225
  }
5312
6226
  };
5313
6227
  //#endregion
5314
- export { FILE_SYSTEM_ERRORS, TEMPLATES, 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, fileExists, getArtifactContentType, getInstallCommand, getRunCommand, getSdkVersion, getSdkVersionSafe, getTemplatePaths, getWidgetPackageCompleteUploadRoute, getWidgetPackageUploadSessionRoute, installDependencies, loadSourceWidgetPackages, normalizeWidgetPackageCompleteResponse, normalizeWidgetPackageUploadSession, pathExists, printDryRunSessionPayload, printRuntimeOnlyNotice, printWidgetPublishSummary, promptProjectConfig, publishWidgetPackageVersion, publishWidgetRuntimeArtifacts, pullCommand, pushCommand, readFileSafe, readMappings, readSnapshot, removeMapping, requestWidgetPackageUploadSession, resolveIdToSlug, resolvePortalWidgetSourceConfig, resolveSlugToId, runPackageManager, sha256, slugFromPath, topLevelWidgetCommand, transformNavigation, transformNavigationItems, transformProfile, transformScreen, transformTheme, updateMapping, uploadWidgetPackageArtifacts, validateCrossReferences, validateSingleSourceWidgetPackage, validateWidgetPublishOutDir, versionCommand, widgetCommand, writeFileSafe, 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 };
5315
6229
 
5316
6230
  //# sourceMappingURL=index.mjs.map