@fluid-app/fluid-cli-portal 0.1.52 → 0.1.53

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,18 +1,18 @@
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-XOsFaqz2.mjs";
1
+ import { A as fluid_os_v0_create_fluid_ostheme, B as fluid_os_v0_list_fluid_osversions, C as writeMappings, D as fluid_os_v0_create_fluid_osnavigation_item, E as fluid_os_v0_create_fluid_osnavigation, F as fluid_os_v0_delete_fluid_osprofile, G as fluid_os_v0_update_fluid_ostheme, H as fluid_os_v0_update_fluid_osnavigation_item, I as fluid_os_v0_delete_fluid_osscreen, K as fluid_os_v0_update_fluid_osversion, L as fluid_os_v0_delete_fluid_ostheme, M as fluid_os_v0_create_widget_package_version, N as fluid_os_v0_delete_fluid_osnavigation, O as fluid_os_v0_create_fluid_osprofile, P as fluid_os_v0_delete_fluid_osnavigation_item, R as fluid_os_v0_list_fluid_osdefinitions, S as updateMapping, T as fluid_os_v0_complete_widget_package_version_upload, U as fluid_os_v0_update_fluid_osprofile, V as fluid_os_v0_update_fluid_osnavigation, W as fluid_os_v0_update_fluid_osscreen, _ 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_fluid_osversion, k as fluid_os_v0_create_fluid_osscreen, l as transformProfile, m as diffAgainstSnapshot, o as deriveScreenSlug, p as computeFileHash, q as createFetchClient, r as buildIdToSlugMap, s as transformNavigation, t as pullCommand, u as transformScreen, v as readMappings, w as atomicWriteFile, x as resolveSlugToId, y as removeMapping, z as fluid_os_v0_list_fluid_osnavigation_items } from "./pull-C3yj9L07.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
- import path, { basename, dirname, join, relative, resolve } from "node:path";
6
+ import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
- import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
8
+ import { chmodSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
10
+ import os, { tmpdir } from "node:os";
9
11
  import { failure, findProjectConfig, getActiveProfile, getAuthToken, listProfileNames, readConfig, success } from "@fluid-app/fluid-cli";
10
12
  import prompts from "prompts";
11
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
12
13
  import Handlebars from "handlebars";
13
14
  import { execa } from "execa";
14
- import os from "node:os";
15
- import { createHash } from "node:crypto";
15
+ import { createHash, randomUUID } from "node:crypto";
16
16
  import fs from "fs-extra";
17
17
  //#region src/types.ts
18
18
  /**
@@ -95,9 +95,9 @@ async function promptProjectConfig(projectName, options) {
95
95
  //#endregion
96
96
  //#region src/utils/file-system.ts
97
97
  const _currentDir$1 = dirname(fileURLToPath(import.meta.url));
98
- const EMBEDDED_PORTAL_SDK_VERSION = "0.1.461";
98
+ const EMBEDDED_PORTAL_SDK_VERSION = "0.1.462";
99
99
  const EMBEDDED_CLI_CORE_VERSION = "0.1.14";
100
- const EMBEDDED_CLI_PORTAL_VERSION = "0.1.52";
100
+ const EMBEDDED_CLI_PORTAL_VERSION = "0.1.53";
101
101
  /**
102
102
  * Find the package root by walking up from the current directory to the nearest package.json.
103
103
  * Works whether running from dist/ (bundled) or src/utils/ (tsx dev mode).
@@ -296,6 +296,40 @@ async function getCliVersion() {
296
296
  async function getPortalCliVersion() {
297
297
  return getTemplatePackageVersion(["cli", "portal"], EMBEDDED_CLI_PORTAL_VERSION);
298
298
  }
299
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org";
300
+ const NPM_REGISTRY_TIMEOUT_MS = 5e3;
301
+ /**
302
+ * Clamp a scaffold dependency pin to a version that `pnpm install` can
303
+ * actually resolve from the registry.
304
+ *
305
+ * The versions above are whatever this CLI build carries — embedded at build
306
+ * time, or read from the workspace when running from source. Both run ahead
307
+ * of npm whenever the monorepo holds a release bump that has not been
308
+ * published yet (routine for anyone running the CLI from a checkout, e.g.
309
+ * Mist in development), and a scaffold pinned to that version fails to
310
+ * install. Only a definitive registry 404 for the exact version downgrades
311
+ * the pin to the registry's `latest`; a network failure or any other registry
312
+ * response keeps the original version, so offline scaffolds behave exactly as
313
+ * before.
314
+ *
315
+ * Scaffold pins are the ONLY sound callsite: consumers that report which
316
+ * version is running (e.g. widget-package-publish) must not route through
317
+ * this — it would silently misreport an unpublished build as the older
318
+ * published one.
319
+ */
320
+ async function resolveInstallableTemplateVersion(packageName, version) {
321
+ try {
322
+ if ((await fetch(`${NPM_REGISTRY_URL}/${packageName}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(NPM_REGISTRY_TIMEOUT_MS) })).status !== 404) return version;
323
+ const latest = await fetch(`${NPM_REGISTRY_URL}/${packageName}/latest`, { signal: AbortSignal.timeout(NPM_REGISTRY_TIMEOUT_MS) });
324
+ if (!latest.ok) return version;
325
+ const pkg = await latest.json();
326
+ if (typeof pkg.version !== "string") return version;
327
+ console.warn(`${packageName}@${version} is not published yet; the scaffold pins the latest published version ${pkg.version} instead.`);
328
+ return pkg.version;
329
+ } catch {
330
+ return version;
331
+ }
332
+ }
299
333
  async function getTemplatePackageVersion(packagePathSegments, embeddedVersion) {
300
334
  if (embeddedVersion) return embeddedVersion;
301
335
  try {
@@ -486,6 +520,10 @@ function splitRemoteCredentials(remoteUrl) {
486
520
  password
487
521
  };
488
522
  }
523
+ /** Strip credential userinfo from a minted remote URL for safe comparison. */
524
+ function cleanGitRemoteUrl(remoteUrl) {
525
+ return splitRemoteCredentials(remoteUrl).cleanUrl;
526
+ }
489
527
  /**
490
528
  * Write a stub GIT_ASKPASS shell script to a per-process tmpdir and
491
529
  * return its path. The script contains no secret — it echoes back
@@ -519,9 +557,9 @@ function ensureAskpassScript() {
519
557
  * credentials are empty (auth should fail fast, not hang).
520
558
  */
521
559
  function gitAuthSpawn(remoteUrl) {
522
- const { cleanUrl, username, password } = splitRemoteCredentials(remoteUrl);
560
+ const { username, password } = splitRemoteCredentials(remoteUrl);
523
561
  return {
524
- cleanUrl,
562
+ cleanUrl: cleanGitRemoteUrl(remoteUrl),
525
563
  env: {
526
564
  ...process.env,
527
565
  GIT_ASKPASS: ensureAskpassScript(),
@@ -791,6 +829,245 @@ async function gitMergeFastForward(cwd) {
791
829
  }
792
830
  }
793
831
  /**
832
+ * Local-only state that must never reach a portal's provisioned repo,
833
+ * regardless of which tool created the checkout.
834
+ *
835
+ * `.mist-desktop/` is Mist Desktop's per-machine working state for the
836
+ * project directory it opened — chat transcripts, user attachments,
837
+ * screenshots, source baselines, and a media cache. When Desktop drives
838
+ * `fluid portal push`, that directory sits in the project root, so a
839
+ * whole-tree `git add -A` publishes every private conversation to the
840
+ * portal's GitHub repo. `.mist` is the sibling clone marker (a slug
841
+ * pointer); committing it means a later revert deletes it and breaks
842
+ * slug resolution for every subsequent command.
843
+ *
844
+ * Neither is portal content: the portal's own material lives under
845
+ * `portal/` and is pushed separately by the content push.
846
+ */
847
+ const LOCAL_ONLY_GIT_PATHS = [".mist", ".mist-desktop/"];
848
+ /**
849
+ * Append {@link LOCAL_ONLY_GIT_PATHS} to `.git/info/exclude` when absent.
850
+ *
851
+ * Why the local exclude and not `.gitignore`: a portal's `.gitignore`
852
+ * ships from the starter template (bundled or the GitHub
853
+ * `portal-template` repo), so a template edit alone would never reach
854
+ * the clones that already exist on people's machines. `.git/info/exclude`
855
+ * is per-checkout, so writing it here — at sync time, on every push —
856
+ * heals a pre-existing clone on its very next push with no template
857
+ * release required. The template is updated too, for fresh scaffolds.
858
+ *
859
+ * Idempotent and best-effort: never throws. A repo with an unusual `.git`
860
+ * layout (missing `info/`, `.git` as a worktree pointer file) just skips
861
+ * the exclude — {@link gitAutoCommit} also cleans these paths out of the
862
+ * index after staging, so the exclude is a convenience, not the only
863
+ * defense.
864
+ *
865
+ * Mirrors `packages/cli/mist/src/lib/git.ts::excludeMistMarkerFromGit`.
866
+ */
867
+ function excludeLocalStateFromGit(cwd) {
868
+ try {
869
+ const gitDir = path.join(cwd, ".git");
870
+ if (!existsSync(gitDir)) return;
871
+ const infoDir = path.join(gitDir, "info");
872
+ if (!existsSync(infoDir)) mkdirSync(infoDir, { recursive: true });
873
+ const excludePath = path.join(infoDir, "exclude");
874
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
875
+ const existingLines = new Set(existing.split("\n").map((line) => line.trim()).filter(Boolean));
876
+ const missing = LOCAL_ONLY_GIT_PATHS.filter((entry) => !existingLines.has(entry));
877
+ if (missing.length === 0) return;
878
+ writeFileSync(excludePath, `${existing}${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
879
+ } catch {}
880
+ }
881
+ /**
882
+ * Absolute path of the repository containing `cwd`, or `cwd` itself when
883
+ * resolution fails (not a repo / git missing — the caller's own git
884
+ * command surfaces the real error).
885
+ *
886
+ * Every local-state defense must anchor here rather than to `cwd`: a push
887
+ * run from a project subdirectory would otherwise look for
888
+ * `<subdir>/.git`, silently skip the exclude write, and then let
889
+ * `git add -A` — which always stages the WHOLE repository — sweep the
890
+ * root `.mist-desktop/` into the commit anyway.
891
+ */
892
+ async function resolveRepoRoot(cwd) {
893
+ try {
894
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"], { cwd });
895
+ const root = stdout.trim();
896
+ if (root.length > 0) return root;
897
+ } catch {}
898
+ return cwd;
899
+ }
900
+ /**
901
+ * Repository-root-anchored {@link excludeLocalStateFromGit}, for callers
902
+ * that only have a possibly-nested `cwd`.
903
+ *
904
+ * Run this BEFORE any working-tree cleanliness check, not just before
905
+ * staging: while `.mist-desktop/` is neither tracked nor excluded, it
906
+ * shows up as untracked in `git status --porcelain`, so a Desktop-driven
907
+ * push reads as "you have uncommitted changes" and the sync skips with
908
+ * advice to `git pull` that the user can't act on. Excluding it first
909
+ * makes the cleanliness check reflect actual portal changes.
910
+ */
911
+ async function ensureLocalStateExcluded(cwd) {
912
+ excludeLocalStateFromGit(await resolveRepoRoot(cwd));
913
+ }
914
+ /** Paths currently tracked under `.mist-desktop/`, or [] when none. */
915
+ async function trackedLocalStatePaths(repoRoot) {
916
+ try {
917
+ const { stdout } = await execa("git", [
918
+ "ls-files",
919
+ "--",
920
+ ".mist-desktop"
921
+ ], { cwd: repoRoot });
922
+ return stdout.split("\n").filter((l) => l.trim().length > 0);
923
+ } catch {
924
+ return [];
925
+ }
926
+ }
927
+ /**
928
+ * Untrack private Mist Desktop state and, if anything was tracked,
929
+ * commit ONLY that removal so the next push deletes it from the remote.
930
+ * Returns true when a purge commit was made.
931
+ *
932
+ * Call this on every sync BEFORE deciding whether there is anything to
933
+ * auto-commit — a clean checkout that already tracks `.mist-desktop/`
934
+ * still needs the removal, and ignore rules alone cannot untrack an
935
+ * existing path.
936
+ *
937
+ * The commit is built through a THROWAWAY index (`GIT_INDEX_FILE`), not
938
+ * the user's: `read-tree HEAD` → `rm --cached` → `write-tree` →
939
+ * `commit-tree` → `update-ref`. A plain `git commit` would capture the
940
+ * user's entire index, so anything they had pre-staged would be swept
941
+ * into the purge commit and pushed — defeating `--no-commit` and
942
+ * mislabelling their work. A pathspec commit (`git commit -- .mist-desktop`)
943
+ * is also wrong: with `--only` git takes the *working tree* content of
944
+ * those paths, and the chats are still on disk, so it would re-add
945
+ * exactly what we are removing.
946
+ *
947
+ * The user's real index is then updated with the same `rm --cached`,
948
+ * touching only this path — so any other staged work stays staged, and
949
+ * the working tree is never modified.
950
+ */
951
+ async function purgeLocalStateFromIndex(cwd, message) {
952
+ const repoRoot = await resolveRepoRoot(cwd);
953
+ if ((await trackedLocalStatePaths(repoRoot)).length === 0) return false;
954
+ let head;
955
+ try {
956
+ const { stdout } = await execa("git", ["rev-parse", "HEAD"], { cwd: repoRoot });
957
+ head = stdout.trim();
958
+ if (!head) return false;
959
+ } catch {
960
+ return false;
961
+ }
962
+ const tmpIndex = path.join(os.tmpdir(), `fluid-portal-purge-${process.pid}-${randomUUID()}.index`);
963
+ const indexEnv = {
964
+ ...process.env,
965
+ GIT_INDEX_FILE: tmpIndex
966
+ };
967
+ let commitSha;
968
+ try {
969
+ await execa("git", ["read-tree", head], {
970
+ cwd: repoRoot,
971
+ env: indexEnv
972
+ });
973
+ await execa("git", [
974
+ "rm",
975
+ "-q",
976
+ "-r",
977
+ "--cached",
978
+ "--ignore-unmatch",
979
+ "--",
980
+ ".mist-desktop"
981
+ ], {
982
+ cwd: repoRoot,
983
+ env: indexEnv
984
+ });
985
+ const { stdout: tree } = await execa("git", ["write-tree"], {
986
+ cwd: repoRoot,
987
+ env: indexEnv
988
+ });
989
+ const { stdout: sha } = await execa("git", [
990
+ "commit-tree",
991
+ tree.trim(),
992
+ "-p",
993
+ head,
994
+ "-m",
995
+ message
996
+ ], {
997
+ cwd: repoRoot,
998
+ env: {
999
+ ...indexEnv,
1000
+ ...await identityEnv(repoRoot)
1001
+ }
1002
+ });
1003
+ commitSha = sha.trim();
1004
+ if (!commitSha) return false;
1005
+ } catch (err) {
1006
+ const missing = gitMissingError(err);
1007
+ if (missing) throw missing;
1008
+ throw toGitError(err, "purge local state");
1009
+ } finally {
1010
+ rmSync(tmpIndex, { force: true });
1011
+ }
1012
+ try {
1013
+ await execa("git", [
1014
+ "update-ref",
1015
+ "HEAD",
1016
+ commitSha,
1017
+ head
1018
+ ], { cwd: repoRoot });
1019
+ } catch (err) {
1020
+ throw toGitError(err, "purge local state");
1021
+ }
1022
+ try {
1023
+ await execa("git", [
1024
+ "rm",
1025
+ "-q",
1026
+ "-r",
1027
+ "--cached",
1028
+ "--ignore-unmatch",
1029
+ "--",
1030
+ ".mist-desktop"
1031
+ ], { cwd: repoRoot });
1032
+ } catch {}
1033
+ return true;
1034
+ }
1035
+ /** `GIT_{AUTHOR,COMMITTER}_*` overrides for `commit-tree`, which (unlike
1036
+ * `git commit`) fails outright rather than prompting when the repo has
1037
+ * no identity. Only set what the repo itself doesn't define. */
1038
+ async function identityEnv(cwd) {
1039
+ const env = {};
1040
+ const read = async (key) => {
1041
+ try {
1042
+ return (await execa("git", ["config", key], { cwd })).stdout.trim().length > 0;
1043
+ } catch {
1044
+ return false;
1045
+ }
1046
+ };
1047
+ if (!await read("user.name")) {
1048
+ env["GIT_AUTHOR_NAME"] = "Fluid Portal CLI";
1049
+ env["GIT_COMMITTER_NAME"] = "Fluid Portal CLI";
1050
+ }
1051
+ if (!await read("user.email")) {
1052
+ env["GIT_AUTHOR_EMAIL"] = "portal@fluid.app";
1053
+ env["GIT_COMMITTER_EMAIL"] = "portal@fluid.app";
1054
+ }
1055
+ return env;
1056
+ }
1057
+ /** Shared `-c user.*` args: use the repo's identity, else a synthetic
1058
+ * Fluid bot identity so the commit isn't rejected. */
1059
+ async function resolveIdentityArgs(cwd) {
1060
+ let hasName = false;
1061
+ let hasEmail = false;
1062
+ try {
1063
+ hasName = (await execa("git", ["config", "user.name"], { cwd })).stdout.trim().length > 0;
1064
+ } catch {}
1065
+ try {
1066
+ hasEmail = (await execa("git", ["config", "user.email"], { cwd })).stdout.trim().length > 0;
1067
+ } catch {}
1068
+ return [...hasName ? [] : ["-c", "user.name=Fluid Portal CLI"], ...hasEmail ? [] : ["-c", "user.email=portal@fluid.app"]];
1069
+ }
1070
+ /**
794
1071
  * `git add -A && git commit -m <message>` (or, with
795
1072
  * `{ ignoreRemovals: true }`, `git add --ignore-removal .` — see
796
1073
  * GitAutoCommitOptions).
@@ -803,11 +1080,42 @@ async function gitMergeFastForward(cwd) {
803
1080
  * `node_modules/`, `dist/`, `.portal-sync/`, logs, `*.tsbuildinfo`), so
804
1081
  * generated output and local secrets stay out of the commit.
805
1082
  *
1083
+ * That boundary is not enough on its own for tool-local state written
1084
+ * INTO the project directory by whatever opened it — most importantly
1085
+ * Mist Desktop's `.mist-desktop/` (private chats, attachments,
1086
+ * screenshots) and the `.mist` clone marker. A `.gitignore`-only fix
1087
+ * can't reach clones that already exist, so this applies belt AND
1088
+ * suspenders, all anchored to the repository root (see
1089
+ * {@link resolveRepoRoot}):
1090
+ *
1091
+ * 1. {@link excludeLocalStateFromGit} — idempotent `.git/info/exclude`
1092
+ * write, so `git add` never picks the paths up in the first place.
1093
+ * Heals pre-existing clones on their next push.
1094
+ * 2. `git reset -- ':(top).mist' .mist` after staging — unstages the
1095
+ * marker if step 1 silently failed. We deliberately do NOT pass a
1096
+ * `:(exclude)` pathspec to `git add`: when the path is *also*
1097
+ * listed in `.git/info/exclude`, git exits 1 with "paths are
1098
+ * ignored by one of your .gitignore files", turning the normal
1099
+ * (already-healed) case into a hard failure. Add-then-reset is a
1100
+ * no-op when there's nothing staged.
1101
+ * 3. `git rm --cached -r --ignore-unmatch .mist-desktop` — untracks
1102
+ * chat history that an older, unhardened push already committed.
1103
+ * Ignore rules alone cannot untrack an existing path. `--cached`
1104
+ * keeps every file on disk; only the index entry goes away. This
1105
+ * runs even under `ignoreRemovals`, where suppressing deletions
1106
+ * protects remote-only *portal* files — private chats are never
1107
+ * that, and leaving them tracked would keep republishing them.
1108
+ *
1109
+ * Steps 2 and 3 are best-effort and never fail the publish; the commit
1110
+ * itself is what must succeed.
1111
+ *
806
1112
  * Uses the repo's configured identity, else a synthetic Fluid bot
807
1113
  * identity so the commit isn't rejected. "nothing to commit" is treated
808
1114
  * as a no-op, not an error.
809
1115
  */
810
1116
  async function gitAutoCommit(cwd, message, options = {}) {
1117
+ const repoRoot = await resolveRepoRoot(cwd);
1118
+ excludeLocalStateFromGit(repoRoot);
811
1119
  const addArgs = options.ignoreRemovals ? [
812
1120
  "add",
813
1121
  "--ignore-removal",
@@ -818,15 +1126,27 @@ async function gitAutoCommit(cwd, message, options = {}) {
818
1126
  } catch (err) {
819
1127
  throw toGitError(err, "add");
820
1128
  }
821
- let hasName = false;
822
- let hasEmail = false;
823
1129
  try {
824
- hasName = (await execa("git", ["config", "user.name"], { cwd })).stdout.trim().length > 0;
1130
+ await execa("git", [
1131
+ "reset",
1132
+ "-q",
1133
+ "--",
1134
+ ":(top).mist",
1135
+ ".mist"
1136
+ ], { cwd });
825
1137
  } catch {}
826
1138
  try {
827
- hasEmail = (await execa("git", ["config", "user.email"], { cwd })).stdout.trim().length > 0;
1139
+ await execa("git", [
1140
+ "rm",
1141
+ "-q",
1142
+ "-r",
1143
+ "--cached",
1144
+ "--ignore-unmatch",
1145
+ "--",
1146
+ ".mist-desktop"
1147
+ ], { cwd: repoRoot });
828
1148
  } catch {}
829
- const identityArgs = [...hasName ? [] : ["-c", "user.name=Fluid Portal CLI"], ...hasEmail ? [] : ["-c", "user.email=portal@fluid.app"]];
1149
+ const identityArgs = await resolveIdentityArgs(cwd);
830
1150
  try {
831
1151
  await execa("git", [
832
1152
  ...identityArgs,
@@ -1055,7 +1375,7 @@ async function createPortalProject(appName, options, config, dependencies = defa
1055
1375
  }).catch(() => {});
1056
1376
  throw error;
1057
1377
  }
1058
- } else await scaffoldFromBundled(targetPath, config, isLocal);
1378
+ } else await scaffoldFromBundled(targetPath, config, isLocal, true);
1059
1379
  if (config.installDeps) {
1060
1380
  const installSpinner = ora("Installing dependencies with pnpm...").start();
1061
1381
  try {
@@ -1105,13 +1425,13 @@ function formatCdPath(targetPath) {
1105
1425
  *
1106
1426
  * Throws on failure; the caller reports and exits.
1107
1427
  */
1108
- async function scaffoldFromBundled(targetPath, config, isLocal) {
1428
+ async function scaffoldFromBundled(targetPath, config, isLocal, createTarget) {
1109
1429
  const templatePaths = getTemplatePaths("starter");
1110
1430
  if (!await directoryExists(templatePaths.base)) throw new Error("Base template not found");
1111
1431
  if (!await directoryExists(templatePaths.overlay)) throw new Error("Starter template not found");
1112
1432
  let sdkVersion;
1113
1433
  let localCoreVersion;
1114
- const [cliVersion, portalCliVersion] = await Promise.all([getCliVersion(), getPortalCliVersion()]);
1434
+ const [cliVersion, portalCliVersion] = await Promise.all([getCliVersion().then((version) => resolveInstallableTemplateVersion("@fluid-app/fluid-cli", version)), getPortalCliVersion().then((version) => resolveInstallableTemplateVersion("@fluid-app/fluid-cli-portal", version))]);
1115
1435
  if (isLocal) {
1116
1436
  const monorepoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
1117
1437
  const sdkPath = join(monorepoRoot, "packages", "portal", "sdk");
@@ -1120,15 +1440,16 @@ async function scaffoldFromBundled(targetPath, config, isLocal) {
1120
1440
  sdkVersion = getLocalPackageLinkVersion(targetPath, sdkPath);
1121
1441
  localCoreVersion = getLocalPackageLinkVersion(targetPath, corePath);
1122
1442
  console.log(chalk.cyan(" Using local packages (--local mode)"));
1123
- } else sdkVersion = await getSdkVersion();
1124
- const spinner = ora("Creating project directory...").start();
1125
- try {
1443
+ } else sdkVersion = await getSdkVersion().then((version) => resolveInstallableTemplateVersion("@fluid-app/portal-sdk", version));
1444
+ const spinner = ora(createTarget ? "Creating project directory..." : "Preparing provisioned portal checkout...").start();
1445
+ if (createTarget) try {
1126
1446
  await createDirectory(targetPath);
1127
1447
  spinner.succeed("Created project directory");
1128
1448
  } catch (error) {
1129
1449
  spinner.fail("Failed to create project directory");
1130
1450
  throw error;
1131
1451
  }
1452
+ else if (!await directoryExists(targetPath)) throw new Error(`Portal checkout does not exist: ${targetPath}`);
1132
1453
  const templateVariables = {
1133
1454
  projectName: config.name,
1134
1455
  sdkVersion,
@@ -1151,6 +1472,79 @@ async function scaffoldFromBundled(targetPath, config, isLocal) {
1151
1472
  throw error;
1152
1473
  }
1153
1474
  }
1475
+ /**
1476
+ * Add the runnable bundled starter to an existing provisioned Git checkout.
1477
+ * Portal repositories begin with a bootstrap commit, so a first clone may
1478
+ * not contain package.json yet. Rendering into the checkout preserves its
1479
+ * .git directory and gives both the CLI and Mist Desktop a usable project
1480
+ * without creating unrelated local history.
1481
+ */
1482
+ async function scaffoldPortalProjectIntoExistingDirectory(targetPath, config) {
1483
+ const pendingMarker = join(targetPath, PORTAL_SCAFFOLD_PENDING_MARKER);
1484
+ const stagingRoot = await mkdtemp(join(tmpdir(), "fluid-portal-scaffold-stage-"));
1485
+ const stagedProject = join(stagingRoot, "portal");
1486
+ let completed = false;
1487
+ try {
1488
+ await writeFile(pendingMarker, "Fluid Portal scaffold pending\n");
1489
+ await scaffoldFromBundled(stagedProject, config, false, true);
1490
+ const preserved = await copyMissingScaffoldFiles(stagedProject, targetPath);
1491
+ if (preserved.length > 0) console.log(chalk.dim(` Preserved ${preserved.length} existing repository ${preserved.length === 1 ? "file" : "files"} while adding the runnable scaffold.`));
1492
+ completed = true;
1493
+ } finally {
1494
+ if (completed) await rm(pendingMarker, { force: true });
1495
+ await rm(stagingRoot, {
1496
+ recursive: true,
1497
+ force: true
1498
+ });
1499
+ }
1500
+ }
1501
+ const PORTAL_SCAFFOLD_PENDING_MARKER = ".fluid-portal-scaffold-pending";
1502
+ async function copyMissingScaffoldFiles(sourceRoot, targetRoot, relativeDirectory = "") {
1503
+ const preserved = [];
1504
+ const entries = await readdir(join(sourceRoot, relativeDirectory), { withFileTypes: true });
1505
+ for (const entry of entries) {
1506
+ const relativePath = join(relativeDirectory, entry.name);
1507
+ const sourcePath = join(sourceRoot, relativePath);
1508
+ const targetPath = join(targetRoot, relativePath);
1509
+ if (entry.isDirectory()) {
1510
+ if (!await ensureScaffoldDirectory(targetPath)) throw new Error(`Cannot add the runnable Portal scaffold because the repository path "${relativePath}" is not a directory. The cloned repository and Git history were preserved.`);
1511
+ preserved.push(...await copyMissingScaffoldFiles(sourceRoot, targetRoot, relativePath));
1512
+ continue;
1513
+ }
1514
+ if (!entry.isFile()) throw new Error(`Portal scaffold contains an unsupported file type at ${relativePath}.`);
1515
+ try {
1516
+ await copyFile(sourcePath, targetPath, constants.COPYFILE_EXCL);
1517
+ } catch (error) {
1518
+ if (error.code !== "EEXIST") throw error;
1519
+ preserved.push(relativePath);
1520
+ }
1521
+ }
1522
+ return preserved;
1523
+ }
1524
+ async function ensureScaffoldDirectory(targetPath) {
1525
+ try {
1526
+ return (await lstat(targetPath)).isDirectory();
1527
+ } catch (error) {
1528
+ const code = error.code;
1529
+ if (code !== "ENOENT") {
1530
+ if (code === "ENOTDIR") return false;
1531
+ throw error;
1532
+ }
1533
+ }
1534
+ try {
1535
+ await mkdir(targetPath);
1536
+ return true;
1537
+ } catch (error) {
1538
+ const code = error.code;
1539
+ if (code !== "EEXIST" && code !== "ENOTDIR") throw error;
1540
+ try {
1541
+ return (await lstat(targetPath)).isDirectory();
1542
+ } catch (statError) {
1543
+ if (statError.code === "ENOTDIR") return false;
1544
+ throw statError;
1545
+ }
1546
+ }
1547
+ }
1154
1548
  //#endregion
1155
1549
  //#region src/commands/dev.ts
1156
1550
  /**
@@ -1164,6 +1558,46 @@ async function scaffoldFromBundled(targetPath, config, isLocal) {
1164
1558
  * or auto-pulls if they are logged in.
1165
1559
  */
1166
1560
  const PORTAL_DIR$1 = "portal";
1561
+ const WATCHED_DIRECTORIES = [
1562
+ "portal",
1563
+ "public",
1564
+ "src"
1565
+ ];
1566
+ const WATCHED_ROOT_FILES = new Set([
1567
+ "index.html",
1568
+ "package.json",
1569
+ "tsconfig.json",
1570
+ "vite.config.cjs",
1571
+ "vite.config.cts",
1572
+ "vite.config.js",
1573
+ "vite.config.mjs",
1574
+ "vite.config.mts",
1575
+ "vite.config.ts"
1576
+ ]);
1577
+ /**
1578
+ * Restrict Vite's root watcher to files that can affect a portal preview.
1579
+ * Directory paths themselves must remain visible to Chokidar so it can
1580
+ * descend into them and discover their files.
1581
+ */
1582
+ function createPortalWatchIgnored(cwd) {
1583
+ const projectRoot = resolve(cwd);
1584
+ return (filePath) => {
1585
+ const projectPath = relative(projectRoot, isAbsolute(filePath) ? filePath : resolve(projectRoot, filePath));
1586
+ if (projectPath === "") return false;
1587
+ if (projectPath === ".." || projectPath.startsWith(`..${sep}`)) return true;
1588
+ const [topLevelPath] = projectPath.split(sep);
1589
+ if (WATCHED_DIRECTORIES.some((directory) => topLevelPath === directory)) return false;
1590
+ const isEnvironmentFile = projectPath === ".env" || projectPath.startsWith(".env.");
1591
+ return !WATCHED_ROOT_FILES.has(projectPath) && !isEnvironmentFile;
1592
+ };
1593
+ }
1594
+ function portalDevServerOptions(port, exposeToNetwork, cwd = process.cwd()) {
1595
+ return {
1596
+ port,
1597
+ host: exposeToNetwork ? true : void 0,
1598
+ watch: { ignored: createPortalWatchIgnored(cwd) }
1599
+ };
1600
+ }
1167
1601
  /**
1168
1602
  * Check if the portal directory exists and has content files.
1169
1603
  * Returns true if at minimum `portal/definition.json` exists.
@@ -1191,7 +1625,7 @@ function checkDependenciesInstalled(cwd) {
1191
1625
  if (existsSync(nodeModulesPath) && hasVite && hasPortalSdk) return { ok: true };
1192
1626
  return {
1193
1627
  ok: false,
1194
- message: `Dependencies aren't installed in ${cwd} — run \`pnpm install\` (or reopen the project in Mist Desktop, which installs automatically), then retry.`
1628
+ message: `Dependencies aren't installed in ${cwd} — run \`pnpm install\`, then retry.`
1195
1629
  };
1196
1630
  }
1197
1631
  /**
@@ -1203,7 +1637,7 @@ async function autoPull(cwd) {
1203
1637
  console.log(chalk.yellow("No portal/ directory found.") + " Attempting to pull content...");
1204
1638
  console.log();
1205
1639
  try {
1206
- const { pullCommand } = await import("./pull-XOsFaqz2.mjs").then((n) => n.n);
1640
+ const { pullCommand } = await import("./pull-C3yj9L07.mjs").then((n) => n.n);
1207
1641
  await pullCommand.parseAsync([], { from: "user" });
1208
1642
  return hasPortalContent(cwd);
1209
1643
  } catch (err) {
@@ -1256,16 +1690,13 @@ const devCommand = new Command("dev").description("Start the development server
1256
1690
  const port = Number.isFinite(parsedPort) ? parsedPort : 5173;
1257
1691
  try {
1258
1692
  const { createServer } = await import("vite");
1259
- const { portalDevPlugin } = await import("./portal-dev-plugin-BHE8Z4yr.mjs").then((n) => n.n);
1693
+ const { portalDevPlugin } = await import("./portal-dev-plugin-ByYelgFg.mjs").then((n) => n.n);
1260
1694
  const { backendDevPlugin } = await import("./backend-dev-plugin-B9-S4Is-.mjs").then((n) => n.n);
1261
1695
  const server = await createServer({
1262
1696
  configFile: viteConfigPath,
1263
1697
  envPrefix: ["VITE_", "NEXT_PUBLIC_"],
1264
1698
  plugins: [portalDevPlugin(), backendDevPlugin()],
1265
- server: {
1266
- port,
1267
- host: options.host ? true : void 0
1268
- }
1699
+ server: portalDevServerOptions(port, options.host === true, cwd)
1269
1700
  });
1270
1701
  await server.listen();
1271
1702
  server.printUrls();
@@ -1401,6 +1832,8 @@ function categorizeChanges(diff) {
1401
1832
  * Validate that all cross-references between local portal files are valid.
1402
1833
  *
1403
1834
  * Checks:
1835
+ * - New and changed resources contain the required local-file fields
1836
+ * - New and changed themes include the required config object
1404
1837
  * - Navigation items' "screen" slugs reference existing screen files or mappings
1405
1838
  * - Profile "navigation" and "mobile_navigation" slugs reference existing nav files or mappings
1406
1839
  * - Profile "themes" slugs reference existing theme files or mappings
@@ -1413,9 +1846,43 @@ async function validateCrossReferences(portalDir, mappings, changes) {
1413
1846
  for (const file of changes.screens.deleted) validScreenSlugs.delete(slugFromPath(file));
1414
1847
  for (const file of changes.navigations.deleted) validNavSlugs.delete(slugFromPath(file));
1415
1848
  for (const file of changes.themes.deleted) validThemeSlugs.delete(slugFromPath(file));
1849
+ const screenFilesToCheck = [...changes.screens.new, ...changes.screens.changed];
1850
+ for (const file of screenFilesToCheck) try {
1851
+ const screen = await readPortalFile$1(portalDir, file);
1852
+ if (typeof screen.name !== "string" || !Array.isArray(screen.component_tree)) errors.push({
1853
+ file,
1854
+ message: "Screen requires a string \"name\" and array \"component_tree\"."
1855
+ });
1856
+ } catch {
1857
+ errors.push({
1858
+ file,
1859
+ message: "Failed to read screen file"
1860
+ });
1861
+ }
1862
+ const themeFilesToCheck = [...changes.themes.new, ...changes.themes.changed];
1863
+ for (const file of themeFilesToCheck) try {
1864
+ const theme = await readPortalFile$1(portalDir, file);
1865
+ if (typeof theme.name !== "string" || typeof theme.active !== "boolean" || !theme.config || typeof theme.config !== "object" || Array.isArray(theme.config)) errors.push({
1866
+ file,
1867
+ message: "Theme requires a string \"name\", boolean \"active\", and \"config\" object. Copy the complete config shape from a pulled theme instead of creating only name/active fields."
1868
+ });
1869
+ } catch {
1870
+ errors.push({
1871
+ file,
1872
+ message: "Failed to read theme file"
1873
+ });
1874
+ }
1416
1875
  const navFilesToCheck = [...changes.navigations.new, ...changes.navigations.changed];
1417
1876
  for (const file of navFilesToCheck) try {
1418
- validateNavigationItems((await readPortalFile$1(portalDir, file)).navigation_items, file, validScreenSlugs, errors);
1877
+ const nav = await readPortalFile$1(portalDir, file);
1878
+ if (typeof nav.name !== "string" || nav.platform !== "web" && nav.platform !== "mobile" || !Array.isArray(nav.navigation_items)) {
1879
+ errors.push({
1880
+ file,
1881
+ message: "Navigation requires a string \"name\", \"platform\" set to \"web\" or \"mobile\", and a \"navigation_items\" array."
1882
+ });
1883
+ continue;
1884
+ }
1885
+ validateNavigationItems(nav.navigation_items, file, validScreenSlugs, errors);
1419
1886
  } catch {
1420
1887
  errors.push({
1421
1888
  file,
@@ -1425,6 +1892,14 @@ async function validateCrossReferences(portalDir, mappings, changes) {
1425
1892
  const profileFilesToCheck = [...changes.profiles.new, ...changes.profiles.changed];
1426
1893
  for (const file of profileFilesToCheck) try {
1427
1894
  const profile = await readPortalFile$1(portalDir, file);
1895
+ const permissions = profile.permissions;
1896
+ if (typeof profile.name !== "string" || typeof profile.default !== "boolean" || !Array.isArray(profile.themes) || !permissions || !Array.isArray(permissions.countries) || !Array.isArray(permissions.ranks) || !Array.isArray(permissions.roles) || !Array.isArray(permissions.platform)) {
1897
+ errors.push({
1898
+ file,
1899
+ message: "Profile requires \"name\", boolean \"default\", a \"themes\" array, and permissions arrays for countries, ranks, roles, and platform."
1900
+ });
1901
+ continue;
1902
+ }
1428
1903
  if (profile.navigation && !validNavSlugs.has(profile.navigation)) errors.push({
1429
1904
  file,
1430
1905
  message: `References navigation "${profile.navigation}" which does not exist`
@@ -1483,15 +1958,71 @@ function validateNavigationItems(items, file, validScreenSlugs, errors) {
1483
1958
  function portalGitCredentialPath(definitionId) {
1484
1959
  return `/api/company/fluid_os/definitions/${definitionId}/git_credential`;
1485
1960
  }
1486
- function extractErrorReason(body, fallback) {
1487
- if (body && typeof body === "object" && "error" in body) {
1488
- const error = body.error;
1489
- if (error && typeof error === "object" && "message" in error) {
1490
- const message = error.message;
1491
- if (typeof message === "string" && message.length > 0) return message;
1492
- }
1961
+ const DEFAULT_RETRY_DELAYS_MS = [
1962
+ 500,
1963
+ 1e3,
1964
+ 2e3,
1965
+ 4e3,
1966
+ 8e3,
1967
+ 1e4,
1968
+ 15e3,
1969
+ 15e3
1970
+ ];
1971
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
1972
+ const RETRYABLE_CONFLICT_TYPES = new Set(["not_provisioned"]);
1973
+ function nonEmptyString(value) {
1974
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1975
+ }
1976
+ function firstString(value) {
1977
+ if (Array.isArray(value)) return nonEmptyString(value[0]);
1978
+ return nonEmptyString(value);
1979
+ }
1980
+ function extractErrorDetails(body, fallback) {
1981
+ if (!body || typeof body !== "object") return { reason: fallback };
1982
+ const envelope = body;
1983
+ const error = envelope.error && typeof envelope.error === "object" ? envelope.error : void 0;
1984
+ const nestedDetails = error?.details && typeof error.details === "object" ? error.details : void 0;
1985
+ const errors = envelope.errors && typeof envelope.errors === "object" ? envelope.errors : void 0;
1986
+ const reason = nonEmptyString(envelope.error_message) ?? nonEmptyString(error?.message) ?? nonEmptyString(envelope.error) ?? fallback;
1987
+ const failureType = firstString(errors?.type) ?? nonEmptyString(nestedDetails?.type);
1988
+ return {
1989
+ reason,
1990
+ ...failureType ? { failureType } : {}
1991
+ };
1992
+ }
1993
+ function sleep(delayMs) {
1994
+ return new Promise((resolve) => {
1995
+ setTimeout(resolve, delayMs);
1996
+ });
1997
+ }
1998
+ /**
1999
+ * Retry clone-credential conflicts while the backend provisions or revives
2000
+ * the portal's repository. The first credential request is the trigger for
2001
+ * older definitions with no repo, so a single-shot clone would always fail
2002
+ * even though the repository becomes ready seconds later.
2003
+ */
2004
+ async function createPortalGitCredentialWithRetry(options) {
2005
+ const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
2006
+ const sleepImpl = options.sleepImpl ?? sleep;
2007
+ const requestOptions = {
2008
+ baseUrl: options.baseUrl,
2009
+ token: options.token,
2010
+ definitionId: options.definitionId,
2011
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
2012
+ ...options.requestTimeoutMs !== void 0 ? { requestTimeoutMs: options.requestTimeoutMs } : {}
2013
+ };
2014
+ let outcome = await createPortalGitCredential(requestOptions);
2015
+ for (const [index, delayMs] of retryDelaysMs.entries()) {
2016
+ if (outcome.status !== "unavailable" || !outcome.retryable) return outcome;
2017
+ options.onRetry?.({
2018
+ attempt: index + 2,
2019
+ totalAttempts: retryDelaysMs.length + 1,
2020
+ reason: outcome.reason
2021
+ });
2022
+ await sleepImpl(delayMs);
2023
+ outcome = await createPortalGitCredential(requestOptions);
1493
2024
  }
1494
- return fallback;
2025
+ return outcome;
1495
2026
  }
1496
2027
  /**
1497
2028
  * Request a git credential for the portal definition's provisioned repo.
@@ -1502,36 +2033,54 @@ function extractErrorReason(body, fallback) {
1502
2033
  async function createPortalGitCredential(options) {
1503
2034
  const { baseUrl, token, definitionId } = options;
1504
2035
  const fetchImpl = options.fetchImpl ?? fetch;
2036
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1505
2037
  const url = `${baseUrl.replace(/\/$/, "")}${portalGitCredentialPath(definitionId)}`;
2038
+ const abortController = new AbortController();
2039
+ let requestTimedOut = false;
2040
+ const timeout = setTimeout(() => {
2041
+ requestTimedOut = true;
2042
+ abortController.abort();
2043
+ }, requestTimeoutMs);
1506
2044
  let resp;
2045
+ let body = null;
1507
2046
  try {
1508
2047
  resp = await fetchImpl(url, {
1509
2048
  method: "POST",
1510
2049
  headers: {
1511
2050
  Authorization: `Bearer ${token}`,
1512
2051
  Accept: "application/json"
1513
- }
2052
+ },
2053
+ signal: abortController.signal
1514
2054
  });
2055
+ if (resp.status !== 404) try {
2056
+ body = await resp.json();
2057
+ } catch (err) {
2058
+ if (requestTimedOut) throw err;
2059
+ }
1515
2060
  } catch (err) {
1516
2061
  return {
1517
2062
  status: "error",
1518
- reason: err instanceof Error ? err.message : String(err)
2063
+ reason: requestTimedOut ? `git credential request timed out after ${requestTimeoutMs}ms` : err instanceof Error ? err.message : String(err)
1519
2064
  };
2065
+ } finally {
2066
+ clearTimeout(timeout);
1520
2067
  }
1521
2068
  if (resp.status === 404) return {
1522
2069
  status: "unavailable",
1523
- reason: "git sync isn't available for portals on this Fluid API yet"
2070
+ reason: "git sync isn't available for portals on this Fluid API yet",
2071
+ retryable: false
1524
2072
  };
1525
- const body = await resp.json().catch(() => null);
1526
2073
  if (!resp.ok) {
1527
- const reason = extractErrorReason(body, `git credential request failed (HTTP ${resp.status})`);
2074
+ const details = extractErrorDetails(body, `git credential request failed (HTTP ${resp.status})`);
1528
2075
  if (resp.status === 409) return {
1529
2076
  status: "unavailable",
1530
- reason
2077
+ reason: details.reason,
2078
+ retryable: details.failureType !== void 0 && RETRYABLE_CONFLICT_TYPES.has(details.failureType),
2079
+ ...details.failureType !== void 0 ? { conflictType: details.failureType } : {}
1531
2080
  };
1532
2081
  return {
1533
2082
  status: "error",
1534
- reason
2083
+ reason: details.reason
1535
2084
  };
1536
2085
  }
1537
2086
  const credential = body?.git_credential;
@@ -1556,6 +2105,9 @@ async function createPortalGitCredential(options) {
1556
2105
  * (no git, no repo, credential endpoint missing, diverged history,
1557
2106
  * network blip) degrades to a skip so the content push always proceeds.
1558
2107
  */
2108
+ /** Subject for the index-only commit that untracks leaked Desktop state.
2109
+ * Kept distinct from the publish message so the history explains itself. */
2110
+ const PURGE_COMMIT_MESSAGE = "Remove private Mist Desktop state from the portal repository";
1559
2111
  /**
1560
2112
  * Run the best-effort git sync. Returns a result describing what
1561
2113
  * happened; never throws. Callers surface `skipped` reasons as a dim
@@ -1568,6 +2120,7 @@ async function runPortalGitSync(options) {
1568
2120
  status: "skipped",
1569
2121
  reason: "not a git repository"
1570
2122
  };
2123
+ await ensureLocalStateExcluded(cwd);
1571
2124
  const outcome = await createPortalGitCredential({
1572
2125
  baseUrl,
1573
2126
  token,
@@ -1594,6 +2147,7 @@ async function runPortalGitSync(options) {
1594
2147
  await gitFetch(cwd, remoteUrl, branch);
1595
2148
  await gitAdoptFetchHead(cwd);
1596
2149
  }
2150
+ await purgeLocalStateFromIndex(cwd, PURGE_COMMIT_MESSAGE);
1597
2151
  if (await hasUncommittedChanges(cwd)) await gitAutoCommit(cwd, commitMessage, { ignoreRemovals: true });
1598
2152
  return await pushCommitted(cwd, remoteUrl, branch);
1599
2153
  }
@@ -1614,6 +2168,7 @@ async function runPortalGitSync(options) {
1614
2168
  };
1615
2169
  await gitMergeFastForward(cwd);
1616
2170
  }
2171
+ await purgeLocalStateFromIndex(cwd, PURGE_COMMIT_MESSAGE);
1617
2172
  if (await hasUncommittedChanges(cwd)) await gitAutoCommit(cwd, commitMessage);
1618
2173
  return await pushCommitted(cwd, remoteUrl, branch);
1619
2174
  } catch (err) {
@@ -1655,6 +2210,9 @@ async function pushCommitted(cwd, remoteUrl, branch) {
1655
2210
  * Detects changes since the last pull/push via snapshot diffing,
1656
2211
  * validates cross-references, and pushes resources in dependency order.
1657
2212
  */
2213
+ var NavigationRemoteStateUnknownError = class extends Error {
2214
+ name = "NavigationRemoteStateUnknownError";
2215
+ };
1658
2216
  const PORTAL_DIR = "portal";
1659
2217
  const PORTAL_SYNC_DIR$1 = ".portal-sync";
1660
2218
  /**
@@ -1682,6 +2240,28 @@ function createClient$2() {
1682
2240
  getAuthToken: () => token
1683
2241
  });
1684
2242
  }
2243
+ async function createAndActivateVersion(client, definitionId) {
2244
+ const versionId = (await fluid_os_v0_create_fluid_osversion(client, definitionId)).version?.id;
2245
+ if (!versionId) throw new Error("the version API returned no version ID");
2246
+ await fluid_os_v0_update_fluid_osversion(client, definitionId, versionId, { version: { active: true } });
2247
+ return String(versionId);
2248
+ }
2249
+ async function activateDraft(definitionId, existingClient) {
2250
+ const spinner = ora("Creating and activating a live version...");
2251
+ spinner.start();
2252
+ try {
2253
+ const versionId = await createAndActivateVersion(existingClient ?? createClient$2(), definitionId);
2254
+ spinner.succeed(`Live version ${versionId} activated`);
2255
+ return true;
2256
+ } catch (err) {
2257
+ spinner.fail("Could not activate a live version");
2258
+ console.log();
2259
+ console.log(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
2260
+ console.log();
2261
+ process.exitCode = 1;
2262
+ return false;
2263
+ }
2264
+ }
1685
2265
  /**
1686
2266
  * Extract an enriched error message from a caught value.
1687
2267
  * Includes structured API error data when available.
@@ -1691,6 +2271,18 @@ function enrichedErrorMessage(err) {
1691
2271
  if (err && typeof err === "object" && "data" in err) msg += ` — ${JSON.stringify(err.data)}`;
1692
2272
  return msg;
1693
2273
  }
2274
+ function navigationPushFailure(err, remoteChangesComplete) {
2275
+ const error = enrichedErrorMessage(err);
2276
+ if (err instanceof NavigationRemoteStateUnknownError) return {
2277
+ advanceSnapshot: true,
2278
+ error
2279
+ };
2280
+ if (!remoteChangesComplete) return { error };
2281
+ return {
2282
+ advanceSnapshot: true,
2283
+ error: `Remote changes were applied before the push failed: ${error}. Run fluid portal pull before editing or pushing again.`
2284
+ };
2285
+ }
1694
2286
  /**
1695
2287
  * Best-effort "invisible git" sync: commit the local project and push it
1696
2288
  * to the portal's provisioned GitHub repo before the content push.
@@ -1985,25 +2577,129 @@ function resolveNavigationItemScreenIds(items, mappings) {
1985
2577
  * The API reconciliation logic requires a flat list to correctly
1986
2578
  * compare against the flat server response.
1987
2579
  */
1988
- function flattenNavigationItems(items) {
1989
- const flat = [];
2580
+ function flattenNavigationItems(items, treeParentIndex, flat = []) {
1990
2581
  for (const item of items) {
1991
2582
  const { children, ...rest } = item;
1992
- flat.push(rest);
1993
- if (children && children.length > 0) flat.push(...flattenNavigationItems(children));
2583
+ const itemIndex = flat.length;
2584
+ flat.push({
2585
+ ...rest,
2586
+ treeParentIndex
2587
+ });
2588
+ if (children && children.length > 0) flattenNavigationItems(children, itemIndex, flat);
2589
+ }
2590
+ return flat;
2591
+ }
2592
+ /**
2593
+ * The navigation-items index returns root items with descendants nested under
2594
+ * `children`. Flatten descendants first so removals cannot orphan or revisit a
2595
+ * child when its parent is also deleted.
2596
+ */
2597
+ function flattenServerNavigationItems(items, flat = []) {
2598
+ for (const item of items) {
2599
+ flattenServerNavigationItems(item.children ?? [], flat);
2600
+ flat.push(item);
1994
2601
  }
1995
2602
  return flat;
1996
2603
  }
2604
+ function resolveNavigationItemParentId(item, serverIdByItemIndex) {
2605
+ if (item.treeParentIndex != null) {
2606
+ const serverParentId = serverIdByItemIndex.get(item.treeParentIndex);
2607
+ if (serverParentId == null) throw new Error("Failed to resolve the server ID for a nested navigation item's parent");
2608
+ return serverParentId;
2609
+ }
2610
+ return null;
2611
+ }
2612
+ async function resolveCreatedNavigationItemId(client, defId, navigationId, responseId, knownServerIds, requestedItem, file) {
2613
+ if (responseId != null) {
2614
+ knownServerIds.add(responseId);
2615
+ return responseId;
2616
+ }
2617
+ let response;
2618
+ try {
2619
+ response = await fluid_os_v0_list_fluid_osnavigation_items(client, defId, navigationId);
2620
+ } catch (recoveryError) {
2621
+ throw new NavigationRemoteStateUnknownError(`Remote navigation state is unknown because item creation succeeded without returning its required ID, and the recovery read failed: ${enrichedErrorMessage(recoveryError)}. Back up ${file}, run fluid portal pull, then reapply pending edits before pushing again.`);
2622
+ }
2623
+ const unseenItems = flattenServerNavigationItems(response.navigation_items ?? []).filter((item) => !knownServerIds.has(item.id));
2624
+ const matchingItems = unseenItems.filter((item) => (item.label ?? "") === requestedItem.label && (item.position ?? 0) === requestedItem.position && (item.icon ?? void 0) === requestedItem.icon && (item.screen_id ?? void 0) === requestedItem.screen_id && (item.slug ?? void 0) === requestedItem.slug && (item.source ?? "user") === (requestedItem.source ?? "user") && (item.parent_id ?? null) === (requestedItem.parent_id ?? null));
2625
+ if (unseenItems.length !== 1 || matchingItems.length !== 1) throw new NavigationRemoteStateUnknownError(`Remote navigation state is unknown because item creation succeeded without returning its required ID, and recovery found ${matchingItems.length} matching items among ${unseenItems.length} new server items. Back up ${file}, run fluid portal pull, then reapply pending edits before pushing again.`);
2626
+ const recoveredId = matchingItems[0]?.id;
2627
+ if (recoveredId == null) throw new NavigationRemoteStateUnknownError(`Remote navigation state is unknown because item creation succeeded without returning its required ID. Back up ${file}, run fluid portal pull, then reapply pending edits before pushing again.`);
2628
+ knownServerIds.add(recoveredId);
2629
+ return recoveredId;
2630
+ }
2631
+ function assignNavigationItemId(navigation, targetIndex, serverId) {
2632
+ let currentIndex = 0;
2633
+ let previousId;
2634
+ function assign(items) {
2635
+ return items.map((item) => {
2636
+ const itemIndex = currentIndex++;
2637
+ const children = assign(item.children ?? []);
2638
+ if (itemIndex !== targetIndex) return {
2639
+ ...item,
2640
+ children
2641
+ };
2642
+ previousId = item.id;
2643
+ return {
2644
+ ...item,
2645
+ id: serverId,
2646
+ children
2647
+ };
2648
+ });
2649
+ }
2650
+ function rewriteParentIds(items) {
2651
+ return items.map((item) => ({
2652
+ ...item,
2653
+ parent_id: item.parent_id === previousId ? serverId : item.parent_id,
2654
+ children: rewriteParentIds(item.children ?? [])
2655
+ }));
2656
+ }
2657
+ const navigationItems = assign(navigation.navigation_items);
2658
+ return {
2659
+ ...navigation,
2660
+ navigation_items: previousId == null || previousId === serverId ? navigationItems : rewriteParentIds(navigationItems)
2661
+ };
2662
+ }
2663
+ async function persistCreatedNavigationItemId(client, defId, navigationId, portalDir, file, navigation, itemIndex, serverId) {
2664
+ const updated = assignNavigationItemId(navigation, itemIndex, serverId);
2665
+ try {
2666
+ await atomicWriteFile(join(portalDir, file), JSON.stringify(updated, null, 2) + "\n");
2667
+ } catch (persistError) {
2668
+ try {
2669
+ await fluid_os_v0_delete_fluid_osnavigation_item(client, defId, navigationId, serverId);
2670
+ } catch (rollbackError) {
2671
+ throw new NavigationRemoteStateUnknownError(`Failed to persist navigation item ${serverId} locally: ${enrichedErrorMessage(persistError)}. Automatic rollback also failed: ${enrichedErrorMessage(rollbackError)}. Back up ${file}, run fluid portal pull, then reapply pending edits before pushing again.`);
2672
+ }
2673
+ throw persistError;
2674
+ }
2675
+ return updated;
2676
+ }
2677
+ /**
2678
+ * Replace a pushed navigation file with the authoritative server tree.
2679
+ * New navigation items receive their IDs from the server, so the local file
2680
+ * must be reconciled before the push snapshot hashes it.
2681
+ */
2682
+ async function reconcileNavigationFile(client, defId, navId, portalDir, file, local, mappings) {
2683
+ const response = await fluid_os_v0_list_fluid_osnavigation_items(client, defId, navId);
2684
+ const reconciled = {
2685
+ ...local,
2686
+ navigation_items: transformNavigationItems(response.navigation_items ?? [], buildIdToSlugMap(mappings.screens))
2687
+ };
2688
+ await atomicWriteFile(join(portalDir, file), JSON.stringify(reconciled, null, 2) + "\n");
2689
+ }
1997
2690
  /**
1998
2691
  * Push navigation changes to the API.
1999
2692
  */
2000
2693
  async function pushNavigations(client, defId, portalDir, changes, mappings) {
2001
2694
  const results = [];
2002
2695
  let currentMappings = mappings;
2003
- for (const file of changes.new) {
2696
+ const newFiles = changes.new.filter((file) => resolveSlugToId(currentMappings, "navigations", slugFromPath(file)) == null);
2697
+ const changedFiles = [...changes.changed, ...changes.new.filter((file) => resolveSlugToId(currentMappings, "navigations", slugFromPath(file)) != null)];
2698
+ for (const file of newFiles) {
2004
2699
  const slug = slugFromPath(file);
2700
+ let remoteChangesComplete = false;
2005
2701
  try {
2006
- const local = await readPortalFile(portalDir, file);
2702
+ let local = await readPortalFile(portalDir, file);
2007
2703
  const newId = (await fluid_os_v0_create_fluid_osnavigation(client, defId, { navigation: {
2008
2704
  name: local.name,
2009
2705
  platform: local.platform
@@ -2011,10 +2707,11 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2011
2707
  if (newId != null) {
2012
2708
  currentMappings = updateMapping(currentMappings, "navigations", slug, newId);
2013
2709
  const resolvedItems = flattenNavigationItems(resolveNavigationItemScreenIds(local.navigation_items, currentMappings));
2014
- const localToServerId = /* @__PURE__ */ new Map();
2015
- for (const item of resolvedItems) {
2016
- const resolvedParentId = item.parent_id != null ? localToServerId.get(item.parent_id) ?? item.parent_id : void 0;
2017
- const created = await fluid_os_v0_create_fluid_osnavigation_item(client, defId, newId, { navigation_item: {
2710
+ const serverIdByItemIndex = /* @__PURE__ */ new Map();
2711
+ const knownServerIds = /* @__PURE__ */ new Set();
2712
+ for (const [itemIndex, item] of resolvedItems.entries()) {
2713
+ const resolvedParentId = resolveNavigationItemParentId(item, serverIdByItemIndex);
2714
+ const createBody = {
2018
2715
  label: item.label ?? "",
2019
2716
  position: item.position ?? 0,
2020
2717
  icon: item.icon ?? void 0,
@@ -2022,9 +2719,13 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2022
2719
  slug: item.slug ?? void 0,
2023
2720
  source: item.source ?? void 0,
2024
2721
  parent_id: resolvedParentId
2025
- } });
2026
- if (item.id != null && created.navigation_item?.id != null) localToServerId.set(item.id, created.navigation_item.id);
2722
+ };
2723
+ const createdId = await resolveCreatedNavigationItemId(client, defId, newId, (await fluid_os_v0_create_fluid_osnavigation_item(client, defId, newId, { navigation_item: createBody })).navigation_item?.id, knownServerIds, createBody, file);
2724
+ local = await persistCreatedNavigationItemId(client, defId, newId, portalDir, file, local, itemIndex, createdId);
2725
+ serverIdByItemIndex.set(itemIndex, createdId);
2027
2726
  }
2727
+ remoteChangesComplete = true;
2728
+ await reconcileNavigationFile(client, defId, newId, portalDir, file, local, currentMappings);
2028
2729
  }
2029
2730
  results.push({
2030
2731
  file,
@@ -2036,7 +2737,7 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2036
2737
  file,
2037
2738
  action: "created",
2038
2739
  success: false,
2039
- error: enrichedErrorMessage(err)
2740
+ ...navigationPushFailure(err, remoteChangesComplete)
2040
2741
  });
2041
2742
  return {
2042
2743
  results,
@@ -2044,7 +2745,7 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2044
2745
  };
2045
2746
  }
2046
2747
  }
2047
- for (const file of changes.changed) {
2748
+ for (const file of changedFiles) {
2048
2749
  const slug = slugFromPath(file);
2049
2750
  const navId = resolveSlugToId(currentMappings, "navigations", slug);
2050
2751
  if (navId == null) {
@@ -2059,20 +2760,21 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2059
2760
  mappings: currentMappings
2060
2761
  };
2061
2762
  }
2763
+ let remoteChangesComplete = false;
2062
2764
  try {
2063
- const local = await readPortalFile(portalDir, file);
2765
+ let local = await readPortalFile(portalDir, file);
2064
2766
  await fluid_os_v0_update_fluid_osnavigation(client, defId, navId, { navigation: {
2065
2767
  name: local.name,
2066
2768
  platform: local.platform
2067
2769
  } });
2068
2770
  const resolvedItems = flattenNavigationItems(resolveNavigationItemScreenIds(local.navigation_items, currentMappings));
2069
- const serverItems = (await fluid_os_v0_list_fluid_osnavigation_items(client, defId, navId)).navigation_items ?? [];
2771
+ const serverItems = flattenServerNavigationItems((await fluid_os_v0_list_fluid_osnavigation_items(client, defId, navId)).navigation_items ?? []);
2070
2772
  const serverById = new Map(serverItems.map((s) => [s.id, s]));
2773
+ const knownServerIds = new Set(serverById.keys());
2071
2774
  const localIds = new Set(resolvedItems.filter((i) => i.id).map((i) => i.id));
2072
- for (const serverItem of serverItems) if (!localIds.has(serverItem.id)) await fluid_os_v0_delete_fluid_osnavigation_item(client, defId, navId, serverItem.id);
2073
- const localToServerId = /* @__PURE__ */ new Map();
2074
- for (const item of resolvedItems) {
2075
- const resolvedParentId = item.parent_id != null ? localToServerId.get(item.parent_id) ?? item.parent_id : void 0;
2775
+ const serverIdByItemIndex = /* @__PURE__ */ new Map();
2776
+ for (const [itemIndex, item] of resolvedItems.entries()) {
2777
+ const resolvedParentId = resolveNavigationItemParentId(item, serverIdByItemIndex);
2076
2778
  const body = {
2077
2779
  label: item.label,
2078
2780
  position: item.position,
@@ -2082,16 +2784,23 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2082
2784
  source: item.source ?? void 0,
2083
2785
  parent_id: resolvedParentId
2084
2786
  };
2085
- if (item.id && serverById.has(item.id)) await fluid_os_v0_update_fluid_osnavigation_item(client, defId, navId, item.id, { navigation_item: body });
2086
- else {
2087
- const created = await fluid_os_v0_create_fluid_osnavigation_item(client, defId, navId, { navigation_item: {
2787
+ if (item.id && serverById.has(item.id)) {
2788
+ await fluid_os_v0_update_fluid_osnavigation_item(client, defId, navId, item.id, { navigation_item: body });
2789
+ serverIdByItemIndex.set(itemIndex, item.id);
2790
+ } else {
2791
+ const createBody = {
2088
2792
  ...body,
2089
2793
  label: body.label ?? "",
2090
2794
  position: body.position ?? 0
2091
- } });
2092
- if (item.id != null && created.navigation_item?.id != null) localToServerId.set(item.id, created.navigation_item.id);
2795
+ };
2796
+ const createdId = await resolveCreatedNavigationItemId(client, defId, navId, (await fluid_os_v0_create_fluid_osnavigation_item(client, defId, navId, { navigation_item: createBody })).navigation_item?.id, knownServerIds, createBody, file);
2797
+ local = await persistCreatedNavigationItemId(client, defId, navId, portalDir, file, local, itemIndex, createdId);
2798
+ serverIdByItemIndex.set(itemIndex, createdId);
2093
2799
  }
2094
2800
  }
2801
+ for (const serverItem of serverItems) if (!localIds.has(serverItem.id)) await fluid_os_v0_delete_fluid_osnavigation_item(client, defId, navId, serverItem.id);
2802
+ remoteChangesComplete = true;
2803
+ await reconcileNavigationFile(client, defId, navId, portalDir, file, local, currentMappings);
2095
2804
  results.push({
2096
2805
  file,
2097
2806
  action: "updated",
@@ -2102,7 +2811,7 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2102
2811
  file,
2103
2812
  action: "updated",
2104
2813
  success: false,
2105
- error: enrichedErrorMessage(err)
2814
+ ...navigationPushFailure(err, remoteChangesComplete)
2106
2815
  });
2107
2816
  return {
2108
2817
  results,
@@ -2302,7 +3011,7 @@ function printPushReport(results, skippedPhases) {
2302
3011
  for (const phase of skippedPhases) console.log(chalk.yellow(" " + phase));
2303
3012
  }
2304
3013
  }
2305
- const pushCommand = new Command("push").description("Push local portal content changes to the Fluid OS API").option("--yes", "Skip confirmation prompt").action(async (options) => {
3014
+ const pushCommand = new Command("push").description("Push local portal content changes to the Fluid OS API").option("--yes", "Skip confirmation prompt").option("--activate", "Create and activate a live version after a successful push").action(async (options) => {
2306
3015
  const cwd = process.cwd();
2307
3016
  const portalDir = join(cwd, PORTAL_DIR);
2308
3017
  const portalSyncDir = join(cwd, PORTAL_SYNC_DIR$1);
@@ -2341,6 +3050,10 @@ const pushCommand = new Command("push").description("Push local portal content c
2341
3050
  if (totalChanges === 0) {
2342
3051
  spinner.succeed("Nothing to push.");
2343
3052
  console.log();
3053
+ if (options.activate) {
3054
+ await activateDraft(definitionId);
3055
+ console.log();
3056
+ }
2344
3057
  return;
2345
3058
  }
2346
3059
  spinner.succeed(`Found ${totalChanges} change(s)`);
@@ -2362,16 +3075,16 @@ const pushCommand = new Command("push").description("Push local portal content c
2362
3075
  console.log();
2363
3076
  }
2364
3077
  const changes = categorizeChanges(diff);
2365
- spinner.start("Validating cross-references...");
3078
+ spinner.start("Validating portal files...");
2366
3079
  const validationErrors = await validateCrossReferences(portalDir, mappings, changes);
2367
3080
  if (validationErrors.length > 0) {
2368
- spinner.fail("Cross-reference validation failed");
3081
+ spinner.fail("Portal file validation failed");
2369
3082
  console.log();
2370
3083
  for (const err of validationErrors) console.log(chalk.red(" " + err.file + ": ") + err.message);
2371
3084
  console.log();
2372
3085
  process.exit(1);
2373
3086
  }
2374
- spinner.succeed("Cross-references valid");
3087
+ spinner.succeed("Portal files valid");
2375
3088
  spinner.start("Authenticating...");
2376
3089
  let client;
2377
3090
  try {
@@ -2442,10 +3155,10 @@ const pushCommand = new Command("push").description("Push local portal content c
2442
3155
  else spinner.succeed("Phase 3 complete");
2443
3156
  }
2444
3157
  await writeMappings(portalSyncDir, currentMappings);
2445
- const successfulFiles = new Set(allResults.filter((r) => r.success).map((r) => r.file));
2446
- if (successfulFiles.size > 0) {
3158
+ const filesToSnapshot = new Set(allResults.filter((r) => r.success || r.advanceSnapshot).map((r) => r.file));
3159
+ if (filesToSnapshot.size > 0) {
2447
3160
  const updatedHashes = { ...snapshot.files };
2448
- for (const file of successfulFiles) {
3161
+ for (const file of filesToSnapshot) {
2449
3162
  const fullPath = join(portalDir, file);
2450
3163
  if (existsSync(fullPath)) updatedHashes[file] = await computeFileHash(fullPath);
2451
3164
  else delete updatedHashes[file];
@@ -2456,11 +3169,20 @@ const pushCommand = new Command("push").description("Push local portal content c
2456
3169
  });
2457
3170
  }
2458
3171
  console.log();
2459
- if (allResults.every((r) => r.success) && !aborted) console.log(chalk.green.bold("Push complete!"));
3172
+ const allSucceeded = allResults.every((r) => r.success);
3173
+ if (allSucceeded && !aborted) console.log(chalk.green.bold("Push complete!"));
2460
3174
  else console.log(chalk.yellow.bold("Push completed with issues:"));
2461
3175
  console.log();
2462
3176
  printPushReport(allResults, skippedPhases);
2463
3177
  console.log();
3178
+ if (!allSucceeded || aborted) {
3179
+ process.exitCode = 1;
3180
+ return;
3181
+ }
3182
+ if (options.activate) {
3183
+ await activateDraft(definitionId, client);
3184
+ console.log();
3185
+ }
2464
3186
  });
2465
3187
  //#endregion
2466
3188
  //#region src/commands/clone.ts
@@ -2470,23 +3192,87 @@ const pushCommand = new Command("push").description("Push local portal content c
2470
3192
  * `./<slug>/`. Mirrors `fluid mist clone` (credential → clone), but
2471
3193
  * against the portal git-credential endpoint.
2472
3194
  *
2473
- * The credential endpoint is part of GitSync phase 006; until it ships
2474
- * the request 404s and we print a friendly "not available yet" message
2475
- * instead of a raw git error.
3195
+ * When the credential endpoint reports that no repository is available
3196
+ * (the company's Git integration isn't connected, the repo is still
3197
+ * unprovisioned after the retry budget, or the endpoint isn't deployed),
3198
+ * clone falls back to scaffolding the portal locally from the bundled
3199
+ * starter template instead of failing. Local files plus API sync
3200
+ * (`fluid portal push`/`pull`) are a fully functional portal — Git is
3201
+ * an optional sync channel, not a prerequisite — so the user still
3202
+ * lands in a working project and Git history can attach later.
2476
3203
  */
2477
3204
  function getBaseUrl() {
2478
3205
  return process.env["FLUID_API_BASE"] ?? "https://api.fluid.app";
2479
3206
  }
3207
+ async function removeIncompleteCheckout(targetDir) {
3208
+ try {
3209
+ await rm(targetDir, {
3210
+ recursive: true,
3211
+ force: true
3212
+ });
3213
+ return null;
3214
+ } catch (error) {
3215
+ return error instanceof Error ? error.message : String(error);
3216
+ }
3217
+ }
2480
3218
  function createClient$1(token) {
2481
3219
  return createFetchClient({
2482
3220
  baseUrl: getBaseUrl(),
2483
3221
  getAuthToken: () => token
2484
3222
  });
2485
3223
  }
2486
- /** Select a definition by --app name, else interactively. */
2487
- async function selectDefinition(client, appName) {
2488
- const definitions = [];
2489
- let page = 1;
3224
+ /**
3225
+ * Human message for the git-sync-unavailable fallback, split by conflict
3226
+ * type. `not_connected` is a durable state (the company's Git integration
3227
+ * isn't connected), so it must not claim that waiting will fix anything;
3228
+ * `not_provisioned` genuinely may resolve later, so it says so.
3229
+ */
3230
+ function gitSyncUnavailableNotice(outcome) {
3231
+ if (outcome.conflictType === "not_connected") return `This company's Git integration isn't connected, so the portal has no Git repository to clone. Creating the portal locally from the starter template instead — ${chalk.cyan("fluid portal push")}/${chalk.cyan("pull")} sync content without Git, and Git sync can attach once the integration is connected.`;
3232
+ if (outcome.conflictType === "not_provisioned") return "The portal's Git repository isn't provisioned yet. Creating the portal locally from the starter template so you can start now — Git sync can attach once the repository is ready.";
3233
+ return `Git sync isn't available (${outcome.reason}). Creating the portal locally from the starter template instead.`;
3234
+ }
3235
+ /**
3236
+ * A git-less fallback scaffold that failed or was interrupted leaves the
3237
+ * pending marker behind with no `.git`. Only that exact shape is safe to
3238
+ * resume — anything else is an unrelated pre-existing directory.
3239
+ */
3240
+ async function canResumeGitlessPortalScaffold(targetDir) {
3241
+ return await fileExists(join(targetDir, ".fluid-portal-scaffold-pending")) && !existsSync(join(targetDir, ".git"));
3242
+ }
3243
+ async function canResumePortalScaffold(targetDir, credentialRemoteUrl) {
3244
+ if (!await fileExists(join(targetDir, ".fluid-portal-scaffold-pending")) || !await isGitRepo(targetDir)) return false;
3245
+ const origin = await getOriginUrl(targetDir);
3246
+ if (!origin) return false;
3247
+ const expected = cleanGitRemoteUrl(credentialRemoteUrl);
3248
+ return origin.replace(/\/$/, "") === expected.replace(/\/$/, "");
3249
+ }
3250
+ function resolveDefinition(definitions, appName, rawDefinitionId) {
3251
+ if (rawDefinitionId !== void 0) {
3252
+ if (!/^[1-9]\d*$/.test(rawDefinitionId)) throw new Error("--id must be a positive integer");
3253
+ const definitionId = Number.parseInt(rawDefinitionId, 10);
3254
+ const match = definitions.find((definition) => definition.id === definitionId);
3255
+ if (!match) throw new Error(`No definition found with id ${definitionId}.`);
3256
+ return {
3257
+ id: definitionId,
3258
+ name: match.name ?? `portal-${definitionId}`
3259
+ };
3260
+ }
3261
+ if (!appName) return null;
3262
+ const match = definitions.find((definition) => definition.name?.toLowerCase() === appName.toLowerCase());
3263
+ if (!match || match.id == null) {
3264
+ const available = definitions.map((definition) => chalk.cyan(definition.name ?? "(unnamed)")).join(", ");
3265
+ throw new Error(`No definition found matching "${appName}". Available: ${available}`);
3266
+ }
3267
+ return {
3268
+ id: match.id,
3269
+ name: match.name ?? `portal-${match.id}`
3270
+ };
3271
+ }
3272
+ /** Select a definition by --app name, else interactively. */
3273
+ async function selectDefinition(client, appName, rawDefinitionId) {
3274
+ const definitions = [];
3275
+ let page = 1;
2490
3276
  while (true) {
2491
3277
  const response = await fluid_os_v0_list_fluid_osdefinitions(client, {
2492
3278
  page,
@@ -2499,17 +3285,8 @@ async function selectDefinition(client, appName) {
2499
3285
  page++;
2500
3286
  }
2501
3287
  if (definitions.length === 0) throw new Error("No Fluid OS definitions found. Create one in the admin dashboard first.");
2502
- if (appName) {
2503
- const match = definitions.find((d) => d.name?.toLowerCase() === appName.toLowerCase());
2504
- if (!match || match.id == null) {
2505
- const available = definitions.map((d) => chalk.cyan(d.name ?? "(unnamed)")).join(", ");
2506
- throw new Error(`No definition found matching "${appName}". Available: ${available}`);
2507
- }
2508
- return {
2509
- id: match.id,
2510
- name: match.name ?? `portal-${match.id}`
2511
- };
2512
- }
3288
+ const resolved = resolveDefinition(definitions, appName, rawDefinitionId);
3289
+ if (resolved) return resolved;
2513
3290
  const { definitionId } = await prompts({
2514
3291
  type: "select",
2515
3292
  name: "definitionId",
@@ -2527,9 +3304,8 @@ async function selectDefinition(client, appName) {
2527
3304
  name: selected.name ?? `portal-${selected.id}`
2528
3305
  };
2529
3306
  }
2530
- 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) => {
3307
+ 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("--id <id>", "Definition id (avoids ambiguity when names repeat)").option("--dir <dir>", "Target directory (defaults to ./<slug>/)").action(async (appArg, options) => {
2531
3308
  try {
2532
- await ensureGitAvailable();
2533
3309
  const token = getAuthToken();
2534
3310
  if (!token) {
2535
3311
  const profile = getActiveProfile();
@@ -2537,32 +3313,99 @@ const cloneCommand = new Command("clone").description("Clone a portal definition
2537
3313
  }
2538
3314
  const client = createClient$1(token);
2539
3315
  const spinner = ora("Resolving definition…").start();
2540
- const definition = await selectDefinition(client, options.app ?? appArg);
3316
+ const definition = await selectDefinition(client, options.app ?? appArg, options.id);
2541
3317
  spinner.text = "Minting git credential…";
2542
- const outcome = await createPortalGitCredential({
3318
+ const outcome = await createPortalGitCredentialWithRetry({
2543
3319
  baseUrl: getBaseUrl(),
2544
3320
  token,
2545
- definitionId: definition.id
3321
+ definitionId: definition.id,
3322
+ onRetry: ({ attempt, totalAttempts }) => {
3323
+ spinner.text = `Preparing Git repository… retrying (${attempt}/${totalAttempts})`;
3324
+ }
2546
3325
  });
2547
- if (outcome.status !== "ok") {
3326
+ if (outcome.status === "error") {
2548
3327
  spinner.warn(`Can't clone — ${outcome.reason}`);
2549
3328
  process.exit(1);
2550
3329
  }
2551
3330
  const slug = deriveSlug(definition.name);
2552
3331
  const targetDir = resolve(options.dir ?? slug);
2553
- if (existsSync(targetDir)) {
3332
+ if (outcome.status === "unavailable") {
3333
+ spinner.info(gitSyncUnavailableNotice(outcome));
3334
+ const resumeGitless = existsSync(targetDir) && await canResumeGitlessPortalScaffold(targetDir);
3335
+ if (existsSync(targetDir) && !resumeGitless) {
3336
+ spinner.fail(`Directory ${targetDir} already exists. Pass --dir or remove it first.`);
3337
+ process.exit(1);
3338
+ }
3339
+ if (!resumeGitless) {
3340
+ await mkdir(dirname(targetDir), { recursive: true });
3341
+ try {
3342
+ await mkdir(targetDir);
3343
+ } catch (error) {
3344
+ if (error.code === "EEXIST") {
3345
+ spinner.fail(`Directory ${targetDir} was created by another process. Choose a different --dir or retry after it finishes.`);
3346
+ process.exit(1);
3347
+ }
3348
+ throw error;
3349
+ }
3350
+ }
3351
+ try {
3352
+ spinner.text = resumeGitless ? "Resuming portal scaffold…" : "Scaffolding portal from the starter template…";
3353
+ await scaffoldPortalProjectIntoExistingDirectory(targetDir, {
3354
+ name: slug,
3355
+ installDeps: false,
3356
+ selectedPages: [],
3357
+ profileName: getActiveProfile()?.name ?? ""
3358
+ });
3359
+ } catch (err) {
3360
+ spinner.fail(`Git sync is unavailable and the local scaffold failed at ${targetDir}. Fix the reported path or permission conflict and retry.`);
3361
+ throw err;
3362
+ }
3363
+ spinner.succeed(`Created ${chalk.cyan(targetDir)} from the starter template (no Git history yet)`);
3364
+ console.log(chalk.dim(` cd ${targetDir}`));
3365
+ console.log(chalk.dim(` ${chalk.cyan("fluid portal pull")} to sync content`));
3366
+ return;
3367
+ }
3368
+ await ensureGitAvailable();
3369
+ const resumeScaffold = existsSync(targetDir) && await canResumePortalScaffold(targetDir, outcome.credential.remote_url);
3370
+ if (existsSync(targetDir) && !resumeScaffold) {
2554
3371
  spinner.fail(`Directory ${targetDir} already exists. Pass --dir or remove it first.`);
2555
3372
  process.exit(1);
2556
3373
  }
2557
- spinner.text = `Cloning into ${slug}/…`;
2558
- try {
2559
- await gitClone(outcome.credential.remote_url, targetDir);
3374
+ if (!resumeScaffold) {
3375
+ await mkdir(dirname(targetDir), { recursive: true });
3376
+ try {
3377
+ await mkdir(targetDir);
3378
+ } catch (error) {
3379
+ if (error.code === "EEXIST") {
3380
+ spinner.fail(`Directory ${targetDir} was created by another process. Choose a different --dir or retry after it finishes.`);
3381
+ process.exit(1);
3382
+ }
3383
+ throw error;
3384
+ }
3385
+ spinner.text = `Cloning into ${slug}/…`;
3386
+ try {
3387
+ await gitClone(outcome.credential.remote_url, targetDir);
3388
+ } catch (err) {
3389
+ const cleanupError = await removeIncompleteCheckout(targetDir);
3390
+ spinner.fail(cleanupError ? `git clone failed, and Fluid could not remove the incomplete checkout: ${cleanupError}` : "git clone failed; removed the incomplete checkout");
3391
+ throw err;
3392
+ }
3393
+ excludeLocalStateFromGit(targetDir);
3394
+ }
3395
+ if (resumeScaffold || !await fileExists(join(targetDir, "package.json"))) try {
3396
+ spinner.text = resumeScaffold ? "Resuming runnable portal scaffold…" : "Adding runnable portal scaffold…";
3397
+ await scaffoldPortalProjectIntoExistingDirectory(targetDir, {
3398
+ name: slug,
3399
+ installDeps: false,
3400
+ selectedPages: [],
3401
+ profileName: getActiveProfile()?.name ?? ""
3402
+ });
2560
3403
  } catch (err) {
2561
- spinner.fail("git clone failed");
3404
+ spinner.fail(`Repository cloned at ${targetDir}, but the runnable scaffold failed. The provisioned Git history and repository files were preserved; fix the reported path or permission conflict and retry the scaffold.`);
2562
3405
  throw err;
2563
3406
  }
2564
3407
  spinner.succeed(`Cloned into ${chalk.cyan(targetDir)}`);
2565
- console.log(chalk.dim(` cd ${slug}`));
3408
+ console.log(chalk.dim(` cd ${targetDir}`));
2566
3409
  console.log(chalk.dim(` ${chalk.cyan("fluid portal pull")} to sync content`));
2567
3410
  } catch (error) {
2568
3411
  console.log();
@@ -2770,206 +3613,19 @@ export const widgetPackage = defineWidgetPackage({
2770
3613
  }
2771
3614
  const widgetCommand = new Command("widget").description("Manage Remote DOM portal widgets").addCommand(createSubcommand);
2772
3615
  //#endregion
2773
- //#region src/utils/widget-package-config.ts
2774
- const WIDGET_CONFIG_PATH = "src/widgets.config.ts";
2775
- const SOURCE_PACKAGE_EXTRACT_FILENAME = "extract-widget-packages.ts";
2776
- const SOURCE_PACKAGE_OUTPUT_FILENAME = "source-widget-packages.json";
2777
- const SOURCE_PACKAGE_OUTPUT_SENTINEL = "fluid-widget-source-packages:v1";
2778
- const TSX_CLI_PATH = createRequire(import.meta.url).resolve("tsx/cli");
2779
- async function resolvePortalWidgetSourceConfig(projectDir) {
2780
- const candidate = path.join(projectDir, WIDGET_CONFIG_PATH);
2781
- return await fs.pathExists(candidate) ? {
2782
- path: candidate,
2783
- relativePath: WIDGET_CONFIG_PATH
2784
- } : void 0;
2785
- }
2786
- async function loadSourceWidgetPackages(projectDir) {
2787
- const config = await resolvePortalWidgetSourceConfig(projectDir);
2788
- if (!config) return success([]);
2789
- let tempDir;
2790
- try {
2791
- tempDir = await createTempDirectory(projectDir);
2792
- const extractFile = path.join(tempDir, SOURCE_PACKAGE_EXTRACT_FILENAME);
2793
- const outputFile = path.join(tempDir, SOURCE_PACKAGE_OUTPUT_FILENAME);
2794
- await fs.writeFile(extractFile, createSourcePackageExtractorScript({
2795
- projectDir,
2796
- configPath: config.path
2797
- }), {
2798
- encoding: "utf-8",
2799
- flag: "wx"
2800
- });
2801
- await execa(process.execPath, [
2802
- TSX_CLI_PATH,
2803
- extractFile,
2804
- outputFile
2805
- ], {
2806
- cwd: projectDir,
2807
- stdio: "pipe",
2808
- env: {
2809
- ...process.env,
2810
- NODE_ENV: "production"
2811
- }
2812
- });
2813
- const outputResult = await readSourcePackageExtractorOutput(outputFile);
2814
- if (!outputResult.success) return outputResult;
2815
- const parsed = outputResult.value;
2816
- if (parsed.length === 0) return success([]);
2817
- return success(parsed);
2818
- } catch (err) {
2819
- const error = err;
2820
- return failure({
2821
- code: "CONFIG_LOAD_FAILED",
2822
- message: `Failed to load widget packages from ${config.relativePath}`,
2823
- details: error.stderr ?? error.message ?? String(err)
2824
- });
2825
- } finally {
2826
- if (tempDir) await fs.remove(tempDir).catch(() => {});
2827
- }
2828
- }
2829
- async function createTempDirectory(projectDir) {
2830
- const projectTmpDir = path.join(projectDir, ".fluid", "tmp");
2831
- try {
2832
- await fs.ensureDir(projectTmpDir);
2833
- return await fs.mkdtemp(path.join(projectTmpDir, "widget-packages-"));
2834
- } catch (err) {
2835
- const message = err instanceof Error ? err.message : String(err);
2836
- throw new Error(`Unable to create project-local temporary directory at ${projectTmpDir}: ${message}`);
2837
- }
2838
- }
2839
- async function readSourcePackageExtractorOutput(outputFile) {
2840
- let output;
2841
- try {
2842
- output = await fs.readFile(outputFile, "utf-8");
2843
- } catch (err) {
2844
- return failure({
2845
- code: "CONFIG_LOAD_FAILED",
2846
- message: "Widget package extractor did not write an output file",
2847
- details: err instanceof Error ? err.message : String(err)
2848
- });
2849
- }
2850
- let parsed;
2851
- try {
2852
- parsed = JSON.parse(output);
2853
- } catch {
2854
- return failure({
2855
- code: "INVALID_FORMAT",
2856
- message: "Failed to parse widget package output file as JSON",
2857
- details: `Output was: ${output.slice(0, 200)}`
2858
- });
3616
+ //#region ../widget-tooling/src/errors.ts
3617
+ var WidgetToolingError = class extends Error {
3618
+ code;
3619
+ details;
3620
+ diagnostics;
3621
+ constructor(code, message, options = {}) {
3622
+ super(message, options);
3623
+ this.name = "WidgetToolingError";
3624
+ this.code = code;
3625
+ this.details = options.details;
3626
+ this.diagnostics = options.diagnostics;
2859
3627
  }
2860
- if (!isRecord$5(parsed) || parsed.sentinel !== SOURCE_PACKAGE_OUTPUT_SENTINEL) return failure({
2861
- code: "INVALID_FORMAT",
2862
- message: "Widget package extractor output file had an invalid sentinel"
2863
- });
2864
- if (!Array.isArray(parsed.data)) return failure({
2865
- code: "INVALID_FORMAT",
2866
- message: "Extracted widget package output is not an array",
2867
- details: `Expected an array, got: ${typeof parsed.data}`
2868
- });
2869
- return success(parsed.data);
2870
- }
2871
- function createSourcePackageExtractorScript(options) {
2872
- return `
2873
- import fs from "node:fs/promises";
2874
- import path from "node:path";
2875
- import { createServer, normalizePath } from "vite";
2876
-
2877
- const SOURCE_PACKAGE_MARKER = "__fluidSourceWidgetPackage";
2878
- const OUTPUT_SENTINEL = ${JSON.stringify(SOURCE_PACKAGE_OUTPUT_SENTINEL)};
2879
- const projectRoot = ${JSON.stringify(options.projectDir)};
2880
- const widgetConfigPath = ${JSON.stringify(options.configPath)};
2881
- const outputPath = process.argv[2];
2882
- if (!outputPath) throw new Error("Missing widget package extractor output path.");
2883
-
2884
- function toViteModuleId(modulePath) {
2885
- const relativePath = path.relative(projectRoot, modulePath);
2886
- if (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
2887
- return "/" + normalizePath(relativePath);
2888
- }
2889
- return normalizePath(modulePath);
2890
- }
2891
-
2892
- const server = await createServer({
2893
- root: projectRoot,
2894
- mode: "production",
2895
- server: { middlewareMode: true },
2896
- appType: "custom",
2897
- logLevel: "error",
2898
- clearScreen: false,
2899
- resolve: {
2900
- conditions: ["module", "browser", "development|production"],
2901
- },
2902
- ssr: {
2903
- noExternal: ["@fluid-app/portal-sdk"],
2904
- resolve: {
2905
- conditions: ["module", "node", "development|production"],
2906
- },
2907
- },
2908
- });
2909
-
2910
- try {
2911
- const widgetConfig = await server.ssrLoadModule(toViteModuleId(widgetConfigPath));
2912
-
2913
- function isSourceWidgetPackage(value) {
2914
- return Boolean(value && typeof value === "object" && value[SOURCE_PACKAGE_MARKER] === true);
2915
- }
2916
-
2917
- function collectSourceWidgetPackages(mod) {
2918
- const candidates = [
2919
- mod.widgetPackage,
2920
- ...(Array.isArray(mod.widgetPackages) ? mod.widgetPackages : []),
2921
- mod.default,
2922
- ];
2923
- const byPackageId = new Map();
2924
- for (const candidate of candidates) {
2925
- if (!isSourceWidgetPackage(candidate)) continue;
2926
- if (!byPackageId.has(candidate.packageId)) byPackageId.set(candidate.packageId, candidate);
2927
- }
2928
- return Array.from(byPackageId.values());
2929
- }
2930
-
2931
- function serializeWidget(widget) {
2932
- if (!widget || typeof widget !== "object" || Array.isArray(widget)) {
2933
- return widget;
2934
- }
2935
- const { component, ...metadata } = widget;
2936
- return metadata;
2937
- }
2938
-
2939
- function serializePackage(sourcePackage) {
2940
- return {
2941
- manifestVersion: sourcePackage.manifestVersion,
2942
- scope: sourcePackage.scope,
2943
- packageStableId: sourcePackage.packageStableId,
2944
- packageId: sourcePackage.packageId,
2945
- packageType: sourcePackage.packageType,
2946
- version: sourcePackage.version,
2947
- cssUrls: sourcePackage.cssUrls,
2948
- widgets: Array.isArray(sourcePackage.widgets)
2949
- ? sourcePackage.widgets.map(serializeWidget)
2950
- : sourcePackage.widgets,
2951
- };
2952
- }
2953
-
2954
- await fs.writeFile(
2955
- outputPath,
2956
- JSON.stringify({
2957
- sentinel: OUTPUT_SENTINEL,
2958
- data: collectSourceWidgetPackages(widgetConfig).map(serializePackage),
2959
- }),
2960
- "utf-8",
2961
- );
2962
- } finally {
2963
- await server.close();
2964
- }
2965
- `;
2966
- }
2967
- function isRecord$5(value) {
2968
- return typeof value === "object" && value !== null && !Array.isArray(value);
2969
- }
2970
- //#endregion
2971
- //#region src/utils/widget-package-artifacts.ts
2972
- const WIDGET_PACKAGE_BUILDER_VERSION = "1";
3628
+ };
2973
3629
  const CONTENT_TYPES = {
2974
3630
  ".css": "text/css",
2975
3631
  ".js": "application/javascript",
@@ -3000,7 +3656,7 @@ function createPublishManifestMetadata(options) {
3000
3656
  return {
3001
3657
  packageId: options.packageId,
3002
3658
  version: options.version,
3003
- builderVersion: options.builderVersion ?? "1",
3659
+ builderVersion: options.builderVersion ?? "0.1.0",
3004
3660
  cliVersion: options.cliVersion,
3005
3661
  runtimeVersion: options.runtimeVersion,
3006
3662
  manifestHash: sha256(options.manifestContent),
@@ -3087,15 +3743,15 @@ function isJsonValueAtDepth(value, ancestors, depth) {
3087
3743
  if (typeof value !== "object" || depth > 50 || ancestors.has(value)) return false;
3088
3744
  ancestors.add(value);
3089
3745
  const prototype = Object.getPrototypeOf(value);
3090
- const isJson = Array.isArray(value) ? value.every((item) => isJsonValueAtDepth(item, ancestors, depth + 1)) : (prototype === Object.prototype || prototype === null) && isRecord$4(value) && Object.entries(value).every(([key, item]) => !UNSAFE_JSON_KEYS.has(key) && isJsonValueAtDepth(item, ancestors, depth + 1));
3746
+ const isJson = Array.isArray(value) ? value.every((item) => isJsonValueAtDepth(item, ancestors, depth + 1)) : (prototype === Object.prototype || prototype === null) && isRecord$5(value) && Object.entries(value).every(([key, item]) => !UNSAFE_JSON_KEYS.has(key) && isJsonValueAtDepth(item, ancestors, depth + 1));
3091
3747
  ancestors.delete(value);
3092
3748
  return isJson;
3093
3749
  }
3094
- function isRecord$4(value) {
3750
+ function isRecord$5(value) {
3095
3751
  return typeof value === "object" && value !== null && !Array.isArray(value);
3096
3752
  }
3097
3753
  //#endregion
3098
- //#region src/utils/widget-package-validation.ts
3754
+ //#region ../widget-tooling/src/internal/widget-package-validation.ts
3099
3755
  const URL_SAFE_SEGMENT_SOURCE = "[A-Za-z0-9][A-Za-z0-9_~-]*";
3100
3756
  const URL_SAFE_WIDGET_NAME_PATTERN = new RegExp(`^${URL_SAFE_SEGMENT_SOURCE}$`);
3101
3757
  const URL_SAFE_DOTTED_IDENTIFIER_PATTERN = new RegExp(`^${URL_SAFE_SEGMENT_SOURCE}(?:\\.${URL_SAFE_SEGMENT_SOURCE})*$`);
@@ -3134,7 +3790,17 @@ function validateSingleSourceWidgetPackage(sourcePackages, options = {}) {
3134
3790
  path: "packageType",
3135
3791
  message: "Widget package publish/deploy currently supports company and droplet owners. Set packageType to \"company\" or \"droplet\"."
3136
3792
  });
3793
+ else if (options.owner !== void 0 && sourcePackage.packageType !== options.owner) errors.push({
3794
+ code: "UNSUPPORTED_OWNER",
3795
+ path: "packageType",
3796
+ message: `Widget package metadata must set packageType to "${options.owner}" for this workflow.`
3797
+ });
3137
3798
  const resolvedOwner = owner === "droplet" ? "droplet" : "company";
3799
+ if (resolvedOwner === "droplet" && !readNonEmptyTrimmedString(sourcePackage.packageStableId)) errors.push({
3800
+ code: "INVALID_PACKAGE_KEY",
3801
+ path: "packageStableId",
3802
+ message: "Droplet widget packages must set packageStableId so published widget types remain stable across builds."
3803
+ });
3138
3804
  const packageKey = resolvePackageKey(sourcePackage, resolvedOwner);
3139
3805
  const packageKeyPath = readNonEmptyTrimmedString(sourcePackage.packageStableId) ? "packageStableId" : "packageId";
3140
3806
  if (!isUrlSafeDottedIdentifier(packageKey)) errors.push({
@@ -3255,7 +3921,7 @@ function validationFailure(error) {
3255
3921
  };
3256
3922
  }
3257
3923
  function validateSourcePackageShape(value) {
3258
- if (!isRecord$3(value)) return [{
3924
+ if (!isRecord$4(value)) return [{
3259
3925
  code: "INVALID_SOURCE_PACKAGE",
3260
3926
  message: "Widget source package must be a JSON object."
3261
3927
  }];
@@ -3265,6 +3931,11 @@ function validateSourcePackageShape(value) {
3265
3931
  validateOptionalStringField(value, "scope", "scope", errors);
3266
3932
  validateOptionalStringField(value, "packageStableId", "packageStableId", errors);
3267
3933
  validateOptionalStringField(value, "packageType", "packageType", errors);
3934
+ if (Object.prototype.hasOwnProperty.call(value, "navigationOrigins")) errors.push({
3935
+ code: "INVALID_SOURCE_PACKAGE",
3936
+ path: "navigationOrigins",
3937
+ message: "navigationOrigins has been removed. Declare the externalNavigation capability and call allowAnchorUrl(url) for each external link."
3938
+ });
3268
3939
  if (value.cssUrls !== void 0) if (!Array.isArray(value.cssUrls)) errors.push({
3269
3940
  code: "INVALID_SOURCE_PACKAGE",
3270
3941
  path: "cssUrls",
@@ -3283,7 +3954,7 @@ function validateSourcePackageShape(value) {
3283
3954
  }
3284
3955
  value.widgets.forEach((widget, index) => {
3285
3956
  const widgetPath = `widgets[${index}]`;
3286
- if (!isRecord$3(widget)) {
3957
+ if (!isRecord$4(widget)) {
3287
3958
  errors.push({
3288
3959
  code: "INVALID_WIDGET_METADATA",
3289
3960
  path: widgetPath,
@@ -3351,7 +4022,7 @@ function validateCssUrl(value, cssUrlPath, errors) {
3351
4022
  function validateOptionalJsonRecordField(record, key, path, errors) {
3352
4023
  const value = record[key];
3353
4024
  if (value === void 0) return;
3354
- if (isRecord$3(value) && isJsonValue(value)) return;
4025
+ if (isRecord$4(value) && isJsonValue(value)) return;
3355
4026
  errors.push({
3356
4027
  code: "INVALID_WIDGET_METADATA",
3357
4028
  path,
@@ -3372,7 +4043,7 @@ function validateOptionalCapabilities(record, key, path, errors) {
3372
4043
  const names = /* @__PURE__ */ new Set();
3373
4044
  value.forEach((candidate, index) => {
3374
4045
  const capabilityPath = `${path}[${index}]`;
3375
- if (!isRecord$3(candidate)) {
4046
+ if (!isRecord$4(candidate)) {
3376
4047
  errors.push({
3377
4048
  code: "INVALID_WIDGET_METADATA",
3378
4049
  path: capabilityPath,
@@ -3441,7 +4112,7 @@ function normalizeContainer(value) {
3441
4112
  function normalizeResizable(value) {
3442
4113
  if (typeof value === "boolean") return value;
3443
4114
  if (value === "horizontal" || value === "vertical" || value === "both") return value;
3444
- if (!isRecord$3(value)) return false;
4115
+ if (!isRecord$4(value)) return false;
3445
4116
  const horizontal = value.horizontal === true;
3446
4117
  const vertical = value.vertical === true;
3447
4118
  if (horizontal && vertical) return "both";
@@ -3484,24 +4155,225 @@ function isUrlSafeWidgetName(value) {
3484
4155
  function isUrlSafeSemver(value) {
3485
4156
  return SEMVER_URL_SAFE_PATTERN.test(value);
3486
4157
  }
3487
- function isRecord$3(value) {
4158
+ function isRecord$4(value) {
3488
4159
  return typeof value === "object" && value !== null && !Array.isArray(value);
3489
4160
  }
3490
4161
  //#endregion
3491
- //#region src/utils/widget-package-builder.ts
3492
- const DEFAULT_PUBLISH_DIR = ".fluid/widget-dist";
3493
- const DEFAULT_RUNTIME_VERSION = "1";
3494
- const TEMP_BUILD_DIR = ".fluid/widget-build";
3495
- const TEMP_CLI_DIR = ".fluid/tmp";
3496
- const PROJECT_VITE_CONFIG_CANDIDATES = [
3497
- "vite.config.ts",
3498
- "vite.config.mts",
3499
- "vite.config.js",
3500
- "vite.config.mjs"
3501
- ];
3502
- const BLOCKED_FLUID_PROJECT_PLUGIN_NAMES = ["fluid-portal-dev", "fluid-backend-dev"];
3503
- const require = createRequire(import.meta.url);
3504
- function validateSharedWidgetPackagePublishDir(projectDir, publishDir = DEFAULT_PUBLISH_DIR, options = {}) {
4162
+ //#region ../widget-tooling/src/internal/widget-package-config.ts
4163
+ const WIDGET_CONFIG_PATH = "src/widgets.config.ts";
4164
+ const SOURCE_PACKAGE_EXTRACT_FILENAME = "extract-widget-packages.ts";
4165
+ const SOURCE_PACKAGE_OUTPUT_FILENAME = "source-widget-packages.json";
4166
+ const SOURCE_PACKAGE_OUTPUT_SENTINEL = "fluid-widget-source-packages:v1";
4167
+ const TSX_CLI_PATH = createRequire(import.meta.url).resolve("tsx/cli");
4168
+ async function resolvePortalWidgetSourceConfig(projectDir) {
4169
+ const candidate = path.join(projectDir, WIDGET_CONFIG_PATH);
4170
+ return await fs.pathExists(candidate) ? {
4171
+ path: candidate,
4172
+ relativePath: WIDGET_CONFIG_PATH
4173
+ } : void 0;
4174
+ }
4175
+ async function loadSourceWidgetPackages(projectDir) {
4176
+ const config = await resolvePortalWidgetSourceConfig(projectDir);
4177
+ if (!config) return success([]);
4178
+ let tempDir;
4179
+ try {
4180
+ tempDir = await createTempDirectory(projectDir);
4181
+ const extractFile = path.join(tempDir, SOURCE_PACKAGE_EXTRACT_FILENAME);
4182
+ const outputFile = path.join(tempDir, SOURCE_PACKAGE_OUTPUT_FILENAME);
4183
+ await fs.writeFile(extractFile, createSourcePackageExtractorScript({
4184
+ projectDir,
4185
+ configPath: config.path
4186
+ }), {
4187
+ encoding: "utf-8",
4188
+ flag: "wx"
4189
+ });
4190
+ await execa(process.execPath, [
4191
+ TSX_CLI_PATH,
4192
+ extractFile,
4193
+ outputFile
4194
+ ], {
4195
+ cwd: projectDir,
4196
+ stdio: "pipe",
4197
+ env: {
4198
+ ...process.env,
4199
+ NODE_ENV: "production"
4200
+ }
4201
+ });
4202
+ const outputResult = await readSourcePackageExtractorOutput(outputFile);
4203
+ if (!outputResult.success) return outputResult;
4204
+ const parsed = outputResult.value;
4205
+ if (parsed.length === 0) return success([]);
4206
+ return success(parsed);
4207
+ } catch (err) {
4208
+ const error = err;
4209
+ return failure({
4210
+ code: "CONFIG_LOAD_FAILED",
4211
+ message: `Failed to load widget packages from ${config.relativePath}`,
4212
+ details: error.stderr ?? error.message ?? String(err)
4213
+ });
4214
+ } finally {
4215
+ if (tempDir) await fs.remove(tempDir).catch(() => {});
4216
+ }
4217
+ }
4218
+ async function createTempDirectory(projectDir) {
4219
+ const projectTmpDir = path.join(projectDir, ".fluid", "tmp");
4220
+ try {
4221
+ await fs.ensureDir(projectTmpDir);
4222
+ return await fs.mkdtemp(path.join(projectTmpDir, "widget-packages-"));
4223
+ } catch (err) {
4224
+ const message = err instanceof Error ? err.message : String(err);
4225
+ throw new Error(`Unable to create project-local temporary directory at ${projectTmpDir}: ${message}`);
4226
+ }
4227
+ }
4228
+ async function readSourcePackageExtractorOutput(outputFile) {
4229
+ let output;
4230
+ try {
4231
+ output = await fs.readFile(outputFile, "utf-8");
4232
+ } catch (err) {
4233
+ return failure({
4234
+ code: "CONFIG_LOAD_FAILED",
4235
+ message: "Widget package extractor did not write an output file",
4236
+ details: err instanceof Error ? err.message : String(err)
4237
+ });
4238
+ }
4239
+ let parsed;
4240
+ try {
4241
+ parsed = JSON.parse(output);
4242
+ } catch {
4243
+ return failure({
4244
+ code: "INVALID_FORMAT",
4245
+ message: "Failed to parse widget package output file as JSON",
4246
+ details: `Output was: ${output.slice(0, 200)}`
4247
+ });
4248
+ }
4249
+ if (!isRecord$3(parsed) || parsed.sentinel !== SOURCE_PACKAGE_OUTPUT_SENTINEL) return failure({
4250
+ code: "INVALID_FORMAT",
4251
+ message: "Widget package extractor output file had an invalid sentinel"
4252
+ });
4253
+ if (!Array.isArray(parsed.data)) return failure({
4254
+ code: "INVALID_FORMAT",
4255
+ message: "Extracted widget package output is not an array",
4256
+ details: `Expected an array, got: ${typeof parsed.data}`
4257
+ });
4258
+ return success(parsed.data);
4259
+ }
4260
+ function createSourcePackageExtractorScript(options) {
4261
+ return `
4262
+ import fs from "node:fs/promises";
4263
+ import path from "node:path";
4264
+ import { createServer, normalizePath } from "vite";
4265
+
4266
+ const SOURCE_PACKAGE_MARKER = "__fluidSourceWidgetPackage";
4267
+ const OUTPUT_SENTINEL = ${JSON.stringify(SOURCE_PACKAGE_OUTPUT_SENTINEL)};
4268
+ const projectRoot = ${JSON.stringify(options.projectDir)};
4269
+ const widgetConfigPath = ${JSON.stringify(options.configPath)};
4270
+ const outputPath = process.argv[2];
4271
+ if (!outputPath) throw new Error("Missing widget package extractor output path.");
4272
+
4273
+ function toViteModuleId(modulePath) {
4274
+ const relativePath = path.relative(projectRoot, modulePath);
4275
+ if (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
4276
+ return "/" + normalizePath(relativePath);
4277
+ }
4278
+ return normalizePath(modulePath);
4279
+ }
4280
+
4281
+ const server = await createServer({
4282
+ root: projectRoot,
4283
+ mode: "production",
4284
+ server: { middlewareMode: true },
4285
+ appType: "custom",
4286
+ logLevel: "error",
4287
+ clearScreen: false,
4288
+ resolve: {
4289
+ conditions: ["module", "browser", "development|production"],
4290
+ },
4291
+ ssr: {
4292
+ noExternal: ["@fluid-app/portal-sdk"],
4293
+ resolve: {
4294
+ conditions: ["module", "node", "development|production"],
4295
+ },
4296
+ },
4297
+ });
4298
+
4299
+ try {
4300
+ const widgetConfig = await server.ssrLoadModule(toViteModuleId(widgetConfigPath));
4301
+
4302
+ function isSourceWidgetPackage(value) {
4303
+ return Boolean(value && typeof value === "object" && value[SOURCE_PACKAGE_MARKER] === true);
4304
+ }
4305
+
4306
+ function collectSourceWidgetPackages(mod) {
4307
+ const candidates = [
4308
+ mod.widgetPackage,
4309
+ ...(Array.isArray(mod.widgetPackages) ? mod.widgetPackages : []),
4310
+ mod.default,
4311
+ ];
4312
+ const byPackageId = new Map();
4313
+ for (const candidate of candidates) {
4314
+ if (!isSourceWidgetPackage(candidate)) continue;
4315
+ if (!byPackageId.has(candidate.packageId)) byPackageId.set(candidate.packageId, candidate);
4316
+ }
4317
+ return Array.from(byPackageId.values());
4318
+ }
4319
+
4320
+ function serializeWidget(widget) {
4321
+ if (!widget || typeof widget !== "object" || Array.isArray(widget)) {
4322
+ return widget;
4323
+ }
4324
+ const { component, ...metadata } = widget;
4325
+ return metadata;
4326
+ }
4327
+
4328
+ function serializePackage(sourcePackage) {
4329
+ return {
4330
+ manifestVersion: sourcePackage.manifestVersion,
4331
+ scope: sourcePackage.scope,
4332
+ packageStableId: sourcePackage.packageStableId,
4333
+ packageId: sourcePackage.packageId,
4334
+ packageType: sourcePackage.packageType,
4335
+ version: sourcePackage.version,
4336
+ cssUrls: sourcePackage.cssUrls,
4337
+ ...(Object.prototype.hasOwnProperty.call(sourcePackage, "navigationOrigins")
4338
+ ? { navigationOrigins: sourcePackage.navigationOrigins }
4339
+ : {}),
4340
+ widgets: Array.isArray(sourcePackage.widgets)
4341
+ ? sourcePackage.widgets.map(serializeWidget)
4342
+ : sourcePackage.widgets,
4343
+ };
4344
+ }
4345
+
4346
+ await fs.writeFile(
4347
+ outputPath,
4348
+ JSON.stringify({
4349
+ sentinel: OUTPUT_SENTINEL,
4350
+ data: collectSourceWidgetPackages(widgetConfig).map(serializePackage),
4351
+ }),
4352
+ "utf-8",
4353
+ );
4354
+ } finally {
4355
+ await server.close();
4356
+ }
4357
+ `;
4358
+ }
4359
+ function isRecord$3(value) {
4360
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4361
+ }
4362
+ //#endregion
4363
+ //#region ../widget-tooling/src/internal/widget-package-builder.ts
4364
+ const DEFAULT_PUBLISH_DIR = ".fluid/widget-dist";
4365
+ const DEFAULT_RUNTIME_VERSION = "1";
4366
+ const TEMP_BUILD_DIR = ".fluid/widget-build";
4367
+ const TEMP_CLI_DIR = ".fluid/tmp";
4368
+ const PROJECT_VITE_CONFIG_CANDIDATES = [
4369
+ "vite.config.ts",
4370
+ "vite.config.mts",
4371
+ "vite.config.js",
4372
+ "vite.config.mjs"
4373
+ ];
4374
+ const BLOCKED_FLUID_PROJECT_PLUGIN_NAMES = ["fluid-portal-dev", "fluid-backend-dev"];
4375
+ const require = createRequire(import.meta.url);
4376
+ function validateSharedWidgetPackagePublishDir(projectDir, publishDir = DEFAULT_PUBLISH_DIR, options = {}) {
3505
4377
  const label = options.optionName ?? "publishDir";
3506
4378
  if (path.isAbsolute(publishDir)) throw new Error(`${label} must be relative to the project directory.`);
3507
4379
  const normalizedPublishDir = normalizePublishDirForValidation(publishDir);
@@ -3575,7 +4447,7 @@ async function buildSharedWidgetPackage(options) {
3575
4447
  const publishManifest = createPublishManifestMetadata({
3576
4448
  packageId: publishPackageId,
3577
4449
  version: validation.value.version,
3578
- cliVersion: readCliVersion(),
4450
+ cliVersion: options.cliVersion,
3579
4451
  runtimeVersion: DEFAULT_RUNTIME_VERSION,
3580
4452
  manifestContent,
3581
4453
  artifacts
@@ -3873,24 +4745,6 @@ function toPosixPath(value) {
3873
4745
  function stableStringify(value) {
3874
4746
  return `${JSON.stringify(value, null, 2)}\n`;
3875
4747
  }
3876
- function readCliVersion() {
3877
- return resolveCliVersionFromPackageMetadata(path.dirname(fileURLToPath(import.meta.url)), require);
3878
- }
3879
- function resolveCliVersionFromPackageMetadata(startDir, loadPackageMetadata) {
3880
- for (const packageJsonPath of candidatePackageJsonPaths(startDir)) {
3881
- let packageJson;
3882
- try {
3883
- packageJson = loadPackageMetadata(packageJsonPath);
3884
- } catch (error) {
3885
- if (isModuleResolutionError(error)) continue;
3886
- throw new Error("Failed to read Fluid CLI package metadata.", { cause: error });
3887
- }
3888
- if (!isRecord$2(packageJson) || packageJson.name !== "@fluid-app/fluid-cli-portal") continue;
3889
- if (typeof packageJson.version !== "string" || packageJson.version.length === 0) throw new Error("Fluid CLI package metadata does not declare a valid version.");
3890
- return packageJson.version;
3891
- }
3892
- throw new Error("Unable to find Fluid CLI package metadata.");
3893
- }
3894
4748
  function resolveViteCliPathFromRequire(moduleRequire) {
3895
4749
  try {
3896
4750
  const packageJsonPath = moduleRequire.resolve("vite/package.json");
@@ -3917,37 +4771,183 @@ function isNodeError(value) {
3917
4771
  function isModuleResolutionError(value) {
3918
4772
  return isNodeError(value) && (value.code === "MODULE_NOT_FOUND" || value.code === "ERR_MODULE_NOT_FOUND");
3919
4773
  }
3920
- function candidatePackageJsonPaths(startDir) {
3921
- const paths = [];
3922
- let current = startDir;
3923
- while (true) {
3924
- paths.push(path.join(current, "package.json"));
3925
- const parent = path.dirname(current);
3926
- if (parent === current) return paths;
3927
- current = parent;
3928
- }
3929
- }
3930
4774
  //#endregion
3931
- //#region src/utils/widget-package-upload.ts
3932
- var WidgetPackageUploadError = class extends Error {
3933
- code;
3934
- constructor(code, message) {
3935
- super(message);
3936
- this.name = "WidgetPackageUploadError";
3937
- this.code = code;
3938
- }
3939
- };
3940
- function buildWidgetPackageArtifactPayload(artifact) {
4775
+ //#region ../widget-tooling/src/internal/widget-package-publish-request.ts
4776
+ async function createWidgetPackagePublishRequest(options) {
4777
+ const manifestContent = await fs.readFile(options.manifestPath);
4778
+ const manifest = parseJsonRecord(manifestContent.toString("utf-8"), options.manifestPath);
4779
+ const publishManifest = await readPublishManifest(options.publishManifestPath);
4780
+ assertManifestIdentityMatches(manifest, publishManifest, options.manifestPath);
4781
+ assertMetadataMatches({
4782
+ label: "manifestHash",
4783
+ path: "manifest.json",
4784
+ expected: publishManifest.manifestHash,
4785
+ actual: sha256(manifestContent)
4786
+ });
4787
+ const collectedArtifacts = await collectWidgetPackageArtifacts(options.publishDir);
4788
+ assertArtifactInventoryMatches(publishManifest.artifacts, collectedArtifacts);
4789
+ const artifacts = await readUploadArtifacts(options.publishDir, publishManifest.artifacts);
3941
4790
  return {
3942
- path: artifact.path,
3943
- sha256: artifact.sha256,
3944
- bytes: artifact.bytes,
3945
- contentType: artifact.contentType
4791
+ version: publishManifest.version,
4792
+ packageKey: stripOwnerPrefix(publishManifest.packageId, options.owner),
4793
+ builderVersion: publishManifest.builderVersion,
4794
+ cliVersion: publishManifest.cliVersion,
4795
+ runtimeVersion: publishManifest.runtimeVersion,
4796
+ manifestHash: publishManifest.manifestHash,
4797
+ manifest,
4798
+ artifacts
3946
4799
  };
3947
4800
  }
3948
- function buildWidgetPackageUploadSessionPayload(request) {
3949
- return {
3950
- version: request.version,
4801
+ async function readJsonRecord(filePath) {
4802
+ return parseJsonRecord(await fs.readFile(filePath, "utf-8"), filePath);
4803
+ }
4804
+ function parseJsonRecord(content, filePath) {
4805
+ const value = JSON.parse(content);
4806
+ if (!isRecord$1(value) || Array.isArray(value)) throw new Error(`${path.basename(filePath)} must contain a JSON object.`);
4807
+ return value;
4808
+ }
4809
+ async function readPublishManifest(filePath) {
4810
+ const record = await readJsonRecord(filePath);
4811
+ const artifactsValue = record["artifacts"];
4812
+ if (!Array.isArray(artifactsValue)) throw new Error("publish-manifest.json must include an artifacts array.");
4813
+ return {
4814
+ packageId: readRequiredString(record, "packageId", filePath),
4815
+ version: readRequiredString(record, "version", filePath),
4816
+ builderVersion: readRequiredString(record, "builderVersion", filePath),
4817
+ cliVersion: readRequiredString(record, "cliVersion", filePath),
4818
+ runtimeVersion: readRequiredString(record, "runtimeVersion", filePath),
4819
+ manifestHash: readRequiredString(record, "manifestHash", filePath),
4820
+ artifacts: artifactsValue.map((artifact, index) => readArtifactMetadata(artifact, index))
4821
+ };
4822
+ }
4823
+ function readArtifactMetadata(value, index) {
4824
+ if (!isRecord$1(value) || Array.isArray(value)) throw new Error(`publish-manifest.json artifacts[${index}] must be a JSON object.`);
4825
+ return {
4826
+ path: readRequiredString(value, "path", "publish-manifest.json"),
4827
+ sha256: readRequiredString(value, "sha256", "publish-manifest.json"),
4828
+ bytes: readRequiredNumber(value, "bytes", "publish-manifest.json"),
4829
+ contentType: readRequiredString(value, "contentType", "publish-manifest.json")
4830
+ };
4831
+ }
4832
+ async function readUploadArtifacts(publishDir, artifacts) {
4833
+ const resolvedPublishDir = path.resolve(publishDir);
4834
+ return Promise.all(artifacts.map(async (artifact) => {
4835
+ const artifactPath = resolveArtifactPath(resolvedPublishDir, artifact.path);
4836
+ const actual = await computeArtifactMetadata(artifactPath, resolvedPublishDir);
4837
+ assertArtifactMetadataMatches(artifact, actual);
4838
+ return {
4839
+ ...actual,
4840
+ body: await fs.readFile(artifactPath)
4841
+ };
4842
+ }));
4843
+ }
4844
+ function assertManifestIdentityMatches(manifest, publishManifest, manifestPath) {
4845
+ assertMetadataMatches({
4846
+ label: "packageId",
4847
+ path: "manifest.json",
4848
+ expected: publishManifest.packageId,
4849
+ actual: readRequiredString(manifest, "packageId", manifestPath)
4850
+ });
4851
+ assertMetadataMatches({
4852
+ label: "version",
4853
+ path: "manifest.json",
4854
+ expected: publishManifest.version,
4855
+ actual: readRequiredString(manifest, "version", manifestPath)
4856
+ });
4857
+ }
4858
+ function assertArtifactInventoryMatches(expected, actual) {
4859
+ const expectedByPath = /* @__PURE__ */ new Map();
4860
+ for (const artifact of expected) {
4861
+ if (expectedByPath.has(artifact.path)) throw new Error(`publish-manifest.json artifacts include duplicate path ${JSON.stringify(artifact.path)}. Rebuild the widget package before publishing.`);
4862
+ expectedByPath.set(artifact.path, artifact);
4863
+ }
4864
+ const actualPaths = /* @__PURE__ */ new Set();
4865
+ for (const actualArtifact of actual) {
4866
+ actualPaths.add(actualArtifact.path);
4867
+ const expectedArtifact = expectedByPath.get(actualArtifact.path);
4868
+ if (!expectedArtifact) throw new Error(`publish-manifest.json artifacts omit ${JSON.stringify(actualArtifact.path)}. Rebuild the widget package before publishing.`);
4869
+ assertArtifactMetadataMatches(expectedArtifact, actualArtifact);
4870
+ }
4871
+ for (const expectedArtifact of expected) {
4872
+ if (actualPaths.has(expectedArtifact.path)) continue;
4873
+ throw new Error(`publish-manifest.json artifacts include ${JSON.stringify(expectedArtifact.path)}, but that file is not present on disk. Rebuild the widget package before publishing.`);
4874
+ }
4875
+ }
4876
+ function assertArtifactMetadataMatches(expected, actual) {
4877
+ assertMetadataMatches({
4878
+ label: "path",
4879
+ path: expected.path,
4880
+ expected: expected.path,
4881
+ actual: actual.path
4882
+ });
4883
+ assertMetadataMatches({
4884
+ label: "sha256",
4885
+ path: expected.path,
4886
+ expected: expected.sha256,
4887
+ actual: actual.sha256
4888
+ });
4889
+ assertMetadataMatches({
4890
+ label: "bytes",
4891
+ path: expected.path,
4892
+ expected: expected.bytes,
4893
+ actual: actual.bytes
4894
+ });
4895
+ assertMetadataMatches({
4896
+ label: "contentType",
4897
+ path: expected.path,
4898
+ expected: expected.contentType,
4899
+ actual: actual.contentType
4900
+ });
4901
+ }
4902
+ function assertMetadataMatches(options) {
4903
+ if (options.expected === options.actual) return;
4904
+ throw new Error(`publish-manifest.json ${options.label} for ${JSON.stringify(options.path)} does not match the file on disk. Recorded ${JSON.stringify(options.expected)}, computed ${JSON.stringify(options.actual)}. Rebuild the widget package before publishing.`);
4905
+ }
4906
+ function resolveArtifactPath(publishDir, artifactPath) {
4907
+ const resolved = path.resolve(publishDir, artifactPath);
4908
+ const relative = path.relative(publishDir, resolved);
4909
+ if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Artifact path ${JSON.stringify(artifactPath)} escapes the publish directory.`);
4910
+ return resolved;
4911
+ }
4912
+ function stripOwnerPrefix(packageId, owner) {
4913
+ const prefix = `${owner}.`;
4914
+ if (packageId.startsWith(prefix) && packageId.length > prefix.length) return packageId.slice(prefix.length);
4915
+ return packageId;
4916
+ }
4917
+ function readRequiredString(record, key, filePath) {
4918
+ const value = record[key];
4919
+ if (typeof value === "string" && value.trim().length > 0) return value;
4920
+ throw new Error(`${path.basename(filePath)} must include string ${key}.`);
4921
+ }
4922
+ function readRequiredNumber(record, key, filePath) {
4923
+ const value = record[key];
4924
+ if (typeof value === "number" && Number.isFinite(value)) return value;
4925
+ throw new Error(`${path.basename(filePath)} must include number ${key}.`);
4926
+ }
4927
+ function isRecord$1(value) {
4928
+ return typeof value === "object" && value !== null;
4929
+ }
4930
+ //#endregion
4931
+ //#region ../widget-tooling/src/internal/widget-package-upload.ts
4932
+ var WidgetPackageUploadError = class extends Error {
4933
+ code;
4934
+ constructor(code, message) {
4935
+ super(message);
4936
+ this.name = "WidgetPackageUploadError";
4937
+ this.code = code;
4938
+ }
4939
+ };
4940
+ function buildWidgetPackageArtifactPayload(artifact) {
4941
+ return {
4942
+ path: artifact.path,
4943
+ sha256: artifact.sha256,
4944
+ bytes: artifact.bytes,
4945
+ contentType: artifact.contentType
4946
+ };
4947
+ }
4948
+ function buildWidgetPackageUploadSessionPayload(request) {
4949
+ return {
4950
+ version: request.version,
3951
4951
  package_key: request.packageKey,
3952
4952
  builder_version: request.builderVersion,
3953
4953
  cli_version: request.cliVersion,
@@ -4100,10 +5100,10 @@ function mergeUploadHeaders(headers, contentType) {
4100
5100
  return merged;
4101
5101
  }
4102
5102
  function requireRecord(value, code, message) {
4103
- if (!isRecord$1(value) || Array.isArray(value)) throw new WidgetPackageUploadError(code, message);
5103
+ if (!isRecord(value) || Array.isArray(value)) throw new WidgetPackageUploadError(code, message);
4104
5104
  return value;
4105
5105
  }
4106
- function isRecord$1(value) {
5106
+ function isRecord(value) {
4107
5107
  return typeof value === "object" && value !== null;
4108
5108
  }
4109
5109
  function requireStringProperty(record, key, code) {
@@ -4123,7 +5123,7 @@ function requireStringArrayProperty(record, key, code) {
4123
5123
  }
4124
5124
  function formatUnknownError(err) {
4125
5125
  const base = err instanceof Error ? err.message : String(err);
4126
- if (isRecord$1(err) && "data" in err) return `${base} — ${safeStringify(err.data)}`;
5126
+ if (isRecord(err) && "data" in err) return `${base} — ${safeStringify(err.data)}`;
4127
5127
  return base;
4128
5128
  }
4129
5129
  function safeStringify(value) {
@@ -4141,73 +5141,100 @@ function safeStringify(value) {
4141
5141
  }
4142
5142
  }
4143
5143
  //#endregion
5144
+ //#region ../widget-tooling/src/workflows.ts
5145
+ async function buildWidgetPackage(options) {
5146
+ const result = await buildSharedWidgetPackage({
5147
+ projectDir: options.projectDir,
5148
+ publishDir: options.outDir,
5149
+ owner: options.owner,
5150
+ cliVersion: options.cliVersion
5151
+ });
5152
+ if (result.success) return result.value;
5153
+ throw new WidgetToolingError(result.error.code === "CONFIG_LOAD_FAILED" ? "CONFIG_LOAD_FAILED" : result.error.code === "VALIDATION_FAILED" ? "VALIDATION_FAILED" : result.error.code === "INVALID_PUBLISH_DIR" ? "INVALID_OUTPUT_DIR" : "BUILD_FAILED", result.error.message, { details: result.error.details });
5154
+ }
5155
+ async function publishWidgetPackage(options) {
5156
+ if (options.owner !== options.uploadOwner.kind) throw new WidgetToolingError("OWNER_MISMATCH", `Widget package build owner "${options.owner}" must match upload owner kind "${options.uploadOwner.kind}".`);
5157
+ if (!options.dryRun && !options.client) throw new WidgetToolingError("CLIENT_REQUIRED", "A FetchClient is required when dryRun is false.");
5158
+ const build = await buildWidgetPackage(options);
5159
+ let request;
5160
+ try {
5161
+ request = await createWidgetPackagePublishRequest({
5162
+ publishDir: build.publishDir,
5163
+ manifestPath: build.manifestPath,
5164
+ publishManifestPath: build.publishManifestPath,
5165
+ owner: options.owner
5166
+ });
5167
+ } catch (error) {
5168
+ throw new WidgetToolingError("ARTIFACT_VERIFICATION_FAILED", error instanceof Error ? error.message : "Failed to verify widget package artifacts.", { cause: error });
5169
+ }
5170
+ try {
5171
+ const publish = options.dryRun ? await publishWidgetPackageVersion({
5172
+ owner: options.uploadOwner,
5173
+ request,
5174
+ dryRun: true,
5175
+ fetchImpl: options.fetchImpl
5176
+ }) : await publishWidgetPackageVersion({
5177
+ client: options.client,
5178
+ owner: options.uploadOwner,
5179
+ request,
5180
+ fetchImpl: options.fetchImpl
5181
+ });
5182
+ return {
5183
+ build,
5184
+ request,
5185
+ publish
5186
+ };
5187
+ } catch (error) {
5188
+ if (error instanceof WidgetToolingError) throw error;
5189
+ if (error instanceof WidgetPackageUploadError) throw new WidgetToolingError(toPublicUploadErrorCode(error.code), error.message, { cause: error });
5190
+ throw new WidgetToolingError("PUBLISH_FAILED", error instanceof Error ? error.message : "Failed to publish widget package.", { cause: error });
5191
+ }
5192
+ }
5193
+ function toPublicUploadErrorCode(code) {
5194
+ switch (code) {
5195
+ case "SESSION_REQUEST_FAILED":
5196
+ case "INVALID_SESSION_RESPONSE":
5197
+ case "MISSING_SIGNED_URL":
5198
+ case "CONTENT_TYPE_MISMATCH":
5199
+ case "ARTIFACT_UPLOAD_FAILED":
5200
+ case "COMPLETE_UPLOAD_FAILED":
5201
+ case "INVALID_COMPLETE_RESPONSE": return code;
5202
+ default: return "PUBLISH_FAILED";
5203
+ }
5204
+ }
5205
+ //#endregion
4144
5206
  //#region src/commands/widget-package-publish.ts
4145
- async function publishWidgetRuntimeArtifacts(options, dependencies = {}) {
4146
- const buildPackage = dependencies.buildSharedWidgetPackage ?? buildSharedWidgetPackage;
4147
- const publishVersion = dependencies.publishWidgetPackageVersion ?? publishWidgetPackageVersion;
5207
+ async function publishWidgetRuntimeArtifacts(options) {
4148
5208
  const isDryRun = options.dryRun === true;
4149
5209
  const client = options.client;
4150
5210
  if (!isDryRun && !client) throw new Error("A FetchClient is required when dryRun is false.");
4151
- const safeOutDir = validateWidgetPublishOutDir(options.projectDir, options.outDir);
4152
- const buildResult = await buildPackage({
4153
- projectDir: options.projectDir,
4154
- publishDir: safeOutDir,
4155
- owner: options.buildOwner
4156
- });
4157
- if (!buildResult.success) {
4158
- const details = buildResult.error.details ? `\n${buildResult.error.details}` : "";
4159
- throw new Error(`${buildResult.error.message}${details}`);
4160
- }
4161
- const request = await createWidgetPackagePublishRequest({
4162
- publishDir: buildResult.value.publishDir,
4163
- manifestPath: buildResult.value.manifestPath,
4164
- publishManifestPath: buildResult.value.publishManifestPath,
4165
- owner: options.buildOwner
4166
- });
4167
- let publish;
4168
- if (isDryRun) publish = await publishVersion({
4169
- owner: options.uploadOwner,
4170
- request,
4171
- dryRun: true
4172
- });
4173
- else {
5211
+ const cliVersion = await getPortalCliVersion();
5212
+ try {
5213
+ if (isDryRun) return await publishWidgetPackage({
5214
+ projectDir: options.projectDir,
5215
+ outDir: options.outDir,
5216
+ owner: options.buildOwner,
5217
+ uploadOwner: options.uploadOwner,
5218
+ cliVersion,
5219
+ dryRun: true
5220
+ });
4174
5221
  if (!client) throw new Error("A FetchClient is required when dryRun is false.");
4175
- publish = await publishVersion({
4176
- client,
4177
- owner: options.uploadOwner,
4178
- request
5222
+ return await publishWidgetPackage({
5223
+ projectDir: options.projectDir,
5224
+ outDir: options.outDir,
5225
+ owner: options.buildOwner,
5226
+ uploadOwner: options.uploadOwner,
5227
+ cliVersion,
5228
+ client
4179
5229
  });
5230
+ } catch (error) {
5231
+ throwWithDetails(error);
4180
5232
  }
4181
- return {
4182
- build: buildResult.value,
4183
- request,
4184
- publish
4185
- };
4186
5233
  }
4187
- async function createWidgetPackagePublishRequest(options) {
4188
- const manifestContent = await fs.readFile(options.manifestPath);
4189
- const manifest = parseJsonRecord(manifestContent.toString("utf-8"), options.manifestPath);
4190
- const publishManifest = await readPublishManifest(options.publishManifestPath);
4191
- assertManifestIdentityMatches(manifest, publishManifest, options.manifestPath);
4192
- assertMetadataMatches({
4193
- label: "manifestHash",
4194
- path: "manifest.json",
4195
- expected: publishManifest.manifestHash,
4196
- actual: sha256(manifestContent)
4197
- });
4198
- const collectedArtifacts = await collectWidgetPackageArtifacts(options.publishDir);
4199
- assertArtifactInventoryMatches(publishManifest.artifacts, collectedArtifacts);
4200
- const artifacts = await readUploadArtifacts(options.publishDir, publishManifest.artifacts);
4201
- return {
4202
- version: publishManifest.version,
4203
- packageKey: stripOwnerPrefix(publishManifest.packageId, options.owner),
4204
- builderVersion: publishManifest.builderVersion,
4205
- cliVersion: publishManifest.cliVersion,
4206
- runtimeVersion: publishManifest.runtimeVersion,
4207
- manifestHash: publishManifest.manifestHash,
4208
- manifest,
4209
- artifacts
4210
- };
5234
+ function throwWithDetails(error) {
5235
+ if (!(error instanceof Error)) throw error;
5236
+ if (!("details" in error) || typeof error.details !== "string" || error.details.length === 0) throw error;
5237
+ throw new Error(`${error.message}\n${error.details}`, { cause: error });
4211
5238
  }
4212
5239
  function createAuthenticatedWidgetPackageClient() {
4213
5240
  const { profile, profileName, token } = resolveAuthenticatedProfile();
@@ -4261,139 +5288,6 @@ function printDryRunSessionPayload(result) {
4261
5288
  console.log(JSON.stringify(result.publish.sessionPayload, null, 2));
4262
5289
  console.log();
4263
5290
  }
4264
- function validateWidgetPublishOutDir(projectDir, outDir) {
4265
- validateSharedWidgetPackagePublishDir(projectDir, outDir, { optionName: "--out-dir" });
4266
- return outDir;
4267
- }
4268
- async function readJsonRecord(filePath) {
4269
- return parseJsonRecord(await fs.readFile(filePath, "utf-8"), filePath);
4270
- }
4271
- function parseJsonRecord(content, filePath) {
4272
- const value = JSON.parse(content);
4273
- if (!isRecord(value) || Array.isArray(value)) throw new Error(`${path.basename(filePath)} must contain a JSON object.`);
4274
- return value;
4275
- }
4276
- async function readPublishManifest(filePath) {
4277
- const record = await readJsonRecord(filePath);
4278
- const artifactsValue = record["artifacts"];
4279
- if (!Array.isArray(artifactsValue)) throw new Error("publish-manifest.json must include an artifacts array.");
4280
- return {
4281
- packageId: readRequiredString(record, "packageId", filePath),
4282
- version: readRequiredString(record, "version", filePath),
4283
- builderVersion: readRequiredString(record, "builderVersion", filePath),
4284
- cliVersion: readRequiredString(record, "cliVersion", filePath),
4285
- runtimeVersion: readRequiredString(record, "runtimeVersion", filePath),
4286
- manifestHash: readRequiredString(record, "manifestHash", filePath),
4287
- artifacts: artifactsValue.map((artifact, index) => readArtifactMetadata(artifact, index))
4288
- };
4289
- }
4290
- function readArtifactMetadata(value, index) {
4291
- if (!isRecord(value) || Array.isArray(value)) throw new Error(`publish-manifest.json artifacts[${index}] must be a JSON object.`);
4292
- return {
4293
- path: readRequiredString(value, "path", "publish-manifest.json"),
4294
- sha256: readRequiredString(value, "sha256", "publish-manifest.json"),
4295
- bytes: readRequiredNumber(value, "bytes", "publish-manifest.json"),
4296
- contentType: readRequiredString(value, "contentType", "publish-manifest.json")
4297
- };
4298
- }
4299
- async function readUploadArtifacts(publishDir, artifacts) {
4300
- const resolvedPublishDir = path.resolve(publishDir);
4301
- return Promise.all(artifacts.map(async (artifact) => {
4302
- const artifactPath = resolveArtifactPath(resolvedPublishDir, artifact.path);
4303
- const actual = await computeArtifactMetadata(artifactPath, resolvedPublishDir);
4304
- assertArtifactMetadataMatches(artifact, actual);
4305
- return {
4306
- ...actual,
4307
- body: await fs.readFile(artifactPath)
4308
- };
4309
- }));
4310
- }
4311
- function assertManifestIdentityMatches(manifest, publishManifest, manifestPath) {
4312
- assertMetadataMatches({
4313
- label: "packageId",
4314
- path: "manifest.json",
4315
- expected: publishManifest.packageId,
4316
- actual: readRequiredString(manifest, "packageId", manifestPath)
4317
- });
4318
- assertMetadataMatches({
4319
- label: "version",
4320
- path: "manifest.json",
4321
- expected: publishManifest.version,
4322
- actual: readRequiredString(manifest, "version", manifestPath)
4323
- });
4324
- }
4325
- function assertArtifactInventoryMatches(expected, actual) {
4326
- const expectedByPath = /* @__PURE__ */ new Map();
4327
- for (const artifact of expected) {
4328
- if (expectedByPath.has(artifact.path)) throw new Error(`publish-manifest.json artifacts include duplicate path ${JSON.stringify(artifact.path)}. Rebuild the widget package before publishing.`);
4329
- expectedByPath.set(artifact.path, artifact);
4330
- }
4331
- const actualPaths = /* @__PURE__ */ new Set();
4332
- for (const actualArtifact of actual) {
4333
- actualPaths.add(actualArtifact.path);
4334
- const expectedArtifact = expectedByPath.get(actualArtifact.path);
4335
- if (!expectedArtifact) throw new Error(`publish-manifest.json artifacts omit ${JSON.stringify(actualArtifact.path)}. Rebuild the widget package before publishing.`);
4336
- assertArtifactMetadataMatches(expectedArtifact, actualArtifact);
4337
- }
4338
- for (const expectedArtifact of expected) {
4339
- if (actualPaths.has(expectedArtifact.path)) continue;
4340
- throw new Error(`publish-manifest.json artifacts include ${JSON.stringify(expectedArtifact.path)}, but that file is not present on disk. Rebuild the widget package before publishing.`);
4341
- }
4342
- }
4343
- function assertArtifactMetadataMatches(expected, actual) {
4344
- assertMetadataMatches({
4345
- label: "path",
4346
- path: expected.path,
4347
- expected: expected.path,
4348
- actual: actual.path
4349
- });
4350
- assertMetadataMatches({
4351
- label: "sha256",
4352
- path: expected.path,
4353
- expected: expected.sha256,
4354
- actual: actual.sha256
4355
- });
4356
- assertMetadataMatches({
4357
- label: "bytes",
4358
- path: expected.path,
4359
- expected: expected.bytes,
4360
- actual: actual.bytes
4361
- });
4362
- assertMetadataMatches({
4363
- label: "contentType",
4364
- path: expected.path,
4365
- expected: expected.contentType,
4366
- actual: actual.contentType
4367
- });
4368
- }
4369
- function assertMetadataMatches(options) {
4370
- if (options.expected === options.actual) return;
4371
- throw new Error(`publish-manifest.json ${options.label} for ${JSON.stringify(options.path)} does not match the file on disk. Recorded ${JSON.stringify(options.expected)}, computed ${JSON.stringify(options.actual)}. Rebuild the widget package before publishing.`);
4372
- }
4373
- function resolveArtifactPath(publishDir, artifactPath) {
4374
- const resolved = path.resolve(publishDir, artifactPath);
4375
- const relative = path.relative(publishDir, resolved);
4376
- if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Artifact path ${JSON.stringify(artifactPath)} escapes the publish directory.`);
4377
- return resolved;
4378
- }
4379
- function stripOwnerPrefix(packageId, owner) {
4380
- const prefix = `${owner}.`;
4381
- if (packageId.startsWith(prefix) && packageId.length > prefix.length) return packageId.slice(prefix.length);
4382
- return packageId;
4383
- }
4384
- function readRequiredString(record, key, filePath) {
4385
- const value = record[key];
4386
- if (typeof value === "string" && value.trim().length > 0) return value;
4387
- throw new Error(`${path.basename(filePath)} must include string ${key}.`);
4388
- }
4389
- function readRequiredNumber(record, key, filePath) {
4390
- const value = record[key];
4391
- if (typeof value === "number" && Number.isFinite(value)) return value;
4392
- throw new Error(`${path.basename(filePath)} must include number ${key}.`);
4393
- }
4394
- function isRecord(value) {
4395
- return typeof value === "object" && value !== null;
4396
- }
4397
5291
  //#endregion
4398
5292
  //#region src/commands/deploy.ts
4399
5293
  const DEFAULT_OUT_DIR$1 = ".fluid/widget-dist";
@@ -4793,6 +5687,6 @@ const plugin = {
4793
5687
  }
4794
5688
  };
4795
5689
  //#endregion
4796
- export { CANONICAL_PROJECT_NAME, FILE_SYSTEM_ERRORS, GitError, TEMPLATES, TEMPLATE_REPO_URL, WIDGET_PACKAGE_BUILDER_VERSION, WidgetPackageUploadError, buildIdToSlugMap, buildNavigationIdToSlugMap, buildSharedWidgetPackage, buildSnapshot, buildThemeIdToSlugMap, buildWidgetPackageCompleteUploadPayload, buildWidgetPackagePublishManifest, buildWidgetPackageUploadSessionPayload, categorizeChanges, cloneCommand, collectWidgetPackageArtifacts, completeWidgetPackageUpload, computeArtifactMetadata, computeFileHash, copyTemplate, copyTemplateSafe, createAuthenticatedWidgetPackageClient, createCommand, createDirectory, createDirectorySafe, createPortalGitCredential, createPortalProject, 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 };
5690
+ export { CANONICAL_PROJECT_NAME, FILE_SYSTEM_ERRORS, GitError, TEMPLATES, TEMPLATE_REPO_URL, buildIdToSlugMap, buildNavigationIdToSlugMap, buildSnapshot, buildThemeIdToSlugMap, categorizeChanges, cloneCommand, computeFileHash, copyTemplate, copyTemplateSafe, createCommand, createDirectory, createDirectorySafe, createPortalGitCredential, createPortalProject, plugin as default, deployCommand, deriveScreenSlug, deriveSlug, diffAgainstSnapshot, directoryExists, doctorCommand, ensureGitAvailable, fileExists, getInstallCommand, getOriginUrl, getRunCommand, getSdkVersion, getSdkVersionSafe, getTemplatePaths, gitAheadBehindVsFetchHead, gitAutoCommit, gitClone, gitFetch, gitInit, gitMergeFastForward, gitMissingError, gitPush, hasUncommittedChanges, installDependencies, isGitAvailable, isGitRepo, pathExists, portalGitCredentialPath, promptProjectConfig, pullCommand, pushCommand, readFileSafe, readMappings, readSnapshot, removeMapping, resolveIdToSlug, resolveSlugToId, rewriteProjectName, runPackageManager, runPortalGitSync, scaffoldFromGitHub, slugFromPath, topLevelWidgetCommand, transformNavigation, transformNavigationItems, transformProfile, transformScreen, transformTheme, updateMapping, validateCrossReferences, versionCommand, widgetCommand, writeFileSafe, writeFluidrcProfile, writeMappings, writeSnapshot };
4797
5691
 
4798
5692
  //# sourceMappingURL=index.mjs.map