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

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.
Files changed (35) hide show
  1. package/README.md +3 -0
  2. package/dist/{backend-dev-plugin-B9-S4Is-.mjs → backend-dev-plugin-CY3yxgJ1.mjs} +1 -1
  3. package/dist/{backend-dev-plugin-B9-S4Is-.mjs.map → backend-dev-plugin-CY3yxgJ1.mjs.map} +1 -1
  4. package/dist/build-manifest.d.mts +91 -0
  5. package/dist/build-manifest.d.mts.map +1 -0
  6. package/dist/build-manifest.mjs +180 -0
  7. package/dist/build-manifest.mjs.map +1 -0
  8. package/dist/index.d.mts +657 -741
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1627 -1829
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/portal-dev-plugin-CHlxpXc5.mjs +232 -0
  13. package/dist/portal-dev-plugin-CHlxpXc5.mjs.map +1 -0
  14. package/dist/portal-widget-dev-plugin-B53Lf9Ke.mjs +144 -0
  15. package/dist/portal-widget-dev-plugin-B53Lf9Ke.mjs.map +1 -0
  16. package/dist/pull-CUR8tbW9.mjs +1633 -0
  17. package/dist/pull-CUR8tbW9.mjs.map +1 -0
  18. package/dist/sdk.gen-DuPhfdxN.mjs +469 -0
  19. package/dist/sdk.gen-DuPhfdxN.mjs.map +1 -0
  20. package/dist/src-CpH5SpDH.mjs +2157 -0
  21. package/dist/src-CpH5SpDH.mjs.map +1 -0
  22. package/dist/vite-plugin.d.mts +15 -56
  23. package/dist/vite-plugin.d.mts.map +1 -1
  24. package/dist/vite-plugin.mjs +6 -3
  25. package/package.json +12 -3
  26. package/templates/base/.gitignore.template +6 -0
  27. package/templates/base/.oxlintrc.json +1 -2
  28. package/templates/base/AGENTS.md +4 -0
  29. package/templates/base/skills/fluid-portal-authoring/SKILL.md +238 -10
  30. package/templates/starter/.env.example +4 -3
  31. package/templates/starter/README.md.template +14 -1
  32. package/dist/portal-dev-plugin-BHE8Z4yr.mjs +0 -289
  33. package/dist/portal-dev-plugin-BHE8Z4yr.mjs.map +0 -1
  34. package/dist/pull-XOsFaqz2.mjs +0 -1204
  35. package/dist/pull-XOsFaqz2.mjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,18 +1,19 @@
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";
2
- import { createRequire } from "node:module";
1
+ import { A as STANDALONE_PORTAL_MARKER, C as writeMappings, D as toFluidOsDynamicObject, E as fluidOsApi, O as createFluidOsHeyApiClient, S as updateMapping, T as createFluidOsClient, _ 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 assertStandalonePortalRemoteActionAllowed, k as ApiError, 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 atomicWriteFile, x as resolveSlugToId, y as removeMapping } from "./pull-CUR8tbW9.mjs";
2
+ import { l as fluidOsV0DefinitionGitCredentialCreate } from "./sdk.gen-DuPhfdxN.mjs";
3
+ import { a as resolvePublishVersion, i as readCompanyWidgetPackages, n as preflightCompanyPackage, o as loadAndValidateWidgetPackage, r as readCompanyWidgetPackage, s as publishWidgetPackage, t as formatPackageKeyMismatch } from "./src-CpH5SpDH.mjs";
3
4
  import { Command } from "commander";
4
5
  import chalk from "chalk";
5
6
  import ora from "ora";
6
- import path, { basename, dirname, join, relative, resolve } from "node:path";
7
- import { fileURLToPath, pathToFileURL } from "node:url";
8
- import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
7
+ import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
10
+ import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
11
+ import os, { tmpdir } from "node:os";
9
12
  import { failure, findProjectConfig, getActiveProfile, getAuthToken, listProfileNames, readConfig, success } from "@fluid-app/fluid-cli";
10
13
  import prompts from "prompts";
11
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
12
14
  import Handlebars from "handlebars";
13
15
  import { execa } from "execa";
14
- import os from "node:os";
15
- import { createHash } from "node:crypto";
16
+ import { randomUUID } from "node:crypto";
16
17
  import fs from "fs-extra";
17
18
  //#region src/types.ts
18
19
  /**
@@ -95,9 +96,9 @@ async function promptProjectConfig(projectName, options) {
95
96
  //#endregion
96
97
  //#region src/utils/file-system.ts
97
98
  const _currentDir$1 = dirname(fileURLToPath(import.meta.url));
98
- const EMBEDDED_PORTAL_SDK_VERSION = "0.1.461";
99
+ const EMBEDDED_PORTAL_SDK_VERSION = "0.1.463";
99
100
  const EMBEDDED_CLI_CORE_VERSION = "0.1.14";
100
- const EMBEDDED_CLI_PORTAL_VERSION = "0.1.52";
101
+ const EMBEDDED_CLI_PORTAL_VERSION = "0.1.54";
101
102
  /**
102
103
  * Find the package root by walking up from the current directory to the nearest package.json.
103
104
  * Works whether running from dist/ (bundled) or src/utils/ (tsx dev mode).
@@ -296,6 +297,40 @@ async function getCliVersion() {
296
297
  async function getPortalCliVersion() {
297
298
  return getTemplatePackageVersion(["cli", "portal"], EMBEDDED_CLI_PORTAL_VERSION);
298
299
  }
300
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org";
301
+ const NPM_REGISTRY_TIMEOUT_MS = 5e3;
302
+ /**
303
+ * Clamp a scaffold dependency pin to a version that `pnpm install` can
304
+ * actually resolve from the registry.
305
+ *
306
+ * The versions above are whatever this CLI build carries — embedded at build
307
+ * time, or read from the workspace when running from source. Both run ahead
308
+ * of npm whenever the monorepo holds a release bump that has not been
309
+ * published yet (routine for anyone running the CLI from a checkout, e.g.
310
+ * Mist in development), and a scaffold pinned to that version fails to
311
+ * install. Only a definitive registry 404 for the exact version downgrades
312
+ * the pin to the registry's `latest`; a network failure or any other registry
313
+ * response keeps the original version, so offline scaffolds behave exactly as
314
+ * before.
315
+ *
316
+ * Scaffold pins are the ONLY sound callsite: consumers that report which
317
+ * version is running (e.g. widget-package-publish) must not route through
318
+ * this — it would silently misreport an unpublished build as the older
319
+ * published one.
320
+ */
321
+ async function resolveInstallableTemplateVersion(packageName, version) {
322
+ try {
323
+ if ((await fetch(`${NPM_REGISTRY_URL}/${packageName}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(NPM_REGISTRY_TIMEOUT_MS) })).status !== 404) return version;
324
+ const latest = await fetch(`${NPM_REGISTRY_URL}/${packageName}/latest`, { signal: AbortSignal.timeout(NPM_REGISTRY_TIMEOUT_MS) });
325
+ if (!latest.ok) return version;
326
+ const pkg = await latest.json();
327
+ if (typeof pkg.version !== "string") return version;
328
+ console.warn(`${packageName}@${version} is not published yet; the scaffold pins the latest published version ${pkg.version} instead.`);
329
+ return pkg.version;
330
+ } catch {
331
+ return version;
332
+ }
333
+ }
299
334
  async function getTemplatePackageVersion(packagePathSegments, embeddedVersion) {
300
335
  if (embeddedVersion) return embeddedVersion;
301
336
  try {
@@ -486,6 +521,10 @@ function splitRemoteCredentials(remoteUrl) {
486
521
  password
487
522
  };
488
523
  }
524
+ /** Strip credential userinfo from a minted remote URL for safe comparison. */
525
+ function cleanGitRemoteUrl(remoteUrl) {
526
+ return splitRemoteCredentials(remoteUrl).cleanUrl;
527
+ }
489
528
  /**
490
529
  * Write a stub GIT_ASKPASS shell script to a per-process tmpdir and
491
530
  * return its path. The script contains no secret — it echoes back
@@ -519,9 +558,9 @@ function ensureAskpassScript() {
519
558
  * credentials are empty (auth should fail fast, not hang).
520
559
  */
521
560
  function gitAuthSpawn(remoteUrl) {
522
- const { cleanUrl, username, password } = splitRemoteCredentials(remoteUrl);
561
+ const { username, password } = splitRemoteCredentials(remoteUrl);
523
562
  return {
524
- cleanUrl,
563
+ cleanUrl: cleanGitRemoteUrl(remoteUrl),
525
564
  env: {
526
565
  ...process.env,
527
566
  GIT_ASKPASS: ensureAskpassScript(),
@@ -791,6 +830,245 @@ async function gitMergeFastForward(cwd) {
791
830
  }
792
831
  }
793
832
  /**
833
+ * Local-only state that must never reach a portal's provisioned repo,
834
+ * regardless of which tool created the checkout.
835
+ *
836
+ * `.mist-desktop/` is Mist Desktop's per-machine working state for the
837
+ * project directory it opened — chat transcripts, user attachments,
838
+ * screenshots, source baselines, and a media cache. When Desktop drives
839
+ * `fluid portal push`, that directory sits in the project root, so a
840
+ * whole-tree `git add -A` publishes every private conversation to the
841
+ * portal's GitHub repo. `.mist` is the sibling clone marker (a slug
842
+ * pointer); committing it means a later revert deletes it and breaks
843
+ * slug resolution for every subsequent command.
844
+ *
845
+ * Neither is portal content: the portal's own material lives under
846
+ * `portal/` and is pushed separately by the content push.
847
+ */
848
+ const LOCAL_ONLY_GIT_PATHS = [".mist", ".mist-desktop/"];
849
+ /**
850
+ * Append {@link LOCAL_ONLY_GIT_PATHS} to `.git/info/exclude` when absent.
851
+ *
852
+ * Why the local exclude and not `.gitignore`: a portal's `.gitignore`
853
+ * ships from the starter template (bundled or the GitHub
854
+ * `portal-template` repo), so a template edit alone would never reach
855
+ * the clones that already exist on people's machines. `.git/info/exclude`
856
+ * is per-checkout, so writing it here — at sync time, on every push —
857
+ * heals a pre-existing clone on its very next push with no template
858
+ * release required. The template is updated too, for fresh scaffolds.
859
+ *
860
+ * Idempotent and best-effort: never throws. A repo with an unusual `.git`
861
+ * layout (missing `info/`, `.git` as a worktree pointer file) just skips
862
+ * the exclude — {@link gitAutoCommit} also cleans these paths out of the
863
+ * index after staging, so the exclude is a convenience, not the only
864
+ * defense.
865
+ *
866
+ * Mirrors `packages/cli/mist/src/lib/git.ts::excludeMistMarkerFromGit`.
867
+ */
868
+ function excludeLocalStateFromGit(cwd) {
869
+ try {
870
+ const gitDir = path.join(cwd, ".git");
871
+ if (!existsSync(gitDir)) return;
872
+ const infoDir = path.join(gitDir, "info");
873
+ if (!existsSync(infoDir)) mkdirSync(infoDir, { recursive: true });
874
+ const excludePath = path.join(infoDir, "exclude");
875
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
876
+ const existingLines = new Set(existing.split("\n").map((line) => line.trim()).filter(Boolean));
877
+ const missing = LOCAL_ONLY_GIT_PATHS.filter((entry) => !existingLines.has(entry));
878
+ if (missing.length === 0) return;
879
+ writeFileSync(excludePath, `${existing}${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
880
+ } catch {}
881
+ }
882
+ /**
883
+ * Absolute path of the repository containing `cwd`, or `cwd` itself when
884
+ * resolution fails (not a repo / git missing — the caller's own git
885
+ * command surfaces the real error).
886
+ *
887
+ * Every local-state defense must anchor here rather than to `cwd`: a push
888
+ * run from a project subdirectory would otherwise look for
889
+ * `<subdir>/.git`, silently skip the exclude write, and then let
890
+ * `git add -A` — which always stages the WHOLE repository — sweep the
891
+ * root `.mist-desktop/` into the commit anyway.
892
+ */
893
+ async function resolveRepoRoot(cwd) {
894
+ try {
895
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"], { cwd });
896
+ const root = stdout.trim();
897
+ if (root.length > 0) return root;
898
+ } catch {}
899
+ return cwd;
900
+ }
901
+ /**
902
+ * Repository-root-anchored {@link excludeLocalStateFromGit}, for callers
903
+ * that only have a possibly-nested `cwd`.
904
+ *
905
+ * Run this BEFORE any working-tree cleanliness check, not just before
906
+ * staging: while `.mist-desktop/` is neither tracked nor excluded, it
907
+ * shows up as untracked in `git status --porcelain`, so a Desktop-driven
908
+ * push reads as "you have uncommitted changes" and the sync skips with
909
+ * advice to `git pull` that the user can't act on. Excluding it first
910
+ * makes the cleanliness check reflect actual portal changes.
911
+ */
912
+ async function ensureLocalStateExcluded(cwd) {
913
+ excludeLocalStateFromGit(await resolveRepoRoot(cwd));
914
+ }
915
+ /** Paths currently tracked under `.mist-desktop/`, or [] when none. */
916
+ async function trackedLocalStatePaths(repoRoot) {
917
+ try {
918
+ const { stdout } = await execa("git", [
919
+ "ls-files",
920
+ "--",
921
+ ".mist-desktop"
922
+ ], { cwd: repoRoot });
923
+ return stdout.split("\n").filter((l) => l.trim().length > 0);
924
+ } catch {
925
+ return [];
926
+ }
927
+ }
928
+ /**
929
+ * Untrack private Mist Desktop state and, if anything was tracked,
930
+ * commit ONLY that removal so the next push deletes it from the remote.
931
+ * Returns true when a purge commit was made.
932
+ *
933
+ * Call this on every sync BEFORE deciding whether there is anything to
934
+ * auto-commit — a clean checkout that already tracks `.mist-desktop/`
935
+ * still needs the removal, and ignore rules alone cannot untrack an
936
+ * existing path.
937
+ *
938
+ * The commit is built through a THROWAWAY index (`GIT_INDEX_FILE`), not
939
+ * the user's: `read-tree HEAD` → `rm --cached` → `write-tree` →
940
+ * `commit-tree` → `update-ref`. A plain `git commit` would capture the
941
+ * user's entire index, so anything they had pre-staged would be swept
942
+ * into the purge commit and pushed — defeating `--no-commit` and
943
+ * mislabelling their work. A pathspec commit (`git commit -- .mist-desktop`)
944
+ * is also wrong: with `--only` git takes the *working tree* content of
945
+ * those paths, and the chats are still on disk, so it would re-add
946
+ * exactly what we are removing.
947
+ *
948
+ * The user's real index is then updated with the same `rm --cached`,
949
+ * touching only this path — so any other staged work stays staged, and
950
+ * the working tree is never modified.
951
+ */
952
+ async function purgeLocalStateFromIndex(cwd, message) {
953
+ const repoRoot = await resolveRepoRoot(cwd);
954
+ if ((await trackedLocalStatePaths(repoRoot)).length === 0) return false;
955
+ let head;
956
+ try {
957
+ const { stdout } = await execa("git", ["rev-parse", "HEAD"], { cwd: repoRoot });
958
+ head = stdout.trim();
959
+ if (!head) return false;
960
+ } catch {
961
+ return false;
962
+ }
963
+ const tmpIndex = path.join(os.tmpdir(), `fluid-portal-purge-${process.pid}-${randomUUID()}.index`);
964
+ const indexEnv = {
965
+ ...process.env,
966
+ GIT_INDEX_FILE: tmpIndex
967
+ };
968
+ let commitSha;
969
+ try {
970
+ await execa("git", ["read-tree", head], {
971
+ cwd: repoRoot,
972
+ env: indexEnv
973
+ });
974
+ await execa("git", [
975
+ "rm",
976
+ "-q",
977
+ "-r",
978
+ "--cached",
979
+ "--ignore-unmatch",
980
+ "--",
981
+ ".mist-desktop"
982
+ ], {
983
+ cwd: repoRoot,
984
+ env: indexEnv
985
+ });
986
+ const { stdout: tree } = await execa("git", ["write-tree"], {
987
+ cwd: repoRoot,
988
+ env: indexEnv
989
+ });
990
+ const { stdout: sha } = await execa("git", [
991
+ "commit-tree",
992
+ tree.trim(),
993
+ "-p",
994
+ head,
995
+ "-m",
996
+ message
997
+ ], {
998
+ cwd: repoRoot,
999
+ env: {
1000
+ ...indexEnv,
1001
+ ...await identityEnv(repoRoot)
1002
+ }
1003
+ });
1004
+ commitSha = sha.trim();
1005
+ if (!commitSha) return false;
1006
+ } catch (err) {
1007
+ const missing = gitMissingError(err);
1008
+ if (missing) throw missing;
1009
+ throw toGitError(err, "purge local state");
1010
+ } finally {
1011
+ rmSync(tmpIndex, { force: true });
1012
+ }
1013
+ try {
1014
+ await execa("git", [
1015
+ "update-ref",
1016
+ "HEAD",
1017
+ commitSha,
1018
+ head
1019
+ ], { cwd: repoRoot });
1020
+ } catch (err) {
1021
+ throw toGitError(err, "purge local state");
1022
+ }
1023
+ try {
1024
+ await execa("git", [
1025
+ "rm",
1026
+ "-q",
1027
+ "-r",
1028
+ "--cached",
1029
+ "--ignore-unmatch",
1030
+ "--",
1031
+ ".mist-desktop"
1032
+ ], { cwd: repoRoot });
1033
+ } catch {}
1034
+ return true;
1035
+ }
1036
+ /** `GIT_{AUTHOR,COMMITTER}_*` overrides for `commit-tree`, which (unlike
1037
+ * `git commit`) fails outright rather than prompting when the repo has
1038
+ * no identity. Only set what the repo itself doesn't define. */
1039
+ async function identityEnv(cwd) {
1040
+ const env = {};
1041
+ const read = async (key) => {
1042
+ try {
1043
+ return (await execa("git", ["config", key], { cwd })).stdout.trim().length > 0;
1044
+ } catch {
1045
+ return false;
1046
+ }
1047
+ };
1048
+ if (!await read("user.name")) {
1049
+ env["GIT_AUTHOR_NAME"] = "Fluid Portal CLI";
1050
+ env["GIT_COMMITTER_NAME"] = "Fluid Portal CLI";
1051
+ }
1052
+ if (!await read("user.email")) {
1053
+ env["GIT_AUTHOR_EMAIL"] = "portal@fluid.app";
1054
+ env["GIT_COMMITTER_EMAIL"] = "portal@fluid.app";
1055
+ }
1056
+ return env;
1057
+ }
1058
+ /** Shared `-c user.*` args: use the repo's identity, else a synthetic
1059
+ * Fluid bot identity so the commit isn't rejected. */
1060
+ async function resolveIdentityArgs(cwd) {
1061
+ let hasName = false;
1062
+ let hasEmail = false;
1063
+ try {
1064
+ hasName = (await execa("git", ["config", "user.name"], { cwd })).stdout.trim().length > 0;
1065
+ } catch {}
1066
+ try {
1067
+ hasEmail = (await execa("git", ["config", "user.email"], { cwd })).stdout.trim().length > 0;
1068
+ } catch {}
1069
+ return [...hasName ? [] : ["-c", "user.name=Fluid Portal CLI"], ...hasEmail ? [] : ["-c", "user.email=portal@fluid.app"]];
1070
+ }
1071
+ /**
794
1072
  * `git add -A && git commit -m <message>` (or, with
795
1073
  * `{ ignoreRemovals: true }`, `git add --ignore-removal .` — see
796
1074
  * GitAutoCommitOptions).
@@ -803,11 +1081,42 @@ async function gitMergeFastForward(cwd) {
803
1081
  * `node_modules/`, `dist/`, `.portal-sync/`, logs, `*.tsbuildinfo`), so
804
1082
  * generated output and local secrets stay out of the commit.
805
1083
  *
1084
+ * That boundary is not enough on its own for tool-local state written
1085
+ * INTO the project directory by whatever opened it — most importantly
1086
+ * Mist Desktop's `.mist-desktop/` (private chats, attachments,
1087
+ * screenshots) and the `.mist` clone marker. A `.gitignore`-only fix
1088
+ * can't reach clones that already exist, so this applies belt AND
1089
+ * suspenders, all anchored to the repository root (see
1090
+ * {@link resolveRepoRoot}):
1091
+ *
1092
+ * 1. {@link excludeLocalStateFromGit} — idempotent `.git/info/exclude`
1093
+ * write, so `git add` never picks the paths up in the first place.
1094
+ * Heals pre-existing clones on their next push.
1095
+ * 2. `git reset -- ':(top).mist' .mist` after staging — unstages the
1096
+ * marker if step 1 silently failed. We deliberately do NOT pass a
1097
+ * `:(exclude)` pathspec to `git add`: when the path is *also*
1098
+ * listed in `.git/info/exclude`, git exits 1 with "paths are
1099
+ * ignored by one of your .gitignore files", turning the normal
1100
+ * (already-healed) case into a hard failure. Add-then-reset is a
1101
+ * no-op when there's nothing staged.
1102
+ * 3. `git rm --cached -r --ignore-unmatch .mist-desktop` — untracks
1103
+ * chat history that an older, unhardened push already committed.
1104
+ * Ignore rules alone cannot untrack an existing path. `--cached`
1105
+ * keeps every file on disk; only the index entry goes away. This
1106
+ * runs even under `ignoreRemovals`, where suppressing deletions
1107
+ * protects remote-only *portal* files — private chats are never
1108
+ * that, and leaving them tracked would keep republishing them.
1109
+ *
1110
+ * Steps 2 and 3 are best-effort and never fail the publish; the commit
1111
+ * itself is what must succeed.
1112
+ *
806
1113
  * Uses the repo's configured identity, else a synthetic Fluid bot
807
1114
  * identity so the commit isn't rejected. "nothing to commit" is treated
808
1115
  * as a no-op, not an error.
809
1116
  */
810
1117
  async function gitAutoCommit(cwd, message, options = {}) {
1118
+ const repoRoot = await resolveRepoRoot(cwd);
1119
+ excludeLocalStateFromGit(repoRoot);
811
1120
  const addArgs = options.ignoreRemovals ? [
812
1121
  "add",
813
1122
  "--ignore-removal",
@@ -818,15 +1127,27 @@ async function gitAutoCommit(cwd, message, options = {}) {
818
1127
  } catch (err) {
819
1128
  throw toGitError(err, "add");
820
1129
  }
821
- let hasName = false;
822
- let hasEmail = false;
823
1130
  try {
824
- hasName = (await execa("git", ["config", "user.name"], { cwd })).stdout.trim().length > 0;
1131
+ await execa("git", [
1132
+ "reset",
1133
+ "-q",
1134
+ "--",
1135
+ ":(top).mist",
1136
+ ".mist"
1137
+ ], { cwd });
825
1138
  } catch {}
826
1139
  try {
827
- hasEmail = (await execa("git", ["config", "user.email"], { cwd })).stdout.trim().length > 0;
1140
+ await execa("git", [
1141
+ "rm",
1142
+ "-q",
1143
+ "-r",
1144
+ "--cached",
1145
+ "--ignore-unmatch",
1146
+ "--",
1147
+ ".mist-desktop"
1148
+ ], { cwd: repoRoot });
828
1149
  } catch {}
829
- const identityArgs = [...hasName ? [] : ["-c", "user.name=Fluid Portal CLI"], ...hasEmail ? [] : ["-c", "user.email=portal@fluid.app"]];
1150
+ const identityArgs = await resolveIdentityArgs(cwd);
830
1151
  try {
831
1152
  await execa("git", [
832
1153
  ...identityArgs,
@@ -1055,7 +1376,7 @@ async function createPortalProject(appName, options, config, dependencies = defa
1055
1376
  }).catch(() => {});
1056
1377
  throw error;
1057
1378
  }
1058
- } else await scaffoldFromBundled(targetPath, config, isLocal);
1379
+ } else await scaffoldFromBundled(targetPath, config, isLocal, true);
1059
1380
  if (config.installDeps) {
1060
1381
  const installSpinner = ora("Installing dependencies with pnpm...").start();
1061
1382
  try {
@@ -1105,13 +1426,13 @@ function formatCdPath(targetPath) {
1105
1426
  *
1106
1427
  * Throws on failure; the caller reports and exits.
1107
1428
  */
1108
- async function scaffoldFromBundled(targetPath, config, isLocal) {
1429
+ async function scaffoldFromBundled(targetPath, config, isLocal, createTarget) {
1109
1430
  const templatePaths = getTemplatePaths("starter");
1110
1431
  if (!await directoryExists(templatePaths.base)) throw new Error("Base template not found");
1111
1432
  if (!await directoryExists(templatePaths.overlay)) throw new Error("Starter template not found");
1112
1433
  let sdkVersion;
1113
1434
  let localCoreVersion;
1114
- const [cliVersion, portalCliVersion] = await Promise.all([getCliVersion(), getPortalCliVersion()]);
1435
+ 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
1436
  if (isLocal) {
1116
1437
  const monorepoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
1117
1438
  const sdkPath = join(monorepoRoot, "packages", "portal", "sdk");
@@ -1120,15 +1441,16 @@ async function scaffoldFromBundled(targetPath, config, isLocal) {
1120
1441
  sdkVersion = getLocalPackageLinkVersion(targetPath, sdkPath);
1121
1442
  localCoreVersion = getLocalPackageLinkVersion(targetPath, corePath);
1122
1443
  console.log(chalk.cyan(" Using local packages (--local mode)"));
1123
- } else sdkVersion = await getSdkVersion();
1124
- const spinner = ora("Creating project directory...").start();
1125
- try {
1444
+ } else sdkVersion = await getSdkVersion().then((version) => resolveInstallableTemplateVersion("@fluid-app/portal-sdk", version));
1445
+ const spinner = ora(createTarget ? "Creating project directory..." : "Preparing provisioned portal checkout...").start();
1446
+ if (createTarget) try {
1126
1447
  await createDirectory(targetPath);
1127
1448
  spinner.succeed("Created project directory");
1128
1449
  } catch (error) {
1129
1450
  spinner.fail("Failed to create project directory");
1130
1451
  throw error;
1131
1452
  }
1453
+ else if (!await directoryExists(targetPath)) throw new Error(`Portal checkout does not exist: ${targetPath}`);
1132
1454
  const templateVariables = {
1133
1455
  projectName: config.name,
1134
1456
  sdkVersion,
@@ -1151,6 +1473,79 @@ async function scaffoldFromBundled(targetPath, config, isLocal) {
1151
1473
  throw error;
1152
1474
  }
1153
1475
  }
1476
+ /**
1477
+ * Add the runnable bundled starter to an existing provisioned Git checkout.
1478
+ * Portal repositories begin with a bootstrap commit, so a first clone may
1479
+ * not contain package.json yet. Rendering into the checkout preserves its
1480
+ * .git directory and gives both the CLI and Mist Desktop a usable project
1481
+ * without creating unrelated local history.
1482
+ */
1483
+ async function scaffoldPortalProjectIntoExistingDirectory(targetPath, config) {
1484
+ const pendingMarker = join(targetPath, PORTAL_SCAFFOLD_PENDING_MARKER);
1485
+ const stagingRoot = await mkdtemp(join(tmpdir(), "fluid-portal-scaffold-stage-"));
1486
+ const stagedProject = join(stagingRoot, "portal");
1487
+ let completed = false;
1488
+ try {
1489
+ await writeFile(pendingMarker, "Fluid Portal scaffold pending\n");
1490
+ await scaffoldFromBundled(stagedProject, config, false, true);
1491
+ const preserved = await copyMissingScaffoldFiles(stagedProject, targetPath);
1492
+ if (preserved.length > 0) console.log(chalk.dim(` Preserved ${preserved.length} existing repository ${preserved.length === 1 ? "file" : "files"} while adding the runnable scaffold.`));
1493
+ completed = true;
1494
+ } finally {
1495
+ if (completed) await rm(pendingMarker, { force: true });
1496
+ await rm(stagingRoot, {
1497
+ recursive: true,
1498
+ force: true
1499
+ });
1500
+ }
1501
+ }
1502
+ const PORTAL_SCAFFOLD_PENDING_MARKER = ".fluid-portal-scaffold-pending";
1503
+ async function copyMissingScaffoldFiles(sourceRoot, targetRoot, relativeDirectory = "") {
1504
+ const preserved = [];
1505
+ const entries = await readdir(join(sourceRoot, relativeDirectory), { withFileTypes: true });
1506
+ for (const entry of entries) {
1507
+ const relativePath = join(relativeDirectory, entry.name);
1508
+ const sourcePath = join(sourceRoot, relativePath);
1509
+ const targetPath = join(targetRoot, relativePath);
1510
+ if (entry.isDirectory()) {
1511
+ 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.`);
1512
+ preserved.push(...await copyMissingScaffoldFiles(sourceRoot, targetRoot, relativePath));
1513
+ continue;
1514
+ }
1515
+ if (!entry.isFile()) throw new Error(`Portal scaffold contains an unsupported file type at ${relativePath}.`);
1516
+ try {
1517
+ await copyFile(sourcePath, targetPath, constants.COPYFILE_EXCL);
1518
+ } catch (error) {
1519
+ if (error.code !== "EEXIST") throw error;
1520
+ preserved.push(relativePath);
1521
+ }
1522
+ }
1523
+ return preserved;
1524
+ }
1525
+ async function ensureScaffoldDirectory(targetPath) {
1526
+ try {
1527
+ return (await lstat(targetPath)).isDirectory();
1528
+ } catch (error) {
1529
+ const code = error.code;
1530
+ if (code !== "ENOENT") {
1531
+ if (code === "ENOTDIR") return false;
1532
+ throw error;
1533
+ }
1534
+ }
1535
+ try {
1536
+ await mkdir(targetPath);
1537
+ return true;
1538
+ } catch (error) {
1539
+ const code = error.code;
1540
+ if (code !== "EEXIST" && code !== "ENOTDIR") throw error;
1541
+ try {
1542
+ return (await lstat(targetPath)).isDirectory();
1543
+ } catch (statError) {
1544
+ if (statError.code === "ENOTDIR") return false;
1545
+ throw statError;
1546
+ }
1547
+ }
1548
+ }
1154
1549
  //#endregion
1155
1550
  //#region src/commands/dev.ts
1156
1551
  /**
@@ -1164,6 +1559,55 @@ async function scaffoldFromBundled(targetPath, config, isLocal) {
1164
1559
  * or auto-pulls if they are logged in.
1165
1560
  */
1166
1561
  const PORTAL_DIR$1 = "portal";
1562
+ const WATCHED_DIRECTORIES = [
1563
+ "portal",
1564
+ "public",
1565
+ "src"
1566
+ ];
1567
+ const WATCHED_ROOT_FILES = new Set([
1568
+ "index.html",
1569
+ "package.json",
1570
+ "tsconfig.json",
1571
+ "vite.config.cjs",
1572
+ "vite.config.cts",
1573
+ "vite.config.js",
1574
+ "vite.config.mjs",
1575
+ "vite.config.mts",
1576
+ "vite.config.ts"
1577
+ ]);
1578
+ /**
1579
+ * Restrict Vite's root watcher to files that can affect a portal preview.
1580
+ * Directory paths themselves must remain visible to Chokidar so it can
1581
+ * descend into them and discover their files.
1582
+ */
1583
+ function createPortalWatchIgnored(cwd) {
1584
+ const projectRoot = resolve(cwd);
1585
+ return (filePath) => {
1586
+ const projectPath = relative(projectRoot, isAbsolute(filePath) ? filePath : resolve(projectRoot, filePath));
1587
+ if (projectPath === "") return false;
1588
+ if (projectPath === ".." || projectPath.startsWith(`..${sep}`)) return true;
1589
+ const [topLevelPath] = projectPath.split(sep);
1590
+ if (WATCHED_DIRECTORIES.some((directory) => topLevelPath === directory)) return false;
1591
+ const isEnvironmentFile = projectPath === ".env" || projectPath.startsWith(".env.");
1592
+ return !WATCHED_ROOT_FILES.has(projectPath) && !isEnvironmentFile;
1593
+ };
1594
+ }
1595
+ function portalDevServerOptions(port, exposeToNetwork, cwd = process.cwd()) {
1596
+ return {
1597
+ port,
1598
+ host: exposeToNetwork ? true : void 0,
1599
+ watch: { ignored: createPortalWatchIgnored(cwd) }
1600
+ };
1601
+ }
1602
+ function canFallbackToBareVite(cwd) {
1603
+ try {
1604
+ lstatSync(join(cwd, STANDALONE_PORTAL_MARKER));
1605
+ return false;
1606
+ } catch (error) {
1607
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return true;
1608
+ throw error;
1609
+ }
1610
+ }
1167
1611
  /**
1168
1612
  * Check if the portal directory exists and has content files.
1169
1613
  * Returns true if at minimum `portal/definition.json` exists.
@@ -1191,7 +1635,7 @@ function checkDependenciesInstalled(cwd) {
1191
1635
  if (existsSync(nodeModulesPath) && hasVite && hasPortalSdk) return { ok: true };
1192
1636
  return {
1193
1637
  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.`
1638
+ message: `Dependencies aren't installed in ${cwd} — run \`pnpm install\`, then retry.`
1195
1639
  };
1196
1640
  }
1197
1641
  /**
@@ -1203,7 +1647,7 @@ async function autoPull(cwd) {
1203
1647
  console.log(chalk.yellow("No portal/ directory found.") + " Attempting to pull content...");
1204
1648
  console.log();
1205
1649
  try {
1206
- const { pullCommand } = await import("./pull-XOsFaqz2.mjs").then((n) => n.n);
1650
+ const { pullCommand } = await import("./pull-CUR8tbW9.mjs").then((n) => n.n);
1207
1651
  await pullCommand.parseAsync([], { from: "user" });
1208
1652
  return hasPortalContent(cwd);
1209
1653
  } catch (err) {
@@ -1256,16 +1700,18 @@ const devCommand = new Command("dev").description("Start the development server
1256
1700
  const port = Number.isFinite(parsedPort) ? parsedPort : 5173;
1257
1701
  try {
1258
1702
  const { createServer } = await import("vite");
1259
- const { portalDevPlugin } = await import("./portal-dev-plugin-BHE8Z4yr.mjs").then((n) => n.n);
1260
- const { backendDevPlugin } = await import("./backend-dev-plugin-B9-S4Is-.mjs").then((n) => n.n);
1703
+ const { portalDevPlugin } = await import("./portal-dev-plugin-CHlxpXc5.mjs").then((n) => n.n);
1704
+ const { backendDevPlugin } = await import("./backend-dev-plugin-CY3yxgJ1.mjs").then((n) => n.n);
1705
+ const { portalWidgetDevPlugin } = await import("./portal-widget-dev-plugin-B53Lf9Ke.mjs").then((n) => n.n);
1261
1706
  const server = await createServer({
1262
1707
  configFile: viteConfigPath,
1263
1708
  envPrefix: ["VITE_", "NEXT_PUBLIC_"],
1264
- plugins: [portalDevPlugin(), backendDevPlugin()],
1265
- server: {
1266
- port,
1267
- host: options.host ? true : void 0
1268
- }
1709
+ plugins: [
1710
+ portalDevPlugin(),
1711
+ backendDevPlugin(),
1712
+ ...portalWidgetDevPlugin()
1713
+ ],
1714
+ server: portalDevServerOptions(port, options.host === true, cwd)
1269
1715
  });
1270
1716
  await server.listen();
1271
1717
  server.printUrls();
@@ -1276,6 +1722,12 @@ const devCommand = new Command("dev").description("Start the development server
1276
1722
  process.on("SIGTERM", shutdown);
1277
1723
  await new Promise(() => {});
1278
1724
  } catch (error) {
1725
+ if (!canFallbackToBareVite(cwd)) {
1726
+ console.error(chalk.red("Could not start the enriched standalone portal dev server."));
1727
+ console.error(chalk.yellow("Standalone portals require the local manifest and widget plugins; bare Vite cannot serve this project correctly."));
1728
+ console.error(error instanceof Error ? error.message : String(error));
1729
+ process.exit(1);
1730
+ }
1279
1731
  console.warn(chalk.yellow("Could not start the enriched dev server; falling back to `vite`. Local portal content requires portalDevPlugin in vite.config for the fallback path."), error instanceof Error ? error.message : String(error));
1280
1732
  try {
1281
1733
  await execa("pnpm", viteArgs, {
@@ -1329,6 +1781,43 @@ const buildCommand = new Command("build").description("Build the application for
1329
1781
  }
1330
1782
  });
1331
1783
  //#endregion
1784
+ //#region ../../portal/core/src/registries/container-registry.ts
1785
+ /**
1786
+ * Default container registry with LayoutWidget and ContainerWidget
1787
+ */
1788
+ const DEFAULT_CONTAINER_REGISTRY = new Map([
1789
+ ["LayoutWidget", {
1790
+ isContainer: true,
1791
+ getChildren: (props) => props.children || [],
1792
+ setChildren: (props, children) => ({
1793
+ ...props,
1794
+ children
1795
+ })
1796
+ }],
1797
+ ["CardWidget", {
1798
+ isContainer: true,
1799
+ getChildren: (props) => props.children || [],
1800
+ setChildren: (props, children) => ({
1801
+ ...props,
1802
+ children
1803
+ })
1804
+ }],
1805
+ ["ContainerWidget", {
1806
+ isContainer: true,
1807
+ getChildren: (props) => props.children || [],
1808
+ setChildren: (props, children) => ({
1809
+ ...props,
1810
+ children
1811
+ })
1812
+ }]
1813
+ ]);
1814
+ /**
1815
+ * Check if a widget type is a container
1816
+ */
1817
+ function isContainerWidget(widgetType, containerRegistry) {
1818
+ return containerRegistry.has(widgetType);
1819
+ }
1820
+ //#endregion
1332
1821
  //#region src/utils/push-validation.ts
1333
1822
  /**
1334
1823
  * Cross-reference validation and change categorization utilities for the push command.
@@ -1398,9 +1887,68 @@ function categorizeChanges(diff) {
1398
1887
  return result;
1399
1888
  }
1400
1889
  /**
1890
+ * Faults in a screen's widget tree, as `component_tree[1].props.children[0]`
1891
+ * style paths.
1892
+ *
1893
+ * The screen check used to stop at "name is a string, component_tree is an
1894
+ * array" and never look inside. A node missing its `id` published happily —
1895
+ * `✔ Portal files valid`, live version activated — and then the admin App
1896
+ * Builder rendered the whole portal blank, in a different application, with
1897
+ * nothing pointing back at the screen. The builder backfills a missing id
1898
+ * (`widget-utils.ts`), but regenerates it per render while identity lookups
1899
+ * match on it, so such a node never reconciles.
1900
+ *
1901
+ * This matters because hand-written `component_tree` JSON is a first-class
1902
+ * path, not an edge case: it is how Mist's agent places widgets, and how
1903
+ * anything the builder cannot express gets authored.
1904
+ *
1905
+ * Deliberately conservative about what counts as a node. Only entries of
1906
+ * `component_tree` and of a node's `props.children` are checked — the two
1907
+ * places every container in `DEFAULT_CONTAINER_REGISTRY` keeps them. A blind
1908
+ * walk of every nested object would be wrong: screens are full of `type`-
1909
+ * bearing values that are NOT widgets (`background: { type: "solid" }`,
1910
+ * `dataSource: { type: "api" }`), and demanding an `id` of those would reject
1911
+ * screens that work today. A container using some other prop key is a missed
1912
+ * check, which is the safe direction for a gate that blocks publishing.
1913
+ */
1914
+ function collectWidgetNodeErrors(nodes) {
1915
+ return walkWidgetNodes(nodes, "component_tree", false, /* @__PURE__ */ new Map());
1916
+ }
1917
+ function walkWidgetNodes(nodes, path, nullIsAnEmptySlot, seenIds) {
1918
+ const errors = [];
1919
+ nodes.forEach((node, index) => {
1920
+ const at = `${path}[${index}]`;
1921
+ if (node === null || node === void 0) {
1922
+ if (!nullIsAnEmptySlot) errors.push(`${at} must be a widget object.`);
1923
+ return;
1924
+ }
1925
+ if (typeof node !== "object" || Array.isArray(node)) {
1926
+ errors.push(`${at} must be a widget object.`);
1927
+ return;
1928
+ }
1929
+ const record = node;
1930
+ if (typeof record.type !== "string" || record.type.trim().length === 0) errors.push(`${at} is missing a "type".`);
1931
+ else if (record.type !== record.type.trim()) errors.push(`${at} has a "type" with surrounding whitespace (${JSON.stringify(record.type)}). Widget types are matched exactly, so this resolves to no component and renders blank.`);
1932
+ if (typeof record.id !== "string" || record.id.trim().length === 0) errors.push(`${at} is missing an "id". Every widget needs a stable id — without one the screen publishes but renders blank in the app builder.`);
1933
+ else {
1934
+ const firstClaim = seenIds.get(record.id);
1935
+ if (firstClaim === void 0) seenIds.set(record.id, at);
1936
+ else errors.push(`${at} reuses the id ${JSON.stringify(record.id)}, already used by ${firstClaim}. Ids must be unique within a screen — the builder resolves a widget by id and takes the first match, so duplicates make select, delete and drag-drop act on the wrong widget.`);
1937
+ }
1938
+ const props = record.props;
1939
+ if (typeof props !== "object" || props === null || Array.isArray(props)) return;
1940
+ const children = props.children;
1941
+ if (Array.isArray(children)) errors.push(...walkWidgetNodes(children, `${at}.props.children`, true, seenIds));
1942
+ });
1943
+ return errors;
1944
+ }
1945
+ /**
1401
1946
  * Validate that all cross-references between local portal files are valid.
1402
1947
  *
1403
1948
  * Checks:
1949
+ * - New and changed resources contain the required local-file fields
1950
+ * - Every widget node in a changed screen has a string "id" and "type"
1951
+ * - New and changed themes include the required config object
1404
1952
  * - Navigation items' "screen" slugs reference existing screen files or mappings
1405
1953
  * - Profile "navigation" and "mobile_navigation" slugs reference existing nav files or mappings
1406
1954
  * - Profile "themes" slugs reference existing theme files or mappings
@@ -1413,9 +1961,53 @@ async function validateCrossReferences(portalDir, mappings, changes) {
1413
1961
  for (const file of changes.screens.deleted) validScreenSlugs.delete(slugFromPath(file));
1414
1962
  for (const file of changes.navigations.deleted) validNavSlugs.delete(slugFromPath(file));
1415
1963
  for (const file of changes.themes.deleted) validThemeSlugs.delete(slugFromPath(file));
1964
+ const screenFilesToCheck = [...changes.screens.new, ...changes.screens.changed];
1965
+ for (const file of screenFilesToCheck) try {
1966
+ const screen = await readPortalFile$1(portalDir, file);
1967
+ if (typeof screen.name !== "string" || !Array.isArray(screen.component_tree)) errors.push({
1968
+ file,
1969
+ message: "Screen requires a string \"name\" and array \"component_tree\"."
1970
+ });
1971
+ else {
1972
+ if (screen.component_tree.length > 1) errors.push({
1973
+ file,
1974
+ message: `"component_tree" has ${screen.component_tree.length} top-level nodes, and pushing cannot preserve that. More than one root is serialized into an untyped wrapper the admin builder cannot render, so the screen would arrive blank and uneditable. Wrap them in a single ${CONTAINER_TYPE_LIST} node and move them into its "props.children".`
1975
+ });
1976
+ for (const error of collectWidgetNodeErrors(screen.component_tree)) errors.push({
1977
+ file,
1978
+ message: error
1979
+ });
1980
+ }
1981
+ } catch {
1982
+ errors.push({
1983
+ file,
1984
+ message: "Failed to read screen file"
1985
+ });
1986
+ }
1987
+ const themeFilesToCheck = [...changes.themes.new, ...changes.themes.changed];
1988
+ for (const file of themeFilesToCheck) try {
1989
+ const theme = await readPortalFile$1(portalDir, file);
1990
+ if (typeof theme.name !== "string" || typeof theme.active !== "boolean" || !theme.config || typeof theme.config !== "object" || Array.isArray(theme.config)) errors.push({
1991
+ file,
1992
+ 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."
1993
+ });
1994
+ } catch {
1995
+ errors.push({
1996
+ file,
1997
+ message: "Failed to read theme file"
1998
+ });
1999
+ }
1416
2000
  const navFilesToCheck = [...changes.navigations.new, ...changes.navigations.changed];
1417
2001
  for (const file of navFilesToCheck) try {
1418
- validateNavigationItems((await readPortalFile$1(portalDir, file)).navigation_items, file, validScreenSlugs, errors);
2002
+ const nav = await readPortalFile$1(portalDir, file);
2003
+ if (typeof nav.name !== "string" || nav.platform !== "web" && nav.platform !== "mobile" || !Array.isArray(nav.navigation_items)) {
2004
+ errors.push({
2005
+ file,
2006
+ message: "Navigation requires a string \"name\", \"platform\" set to \"web\" or \"mobile\", and a \"navigation_items\" array."
2007
+ });
2008
+ continue;
2009
+ }
2010
+ validateNavigationItems(nav.navigation_items, file, validScreenSlugs, errors);
1419
2011
  } catch {
1420
2012
  errors.push({
1421
2013
  file,
@@ -1425,6 +2017,14 @@ async function validateCrossReferences(portalDir, mappings, changes) {
1425
2017
  const profileFilesToCheck = [...changes.profiles.new, ...changes.profiles.changed];
1426
2018
  for (const file of profileFilesToCheck) try {
1427
2019
  const profile = await readPortalFile$1(portalDir, file);
2020
+ const permissions = profile.permissions;
2021
+ 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)) {
2022
+ errors.push({
2023
+ file,
2024
+ message: "Profile requires \"name\", boolean \"default\", a \"themes\" array, and permissions arrays for countries, ranks, roles, and platform."
2025
+ });
2026
+ continue;
2027
+ }
1428
2028
  if (profile.navigation && !validNavSlugs.has(profile.navigation)) errors.push({
1429
2029
  file,
1430
2030
  message: `References navigation "${profile.navigation}" which does not exist`
@@ -1446,6 +2046,59 @@ async function validateCrossReferences(portalDir, mappings, changes) {
1446
2046
  return errors;
1447
2047
  }
1448
2048
  /**
2049
+ * Warns about screens the admin visual builder will not accept drops on.
2050
+ *
2051
+ * Only container widgets receive the builder's edit-mode child-management
2052
+ * callbacks (`onAddChild`, child select/delete/duplicate), so a screen whose
2053
+ * `component_tree` has no container at the top level renders perfectly — in
2054
+ * local preview and in the live portal — while offering no drop zones at all.
2055
+ * A human opening it in the builder cannot drag anything onto the page.
2056
+ *
2057
+ * That combination (correct everywhere you can look, broken in the one place
2058
+ * you cannot) is why this is worth reporting at push time. It is the most
2059
+ * likely defect in a hand- or agent-authored screen.
2060
+ *
2061
+ * Deliberately a WARNING, never an error. Screens without a container root are
2062
+ * valid definitions that render correctly, and a repository may hold ones that
2063
+ * predate this check; failing their push would break working portals to enforce
2064
+ * an authoring convention.
2065
+ *
2066
+ * Silent on anything it cannot parse. Malformed and unreadable screens are
2067
+ * already reported as hard errors by {@link validateCrossReferences}, and a
2068
+ * second complaint about a file that never parsed would be noise.
2069
+ */
2070
+ async function validateScreenStructure(portalDir, changes) {
2071
+ const warnings = [];
2072
+ const files = [...changes.screens.new, ...changes.screens.changed];
2073
+ for (const file of files) {
2074
+ let screen;
2075
+ try {
2076
+ screen = await readPortalFile$1(portalDir, file);
2077
+ } catch {
2078
+ continue;
2079
+ }
2080
+ const tree = screen.component_tree;
2081
+ if (!Array.isArray(tree) || tree.length === 0) continue;
2082
+ if (tree.length !== 1) continue;
2083
+ const root = tree[0];
2084
+ const rootType = root && typeof root === "object" ? root.type : void 0;
2085
+ if (typeof rootType === "string" && isContainerWidget(rootType, CONTAINER_REGISTRY)) continue;
2086
+ warnings.push({
2087
+ file,
2088
+ message: `No container at the top of "component_tree", so the admin builder will show no drop zones on this screen. Wrap the widgets in a ${CONTAINER_TYPE_LIST} node and move them into its "props.children".`
2089
+ });
2090
+ }
2091
+ return warnings;
2092
+ }
2093
+ /**
2094
+ * Shared with the portal runtime so this check cannot drift from the behaviour
2095
+ * it predicts. `DEFAULT_CONTAINER_REGISTRY` is the same map the builder consults
2096
+ * via `isContainerWidget`.
2097
+ */
2098
+ const CONTAINER_REGISTRY = DEFAULT_CONTAINER_REGISTRY;
2099
+ /** Rendered into the warning so it names the real options, not a stale list. */
2100
+ const CONTAINER_TYPE_LIST = [...DEFAULT_CONTAINER_REGISTRY.keys()].map((type) => `"${type}"`).join(" / ");
2101
+ /**
1449
2102
  * Build a set of valid slugs for a resource type by combining
1450
2103
  * existing mapping slugs with local file slugs on disk.
1451
2104
  */
@@ -1474,6 +2127,26 @@ function validateNavigationItems(items, file, validScreenSlugs, errors) {
1474
2127
  //#endregion
1475
2128
  //#region src/api/git-credential.ts
1476
2129
  /**
2130
+ * Portal git-credential adapter (the "invisible git" seam).
2131
+ *
2132
+ * Mints a short-lived (~1h) remote URL with an embedded installation
2133
+ * token for the portal's provisioned GitHub repo. The URL is used
2134
+ * immediately for one git op and discarded — NEVER persisted or logged.
2135
+ *
2136
+ * The backend endpoint (GitSync phase 006, portal adapter) mirrors the
2137
+ * widget contract exactly:
2138
+ *
2139
+ * POST <endpoint> -> 201 { git_credential: { remote_url, expires_at }, status, meta }
2140
+ * errors: 409 not_provisioned/not_connected, 502 vendor_error, 422 else
2141
+ * { error_message, errors: { type: [type] }, meta }
2142
+ *
2143
+ * The exact path is still being finalized as 006 lands, so it lives in
2144
+ * one constant here. Until the endpoint ships to prod a request 404s —
2145
+ * which we surface as `unavailable` so callers degrade gracefully
2146
+ * (warn + skip the git sync, proceed with the normal content push).
2147
+ */
2148
+ /** Minted credential — token embedded in `remote_url`. Treat as secret. */
2149
+ /**
1477
2150
  * Build the portal git-credential path for a definition id.
1478
2151
  *
1479
2152
  * Matches GitSync Phase 006 (fluid-commerce/fluid #19596): a member
@@ -1483,15 +2156,81 @@ function validateNavigationItems(items, file, validScreenSlugs, errors) {
1483
2156
  function portalGitCredentialPath(definitionId) {
1484
2157
  return `/api/company/fluid_os/definitions/${definitionId}/git_credential`;
1485
2158
  }
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
- }
2159
+ const DEFAULT_RETRY_DELAYS_MS = [
2160
+ 500,
2161
+ 1e3,
2162
+ 2e3,
2163
+ 4e3,
2164
+ 8e3,
2165
+ 1e4,
2166
+ 15e3,
2167
+ 15e3
2168
+ ];
2169
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
2170
+ const RETRYABLE_CONFLICT_TYPES = new Set(["not_provisioned", "not_connected"]);
2171
+ const DURABLE_REPO_STATUSES = new Set(["disconnected"]);
2172
+ function nonEmptyString(value) {
2173
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2174
+ }
2175
+ function firstString(value) {
2176
+ if (Array.isArray(value)) return nonEmptyString(value[0]);
2177
+ return nonEmptyString(value);
2178
+ }
2179
+ function apiErrorBody(error) {
2180
+ if (error.data !== null && typeof error.data === "object" && ("error" in error.data || "error_message" in error.data || "errors" in error.data)) return error.data;
2181
+ return {
2182
+ error_message: error.message,
2183
+ errors: error.data
2184
+ };
2185
+ }
2186
+ function extractErrorDetails(body, fallback) {
2187
+ if (!body || typeof body !== "object") return { reason: fallback };
2188
+ const envelope = body;
2189
+ const error = envelope.error && typeof envelope.error === "object" ? envelope.error : void 0;
2190
+ const nestedDetails = error?.details && typeof error.details === "object" ? error.details : void 0;
2191
+ const errors = envelope.errors && typeof envelope.errors === "object" ? envelope.errors : void 0;
2192
+ const reason = nonEmptyString(envelope.error_message) ?? nonEmptyString(error?.message) ?? nonEmptyString(envelope.error) ?? fallback;
2193
+ const failureType = firstString(errors?.type) ?? nonEmptyString(nestedDetails?.type);
2194
+ const repoStatus = firstString(errors?.repo_status) ?? nonEmptyString(nestedDetails?.repo_status);
2195
+ return {
2196
+ reason,
2197
+ ...failureType ? { failureType } : {},
2198
+ ...repoStatus ? { repoStatus } : {}
2199
+ };
2200
+ }
2201
+ function sleep(delayMs) {
2202
+ return new Promise((resolve) => {
2203
+ setTimeout(resolve, delayMs);
2204
+ });
2205
+ }
2206
+ /**
2207
+ * Retry clone-credential conflicts while the backend provisions or revives
2208
+ * the portal's repository. The first credential request is the trigger for
2209
+ * older definitions with no repo, so a single-shot clone would always fail
2210
+ * even though the repository becomes ready seconds later.
2211
+ */
2212
+ async function createPortalGitCredentialWithRetry(options) {
2213
+ const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
2214
+ const sleepImpl = options.sleepImpl ?? sleep;
2215
+ const requestOptions = {
2216
+ baseUrl: options.baseUrl,
2217
+ token: options.token,
2218
+ definitionId: options.definitionId,
2219
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
2220
+ ...options.requestTimeoutMs !== void 0 ? { requestTimeoutMs: options.requestTimeoutMs } : {}
2221
+ };
2222
+ let outcome = await createPortalGitCredential(requestOptions);
2223
+ for (const [index, delayMs] of retryDelaysMs.entries()) {
2224
+ if (outcome.status !== "unavailable" || !outcome.retryable) return outcome;
2225
+ options.onRetry?.({
2226
+ attempt: index + 2,
2227
+ totalAttempts: retryDelaysMs.length + 1,
2228
+ reason: outcome.reason
2229
+ });
2230
+ await sleepImpl(delayMs);
2231
+ outcome = await createPortalGitCredential(requestOptions);
1493
2232
  }
1494
- return fallback;
2233
+ return outcome;
1495
2234
  }
1496
2235
  /**
1497
2236
  * Request a git credential for the portal definition's provisioned repo.
@@ -1502,47 +2241,65 @@ function extractErrorReason(body, fallback) {
1502
2241
  async function createPortalGitCredential(options) {
1503
2242
  const { baseUrl, token, definitionId } = options;
1504
2243
  const fetchImpl = options.fetchImpl ?? fetch;
1505
- const url = `${baseUrl.replace(/\/$/, "")}${portalGitCredentialPath(definitionId)}`;
1506
- let resp;
2244
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
2245
+ const abortController = new AbortController();
2246
+ let requestTimedOut = false;
2247
+ const timeout = setTimeout(() => {
2248
+ requestTimedOut = true;
2249
+ abortController.abort();
2250
+ }, requestTimeoutMs);
1507
2251
  try {
1508
- resp = await fetchImpl(url, {
1509
- method: "POST",
1510
- headers: {
1511
- Authorization: `Bearer ${token}`,
1512
- Accept: "application/json"
1513
- }
1514
- });
2252
+ const credential = (await fluidOsV0DefinitionGitCredentialCreate({
2253
+ client: createFluidOsHeyApiClient({
2254
+ baseUrl,
2255
+ fetch: fetchImpl,
2256
+ getAuthToken: () => token
2257
+ }),
2258
+ path: { id: definitionId },
2259
+ signal: abortController.signal
2260
+ })).git_credential;
2261
+ if (!credential?.remote_url) return {
2262
+ status: "error",
2263
+ reason: "git credential response missing remote_url"
2264
+ };
2265
+ return {
2266
+ status: "ok",
2267
+ credential
2268
+ };
1515
2269
  } catch (err) {
2270
+ if (requestTimedOut) return {
2271
+ status: "error",
2272
+ reason: `git credential request timed out after ${requestTimeoutMs}ms`
2273
+ };
2274
+ if (err instanceof ApiError) {
2275
+ if (err.status === 404) return {
2276
+ status: "unavailable",
2277
+ reason: "git sync isn't available for portals on this Fluid API yet",
2278
+ retryable: false
2279
+ };
2280
+ const details = extractErrorDetails(apiErrorBody(err), `git credential request failed (HTTP ${err.status})`);
2281
+ if (err.status === 409) {
2282
+ const durable = details.repoStatus !== void 0 && DURABLE_REPO_STATUSES.has(details.repoStatus);
2283
+ return {
2284
+ status: "unavailable",
2285
+ reason: details.reason,
2286
+ retryable: !durable && details.failureType !== void 0 && RETRYABLE_CONFLICT_TYPES.has(details.failureType),
2287
+ ...details.failureType !== void 0 ? { conflictType: details.failureType } : {},
2288
+ ...details.repoStatus !== void 0 ? { repoStatus: details.repoStatus } : {}
2289
+ };
2290
+ }
2291
+ return {
2292
+ status: "error",
2293
+ reason: details.reason
2294
+ };
2295
+ }
1516
2296
  return {
1517
2297
  status: "error",
1518
2298
  reason: err instanceof Error ? err.message : String(err)
1519
2299
  };
2300
+ } finally {
2301
+ clearTimeout(timeout);
1520
2302
  }
1521
- if (resp.status === 404) return {
1522
- status: "unavailable",
1523
- reason: "git sync isn't available for portals on this Fluid API yet"
1524
- };
1525
- const body = await resp.json().catch(() => null);
1526
- if (!resp.ok) {
1527
- const reason = extractErrorReason(body, `git credential request failed (HTTP ${resp.status})`);
1528
- if (resp.status === 409) return {
1529
- status: "unavailable",
1530
- reason
1531
- };
1532
- return {
1533
- status: "error",
1534
- reason
1535
- };
1536
- }
1537
- const credential = body?.git_credential;
1538
- if (!credential?.remote_url) return {
1539
- status: "error",
1540
- reason: "git credential response missing remote_url"
1541
- };
1542
- return {
1543
- status: "ok",
1544
- credential
1545
- };
1546
2303
  }
1547
2304
  //#endregion
1548
2305
  //#region src/lib/git-sync.ts
@@ -1556,6 +2313,9 @@ async function createPortalGitCredential(options) {
1556
2313
  * (no git, no repo, credential endpoint missing, diverged history,
1557
2314
  * network blip) degrades to a skip so the content push always proceeds.
1558
2315
  */
2316
+ /** Subject for the index-only commit that untracks leaked Desktop state.
2317
+ * Kept distinct from the publish message so the history explains itself. */
2318
+ const PURGE_COMMIT_MESSAGE = "Remove private Mist Desktop state from the portal repository";
1559
2319
  /**
1560
2320
  * Run the best-effort git sync. Returns a result describing what
1561
2321
  * happened; never throws. Callers surface `skipped` reasons as a dim
@@ -1568,6 +2328,7 @@ async function runPortalGitSync(options) {
1568
2328
  status: "skipped",
1569
2329
  reason: "not a git repository"
1570
2330
  };
2331
+ await ensureLocalStateExcluded(cwd);
1571
2332
  const outcome = await createPortalGitCredential({
1572
2333
  baseUrl,
1573
2334
  token,
@@ -1594,6 +2355,7 @@ async function runPortalGitSync(options) {
1594
2355
  await gitFetch(cwd, remoteUrl, branch);
1595
2356
  await gitAdoptFetchHead(cwd);
1596
2357
  }
2358
+ await purgeLocalStateFromIndex(cwd, PURGE_COMMIT_MESSAGE);
1597
2359
  if (await hasUncommittedChanges(cwd)) await gitAutoCommit(cwd, commitMessage, { ignoreRemovals: true });
1598
2360
  return await pushCommitted(cwd, remoteUrl, branch);
1599
2361
  }
@@ -1614,6 +2376,7 @@ async function runPortalGitSync(options) {
1614
2376
  };
1615
2377
  await gitMergeFastForward(cwd);
1616
2378
  }
2379
+ await purgeLocalStateFromIndex(cwd, PURGE_COMMIT_MESSAGE);
1617
2380
  if (await hasUncommittedChanges(cwd)) await gitAutoCommit(cwd, commitMessage);
1618
2381
  return await pushCommitted(cwd, remoteUrl, branch);
1619
2382
  } catch (err) {
@@ -1655,6 +2418,9 @@ async function pushCommitted(cwd, remoteUrl, branch) {
1655
2418
  * Detects changes since the last pull/push via snapshot diffing,
1656
2419
  * validates cross-references, and pushes resources in dependency order.
1657
2420
  */
2421
+ var NavigationRemoteStateUnknownError = class extends Error {
2422
+ name = "NavigationRemoteStateUnknownError";
2423
+ };
1658
2424
  const PORTAL_DIR = "portal";
1659
2425
  const PORTAL_SYNC_DIR$1 = ".portal-sync";
1660
2426
  /**
@@ -1668,7 +2434,7 @@ function toApiComponentTree(tree) {
1668
2434
  return { children: tree };
1669
2435
  }
1670
2436
  /**
1671
- * Create an authenticated FetchClient using the stored CLI profile.
2437
+ * Create an authenticated FluidOsClient using the stored CLI profile.
1672
2438
  */
1673
2439
  function createClient$2() {
1674
2440
  const token = getAuthToken();
@@ -1677,11 +2443,33 @@ function createClient$2() {
1677
2443
  if (!profile) throw new Error("Not logged in. Run " + chalk.cyan("fluid login") + " first.");
1678
2444
  throw new Error("No auth token found for profile " + chalk.cyan(profile.name) + ". Run " + chalk.cyan("fluid login") + " to re-authenticate.");
1679
2445
  }
1680
- return createFetchClient({
2446
+ return createFluidOsClient({
1681
2447
  baseUrl: process.env["FLUID_API_BASE"] ?? "https://api.fluid.app",
1682
- getAuthToken: () => token
2448
+ token
1683
2449
  });
1684
2450
  }
2451
+ async function createAndActivateVersion(client, definitionId) {
2452
+ const versionId = (await fluidOsApi.createVersion(client, definitionId)).version?.id;
2453
+ if (!versionId) throw new Error("the version API returned no version ID");
2454
+ await fluidOsApi.updateVersion(client, definitionId, versionId, { version: { active: true } });
2455
+ return String(versionId);
2456
+ }
2457
+ async function activateDraft(definitionId, existingClient) {
2458
+ const spinner = ora("Creating and activating a live version...");
2459
+ spinner.start();
2460
+ try {
2461
+ const versionId = await createAndActivateVersion(existingClient ?? createClient$2(), definitionId);
2462
+ spinner.succeed(`Live version ${versionId} activated`);
2463
+ return true;
2464
+ } catch (err) {
2465
+ spinner.fail("Could not activate a live version");
2466
+ console.log();
2467
+ console.log(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
2468
+ console.log();
2469
+ process.exitCode = 1;
2470
+ return false;
2471
+ }
2472
+ }
1685
2473
  /**
1686
2474
  * Extract an enriched error message from a caught value.
1687
2475
  * Includes structured API error data when available.
@@ -1691,6 +2479,18 @@ function enrichedErrorMessage(err) {
1691
2479
  if (err && typeof err === "object" && "data" in err) msg += ` — ${JSON.stringify(err.data)}`;
1692
2480
  return msg;
1693
2481
  }
2482
+ function navigationPushFailure(err, remoteChangesComplete) {
2483
+ const error = enrichedErrorMessage(err);
2484
+ if (err instanceof NavigationRemoteStateUnknownError) return {
2485
+ advanceSnapshot: true,
2486
+ error
2487
+ };
2488
+ if (!remoteChangesComplete) return { error };
2489
+ return {
2490
+ advanceSnapshot: true,
2491
+ error: `Remote changes were applied before the push failed: ${error}. Run fluid portal pull before editing or pushing again.`
2492
+ };
2493
+ }
1694
2494
  /**
1695
2495
  * Best-effort "invisible git" sync: commit the local project and push it
1696
2496
  * to the portal's provisioned GitHub repo before the content push.
@@ -1739,10 +2539,10 @@ async function pushScreens(client, defId, portalDir, changes, mappings) {
1739
2539
  const slug = slugFromPath(file);
1740
2540
  try {
1741
2541
  const local = await readPortalFile(portalDir, file);
1742
- const newId = (await fluid_os_v0_create_fluid_osscreen(client, defId, { screen: {
2542
+ const newId = (await fluidOsApi.createScreen(client, defId, { screen: {
1743
2543
  name: local.name,
1744
2544
  slug,
1745
- component_tree: toApiComponentTree(local.component_tree)
2545
+ component_tree: toFluidOsDynamicObject(toApiComponentTree(local.component_tree))
1746
2546
  } })).screen?.id;
1747
2547
  if (newId != null) currentMappings = updateMapping(currentMappings, "screens", slug, newId);
1748
2548
  results.push({
@@ -1780,10 +2580,10 @@ async function pushScreens(client, defId, portalDir, changes, mappings) {
1780
2580
  }
1781
2581
  try {
1782
2582
  const local = await readPortalFile(portalDir, file);
1783
- await fluid_os_v0_update_fluid_osscreen(client, defId, screenId, { screen: {
2583
+ await fluidOsApi.updateScreen(client, defId, screenId, { screen: {
1784
2584
  name: local.name,
1785
2585
  slug,
1786
- component_tree: toApiComponentTree(local.component_tree)
2586
+ component_tree: toFluidOsDynamicObject(toApiComponentTree(local.component_tree))
1787
2587
  } });
1788
2588
  results.push({
1789
2589
  file,
@@ -1819,7 +2619,7 @@ async function pushScreens(client, defId, portalDir, changes, mappings) {
1819
2619
  };
1820
2620
  }
1821
2621
  try {
1822
- await fluid_os_v0_delete_fluid_osscreen(client, defId, screenId);
2622
+ await fluidOsApi.deleteScreen(client, defId, screenId);
1823
2623
  currentMappings = removeMapping(currentMappings, "screens", slug);
1824
2624
  results.push({
1825
2625
  file,
@@ -1854,10 +2654,10 @@ async function pushThemes(client, defId, portalDir, changes, mappings) {
1854
2654
  const slug = slugFromPath(file);
1855
2655
  try {
1856
2656
  const local = await readPortalFile(portalDir, file);
1857
- const newId = (await fluid_os_v0_create_fluid_ostheme(client, defId, { theme: {
2657
+ const newId = (await fluidOsApi.createTheme(client, defId, { theme: {
1858
2658
  name: local.name,
1859
2659
  active: local.active,
1860
- config: local.config
2660
+ config: toFluidOsDynamicObject(local.config)
1861
2661
  } })).theme?.id;
1862
2662
  if (newId != null) currentMappings = updateMapping(currentMappings, "themes", slug, newId);
1863
2663
  results.push({
@@ -1895,10 +2695,10 @@ async function pushThemes(client, defId, portalDir, changes, mappings) {
1895
2695
  }
1896
2696
  try {
1897
2697
  const local = await readPortalFile(portalDir, file);
1898
- await fluid_os_v0_update_fluid_ostheme(client, defId, themeId, { theme: {
2698
+ await fluidOsApi.updateTheme(client, defId, themeId, { theme: {
1899
2699
  name: local.name,
1900
2700
  active: local.active,
1901
- config: local.config
2701
+ config: toFluidOsDynamicObject(local.config)
1902
2702
  } });
1903
2703
  results.push({
1904
2704
  file,
@@ -1934,7 +2734,7 @@ async function pushThemes(client, defId, portalDir, changes, mappings) {
1934
2734
  };
1935
2735
  }
1936
2736
  try {
1937
- await fluid_os_v0_delete_fluid_ostheme(client, defId, themeId);
2737
+ await fluidOsApi.deleteTheme(client, defId, themeId);
1938
2738
  currentMappings = removeMapping(currentMappings, "themes", slug);
1939
2739
  results.push({
1940
2740
  file,
@@ -1985,36 +2785,141 @@ function resolveNavigationItemScreenIds(items, mappings) {
1985
2785
  * The API reconciliation logic requires a flat list to correctly
1986
2786
  * compare against the flat server response.
1987
2787
  */
1988
- function flattenNavigationItems(items) {
1989
- const flat = [];
2788
+ function flattenNavigationItems(items, treeParentIndex, flat = []) {
1990
2789
  for (const item of items) {
1991
2790
  const { children, ...rest } = item;
1992
- flat.push(rest);
1993
- if (children && children.length > 0) flat.push(...flattenNavigationItems(children));
2791
+ const itemIndex = flat.length;
2792
+ flat.push({
2793
+ ...rest,
2794
+ treeParentIndex
2795
+ });
2796
+ if (children && children.length > 0) flattenNavigationItems(children, itemIndex, flat);
2797
+ }
2798
+ return flat;
2799
+ }
2800
+ /**
2801
+ * The navigation-items index returns root items with descendants nested under
2802
+ * `children`. Flatten descendants first so removals cannot orphan or revisit a
2803
+ * child when its parent is also deleted.
2804
+ */
2805
+ function flattenServerNavigationItems(items, flat = []) {
2806
+ for (const item of items) {
2807
+ flattenServerNavigationItems(item.children ?? [], flat);
2808
+ flat.push(item);
1994
2809
  }
1995
2810
  return flat;
1996
2811
  }
2812
+ function resolveNavigationItemParentId(item, serverIdByItemIndex) {
2813
+ if (item.treeParentIndex != null) {
2814
+ const serverParentId = serverIdByItemIndex.get(item.treeParentIndex);
2815
+ if (serverParentId == null) throw new Error("Failed to resolve the server ID for a nested navigation item's parent");
2816
+ return serverParentId;
2817
+ }
2818
+ return null;
2819
+ }
2820
+ async function resolveCreatedNavigationItemId(client, defId, navigationId, responseId, knownServerIds, requestedItem, file) {
2821
+ if (responseId != null) {
2822
+ knownServerIds.add(responseId);
2823
+ return responseId;
2824
+ }
2825
+ let response;
2826
+ try {
2827
+ response = await fluidOsApi.listNavigationItems(client, defId, navigationId);
2828
+ } catch (recoveryError) {
2829
+ 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.`);
2830
+ }
2831
+ const unseenItems = flattenServerNavigationItems(response.navigation_items ?? []).filter((item) => !knownServerIds.has(item.id));
2832
+ 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));
2833
+ 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.`);
2834
+ const recoveredId = matchingItems[0]?.id;
2835
+ 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.`);
2836
+ knownServerIds.add(recoveredId);
2837
+ return recoveredId;
2838
+ }
2839
+ function assignNavigationItemId(navigation, targetIndex, serverId) {
2840
+ let currentIndex = 0;
2841
+ let previousId;
2842
+ function assign(items) {
2843
+ return items.map((item) => {
2844
+ const itemIndex = currentIndex++;
2845
+ const children = assign(item.children ?? []);
2846
+ if (itemIndex !== targetIndex) return {
2847
+ ...item,
2848
+ children
2849
+ };
2850
+ previousId = item.id;
2851
+ return {
2852
+ ...item,
2853
+ id: serverId,
2854
+ children
2855
+ };
2856
+ });
2857
+ }
2858
+ function rewriteParentIds(items) {
2859
+ return items.map((item) => ({
2860
+ ...item,
2861
+ parent_id: item.parent_id === previousId ? serverId : item.parent_id,
2862
+ children: rewriteParentIds(item.children ?? [])
2863
+ }));
2864
+ }
2865
+ const navigationItems = assign(navigation.navigation_items);
2866
+ return {
2867
+ ...navigation,
2868
+ navigation_items: previousId == null || previousId === serverId ? navigationItems : rewriteParentIds(navigationItems)
2869
+ };
2870
+ }
2871
+ async function persistCreatedNavigationItemId(client, defId, navigationId, portalDir, file, navigation, itemIndex, serverId) {
2872
+ const updated = assignNavigationItemId(navigation, itemIndex, serverId);
2873
+ try {
2874
+ await atomicWriteFile(join(portalDir, file), JSON.stringify(updated, null, 2) + "\n");
2875
+ } catch (persistError) {
2876
+ try {
2877
+ await fluidOsApi.deleteNavigationItem(client, defId, navigationId, serverId);
2878
+ } catch (rollbackError) {
2879
+ 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.`);
2880
+ }
2881
+ throw persistError;
2882
+ }
2883
+ return updated;
2884
+ }
2885
+ /**
2886
+ * Replace a pushed navigation file with the authoritative server tree.
2887
+ * New navigation items receive their IDs from the server, so the local file
2888
+ * must be reconciled before the push snapshot hashes it.
2889
+ */
2890
+ async function reconcileNavigationFile(client, defId, navId, portalDir, file, local, mappings) {
2891
+ const response = await fluidOsApi.listNavigationItems(client, defId, navId);
2892
+ const reconciled = {
2893
+ ...local,
2894
+ navigation_items: transformNavigationItems(response.navigation_items ?? [], buildIdToSlugMap(mappings.screens))
2895
+ };
2896
+ await atomicWriteFile(join(portalDir, file), JSON.stringify(reconciled, null, 2) + "\n");
2897
+ }
1997
2898
  /**
1998
2899
  * Push navigation changes to the API.
1999
2900
  */
2000
2901
  async function pushNavigations(client, defId, portalDir, changes, mappings) {
2001
2902
  const results = [];
2002
2903
  let currentMappings = mappings;
2003
- for (const file of changes.new) {
2904
+ const newFiles = changes.new.filter((file) => resolveSlugToId(currentMappings, "navigations", slugFromPath(file)) == null);
2905
+ const changedFiles = [...changes.changed, ...changes.new.filter((file) => resolveSlugToId(currentMappings, "navigations", slugFromPath(file)) != null)];
2906
+ for (const file of newFiles) {
2004
2907
  const slug = slugFromPath(file);
2908
+ let remoteChangesComplete = false;
2005
2909
  try {
2006
- const local = await readPortalFile(portalDir, file);
2007
- const newId = (await fluid_os_v0_create_fluid_osnavigation(client, defId, { navigation: {
2910
+ let local = await readPortalFile(portalDir, file);
2911
+ const newId = (await fluidOsApi.createNavigation(client, defId, { navigation: {
2008
2912
  name: local.name,
2009
2913
  platform: local.platform
2010
2914
  } })).navigation?.id;
2011
2915
  if (newId != null) {
2012
2916
  currentMappings = updateMapping(currentMappings, "navigations", slug, newId);
2013
2917
  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: {
2918
+ const serverIdByItemIndex = /* @__PURE__ */ new Map();
2919
+ const knownServerIds = /* @__PURE__ */ new Set();
2920
+ for (const [itemIndex, item] of resolvedItems.entries()) {
2921
+ const resolvedParentId = resolveNavigationItemParentId(item, serverIdByItemIndex);
2922
+ const createBody = {
2018
2923
  label: item.label ?? "",
2019
2924
  position: item.position ?? 0,
2020
2925
  icon: item.icon ?? void 0,
@@ -2022,9 +2927,13 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2022
2927
  slug: item.slug ?? void 0,
2023
2928
  source: item.source ?? void 0,
2024
2929
  parent_id: resolvedParentId
2025
- } });
2026
- if (item.id != null && created.navigation_item?.id != null) localToServerId.set(item.id, created.navigation_item.id);
2930
+ };
2931
+ const createdId = await resolveCreatedNavigationItemId(client, defId, newId, (await fluidOsApi.createNavigationItem(client, defId, newId, { navigation_item: createBody })).navigation_item?.id, knownServerIds, createBody, file);
2932
+ local = await persistCreatedNavigationItemId(client, defId, newId, portalDir, file, local, itemIndex, createdId);
2933
+ serverIdByItemIndex.set(itemIndex, createdId);
2027
2934
  }
2935
+ remoteChangesComplete = true;
2936
+ await reconcileNavigationFile(client, defId, newId, portalDir, file, local, currentMappings);
2028
2937
  }
2029
2938
  results.push({
2030
2939
  file,
@@ -2036,7 +2945,7 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2036
2945
  file,
2037
2946
  action: "created",
2038
2947
  success: false,
2039
- error: enrichedErrorMessage(err)
2948
+ ...navigationPushFailure(err, remoteChangesComplete)
2040
2949
  });
2041
2950
  return {
2042
2951
  results,
@@ -2044,7 +2953,7 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2044
2953
  };
2045
2954
  }
2046
2955
  }
2047
- for (const file of changes.changed) {
2956
+ for (const file of changedFiles) {
2048
2957
  const slug = slugFromPath(file);
2049
2958
  const navId = resolveSlugToId(currentMappings, "navigations", slug);
2050
2959
  if (navId == null) {
@@ -2059,20 +2968,21 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2059
2968
  mappings: currentMappings
2060
2969
  };
2061
2970
  }
2971
+ let remoteChangesComplete = false;
2062
2972
  try {
2063
- const local = await readPortalFile(portalDir, file);
2064
- await fluid_os_v0_update_fluid_osnavigation(client, defId, navId, { navigation: {
2973
+ let local = await readPortalFile(portalDir, file);
2974
+ await fluidOsApi.updateNavigation(client, defId, navId, { navigation: {
2065
2975
  name: local.name,
2066
2976
  platform: local.platform
2067
2977
  } });
2068
2978
  const resolvedItems = flattenNavigationItems(resolveNavigationItemScreenIds(local.navigation_items, currentMappings));
2069
- const serverItems = (await fluid_os_v0_list_fluid_osnavigation_items(client, defId, navId)).navigation_items ?? [];
2979
+ const serverItems = flattenServerNavigationItems((await fluidOsApi.listNavigationItems(client, defId, navId)).navigation_items ?? []);
2070
2980
  const serverById = new Map(serverItems.map((s) => [s.id, s]));
2981
+ const knownServerIds = new Set(serverById.keys());
2071
2982
  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;
2983
+ const serverIdByItemIndex = /* @__PURE__ */ new Map();
2984
+ for (const [itemIndex, item] of resolvedItems.entries()) {
2985
+ const resolvedParentId = resolveNavigationItemParentId(item, serverIdByItemIndex);
2076
2986
  const body = {
2077
2987
  label: item.label,
2078
2988
  position: item.position,
@@ -2082,16 +2992,23 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2082
2992
  source: item.source ?? void 0,
2083
2993
  parent_id: resolvedParentId
2084
2994
  };
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: {
2995
+ if (item.id && serverById.has(item.id)) {
2996
+ await fluidOsApi.updateNavigationItem(client, defId, navId, item.id, { navigation_item: body });
2997
+ serverIdByItemIndex.set(itemIndex, item.id);
2998
+ } else {
2999
+ const createBody = {
2088
3000
  ...body,
2089
3001
  label: body.label ?? "",
2090
3002
  position: body.position ?? 0
2091
- } });
2092
- if (item.id != null && created.navigation_item?.id != null) localToServerId.set(item.id, created.navigation_item.id);
3003
+ };
3004
+ const createdId = await resolveCreatedNavigationItemId(client, defId, navId, (await fluidOsApi.createNavigationItem(client, defId, navId, { navigation_item: createBody })).navigation_item?.id, knownServerIds, createBody, file);
3005
+ local = await persistCreatedNavigationItemId(client, defId, navId, portalDir, file, local, itemIndex, createdId);
3006
+ serverIdByItemIndex.set(itemIndex, createdId);
2093
3007
  }
2094
3008
  }
3009
+ for (const serverItem of serverItems) if (!localIds.has(serverItem.id)) await fluidOsApi.deleteNavigationItem(client, defId, navId, serverItem.id);
3010
+ remoteChangesComplete = true;
3011
+ await reconcileNavigationFile(client, defId, navId, portalDir, file, local, currentMappings);
2095
3012
  results.push({
2096
3013
  file,
2097
3014
  action: "updated",
@@ -2102,7 +3019,7 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2102
3019
  file,
2103
3020
  action: "updated",
2104
3021
  success: false,
2105
- error: enrichedErrorMessage(err)
3022
+ ...navigationPushFailure(err, remoteChangesComplete)
2106
3023
  });
2107
3024
  return {
2108
3025
  results,
@@ -2126,7 +3043,7 @@ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2126
3043
  };
2127
3044
  }
2128
3045
  try {
2129
- await fluid_os_v0_delete_fluid_osnavigation(client, defId, navId);
3046
+ await fluidOsApi.deleteNavigation(client, defId, navId);
2130
3047
  currentMappings = removeMapping(currentMappings, "navigations", slug);
2131
3048
  results.push({
2132
3049
  file,
@@ -2160,7 +3077,8 @@ async function pushProfiles(client, defId, portalDir, changes, mappings) {
2160
3077
  for (const file of changes.new) {
2161
3078
  const slug = slugFromPath(file);
2162
3079
  try {
2163
- const newId = (await fluid_os_v0_create_fluid_osprofile(client, defId, { profile: resolveProfileBody(await readPortalFile(portalDir, file), currentMappings) })).profile?.id;
3080
+ const body = resolveProfileBody(await readPortalFile(portalDir, file), currentMappings);
3081
+ const newId = (await fluidOsApi.createProfile(client, defId, { profile: body })).profile?.id;
2164
3082
  if (newId != null) currentMappings = updateMapping(currentMappings, "profiles", slug, newId);
2165
3083
  results.push({
2166
3084
  file,
@@ -2196,7 +3114,8 @@ async function pushProfiles(client, defId, portalDir, changes, mappings) {
2196
3114
  };
2197
3115
  }
2198
3116
  try {
2199
- await fluid_os_v0_update_fluid_osprofile(client, defId, profileId, { profile: resolveProfileBody(await readPortalFile(portalDir, file), currentMappings) });
3117
+ const body = resolveProfileBody(await readPortalFile(portalDir, file), currentMappings);
3118
+ await fluidOsApi.updateProfile(client, defId, profileId, { profile: body });
2200
3119
  results.push({
2201
3120
  file,
2202
3121
  action: "updated",
@@ -2231,7 +3150,7 @@ async function pushProfiles(client, defId, portalDir, changes, mappings) {
2231
3150
  };
2232
3151
  }
2233
3152
  try {
2234
- await fluid_os_v0_delete_fluid_osprofile(client, defId, profileId);
3153
+ await fluidOsApi.deleteProfile(client, defId, profileId);
2235
3154
  currentMappings = removeMapping(currentMappings, "profiles", slug);
2236
3155
  results.push({
2237
3156
  file,
@@ -2302,8 +3221,9 @@ function printPushReport(results, skippedPhases) {
2302
3221
  for (const phase of skippedPhases) console.log(chalk.yellow(" " + phase));
2303
3222
  }
2304
3223
  }
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) => {
3224
+ 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
3225
  const cwd = process.cwd();
3226
+ await assertStandalonePortalRemoteActionAllowed(cwd, "portal push");
2307
3227
  const portalDir = join(cwd, PORTAL_DIR);
2308
3228
  const portalSyncDir = join(cwd, PORTAL_SYNC_DIR$1);
2309
3229
  console.log();
@@ -2341,6 +3261,10 @@ const pushCommand = new Command("push").description("Push local portal content c
2341
3261
  if (totalChanges === 0) {
2342
3262
  spinner.succeed("Nothing to push.");
2343
3263
  console.log();
3264
+ if (options.activate) {
3265
+ await activateDraft(definitionId);
3266
+ console.log();
3267
+ }
2344
3268
  return;
2345
3269
  }
2346
3270
  spinner.succeed(`Found ${totalChanges} change(s)`);
@@ -2362,16 +3286,22 @@ const pushCommand = new Command("push").description("Push local portal content c
2362
3286
  console.log();
2363
3287
  }
2364
3288
  const changes = categorizeChanges(diff);
2365
- spinner.start("Validating cross-references...");
3289
+ spinner.start("Validating portal files...");
2366
3290
  const validationErrors = await validateCrossReferences(portalDir, mappings, changes);
2367
3291
  if (validationErrors.length > 0) {
2368
- spinner.fail("Cross-reference validation failed");
3292
+ spinner.fail("Portal file validation failed");
2369
3293
  console.log();
2370
3294
  for (const err of validationErrors) console.log(chalk.red(" " + err.file + ": ") + err.message);
2371
3295
  console.log();
2372
3296
  process.exit(1);
2373
3297
  }
2374
- spinner.succeed("Cross-references valid");
3298
+ spinner.succeed("Portal files valid");
3299
+ const structureWarnings = await validateScreenStructure(portalDir, changes);
3300
+ if (structureWarnings.length > 0) {
3301
+ console.log();
3302
+ for (const warning of structureWarnings) console.log(chalk.yellow(" warning ") + chalk.white(warning.file + ": ") + warning.message);
3303
+ console.log();
3304
+ }
2375
3305
  spinner.start("Authenticating...");
2376
3306
  let client;
2377
3307
  try {
@@ -2442,10 +3372,10 @@ const pushCommand = new Command("push").description("Push local portal content c
2442
3372
  else spinner.succeed("Phase 3 complete");
2443
3373
  }
2444
3374
  await writeMappings(portalSyncDir, currentMappings);
2445
- const successfulFiles = new Set(allResults.filter((r) => r.success).map((r) => r.file));
2446
- if (successfulFiles.size > 0) {
3375
+ const filesToSnapshot = new Set(allResults.filter((r) => r.success || r.advanceSnapshot).map((r) => r.file));
3376
+ if (filesToSnapshot.size > 0) {
2447
3377
  const updatedHashes = { ...snapshot.files };
2448
- for (const file of successfulFiles) {
3378
+ for (const file of filesToSnapshot) {
2449
3379
  const fullPath = join(portalDir, file);
2450
3380
  if (existsSync(fullPath)) updatedHashes[file] = await computeFileHash(fullPath);
2451
3381
  else delete updatedHashes[file];
@@ -2456,11 +3386,20 @@ const pushCommand = new Command("push").description("Push local portal content c
2456
3386
  });
2457
3387
  }
2458
3388
  console.log();
2459
- if (allResults.every((r) => r.success) && !aborted) console.log(chalk.green.bold("Push complete!"));
3389
+ const allSucceeded = allResults.every((r) => r.success);
3390
+ if (allSucceeded && !aborted) console.log(chalk.green.bold("Push complete!"));
2460
3391
  else console.log(chalk.yellow.bold("Push completed with issues:"));
2461
3392
  console.log();
2462
3393
  printPushReport(allResults, skippedPhases);
2463
3394
  console.log();
3395
+ if (!allSucceeded || aborted) {
3396
+ process.exitCode = 1;
3397
+ return;
3398
+ }
3399
+ if (options.activate) {
3400
+ await activateDraft(definitionId, client);
3401
+ console.log();
3402
+ }
2464
3403
  });
2465
3404
  //#endregion
2466
3405
  //#region src/commands/clone.ts
@@ -2470,25 +3409,93 @@ const pushCommand = new Command("push").description("Push local portal content c
2470
3409
  * `./<slug>/`. Mirrors `fluid mist clone` (credential → clone), but
2471
3410
  * against the portal git-credential endpoint.
2472
3411
  *
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.
3412
+ * When the credential endpoint reports that no repository is available
3413
+ * (the repo was torn down, provisioning failed, it is still not ready
3414
+ * after the retry budget, or the endpoint isn't deployed), clone falls
3415
+ * back to scaffolding the portal locally from the bundled starter
3416
+ * template instead of failing. Local files plus API sync
3417
+ * (`fluid portal push`/`pull`) are a fully functional portal — Git is
3418
+ * an optional sync channel, not a prerequisite — so the user still
3419
+ * lands in a working project and Git history can attach later via
3420
+ * `fluid portal push`, which adopts the remote's history.
2476
3421
  */
2477
3422
  function getBaseUrl() {
2478
3423
  return process.env["FLUID_API_BASE"] ?? "https://api.fluid.app";
2479
3424
  }
3425
+ async function removeIncompleteCheckout(targetDir) {
3426
+ try {
3427
+ await rm(targetDir, {
3428
+ recursive: true,
3429
+ force: true
3430
+ });
3431
+ return null;
3432
+ } catch (error) {
3433
+ return error instanceof Error ? error.message : String(error);
3434
+ }
3435
+ }
2480
3436
  function createClient$1(token) {
2481
- return createFetchClient({
3437
+ return createFluidOsClient({
2482
3438
  baseUrl: getBaseUrl(),
2483
- getAuthToken: () => token
3439
+ token
2484
3440
  });
2485
3441
  }
3442
+ /**
3443
+ * Human message for the git-sync-unavailable fallback, split by conflict
3444
+ * type. `not_connected` is a durable state (the company's Git integration
3445
+ * isn't connected), so it must not claim that waiting will fix anything;
3446
+ * `not_provisioned` genuinely may resolve later, so it says so.
3447
+ */
3448
+ function gitSyncUnavailableNotice(outcome) {
3449
+ const attachHint = `Creating the portal locally from the starter template so you can start now — ${chalk.cyan("fluid portal push")}/${chalk.cyan("pull")} sync content without Git, and Git history can attach later with ${chalk.cyan("fluid portal push")}.`;
3450
+ if (outcome.repoStatus === "disconnected") return `The portal's Git repository was disconnected, so there is nothing to clone. ${attachHint}`;
3451
+ if (outcome.repoStatus === "error") return `The portal's Git repository failed to provision. ${attachHint}`;
3452
+ if (outcome.conflictType === "not_connected") return "The portal's Git repository is still being provisioned — it didn't come online in time. " + attachHint;
3453
+ if (outcome.conflictType === "not_provisioned") return `The portal's Git repository isn't provisioned yet. ${attachHint}`;
3454
+ return `Git sync isn't available (${outcome.reason}). ${attachHint}`;
3455
+ }
3456
+ /**
3457
+ * A git-less fallback scaffold that failed or was interrupted leaves the
3458
+ * pending marker behind with no `.git`. Only that exact shape is safe to
3459
+ * resume — anything else is an unrelated pre-existing directory.
3460
+ */
3461
+ async function canResumeGitlessPortalScaffold(targetDir) {
3462
+ return await fileExists(join(targetDir, ".fluid-portal-scaffold-pending")) && !existsSync(join(targetDir, ".git"));
3463
+ }
3464
+ async function canResumePortalScaffold(targetDir, credentialRemoteUrl) {
3465
+ if (!await fileExists(join(targetDir, ".fluid-portal-scaffold-pending")) || !await isGitRepo(targetDir)) return false;
3466
+ const origin = await getOriginUrl(targetDir);
3467
+ if (!origin) return false;
3468
+ const expected = cleanGitRemoteUrl(credentialRemoteUrl);
3469
+ return origin.replace(/\/$/, "") === expected.replace(/\/$/, "");
3470
+ }
3471
+ function resolveDefinition(definitions, appName, rawDefinitionId) {
3472
+ if (rawDefinitionId !== void 0) {
3473
+ if (!/^[1-9]\d*$/.test(rawDefinitionId)) throw new Error("--id must be a positive integer");
3474
+ const definitionId = Number.parseInt(rawDefinitionId, 10);
3475
+ const match = definitions.find((definition) => definition.id === definitionId);
3476
+ if (!match) throw new Error(`No definition found with id ${definitionId}.`);
3477
+ return {
3478
+ id: definitionId,
3479
+ name: match.name ?? `portal-${definitionId}`
3480
+ };
3481
+ }
3482
+ if (!appName) return null;
3483
+ const match = definitions.find((definition) => definition.name?.toLowerCase() === appName.toLowerCase());
3484
+ if (!match || match.id == null) {
3485
+ const available = definitions.map((definition) => chalk.cyan(definition.name ?? "(unnamed)")).join(", ");
3486
+ throw new Error(`No definition found matching "${appName}". Available: ${available}`);
3487
+ }
3488
+ return {
3489
+ id: match.id,
3490
+ name: match.name ?? `portal-${match.id}`
3491
+ };
3492
+ }
2486
3493
  /** Select a definition by --app name, else interactively. */
2487
- async function selectDefinition(client, appName) {
3494
+ async function selectDefinition(client, appName, rawDefinitionId) {
2488
3495
  const definitions = [];
2489
3496
  let page = 1;
2490
3497
  while (true) {
2491
- const response = await fluid_os_v0_list_fluid_osdefinitions(client, {
3498
+ const response = await fluidOsApi.listDefinitions(client, {
2492
3499
  page,
2493
3500
  per_page: 500
2494
3501
  });
@@ -2499,17 +3506,8 @@ async function selectDefinition(client, appName) {
2499
3506
  page++;
2500
3507
  }
2501
3508
  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
- }
3509
+ const resolved = resolveDefinition(definitions, appName, rawDefinitionId);
3510
+ if (resolved) return resolved;
2513
3511
  const { definitionId } = await prompts({
2514
3512
  type: "select",
2515
3513
  name: "definitionId",
@@ -2527,9 +3525,8 @@ async function selectDefinition(client, appName) {
2527
3525
  name: selected.name ?? `portal-${selected.id}`
2528
3526
  };
2529
3527
  }
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) => {
3528
+ 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
3529
  try {
2532
- await ensureGitAvailable();
2533
3530
  const token = getAuthToken();
2534
3531
  if (!token) {
2535
3532
  const profile = getActiveProfile();
@@ -2537,32 +3534,99 @@ const cloneCommand = new Command("clone").description("Clone a portal definition
2537
3534
  }
2538
3535
  const client = createClient$1(token);
2539
3536
  const spinner = ora("Resolving definition…").start();
2540
- const definition = await selectDefinition(client, options.app ?? appArg);
3537
+ const definition = await selectDefinition(client, options.app ?? appArg, options.id);
2541
3538
  spinner.text = "Minting git credential…";
2542
- const outcome = await createPortalGitCredential({
3539
+ const outcome = await createPortalGitCredentialWithRetry({
2543
3540
  baseUrl: getBaseUrl(),
2544
3541
  token,
2545
- definitionId: definition.id
3542
+ definitionId: definition.id,
3543
+ onRetry: ({ attempt, totalAttempts }) => {
3544
+ spinner.text = `Preparing Git repository… retrying (${attempt}/${totalAttempts})`;
3545
+ }
2546
3546
  });
2547
- if (outcome.status !== "ok") {
3547
+ if (outcome.status === "error") {
2548
3548
  spinner.warn(`Can't clone — ${outcome.reason}`);
2549
3549
  process.exit(1);
2550
3550
  }
2551
3551
  const slug = deriveSlug(definition.name);
2552
3552
  const targetDir = resolve(options.dir ?? slug);
2553
- if (existsSync(targetDir)) {
3553
+ if (outcome.status === "unavailable") {
3554
+ spinner.info(gitSyncUnavailableNotice(outcome));
3555
+ const resumeGitless = existsSync(targetDir) && await canResumeGitlessPortalScaffold(targetDir);
3556
+ if (existsSync(targetDir) && !resumeGitless) {
3557
+ spinner.fail(`Directory ${targetDir} already exists. Pass --dir or remove it first.`);
3558
+ process.exit(1);
3559
+ }
3560
+ if (!resumeGitless) {
3561
+ await mkdir(dirname(targetDir), { recursive: true });
3562
+ try {
3563
+ await mkdir(targetDir);
3564
+ } catch (error) {
3565
+ if (error.code === "EEXIST") {
3566
+ spinner.fail(`Directory ${targetDir} was created by another process. Choose a different --dir or retry after it finishes.`);
3567
+ process.exit(1);
3568
+ }
3569
+ throw error;
3570
+ }
3571
+ }
3572
+ try {
3573
+ spinner.text = resumeGitless ? "Resuming portal scaffold…" : "Scaffolding portal from the starter template…";
3574
+ await scaffoldPortalProjectIntoExistingDirectory(targetDir, {
3575
+ name: slug,
3576
+ installDeps: false,
3577
+ selectedPages: [],
3578
+ profileName: getActiveProfile()?.name ?? ""
3579
+ });
3580
+ } catch (err) {
3581
+ spinner.fail(`Git sync is unavailable and the local scaffold failed at ${targetDir}. Fix the reported path or permission conflict and retry.`);
3582
+ throw err;
3583
+ }
3584
+ spinner.succeed(`Created ${chalk.cyan(targetDir)} from the starter template (no Git history yet)`);
3585
+ console.log(chalk.dim(` cd ${targetDir}`));
3586
+ console.log(chalk.dim(` ${chalk.cyan("fluid portal pull")} to sync content`));
3587
+ return;
3588
+ }
3589
+ await ensureGitAvailable();
3590
+ const resumeScaffold = existsSync(targetDir) && await canResumePortalScaffold(targetDir, outcome.credential.remote_url);
3591
+ if (existsSync(targetDir) && !resumeScaffold) {
2554
3592
  spinner.fail(`Directory ${targetDir} already exists. Pass --dir or remove it first.`);
2555
3593
  process.exit(1);
2556
3594
  }
2557
- spinner.text = `Cloning into ${slug}/…`;
2558
- try {
2559
- await gitClone(outcome.credential.remote_url, targetDir);
3595
+ if (!resumeScaffold) {
3596
+ await mkdir(dirname(targetDir), { recursive: true });
3597
+ try {
3598
+ await mkdir(targetDir);
3599
+ } catch (error) {
3600
+ if (error.code === "EEXIST") {
3601
+ spinner.fail(`Directory ${targetDir} was created by another process. Choose a different --dir or retry after it finishes.`);
3602
+ process.exit(1);
3603
+ }
3604
+ throw error;
3605
+ }
3606
+ spinner.text = `Cloning into ${slug}/…`;
3607
+ try {
3608
+ await gitClone(outcome.credential.remote_url, targetDir);
3609
+ } catch (err) {
3610
+ const cleanupError = await removeIncompleteCheckout(targetDir);
3611
+ spinner.fail(cleanupError ? `git clone failed, and Fluid could not remove the incomplete checkout: ${cleanupError}` : "git clone failed; removed the incomplete checkout");
3612
+ throw err;
3613
+ }
3614
+ excludeLocalStateFromGit(targetDir);
3615
+ }
3616
+ if (resumeScaffold || !await fileExists(join(targetDir, "package.json"))) try {
3617
+ spinner.text = resumeScaffold ? "Resuming runnable portal scaffold…" : "Adding runnable portal scaffold…";
3618
+ await scaffoldPortalProjectIntoExistingDirectory(targetDir, {
3619
+ name: slug,
3620
+ installDeps: false,
3621
+ selectedPages: [],
3622
+ profileName: getActiveProfile()?.name ?? ""
3623
+ });
2560
3624
  } catch (err) {
2561
- spinner.fail("git clone failed");
3625
+ 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
3626
  throw err;
2563
3627
  }
2564
3628
  spinner.succeed(`Cloned into ${chalk.cyan(targetDir)}`);
2565
- console.log(chalk.dim(` cd ${slug}`));
3629
+ console.log(chalk.dim(` cd ${targetDir}`));
2566
3630
  console.log(chalk.dim(` ${chalk.cyan("fluid portal pull")} to sync content`));
2567
3631
  } catch (error) {
2568
3632
  console.log();
@@ -2571,6 +3635,115 @@ const cloneCommand = new Command("clone").description("Clone a portal definition
2571
3635
  }
2572
3636
  });
2573
3637
  //#endregion
3638
+ //#region src/commands/widget-package-publish.ts
3639
+ async function publishWidgetRuntimeArtifacts(options) {
3640
+ await assertStandalonePortalRemoteActionAllowed(options.projectDir, "portal widget package publish");
3641
+ const isDryRun = options.dryRun === true;
3642
+ const client = options.client;
3643
+ if (!isDryRun && !client) throw new Error("A FluidOS API client is required when dryRun is false.");
3644
+ const cliVersion = await getPortalCliVersion();
3645
+ if (isDryRun) return await publishWidgetPackage({
3646
+ projectDir: options.projectDir,
3647
+ outDir: options.outDir,
3648
+ owner: options.buildOwner,
3649
+ uploadOwner: options.uploadOwner,
3650
+ cliVersion,
3651
+ dryRun: true
3652
+ });
3653
+ if (!client) throw new Error("A FluidOS API client is required when dryRun is false.");
3654
+ return await publishWidgetPackage({
3655
+ projectDir: options.projectDir,
3656
+ outDir: options.outDir,
3657
+ owner: options.buildOwner,
3658
+ uploadOwner: options.uploadOwner,
3659
+ cliVersion,
3660
+ ...options.version ? { version: options.version } : {},
3661
+ client
3662
+ });
3663
+ }
3664
+ /**
3665
+ * Prints a publish failure with its `details` block, which is where the
3666
+ * validation tooling names the offending widget and field.
3667
+ */
3668
+ function printWidgetPublishError(error) {
3669
+ console.error(chalk.red("Error:") + " " + (error instanceof Error ? error.message : String(error)));
3670
+ if (typeof error === "object" && error !== null && "details" in error && typeof error.details === "string" && error.details.length > 0) console.error(error.details);
3671
+ }
3672
+ const DEFAULT_API_BASE = "https://api.fluid.app";
3673
+ /**
3674
+ * Builds the client used to publish widget artifacts.
3675
+ *
3676
+ * `FLUID_TOKEN` / `FLUID_API_TOKEN` win over any stored profile, matching the
3677
+ * resolution order the rest of the CLI documents and implements (see
3678
+ * `getAuthToken` in `@fluid-app/fluid-cli`). This path used to read only
3679
+ * `~/.fluid/config.json`, which made it the one command a caller could not
3680
+ * point at a chosen company: a host that spawns the CLI would publish to
3681
+ * whichever profile the terminal happened to have active, and shipping a
3682
+ * company's widgets to the wrong tenant is not a failure they could detect from
3683
+ * the output.
3684
+ *
3685
+ * With the env token set, the base URL comes from `FLUID_API_BASE` rather than
3686
+ * the stored profile's — a token issued elsewhere has no relationship to the
3687
+ * host that profile points at.
3688
+ */
3689
+ function createAuthenticatedWidgetPackageClient() {
3690
+ const envToken = process.env["FLUID_TOKEN"] ?? process.env["FLUID_API_TOKEN"] ?? "";
3691
+ if (envToken.length > 0) return createFluidOsHeyApiClient({
3692
+ baseUrl: process.env["FLUID_API_BASE"] ?? DEFAULT_API_BASE,
3693
+ getAuthToken: () => envToken
3694
+ });
3695
+ const { profile, profileName, token } = resolveAuthenticatedProfile();
3696
+ if (!token) {
3697
+ if (!profileName) throw new Error("Not logged in. Run " + chalk.cyan("fluid login") + " first.");
3698
+ throw new Error("No auth token found for profile " + chalk.cyan(profileName) + ". Run " + chalk.cyan("fluid login") + " to re-authenticate.");
3699
+ }
3700
+ return createFluidOsHeyApiClient({
3701
+ baseUrl: profile?.baseUrl ?? process.env["FLUID_API_BASE"] ?? DEFAULT_API_BASE,
3702
+ getAuthToken: () => token
3703
+ });
3704
+ }
3705
+ function resolveAuthenticatedProfile() {
3706
+ const config = readConfig();
3707
+ const projectConfig = findProjectConfig(process.cwd());
3708
+ if (projectConfig?.profile) {
3709
+ const profile = config.profiles[projectConfig.profile];
3710
+ if (profile) return {
3711
+ profile,
3712
+ profileName: projectConfig.profile,
3713
+ token: profile.token
3714
+ };
3715
+ }
3716
+ if (!config.activeProfile) return {};
3717
+ const profile = config.profiles[config.activeProfile];
3718
+ if (!profile) return {};
3719
+ return {
3720
+ profile,
3721
+ profileName: config.activeProfile,
3722
+ token: profile.token
3723
+ };
3724
+ }
3725
+ function printWidgetPublishSummary(options) {
3726
+ console.log();
3727
+ console.log(chalk.green.bold(`${options.title} complete`));
3728
+ console.log();
3729
+ console.log(chalk.gray("Owner: ") + chalk.white(options.ownerLabel));
3730
+ if (options.environment) console.log(chalk.gray("Environment: ") + chalk.white(options.environment));
3731
+ console.log(chalk.gray("Package: ") + chalk.white(options.packageId));
3732
+ console.log(chalk.gray("Version: ") + chalk.white(options.version));
3733
+ console.log(chalk.gray("Output: ") + chalk.cyan(options.outDir));
3734
+ console.log(chalk.gray("Artifacts: ") + chalk.white(String(options.artifactCount)));
3735
+ console.log();
3736
+ }
3737
+ function printRuntimeOnlyNotice() {
3738
+ console.log(chalk.yellow("This publishes widget runtime artifacts only; it does not deploy the hosted portal shell."));
3739
+ console.log();
3740
+ }
3741
+ function printDryRunSessionPayload(sessionPayload) {
3742
+ console.log(chalk.bold("Dry-run upload session payload:"));
3743
+ console.log(JSON.stringify(sessionPayload, null, 2));
3744
+ console.log();
3745
+ }
3746
+ //#endregion
2574
3747
  //#region src/utils/widget-helpers.ts
2575
3748
  /**
2576
3749
  * Pure helper functions for widget scaffolding.
@@ -2660,12 +3833,14 @@ function insertIntoWidgetPackage(source, exportName) {
2660
3833
  }
2661
3834
  //#endregion
2662
3835
  //#region src/commands/widget-create.ts
2663
- const WIDGET_CONFIG_PATH$1 = path.join("src", "widgets.config.ts");
3836
+ const WIDGET_CONFIG_PATH = path.join("src", "widgets.config.ts");
2664
3837
  const createSubcommand = new Command("create").description("Scaffold a new Remote DOM widget").argument("<name>", "Widget name in kebab-case (e.g., stock-ticker)").option("-c, --category <category>", "Widget category for palette grouping", "components").action(async (name, options) => {
2665
3838
  const cwd = process.cwd();
2666
3839
  const packageJsonPath = path.join(cwd, "package.json");
2667
- const configPath = path.join(cwd, WIDGET_CONFIG_PATH$1);
3840
+ const configPath = path.join(cwd, WIDGET_CONFIG_PATH);
2668
3841
  const widgetDir = path.join(cwd, "src", "widgets", name);
3842
+ let stylesAppended = false;
3843
+ let resolvedPackageKey = null;
2669
3844
  const validationError = validateWidgetName(name);
2670
3845
  if (validationError) {
2671
3846
  console.error(chalk.red(`Error: ${validationError}`));
@@ -2697,28 +3872,38 @@ const createSubcommand = new Command("create").description("Scaffold a new Remot
2697
3872
  displayName,
2698
3873
  category
2699
3874
  }), "utf-8");
3875
+ await appendWidgetStyles(cwd, name);
3876
+ stylesAppended = true;
2700
3877
  if (await fs.pathExists(configPath)) {
2701
3878
  const configSource = await fs.readFile(configPath, "utf-8");
2702
- if (isEmptyWidgetConfig(configSource)) await fs.writeFile(configPath, createWidgetConfigSource(importLine, exportName), "utf-8");
2703
- else {
3879
+ if (isEmptyWidgetConfig(configSource)) {
3880
+ resolvedPackageKey = await resolveCompanyPackageKey();
3881
+ await fs.writeFile(configPath, createWidgetConfigSource(importLine, exportName, resolvedPackageKey ?? void 0), "utf-8");
3882
+ } else {
2704
3883
  const withImport = insertImport(configSource, importLine);
2705
3884
  const updated = withImport ? insertIntoWidgetPackage(withImport, exportName) : null;
2706
- if (!updated) throw new Error(`${WIDGET_CONFIG_PATH$1} must export the starter widgetPackages placeholder or contain a defineWidgetPackage({ widgets: [...] }) declaration.`);
3885
+ if (!updated) throw new Error(`${WIDGET_CONFIG_PATH} must export the starter widgetPackages placeholder or contain a defineWidgetPackage({ widgets: [...] }) declaration.`);
2707
3886
  await fs.writeFile(configPath, updated, "utf-8");
2708
3887
  }
2709
- } else await fs.writeFile(configPath, createWidgetConfigSource(importLine, exportName), "utf-8");
3888
+ } else {
3889
+ resolvedPackageKey = await resolveCompanyPackageKey();
3890
+ await fs.writeFile(configPath, createWidgetConfigSource(importLine, exportName, resolvedPackageKey ?? void 0), "utf-8");
3891
+ }
2710
3892
  } catch (error) {
2711
3893
  await fs.remove(widgetDir).catch(() => {});
3894
+ if (stylesAppended) await removeWidgetStyles(cwd, name).catch(() => {});
2712
3895
  console.error(chalk.red("Error: Failed to scaffold Remote DOM widget."));
2713
3896
  console.error(error);
2714
3897
  process.exit(1);
2715
3898
  }
2716
3899
  console.log();
2717
3900
  console.log(chalk.green("Created widget:") + ` src/widgets/${name}/index.tsx`);
2718
- console.log(chalk.green("Registered") + ` in ${chalk.cyan(WIDGET_CONFIG_PATH$1)}`);
3901
+ console.log(chalk.green("Registered") + ` in ${chalk.cyan(WIDGET_CONFIG_PATH)}`);
3902
+ if (resolvedPackageKey) console.log(chalk.green("Package key:") + ` ${chalk.cyan(resolvedPackageKey)} (this company's existing widget package)`);
2719
3903
  console.log();
2720
3904
  console.log(chalk.yellow("Next steps:"));
2721
3905
  console.log(` 1. Edit ${chalk.cyan(`src/widgets/${name}/index.tsx`)}`);
3906
+ console.log(` Styles live in ${chalk.cyan("styles.css")} at the project root — widgets must not import CSS.`);
2722
3907
  console.log(` 2. Run ${chalk.cyan("fluid portal deploy --dry-run")} to verify the package`);
2723
3908
  });
2724
3909
  function createWidgetSource(options) {
@@ -2728,11 +3913,23 @@ interface ${options.widgetType}Props {
2728
3913
  readonly title?: string;
2729
3914
  }
2730
3915
 
3916
+ // Styling: a stylesheet is the ONLY way to style a widget. The host sanitizes
3917
+ // every Remote DOM mutation before it reaches the shadow root, so a
3918
+ // \`style={{ ... }}\` prop is silently dropped and \`<style>\` is a banned tag.
3919
+ // Give elements classNames and put the rules in the project-root styles.css
3920
+ // (this widget's rules were appended there when it was scaffolded).
3921
+ //
3922
+ // Do NOT add \`import "./styles.css"\` here. In dev, Vite answers a CSS import
3923
+ // with a browser style-injector that loads \`/@vite/client\`, which touches
3924
+ // HTMLElement — absent in a Web Worker — and throws while the package is being
3925
+ // evaluated, taking down every widget in the project, not just this one.
2731
3926
  function ${options.componentName}({ title = ${JSON.stringify(options.displayName)} }: ${options.widgetType}Props) {
2732
3927
  return (
2733
- <section>
2734
- <h3>{title}</h3>
2735
- <p>Edit this widget in src/widgets/${options.name}/index.tsx</p>
3928
+ <section className="${options.name}">
3929
+ <h3 className="${options.name}__title">{title}</h3>
3930
+ <p className="${options.name}__body">
3931
+ Edit this widget in src/widgets/${options.name}/index.tsx
3932
+ </p>
2736
3933
  </section>
2737
3934
  );
2738
3935
  }
@@ -2753,1692 +3950,283 @@ export const ${options.exportName} = defineWidget({
2753
3950
  });
2754
3951
  `;
2755
3952
  }
3953
+ /**
3954
+ * Appends a scaffolded widget's rules to the project-root `styles.css`,
3955
+ * creating the file when a project has none yet.
3956
+ *
3957
+ * The root stylesheet is the single sheet the tooling delivers — dev serves it
3958
+ * as the descriptor's `cssUrls`, and the build entry imports it — so every
3959
+ * widget in a project shares it. Appending (rather than overwriting) keeps
3960
+ * earlier widgets' rules intact; re-scaffolding a name that is already present
3961
+ * is a no-op rather than a duplicate block.
3962
+ */
3963
+ /**
3964
+ * Removes a widget's appended rules from the project-root `styles.css`.
3965
+ *
3966
+ * Only used to undo a failed scaffold — `appendWidgetStyles` runs before the
3967
+ * config update, so a config failure would otherwise strand rules for a widget
3968
+ * directory that has just been deleted. Matches the exact block the append
3969
+ * writes and leaves everything else, including a project's own rules and other
3970
+ * widgets', untouched.
3971
+ */
3972
+ async function removeWidgetStyles(projectDir, name) {
3973
+ const stylesPath = path.join(projectDir, "styles.css");
3974
+ if (!await fs.pathExists(stylesPath)) return;
3975
+ const existing = await fs.readFile(stylesPath, "utf-8");
3976
+ const block = createWidgetStylesSource(name);
3977
+ if (!existing.includes(block)) return;
3978
+ const withoutBlock = existing.replace(block, "");
3979
+ await fs.writeFile(stylesPath, withoutBlock.replace(/\n{3,}/gu, "\n\n"), "utf-8");
3980
+ }
3981
+ async function appendWidgetStyles(projectDir, name) {
3982
+ const stylesPath = path.join(projectDir, "styles.css");
3983
+ const block = createWidgetStylesSource(name);
3984
+ if (!await fs.pathExists(stylesPath)) {
3985
+ await fs.writeFile(stylesPath, `${ROOT_STYLES_HEADER}\n${block}`, "utf-8");
3986
+ return;
3987
+ }
3988
+ const existing = await fs.readFile(stylesPath, "utf-8");
3989
+ if (existing.includes(`.${name} {`)) return;
3990
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
3991
+ await fs.appendFile(stylesPath, `${separator}${block}`, "utf-8");
3992
+ }
3993
+ const ROOT_STYLES_HEADER = `/* Runtime styles for this portal's widgets.
3994
+ *
3995
+ * One sheet per project: dev serves it to the widget shadow root as the
3996
+ * package descriptor's cssUrls, and the package build imports it. Widget
3997
+ * modules must NOT import CSS themselves — in dev that pulls \`/@vite/client\`
3998
+ * into the Web Worker, which throws and stops every widget in the project from
3999
+ * registering.
4000
+ */
4001
+ `;
4002
+ /**
4003
+ * Starter rules for a scaffolded widget.
4004
+ *
4005
+ * Colors, type, and radii come from the host portal's semantic CSS variables so
4006
+ * the widget matches whatever portal it lands on, in both light and dark,
4007
+ * without a second hand-written palette. Each `var()` carries a fallback so the
4008
+ * widget stays legible in a host that has not adopted the portal theme.
4009
+ *
4010
+ * Selectors are prefixed with the widget name so nothing leaks into the host.
4011
+ */
4012
+ function createWidgetStylesSource(name) {
4013
+ return `.${name} {
4014
+ padding: 24px;
4015
+ border: 1px solid var(--border, #e5e7eb);
4016
+ border-radius: var(--radius-lg, 12px);
4017
+ background: var(--card, #ffffff);
4018
+ color: var(--card-foreground, #111827);
4019
+ font-family: var(--font-body, ui-sans-serif, system-ui, sans-serif);
4020
+ }
4021
+
4022
+ .${name}__title {
4023
+ margin: 0 0 8px;
4024
+ color: var(--card-foreground, #111827);
4025
+ font-family: var(--font-header, inherit);
4026
+ font-size: 1.25rem;
4027
+ font-weight: 700;
4028
+ }
4029
+
4030
+ .${name}__body {
4031
+ margin: 0;
4032
+ color: var(--muted-foreground, #6b7280);
4033
+ }
4034
+ `;
4035
+ }
2756
4036
  function isEmptyWidgetConfig(source) {
2757
4037
  return /export\s+const\s+widgetPackages\s*=\s*\[\]\s+as\s+const\s*;/u.test(source);
2758
4038
  }
2759
- function createWidgetConfigSource(importLine, exportName) {
4039
+ /**
4040
+ * The key written when the company's real one cannot be resolved.
4041
+ *
4042
+ * A guess, and one that fails for any company that has already published — a
4043
+ * company has exactly ONE widget package and its key is fixed by whichever
4044
+ * project published first, so this value is only ever correct by accident.
4045
+ */
4046
+ const PLACEHOLDER_PACKAGE_KEY = "portal-widgets";
4047
+ function createWidgetConfigSource(importLine, exportName, packageKey = PLACEHOLDER_PACKAGE_KEY) {
2760
4048
  return `import { defineWidgetPackage } from "@fluid-app/portal-sdk/widgets/worker";
2761
4049
  ${importLine}
2762
4050
 
2763
4051
  export const widgetPackage = defineWidgetPackage({
2764
4052
  scope: "company",
2765
- packageStableId: "portal-widgets",
4053
+ packageStableId: ${JSON.stringify(packageKey)},
2766
4054
  version: "0.0.0-dev",
2767
4055
  widgets: [${exportName}],
2768
4056
  });
2769
4057
  `;
2770
4058
  }
2771
- const widgetCommand = new Command("widget").description("Manage Remote DOM portal widgets").addCommand(createSubcommand);
2772
- //#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");
4059
+ /**
4060
+ * Reads the company's existing widget package key, for a config being CREATED.
4061
+ *
4062
+ * Scaffolding is the one moment this can be done safely. A widget's type is
4063
+ * `company.<key>.<Name>` and those strings are written into screen JSON, so
4064
+ * changing the key later orphans every screen using it — which is why
4065
+ * `fluid portal deploy` reports a mismatch rather than rewriting it. At
4066
+ * `widget create` no screen references the type yet, so resolving it here
4067
+ * prevents the mismatch instead of explaining it afterwards.
4068
+ *
4069
+ * Best-effort by design: this command made no network calls before, and a
4070
+ * scaffold must not fail because a lookup did. Every uncertainty — no token, no
4071
+ * network, a company that has never published, a slow answer — falls back to the
4072
+ * placeholder, which is exactly today's behaviour.
4073
+ *
4074
+ * The deadline is deliberate here, unlike the SDK's dev-server probe where a
4075
+ * timeout discarded a real answer. The fallback IS the correct result for an
4076
+ * offline scaffold, so bounding the wait costs nothing and stops a hung API
4077
+ * from wedging a local command.
4078
+ */
4079
+ async function resolveCompanyPackageKey() {
2831
4080
  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}`);
4081
+ const client = createAuthenticatedWidgetPackageClient();
4082
+ return (await Promise.race([readCompanyWidgetPackages(client), new Promise((resolve) => {
4083
+ setTimeout(() => resolve([]), PACKAGE_KEY_LOOKUP_TIMEOUT_MS);
4084
+ })]))[0]?.key ?? null;
4085
+ } catch {
4086
+ return null;
2837
4087
  }
2838
4088
  }
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
- });
4089
+ const PACKAGE_KEY_LOOKUP_TIMEOUT_MS = 5e3;
4090
+ const widgetCommand = new Command("widget").description("Manage Remote DOM portal widgets").addCommand(createSubcommand);
4091
+ //#endregion
4092
+ //#region src/commands/deploy.ts
4093
+ const DEFAULT_OUT_DIR$1 = ".fluid/widget-dist";
4094
+ const DEFAULT_ENVIRONMENT = "production";
4095
+ const deployCommand = new Command("deploy").description("Publish company-owned portal widget runtime artifacts").option("-e, --environment <name>", "Target environment label for output reporting", DEFAULT_ENVIRONMENT).option("-o, --out-dir <dir>", "Widget artifact output directory", DEFAULT_OUT_DIR$1).option("--dry-run", "Build and validate upload payload without publishing").action(async (options) => {
4096
+ const environment = options.environment ?? DEFAULT_ENVIRONMENT;
4097
+ const outDir = options.outDir ?? DEFAULT_OUT_DIR$1;
4098
+ const dryRun = options.dryRun === true;
4099
+ console.log();
4100
+ console.log(chalk.blue.bold("Fluid Portal Deploy"));
4101
+ console.log();
4102
+ printRuntimeOnlyNotice();
4103
+ console.log(chalk.gray("Environment: ") + chalk.white(environment));
4104
+ console.log(chalk.gray("Output: ") + chalk.cyan(outDir));
4105
+ if (dryRun) console.log(chalk.yellow("Dry run: no upload will be created."));
4106
+ console.log();
4107
+ const spinner = ora("Building company-owned widget package...").start();
4108
+ const result = await runPortalDeploy({
4109
+ projectDir: process.cwd(),
4110
+ outDir,
4111
+ environment,
4112
+ dryRun,
4113
+ onProgress: (label) => {
4114
+ spinner.text = label;
4115
+ }
4116
+ });
4117
+ if (!result.ok) {
4118
+ spinner.fail("Portal deploy failed");
4119
+ console.log();
4120
+ if (result.code === "PACKAGE_KEY_MISMATCH") console.log(chalk.red("Package key does not match this company."));
4121
+ else console.error(chalk.red("Error:") + " " + result.message);
4122
+ if (result.hint) {
4123
+ console.log();
4124
+ console.log(result.hint);
4125
+ }
4126
+ console.log();
4127
+ process.exit(1);
2849
4128
  }
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
- });
4129
+ spinner.succeed("Built widget runtime artifacts");
4130
+ if (dryRun) {
4131
+ console.log(chalk.yellow("Dry run complete — upload session was not requested."));
4132
+ console.log();
4133
+ if (result.sessionPayload !== void 0) printDryRunSessionPayload(result.sessionPayload);
4134
+ } else console.log(chalk.green("Published widget package version."));
4135
+ if (result.requestedVersion) {
4136
+ console.log(chalk.yellow(`Version ${result.requestedVersion} is already published for this company, so this went out as ${result.version}.`));
4137
+ console.log(chalk.gray("Every portal project in a company shares one widget package, so its versions are shared too."));
4138
+ console.log();
2859
4139
  }
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}`
4140
+ printWidgetPublishSummary({
4141
+ title: dryRun ? "Portal deploy dry run" : "Portal deploy",
4142
+ ownerLabel: "company",
4143
+ environment,
4144
+ outDir,
4145
+ packageId: result.packageId,
4146
+ version: result.version,
4147
+ artifactCount: result.artifactCount
2868
4148
  });
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
4149
  });
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";
2973
- const CONTENT_TYPES = {
2974
- ".css": "text/css",
2975
- ".js": "application/javascript",
2976
- ".json": "application/json",
2977
- ".map": "application/json"
2978
- };
2979
- const CSS_ARTIFACT_PATH_PATTERN = /^[A-Za-z0-9._~-]+\.css$/;
2980
- const JS_MAP_ARTIFACT_PATH_PATTERN = /^[A-Za-z0-9._~-]+\.js\.map$/;
2981
- async function computeArtifactMetadata(filePath, baseDir) {
2982
- const relativePath = toPosixPath$1(path.relative(baseDir, filePath));
2983
- assertAllowedArtifactPath(relativePath);
2984
- await assertRegularArtifactFile(filePath, relativePath);
2985
- const buffer = await fs.readFile(filePath);
2986
- return {
2987
- path: relativePath,
2988
- sha256: sha256(buffer),
2989
- bytes: buffer.byteLength,
2990
- contentType: getArtifactContentType(filePath)
2991
- };
2992
- }
2993
- async function collectWidgetPackageArtifacts(publishDir) {
2994
- const artifactPaths = await findArtifactPaths(publishDir);
2995
- const artifacts = await Promise.all(artifactPaths.map((artifactPath) => computeArtifactMetadata(artifactPath, publishDir)));
2996
- assertRequiredUploadArtifacts(artifacts);
2997
- return artifacts.sort((a, b) => a.path.localeCompare(b.path));
2998
- }
2999
- function createPublishManifestMetadata(options) {
3000
- return {
3001
- packageId: options.packageId,
3002
- version: options.version,
3003
- builderVersion: options.builderVersion ?? "1",
3004
- cliVersion: options.cliVersion,
3005
- runtimeVersion: options.runtimeVersion,
3006
- manifestHash: sha256(options.manifestContent),
3007
- artifacts: options.artifacts
3008
- };
3009
- }
3010
- function getArtifactContentType(filePath) {
3011
- if (path.basename(filePath).endsWith(".js.map")) return "application/json";
3012
- return CONTENT_TYPES[path.extname(filePath)] ?? "application/octet-stream";
3013
- }
3014
- function sha256(content) {
3015
- return createHash("sha256").update(content).digest("hex");
3016
- }
3017
- function isWidgetPackageCssArtifactPath(relativePath) {
3018
- return CSS_ARTIFACT_PATH_PATTERN.test(relativePath);
3019
- }
3020
- function isWidgetPackageJsMapArtifactPath(relativePath) {
3021
- return JS_MAP_ARTIFACT_PATH_PATTERN.test(relativePath);
3022
- }
3023
- async function assertRegularArtifactFile(filePath, relativePath) {
3024
- const stat = await fs.lstat(filePath);
3025
- if (stat.isSymbolicLink()) throw new Error(`Widget publish artifact ${JSON.stringify(relativePath)} must be a regular file and must not be a symbolic link.`);
3026
- if (!stat.isFile()) throw new Error(`Widget publish artifact ${JSON.stringify(relativePath)} must be a regular file.`);
3027
- }
3028
- async function findArtifactPaths(dir, baseDir = dir) {
3029
- if (!await fs.pathExists(dir)) return [];
3030
- const entries = await fs.readdir(dir, { withFileTypes: true });
3031
- return (await Promise.all(entries.map(async (entry) => {
3032
- const entryPath = path.join(dir, entry.name);
3033
- const relativePath = toPosixPath$1(path.relative(baseDir, entryPath));
3034
- if (entry.isSymbolicLink()) throw new Error(`Widget publish artifact ${JSON.stringify(relativePath)} must be a regular file and must not be a symbolic link.`);
3035
- if (isUploadArtifact(relativePath)) {
3036
- if (!entry.isFile()) throw new Error(`Widget publish artifact ${JSON.stringify(relativePath)} must be a regular file.`);
3037
- return [entryPath];
3038
- }
3039
- if (entry.isDirectory()) {
3040
- const nestedPaths = await findArtifactPaths(entryPath, baseDir);
3041
- if (nestedPaths.length === 0) throwUnsupportedArtifactPath(`${relativePath}/`);
3042
- return nestedPaths;
3043
- }
3044
- if (isAllowedGeneratedFile(relativePath)) return [];
3045
- throwUnsupportedArtifactPath(relativePath);
3046
- }))).flat();
3047
- }
3048
- function assertRequiredUploadArtifacts(artifacts) {
3049
- const artifactPaths = new Set(artifacts.map((artifact) => artifact.path));
3050
- for (const requiredPath of ["widget.js", "manifest.json"]) {
3051
- if (artifactPaths.has(requiredPath)) continue;
3052
- throw new Error(`Widget package publish output must include regular file ${JSON.stringify(requiredPath)}.`);
3053
- }
3054
- }
3055
- function assertAllowedArtifactPath(relativePath) {
3056
- if (isUploadArtifact(relativePath) || isAllowedGeneratedFile(relativePath)) return;
3057
- throwUnsupportedArtifactPath(relativePath);
3058
- }
3059
- function isUploadArtifact(relativePath) {
3060
- return relativePath === "widget.js" || relativePath === "manifest.json" || isWidgetPackageCssArtifactPath(relativePath) || isWidgetPackageJsMapArtifactPath(relativePath);
3061
- }
3062
- function isAllowedGeneratedFile(relativePath) {
3063
- return isFlatArtifactPath(relativePath) && relativePath === "publish-manifest.json";
3064
- }
3065
- function isFlatArtifactPath(relativePath) {
3066
- return relativePath.length > 0 && !relativePath.includes("/") && !relativePath.includes("\\");
3067
- }
3068
- function throwUnsupportedArtifactPath(relativePath) {
3069
- throw new Error(`Unsupported widget publish artifact ${JSON.stringify(relativePath)}. Allowed files are widget.js, manifest.json, top-level [A-Za-z0-9._~-]+.css, top-level [A-Za-z0-9._~-]+.js.map, and publish-manifest.json.`);
3070
- }
3071
- function toPosixPath$1(value) {
3072
- return value.split(path.sep).join("/");
3073
- }
3074
- //#endregion
3075
- //#region ../../portal/core/src/remote-dom-widget-package.ts
3076
- const UNSAFE_JSON_KEYS = new Set([
3077
- "__proto__",
3078
- "constructor",
3079
- "prototype"
3080
- ]);
3081
- function isJsonValue(value) {
3082
- return isJsonValueAtDepth(value, /* @__PURE__ */ new WeakSet(), 0);
3083
- }
3084
- function isJsonValueAtDepth(value, ancestors, depth) {
3085
- if (value === null || typeof value === "string" || typeof value === "boolean") return true;
3086
- if (typeof value === "number") return Number.isFinite(value);
3087
- if (typeof value !== "object" || depth > 50 || ancestors.has(value)) return false;
3088
- ancestors.add(value);
3089
- 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));
3091
- ancestors.delete(value);
3092
- return isJson;
3093
- }
3094
- function isRecord$4(value) {
3095
- return typeof value === "object" && value !== null && !Array.isArray(value);
3096
- }
3097
- //#endregion
3098
- //#region src/utils/widget-package-validation.ts
3099
- const URL_SAFE_SEGMENT_SOURCE = "[A-Za-z0-9][A-Za-z0-9_~-]*";
3100
- const URL_SAFE_WIDGET_NAME_PATTERN = new RegExp(`^${URL_SAFE_SEGMENT_SOURCE}$`);
3101
- const URL_SAFE_DOTTED_IDENTIFIER_PATTERN = new RegExp(`^${URL_SAFE_SEGMENT_SOURCE}(?:\\.${URL_SAFE_SEGMENT_SOURCE})*$`);
3102
- const SEMVER_CORE_PATTERN = "(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)";
3103
- const SEMVER_PRERELEASE_IDENTIFIER_PATTERN = "(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)";
3104
- const SEMVER_URL_SAFE_PATTERN = new RegExp(`^${SEMVER_CORE_PATTERN}(?:-${SEMVER_PRERELEASE_IDENTIFIER_PATTERN}(?:\\.${SEMVER_PRERELEASE_IDENTIFIER_PATTERN})*)?$`);
3105
- const WIDGET_PACKAGE_MANIFEST_VERSION = 1;
3106
- const DEFAULT_WIDGET_ICON = "box";
3107
- const DEFAULT_WIDGET_CATEGORY = "components";
3108
- const DEFAULT_WIDGET_CONTAINER = "block";
3109
- const DEFAULT_MIN_SDK_VERSION = "0.0.0";
3110
- function validateSingleSourceWidgetPackage(sourcePackages, options = {}) {
3111
- if (sourcePackages.length === 0) return validationFailure({
3112
- code: "NO_SOURCE_PACKAGE",
3113
- message: "No defineWidgetPackage source package was found. Export widgetPackage, widgetPackages, or a default widget package from src/widgets.config.ts."
3114
- });
3115
- if (sourcePackages.length > 1) return validationFailure({
3116
- code: "MULTIPLE_SOURCE_PACKAGES",
3117
- message: "Publish/deploy supports exactly one defineWidgetPackage source package. Export one package or split packages into separate builds."
3118
- });
3119
- const sourcePackageValue = sourcePackages[0];
3120
- if (!sourcePackageValue) return validationFailure({
3121
- code: "NO_SOURCE_PACKAGE",
3122
- message: "No defineWidgetPackage source package was found."
3123
- });
3124
- const shapeErrors = validateSourcePackageShape(sourcePackageValue);
3125
- if (shapeErrors.length > 0) return {
3126
- success: false,
3127
- errors: shapeErrors
3128
- };
3129
- const sourcePackage = sourcePackageValue;
3130
- const errors = [];
3131
- const owner = options.owner ?? sourcePackage.packageType;
3132
- if (owner !== "company" && owner !== "droplet") errors.push({
3133
- code: "UNSUPPORTED_OWNER",
3134
- path: "packageType",
3135
- message: "Widget package publish/deploy currently supports company and droplet owners. Set packageType to \"company\" or \"droplet\"."
3136
- });
3137
- const resolvedOwner = owner === "droplet" ? "droplet" : "company";
3138
- const packageKey = resolvePackageKey(sourcePackage, resolvedOwner);
3139
- const packageKeyPath = readNonEmptyTrimmedString(sourcePackage.packageStableId) ? "packageStableId" : "packageId";
3140
- if (!isUrlSafeDottedIdentifier(packageKey)) errors.push({
3141
- code: "INVALID_PACKAGE_KEY",
3142
- path: packageKeyPath,
3143
- message: "Widget package key must be URL-safe dot-separated text (letters, numbers, '.', '_', '~', and '-' only)."
3144
- });
3145
- if (!isUrlSafeSemver(sourcePackage.version)) errors.push({
3146
- code: "INVALID_VERSION",
3147
- path: "version",
3148
- message: "Widget package version must be a URL-safe SemVer version without build metadata (for example 1.2.3 or 1.2.3-beta.1)."
3149
- });
3150
- if (sourcePackage.widgets.length === 0) errors.push({
3151
- code: "NO_WIDGETS",
3152
- path: "widgets",
3153
- message: "Widget package must include at least one widget."
3154
- });
3155
- const packageId = `${resolvedOwner}.${packageKey}`;
3156
- const seenNames = /* @__PURE__ */ new Set();
3157
- const seenTypes = /* @__PURE__ */ new Set();
3158
- const widgets = [];
3159
- sourcePackage.widgets.forEach((widget, index) => {
3160
- const path = `widgets[${index}]`;
3161
- if (!isUrlSafeWidgetName(widget.name)) {
3162
- errors.push({
3163
- code: "INVALID_WIDGET_NAME",
3164
- path: `${path}.name`,
3165
- message: "Widget name must be URL-safe text (letters, numbers, '_', '~', and '-' only) and cannot be empty."
3166
- });
3167
- return;
3168
- }
3169
- if (seenNames.has(widget.name)) {
3170
- errors.push({
3171
- code: "DUPLICATE_WIDGET",
3172
- path: `${path}.name`,
3173
- message: `Duplicate widget name "${widget.name}" found in package ${packageKey}.`
3174
- });
3175
- return;
3176
- }
3177
- seenNames.add(widget.name);
3178
- const type = `${packageId}.${widget.name}`;
3179
- if (!isUrlSafeDottedIdentifier(type)) {
3180
- errors.push({
3181
- code: "INVALID_WIDGET_TYPE",
3182
- path: `${path}.type`,
3183
- message: `Generated widget type "${type}" is not URL-safe.`
3184
- });
3185
- return;
3186
- }
3187
- if (seenTypes.has(type)) {
3188
- errors.push({
3189
- code: "DUPLICATE_WIDGET",
3190
- path: `${path}.type`,
3191
- message: `Duplicate generated widget type "${type}" found.`
3192
- });
3193
- return;
3194
- }
3195
- seenTypes.add(type);
3196
- widgets.push(sourceWidgetToDescriptor(widget, type));
3197
- });
3198
- if (errors.length > 0) return {
3199
- success: false,
3200
- errors
3201
- };
3202
- return {
3203
- success: true,
3204
- value: {
3205
- sourcePackage,
3206
- packageKey,
3207
- packageId,
3208
- owner: resolvedOwner,
3209
- version: sourcePackage.version,
3210
- widgets
3211
- },
3212
- errors: []
3213
- };
3214
- }
3215
- function buildWidgetPackagePublishManifest(validated, options = {}) {
3216
- return {
3217
- manifestVersion: WIDGET_PACKAGE_MANIFEST_VERSION,
3218
- runtime: "remote-dom",
3219
- packageId: validated.packageId,
3220
- source: validated.owner,
3221
- version: validated.version,
3222
- workerEntryUrl: toWidgetPackageArtifactPath("widget.js"),
3223
- cssUrls: mergeCssUrls(validated.sourcePackage.cssUrls, options.cssUrls),
3224
- assetUrls: [],
3225
- widgets: validated.widgets.map(({ name: _name, ...widget }) => widget)
3226
- };
3227
- }
3228
- function sourceWidgetToDescriptor(widget, type) {
3229
- const displayName = widget.displayName ?? widget.name;
3230
- return {
3231
- type,
3232
- name: widget.name,
3233
- displayName,
3234
- description: widget.description ?? `Custom widget ${displayName}`,
3235
- icon: widget.icon ?? DEFAULT_WIDGET_ICON,
3236
- category: widget.category ?? DEFAULT_WIDGET_CATEGORY,
3237
- propertySchema: normalizePropertySchema(widget.propertySchema, type),
3238
- defaultProps: widget.defaultProps ?? {},
3239
- container: normalizeContainer(widget.container),
3240
- minSdkVersion: widget.minSdkVersion ?? DEFAULT_MIN_SDK_VERSION,
3241
- resizable: normalizeResizable(widget.resizable),
3242
- capabilities: normalizeCapabilities(widget.capabilities ?? [])
3243
- };
3244
- }
3245
- function normalizeCapabilities(capabilities) {
3246
- return capabilities.map((capability) => ({
3247
- name: capability.name,
3248
- version: capability.version ?? "1"
3249
- }));
3250
- }
3251
- function validationFailure(error) {
3252
- return {
3253
- success: false,
3254
- errors: [error]
3255
- };
3256
- }
3257
- function validateSourcePackageShape(value) {
3258
- if (!isRecord$3(value)) return [{
3259
- code: "INVALID_SOURCE_PACKAGE",
3260
- message: "Widget source package must be a JSON object."
3261
- }];
3262
- const errors = [];
3263
- requireStringField(value, "packageId", "packageId", errors);
3264
- requireStringField(value, "version", "version", errors);
3265
- validateOptionalStringField(value, "scope", "scope", errors);
3266
- validateOptionalStringField(value, "packageStableId", "packageStableId", errors);
3267
- validateOptionalStringField(value, "packageType", "packageType", errors);
3268
- if (value.cssUrls !== void 0) if (!Array.isArray(value.cssUrls)) errors.push({
3269
- code: "INVALID_SOURCE_PACKAGE",
3270
- path: "cssUrls",
3271
- message: "Widget package cssUrls must be an array of strings when present."
3272
- });
3273
- else value.cssUrls.forEach((cssUrl, index) => {
3274
- validateCssUrl(cssUrl, `cssUrls[${index}]`, errors);
3275
- });
3276
- if (!Array.isArray(value.widgets)) {
3277
- errors.push({
3278
- code: "INVALID_SOURCE_PACKAGE",
3279
- path: "widgets",
3280
- message: "Widget package widgets must be an array."
3281
- });
3282
- return errors;
3283
- }
3284
- value.widgets.forEach((widget, index) => {
3285
- const widgetPath = `widgets[${index}]`;
3286
- if (!isRecord$3(widget)) {
3287
- errors.push({
3288
- code: "INVALID_WIDGET_METADATA",
3289
- path: widgetPath,
3290
- message: "Widget metadata must be a JSON object."
3291
- });
3292
- return;
3293
- }
3294
- requireStringField(widget, "name", `${widgetPath}.name`, errors, {
3295
- code: "INVALID_WIDGET_NAME",
3296
- message: "Widget name must be a string and cannot be empty."
3297
- });
3298
- validateOptionalStringField(widget, "displayName", `${widgetPath}.displayName`, errors, { requireNonEmpty: true });
3299
- validateOptionalStringField(widget, "description", `${widgetPath}.description`, errors, { requireNonEmpty: true });
3300
- validateOptionalStringField(widget, "icon", `${widgetPath}.icon`, errors, { requireNonEmpty: true });
3301
- validateOptionalStringField(widget, "category", `${widgetPath}.category`, errors, { requireNonEmpty: true });
3302
- validateOptionalStringField(widget, "minSdkVersion", `${widgetPath}.minSdkVersion`, errors, { requireNonEmpty: true });
3303
- validateOptionalJsonRecordField(widget, "propertySchema", `${widgetPath}.propertySchema`, errors);
3304
- validateOptionalJsonRecordField(widget, "defaultProps", `${widgetPath}.defaultProps`, errors);
3305
- validateOptionalCapabilities(widget, "capabilities", `${widgetPath}.capabilities`, errors);
3306
- });
3307
- return errors;
3308
- }
3309
- function requireStringField(record, key, path, errors, override) {
3310
- const value = record[key];
3311
- if (typeof value === "string" && value.trim().length > 0) return;
3312
- errors.push({
3313
- code: override?.code ?? "INVALID_SOURCE_PACKAGE",
3314
- path,
3315
- message: override?.message ?? `${path} must be a string and cannot be empty.`
3316
- });
3317
- }
3318
- function validateOptionalStringField(record, key, path, errors, options = {}) {
3319
- const value = record[key];
3320
- if (value === void 0) return;
3321
- if (typeof value === "string") {
3322
- if (!options.requireNonEmpty || value.trim().length > 0) return;
3323
- errors.push({
3324
- code: "INVALID_SOURCE_PACKAGE",
3325
- path,
3326
- message: `${path} must be a non-empty string when present.`
3327
- });
3328
- return;
3329
- }
3330
- errors.push({
3331
- code: "INVALID_SOURCE_PACKAGE",
3332
- path,
3333
- message: `${path} must be a string when present.`
3334
- });
3335
- }
3336
- function validateCssUrl(value, cssUrlPath, errors) {
3337
- if (typeof value !== "string" || value.trim().length === 0) {
3338
- errors.push({
3339
- code: "INVALID_SOURCE_PACKAGE",
3340
- path: cssUrlPath,
3341
- message: "Widget package cssUrls entries must be non-empty strings."
3342
- });
3343
- return;
3344
- }
3345
- if (!isWidgetPackageCssArtifactPath(value)) errors.push({
3346
- code: "INVALID_SOURCE_PACKAGE",
3347
- path: cssUrlPath,
3348
- message: "Widget package cssUrls entries must be top-level generated CSS artifact filenames matching [A-Za-z0-9._~-]+.css. Self-host external CSS or fonts via @font-face in widget CSS so the build can isolate them."
3349
- });
3350
- }
3351
- function validateOptionalJsonRecordField(record, key, path, errors) {
3352
- const value = record[key];
3353
- if (value === void 0) return;
3354
- if (isRecord$3(value) && isJsonValue(value)) return;
3355
- errors.push({
3356
- code: "INVALID_WIDGET_METADATA",
3357
- path,
3358
- message: `${path} must be a JSON object when present.`
3359
- });
3360
- }
3361
- function validateOptionalCapabilities(record, key, path, errors) {
3362
- const value = record[key];
3363
- if (value === void 0) return;
3364
- if (!Array.isArray(value)) {
3365
- errors.push({
3366
- code: "INVALID_WIDGET_METADATA",
3367
- path,
3368
- message: `${path} must be an array when present.`
3369
- });
3370
- return;
3371
- }
3372
- const names = /* @__PURE__ */ new Set();
3373
- value.forEach((candidate, index) => {
3374
- const capabilityPath = `${path}[${index}]`;
3375
- if (!isRecord$3(candidate)) {
3376
- errors.push({
3377
- code: "INVALID_WIDGET_METADATA",
3378
- path: capabilityPath,
3379
- message: `${capabilityPath} must be a JSON object.`
3380
- });
3381
- return;
3382
- }
3383
- const name = candidate.name;
3384
- if (!isCanonicalMetadataString(name)) {
3385
- errors.push({
3386
- code: "INVALID_WIDGET_METADATA",
3387
- path: `${capabilityPath}.name`,
3388
- message: `${capabilityPath}.name must be a non-empty string without surrounding whitespace or control characters.`
3389
- });
3390
- return;
3391
- }
3392
- const version = candidate.version;
3393
- if (version !== void 0 && !isCanonicalMetadataString(version)) errors.push({
3394
- code: "INVALID_WIDGET_METADATA",
3395
- path: `${capabilityPath}.version`,
3396
- message: `${capabilityPath}.version must be a non-empty string without surrounding whitespace or control characters when present.`
3397
- });
3398
- if (names.has(name)) {
3399
- errors.push({
3400
- code: "INVALID_WIDGET_METADATA",
3401
- path: `${capabilityPath}.name`,
3402
- message: `${path} must not contain duplicate capability name ${JSON.stringify(name)}.`
3403
- });
3404
- return;
3405
- }
3406
- names.add(name);
3407
- });
3408
- }
3409
- function isCanonicalMetadataString(value) {
3410
- return typeof value === "string" && value.length > 0 && value === value.trim() && !/\p{Cc}/u.test(value);
3411
- }
3412
- function mergeCssUrls(sourceCssUrls, generatedCssUrls) {
3413
- const generatedCssUrlSet = new Set(generatedCssUrls ?? []);
3414
- generatedCssUrls?.forEach((cssUrl, index) => {
3415
- validateGeneratedCssArtifactUrl(cssUrl, `generated cssUrls[${index}]`);
3416
- });
3417
- sourceCssUrls?.forEach((cssUrl, index) => {
3418
- validateGeneratedCssArtifactUrl(cssUrl, `cssUrls[${index}]`);
3419
- if (generatedCssUrlSet.has(cssUrl)) return;
3420
- throw new Error(`cssUrls[${index}] URL ${JSON.stringify(cssUrl)} must match a generated top-level CSS artifact. Import the CSS from the widget package bundle or remove it from cssUrls.`);
3421
- });
3422
- return Array.from(new Set([...sourceCssUrls ?? [], ...generatedCssUrls ?? []])).map(toWidgetPackageArtifactPath);
3423
- }
3424
- function toWidgetPackageArtifactPath(value) {
3425
- return value;
3426
- }
3427
- function validateGeneratedCssArtifactUrl(value, cssUrlPath) {
3428
- if (value.trim().length === 0) throw new Error(`${cssUrlPath} must be a non-empty string.`);
3429
- if (!isWidgetPackageCssArtifactPath(value)) throw new Error(`${cssUrlPath} must be a top-level generated CSS artifact filename matching [A-Za-z0-9._~-]+.css. Self-host external CSS or fonts via @font-face in widget CSS so the build can isolate them.`);
3430
- }
3431
- function normalizePropertySchema(value, widgetType) {
3432
- return {
3433
- ...value ?? {},
3434
- widgetType
3435
- };
3436
- }
3437
- function normalizeContainer(value) {
3438
- if (value === "inline" || value === "block" || value === "card" || value === "fullscreen") return value;
3439
- return DEFAULT_WIDGET_CONTAINER;
3440
- }
3441
- function normalizeResizable(value) {
3442
- if (typeof value === "boolean") return value;
3443
- if (value === "horizontal" || value === "vertical" || value === "both") return value;
3444
- if (!isRecord$3(value)) return false;
3445
- const horizontal = value.horizontal === true;
3446
- const vertical = value.vertical === true;
3447
- if (horizontal && vertical) return "both";
3448
- if (horizontal) return "horizontal";
3449
- if (vertical) return "vertical";
3450
- return false;
3451
- }
3452
- function resolvePackageKey(sourcePackage, owner) {
3453
- const packageStableId = readNonEmptyTrimmedString(sourcePackage.packageStableId);
3454
- if (packageStableId) return prefixPackageStableIdWithNonOwnerScope(packageStableId, sourcePackage.scope);
3455
- return stripOwnerScopeFromPackageId(sourcePackage.packageId, sourcePackage.scope, owner);
3456
- }
3457
- function prefixPackageStableIdWithNonOwnerScope(packageStableId, sourceScope) {
3458
- const sourceScopeText = readNonEmptyTrimmedString(sourceScope);
3459
- if (!sourceScopeText || isWidgetPackageOwnerKind(sourceScopeText)) return packageStableId;
3460
- const sourceScopePrefix = `${sourceScopeText}.`;
3461
- if (packageStableId.startsWith(sourceScopePrefix)) return packageStableId;
3462
- return `${sourceScopeText}.${packageStableId}`;
3463
- }
3464
- function stripOwnerScopeFromPackageId(packageId, sourceScope, owner) {
3465
- const ownerPrefix = `${owner}.`;
3466
- if (packageId.startsWith(ownerPrefix) && packageId.length > ownerPrefix.length) return packageId.slice(ownerPrefix.length);
3467
- const sourceScopeText = readNonEmptyTrimmedString(sourceScope);
3468
- if (isWidgetPackageOwnerKind(sourceScopeText) && packageId.startsWith(`${sourceScopeText}.`) && packageId.length > sourceScopeText.length + 1) return packageId.slice(sourceScopeText.length + 1);
3469
- return packageId;
3470
- }
3471
- function readNonEmptyTrimmedString(value) {
3472
- const trimmed = value?.trim();
3473
- return trimmed ? trimmed : void 0;
3474
- }
3475
- function isWidgetPackageOwnerKind(value) {
3476
- return value === "company" || value === "droplet";
3477
- }
3478
- function isUrlSafeDottedIdentifier(value) {
3479
- return URL_SAFE_DOTTED_IDENTIFIER_PATTERN.test(value);
3480
- }
3481
- function isUrlSafeWidgetName(value) {
3482
- return URL_SAFE_WIDGET_NAME_PATTERN.test(value);
3483
- }
3484
- function isUrlSafeSemver(value) {
3485
- return SEMVER_URL_SAFE_PATTERN.test(value);
3486
- }
3487
- function isRecord$3(value) {
3488
- return typeof value === "object" && value !== null && !Array.isArray(value);
3489
- }
3490
- //#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 = {}) {
3505
- const label = options.optionName ?? "publishDir";
3506
- if (path.isAbsolute(publishDir)) throw new Error(`${label} must be relative to the project directory.`);
3507
- const normalizedPublishDir = normalizePublishDirForValidation(publishDir);
3508
- if (normalizedPublishDir === ".fluid" || !normalizedPublishDir.startsWith(".fluid/")) throw new Error(`${label} must be a relative path under .fluid/.`);
3509
- if (isPathWithinReservedDir(normalizedPublishDir, TEMP_BUILD_DIR)) throw new Error(`${label} must not be ${TEMP_BUILD_DIR} because it is reserved for temporary builder output.`);
3510
- if (isPathWithinReservedDir(normalizedPublishDir, TEMP_CLI_DIR)) throw new Error(`${label} must not be ${TEMP_CLI_DIR} because it is reserved for Fluid CLI temporary files.`);
3511
- const resolvedProjectDir = path.resolve(projectDir);
3512
- const resolvedPublishDir = path.resolve(resolvedProjectDir, publishDir);
3513
- const relative = path.relative(resolvedProjectDir, resolvedPublishDir);
3514
- if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`${label} must stay inside the project directory.`);
3515
- }
3516
- async function buildSharedWidgetPackage(options) {
3517
- const publishDirInput = options.publishDir ?? DEFAULT_PUBLISH_DIR;
3518
- try {
3519
- await validateWidgetPackageOutputPaths(options.projectDir, publishDirInput);
3520
- } catch (err) {
3521
- return failure({
3522
- code: "INVALID_PUBLISH_DIR",
3523
- message: "Unsafe widget package publish directory",
3524
- details: (err instanceof Error ? err : new Error(String(err))).message
3525
- });
3526
- }
3527
- const sourcePackageResult = await loadSourceWidgetPackages(options.projectDir);
3528
- if (!sourcePackageResult.success) return failure({
3529
- code: "CONFIG_LOAD_FAILED",
3530
- message: sourcePackageResult.error.message,
3531
- details: sourcePackageResult.error.details
3532
- });
3533
- const validation = validateSingleSourceWidgetPackage(sourcePackageResult.value, { owner: options.owner });
3534
- if (!validation.success || !validation.value) return failure({
3535
- code: "VALIDATION_FAILED",
3536
- message: validation.errors.map((error) => error.message).join("\n")
3537
- });
3538
- try {
3539
- await validateWidgetPackageOutputPaths(options.projectDir, publishDirInput);
3540
- const publishDir = path.resolve(options.projectDir, publishDirInput);
3541
- const widgetScriptPath = path.join(publishDir, "widget.js");
3542
- await fs.emptyDir(publishDir);
3543
- const tempDir = path.resolve(options.projectDir, TEMP_BUILD_DIR);
3544
- await fs.emptyDir(tempDir);
3545
- let buildFailure;
3546
- try {
3547
- await buildWidgetScriptBundle({
3548
- projectDir: options.projectDir,
3549
- tempDir,
3550
- publishDir,
3551
- validated: validation.value
3552
- });
3553
- } catch (error) {
3554
- buildFailure = { error };
3555
- }
3556
- try {
3557
- await fs.remove(tempDir);
3558
- } catch {
3559
- const cleanupFailure = /* @__PURE__ */ new Error("Failed to remove reserved temporary build directory.");
3560
- if (buildFailure) {
3561
- const primaryError = buildFailure.error instanceof Error ? buildFailure.error : new Error(String(buildFailure.error));
3562
- throw new AggregateError([primaryError, cleanupFailure], `${primaryError.message}\n${cleanupFailure.message}`);
3563
- }
3564
- throw cleanupFailure;
3565
- }
3566
- if (buildFailure) throw buildFailure.error;
3567
- const cssUrls = await collectCssUrls(publishDir);
3568
- const descriptor = buildWidgetPackagePublishManifest(validation.value, { cssUrls });
3569
- const manifestContent = stableStringify(descriptor);
3570
- const publishPackageId = descriptor.packageId;
3571
- const manifestPath = path.join(publishDir, "manifest.json");
3572
- const publishManifestPath = path.join(publishDir, "publish-manifest.json");
3573
- await fs.writeFile(manifestPath, manifestContent, "utf-8");
3574
- const artifacts = await collectWidgetPackageArtifacts(publishDir);
3575
- const publishManifest = createPublishManifestMetadata({
3576
- packageId: publishPackageId,
3577
- version: validation.value.version,
3578
- cliVersion: readCliVersion(),
3579
- runtimeVersion: DEFAULT_RUNTIME_VERSION,
3580
- manifestContent,
3581
- artifacts
3582
- });
3583
- await fs.writeFile(publishManifestPath, stableStringify(publishManifest), "utf-8");
3584
- return success({
3585
- publishDir,
3586
- packageId: validation.value.packageId,
3587
- version: validation.value.version,
3588
- manifestPath,
3589
- publishManifestPath,
3590
- widgetScriptPath
3591
- });
3592
- } catch (err) {
3593
- const error = err instanceof Error ? err : new Error(String(err));
3594
- const isPublishDirValidationError = /^(publishDir|reserved temporary build directory) /.test(error.message);
3595
- return failure({
3596
- code: isPublishDirValidationError ? "INVALID_PUBLISH_DIR" : "WRITE_FAILED",
3597
- message: isPublishDirValidationError ? "Unsafe widget package publish directory" : "Failed to build shared widget package artifacts",
3598
- details: error.message
3599
- });
3600
- }
3601
- }
3602
- function createWidgetPackageEntrySource(options) {
3603
- return `import { createElement } from "react";
3604
- import { startWidgetPackage } from "@fluid-app/portal-sdk/widgets/worker";
3605
- ${options.cssImportPath ? `import ${JSON.stringify(options.cssImportPath)};` : ""}
3606
-
3607
- const widgetConfig = await import(${JSON.stringify(options.configImportPath)});
3608
- const SOURCE_PACKAGE_MARKER = "__fluidSourceWidgetPackage";
3609
- const descriptors = ${JSON.stringify(options.validated.widgets, null, 2)};
3610
-
3611
- function isSourceWidgetPackage(value) {
3612
- return Boolean(value && typeof value === "object" && value[SOURCE_PACKAGE_MARKER] === true);
3613
- }
3614
-
3615
- function collectSourceWidgetPackages(mod) {
3616
- return [
3617
- mod.widgetPackage,
3618
- ...(Array.isArray(mod.widgetPackages) ? mod.widgetPackages : []),
3619
- mod.default,
3620
- ].filter(isSourceWidgetPackage);
3621
- }
3622
-
3623
- const sourcePackage = collectSourceWidgetPackages(widgetConfig)[0];
3624
- if (!sourcePackage) {
3625
- throw new Error("No Fluid source widget package found while loading widget package worker bundle.");
3626
- }
3627
-
3628
- const componentsByName = new Map(
3629
- sourcePackage.widgets.map((widget) => [widget.name, widget.component]),
3630
- );
3631
-
3632
- const widgets = descriptors.map((descriptor) => {
3633
- const component = componentsByName.get(descriptor.name);
3634
- if (typeof component !== "function") {
3635
- throw new Error("Widget package is missing Remote DOM component for " + descriptor.name + ".");
3636
- }
3637
- return {
3638
- ...descriptor,
3639
- component: (props) => createElement(component, props),
3640
- };
3641
- });
3642
-
3643
- startWidgetPackage({ widgets });
3644
- `;
3645
- }
3646
- async function buildWidgetScriptBundle(options) {
3647
- const sourceConfig = await resolvePortalWidgetSourceConfig(options.projectDir);
3648
- if (!sourceConfig) throw new Error("No portal widget source config found.");
3649
- const entryPath = path.join(options.tempDir, "widget-entry.ts");
3650
- const viteConfigPath = path.join(options.tempDir, "vite.config.mjs");
3651
- const rootStylesPath = path.join(options.projectDir, "styles.css");
3652
- await fs.writeFile(entryPath, createWidgetPackageEntrySource({
3653
- configImportPath: pathToFileURL(sourceConfig.path).href,
3654
- cssImportPath: await fs.pathExists(rootStylesPath) ? pathToFileURL(rootStylesPath).href : void 0,
3655
- validated: options.validated
3656
- }), "utf-8");
3657
- await fs.writeFile(viteConfigPath, createViteConfigSource({
3658
- entryPath,
3659
- publishDir: options.publishDir,
3660
- projectConfigPath: await resolveProjectViteConfigPath(options.projectDir)
3661
- }), "utf-8");
3662
- const viteCliPath = resolveViteCliPath(options.projectDir);
3663
- await execa(process.execPath, [
3664
- viteCliPath,
3665
- "build",
3666
- "--config",
3667
- viteConfigPath
3668
- ], {
3669
- cwd: options.projectDir,
3670
- stdio: "pipe",
3671
- env: {
3672
- ...process.env,
3673
- NODE_ENV: "production"
3674
- }
3675
- });
3676
- }
3677
- function resolveViteCliPath(projectDir) {
3678
- const projectViteCliPath = resolveViteCliPathFromRequire(createRequire(path.join(projectDir, "package.json")));
3679
- if (projectViteCliPath) return projectViteCliPath;
3680
- const cliViteCliPath = resolveViteCliPathFromRequire(require);
3681
- if (cliViteCliPath) return cliViteCliPath;
3682
- throw new Error("Unable to resolve Vite for the portal project. Install the project dependencies and ensure vite is available before building widget packages.");
3683
- }
3684
- async function resolveProjectViteConfigPath(projectDir) {
3685
- for (const fileName of PROJECT_VITE_CONFIG_CANDIDATES) {
3686
- const candidate = path.join(projectDir, fileName);
3687
- if (await fs.pathExists(candidate)) return candidate;
3688
- }
3689
- }
3690
- function createViteConfigSource(options) {
3691
- const widgetConfigSource = createWidgetViteConfigObjectSource({
3692
- ...options,
3693
- disableConfigFileLookup: !options.projectConfigPath
3694
- });
3695
- if (!options.projectConfigPath) return `import { defineConfig } from "vite";
3696
-
3697
- export default defineConfig(${widgetConfigSource});
3698
- `;
3699
- return `import { defineConfig, loadConfigFromFile, mergeConfig } from "vite";
3700
-
3701
- const projectConfigPath = ${JSON.stringify(options.projectConfigPath)};
3702
- const widgetConfig = ${widgetConfigSource};
3703
- const fluidWidgetDefineInvariants = {
3704
- "process.env.NODE_ENV": JSON.stringify("production"),
3705
- };
3706
- const blockedFluidPluginNames = new Set(${JSON.stringify(BLOCKED_FLUID_PROJECT_PLUGIN_NAMES)});
3707
-
3708
- function isAllowedProjectPlugin(plugin) {
3709
- return !plugin || typeof plugin !== "object" || !blockedFluidPluginNames.has(plugin.name);
3710
- }
3711
-
3712
- function sanitizeProjectPlugins(pluginOption) {
3713
- if (!Array.isArray(pluginOption)) {
3714
- return isAllowedProjectPlugin(pluginOption) ? pluginOption : [];
3715
- }
3716
-
3717
- return pluginOption
3718
- .map((plugin) => Array.isArray(plugin) ? sanitizeProjectPlugins(plugin) : plugin)
3719
- .filter(isAllowedProjectPlugin);
3720
- }
3721
-
3722
- function copyProjectConfigValue(source, target, key) {
3723
- if (Object.prototype.hasOwnProperty.call(source, key)) {
3724
- target[key] = source[key];
3725
- }
3726
- }
3727
-
3728
- function sanitizeProjectConfig(config) {
3729
- if (!config || typeof config !== "object") return {};
3730
-
3731
- const sanitized = {};
3732
- if (Object.prototype.hasOwnProperty.call(config, "plugins")) {
3733
- sanitized.plugins = sanitizeProjectPlugins(config.plugins);
3734
- }
3735
- copyProjectConfigValue(config, sanitized, "resolve");
3736
- copyProjectConfigValue(config, sanitized, "define");
3737
- copyProjectConfigValue(config, sanitized, "css");
3738
- copyProjectConfigValue(config, sanitized, "esbuild");
3739
- copyProjectConfigValue(config, sanitized, "json");
3740
- copyProjectConfigValue(config, sanitized, "assetsInclude");
3741
- copyProjectConfigValue(config, sanitized, "envDir");
3742
- copyProjectConfigValue(config, sanitized, "envPrefix");
3743
- return sanitized;
3744
- }
3745
-
3746
- function copyConfigDefineValue(value) {
3747
- return value && typeof value === "object" && !Array.isArray(value)
3748
- ? { ...value }
3749
- : {};
3750
- }
3751
-
3752
- function mergeFluidWidgetDefineInvariants(mergedConfig, safeProjectConfig, widgetConfig) {
3753
- mergedConfig.define = {
3754
- ...copyConfigDefineValue(safeProjectConfig.define),
3755
- ...copyConfigDefineValue(mergedConfig.define),
3756
- ...copyConfigDefineValue(widgetConfig.define),
3757
- ...fluidWidgetDefineInvariants,
3758
- };
3759
- return mergedConfig;
3760
- }
3761
-
3762
- export default defineConfig(async (configEnv) => {
3763
- const projectConfig = await loadConfigFromFile(configEnv, projectConfigPath);
3764
- const safeProjectConfig = sanitizeProjectConfig(projectConfig?.config ?? {});
3765
- return mergeFluidWidgetDefineInvariants(mergeConfig(safeProjectConfig, widgetConfig), safeProjectConfig, widgetConfig);
3766
- });
3767
- `;
3768
- }
3769
- function createWidgetViteConfigObjectSource(options) {
3770
- const configFileSource = options.disableConfigFileLookup ? " configFile: false,\n" : "";
3771
- return `(() => {
3772
- const fluidWidgetDefineInvariants = {
3773
- "process.env.NODE_ENV": JSON.stringify("production"),
3774
- };
3775
-
3776
- function createFluidWidgetBuildInvariants() {
3777
- return {
3778
- copyPublicDir: false,
3779
- emptyOutDir: false,
3780
- outDir: ${JSON.stringify(options.publishDir)},
3781
- sourcemap: false,
3782
- target: "esnext",
3783
- cssCodeSplit: false,
3784
- lib: {
3785
- entry: ${JSON.stringify(options.entryPath)},
3786
- formats: ["es"],
3787
- fileName: () => "widget.js",
3788
- cssFileName: "widget",
3789
- },
3790
- rollupOptions: {
3791
- output: {
3792
- inlineDynamicImports: true,
3793
- hoistTransitiveImports: false,
3794
- },
3795
- },
3796
- };
3797
- }
3798
-
3799
- function fluidWidgetBuildInvariantsPlugin() {
3800
- return {
3801
- name: "fluid-widget-build-invariants",
3802
- enforce: "post",
3803
- config() {
3804
- return { build: createFluidWidgetBuildInvariants() };
3805
- },
3806
- configResolved(config) {
3807
- config.define = {
3808
- ...(config.define ?? {}),
3809
- ...fluidWidgetDefineInvariants,
3810
- };
3811
- Object.assign(config.build, createFluidWidgetBuildInvariants());
3812
- },
3813
- };
3814
- }
3815
-
3816
- return {
3817
- ${configFileSource} publicDir: false,
3818
- define: fluidWidgetDefineInvariants,
3819
- resolve: {
3820
- conditions: [
3821
- "module",
3822
- "browser",
3823
- "development|production",
3824
- ],
3825
- },
3826
- plugins: [fluidWidgetBuildInvariantsPlugin()],
3827
- build: createFluidWidgetBuildInvariants(),
3828
- };
3829
- })()`;
3830
- }
3831
- async function validateWidgetPackageOutputPaths(projectDir, publishDir) {
3832
- validateSharedWidgetPackagePublishDir(projectDir, publishDir);
3833
- await rejectSymlinkedPathSegments({
3834
- projectDir,
3835
- relativePath: publishDir,
3836
- label: "publishDir"
3837
- });
3838
- await rejectSymlinkedPathSegments({
3839
- projectDir,
3840
- relativePath: TEMP_BUILD_DIR,
3841
- label: "reserved temporary build directory"
3842
- });
3843
- }
3844
- async function collectCssUrls(publishDir) {
3845
- return (await fs.readdir(publishDir, { withFileTypes: true })).filter((entry) => entry.isFile() && isWidgetPackageCssArtifactPath(entry.name)).map((entry) => entry.name).sort();
3846
- }
3847
- async function rejectSymlinkedPathSegments(options) {
3848
- const resolvedProjectDir = path.resolve(options.projectDir);
3849
- const resolvedTargetPath = path.resolve(resolvedProjectDir, options.relativePath);
3850
- const segments = path.relative(resolvedProjectDir, resolvedTargetPath).split(path.sep).filter(Boolean);
3851
- let currentPath = resolvedProjectDir;
3852
- for (const segment of segments) {
3853
- currentPath = path.join(currentPath, segment);
3854
- let stat;
3855
- try {
3856
- stat = await fs.lstat(currentPath);
3857
- } catch (err) {
3858
- if (isNodeError(err) && err.code === "ENOENT") return;
3859
- throw err;
3860
- }
3861
- if (stat.isSymbolicLink()) throw new Error(`${options.label} must not include symbolic links.`);
3862
- }
3863
- }
3864
- function normalizePublishDirForValidation(value) {
3865
- return toPosixPath(path.normalize(value)).replace(/\/+$/, "") || ".";
3866
- }
3867
- function isPathWithinReservedDir(value, reservedDir) {
3868
- return value === reservedDir || value.startsWith(`${reservedDir}/`);
3869
- }
3870
- function toPosixPath(value) {
3871
- return value.split(path.sep).join("/");
3872
- }
3873
- function stableStringify(value) {
3874
- return `${JSON.stringify(value, null, 2)}\n`;
3875
- }
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
- function resolveViteCliPathFromRequire(moduleRequire) {
3895
- try {
3896
- const packageJsonPath = moduleRequire.resolve("vite/package.json");
3897
- const binPath = getViteBinPath(moduleRequire(packageJsonPath).bin);
3898
- if (!binPath) return void 0;
3899
- return path.resolve(path.dirname(packageJsonPath), binPath);
3900
- } catch (err) {
3901
- if (isModuleResolutionError(err)) return void 0;
3902
- throw err;
3903
- }
3904
- }
3905
- function getViteBinPath(bin) {
3906
- if (typeof bin === "string") return bin;
3907
- if (!isRecord$2(bin)) return void 0;
3908
- const viteBin = bin.vite;
3909
- return typeof viteBin === "string" ? viteBin : void 0;
3910
- }
3911
- function isRecord$2(value) {
3912
- return typeof value === "object" && value !== null && !Array.isArray(value);
3913
- }
3914
- function isNodeError(value) {
3915
- return typeof value === "object" && value !== null && "code" in value && typeof value.code === "string";
3916
- }
3917
- function isModuleResolutionError(value) {
3918
- return isNodeError(value) && (value.code === "MODULE_NOT_FOUND" || value.code === "ERR_MODULE_NOT_FOUND");
3919
- }
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
- //#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) {
3941
- return {
3942
- path: artifact.path,
3943
- sha256: artifact.sha256,
3944
- bytes: artifact.bytes,
3945
- contentType: artifact.contentType
3946
- };
3947
- }
3948
- function buildWidgetPackageUploadSessionPayload(request) {
3949
- return {
3950
- version: request.version,
3951
- package_key: request.packageKey,
3952
- builder_version: request.builderVersion,
3953
- cli_version: request.cliVersion,
3954
- runtime_version: request.runtimeVersion,
3955
- manifest_hash: request.manifestHash,
3956
- manifest: request.manifest,
3957
- artifacts: request.artifacts.map(buildWidgetPackageArtifactPayload)
3958
- };
3959
- }
3960
- function buildWidgetPackageCompleteUploadPayload(packageKey, artifacts) {
3961
- return {
3962
- package_key: packageKey,
3963
- artifacts: artifacts.map(buildWidgetPackageArtifactPayload)
3964
- };
3965
- }
3966
- function getWidgetPackageUploadSessionRoute(owner) {
3967
- switch (owner.kind) {
3968
- case "droplet": return `/api/droplets/${encodeURIComponent(owner.uuid)}/widget_package_versions`;
3969
- case "company": return "/api/company/fluid_os/widget_package_versions";
3970
- }
3971
- }
3972
- function getWidgetPackageCompleteUploadRoute(owner, version) {
3973
- switch (owner.kind) {
3974
- case "droplet": return `/api/droplets/${encodeURIComponent(owner.uuid)}/widget_package_versions/${encodeURIComponent(version)}/complete_upload`;
3975
- case "company": return `/api/company/fluid_os/widget_package_versions/${encodeURIComponent(version)}/complete_upload`;
3976
- }
3977
- }
3978
- async function requestWidgetPackageUploadSession(client, owner, request) {
3979
- const payload = buildWidgetPackageUploadSessionPayload(request);
3980
- try {
3981
- return normalizeWidgetPackageUploadSession(owner.kind === "company" ? await fluid_os_v0_create_widget_package_version(client, payload) : await client.post(getWidgetPackageUploadSessionRoute(owner), payload), request.version, request.artifacts);
3982
- } catch (err) {
3983
- if (err instanceof WidgetPackageUploadError) throw err;
3984
- throw new WidgetPackageUploadError("SESSION_REQUEST_FAILED", `Failed to create widget package upload session: ${formatUnknownError(err)}`);
3985
- }
3986
- }
3987
- async function uploadWidgetPackageArtifacts(session, artifacts, fetchImpl = fetch) {
3988
- const uploadsByPath = new Map(session.uploads.map((upload) => [upload.path, upload]));
3989
- const results = [];
3990
- for (const artifact of artifacts) {
3991
- const upload = uploadsByPath.get(artifact.path);
3992
- if (!upload) throw new WidgetPackageUploadError("MISSING_SIGNED_URL", `Upload session did not include a signed URL for artifact "${artifact.path}".`);
3993
- if (upload.contentType !== artifact.contentType) throw new WidgetPackageUploadError("CONTENT_TYPE_MISMATCH", `Upload session expected artifact "${artifact.path}" to use content type "${upload.contentType}", but local artifact uses "${artifact.contentType}".`);
3994
- let response;
3995
- try {
3996
- response = await fetchImpl(upload.url, {
3997
- method: "PUT",
3998
- headers: mergeUploadHeaders(upload.headers, upload.contentType),
3999
- body: artifact.body
4000
- });
4001
- } catch (err) {
4002
- throw new WidgetPackageUploadError("ARTIFACT_UPLOAD_FAILED", `Failed to upload artifact "${artifact.path}": ${formatUnknownError(err)}`);
4003
- }
4004
- if (!response.ok) {
4005
- const body = await response.text().catch(() => "");
4006
- const details = body.trim() ? `: ${body.trim().slice(0, 500)}` : "";
4007
- throw new WidgetPackageUploadError("ARTIFACT_UPLOAD_FAILED", `Failed to upload artifact "${artifact.path}": PUT returned HTTP ${response.status}${details}`);
4008
- }
4009
- results.push({
4010
- path: artifact.path,
4011
- status: response.status,
4012
- contentType: upload.contentType
4013
- });
4014
- }
4015
- return results;
4016
- }
4017
- async function completeWidgetPackageUpload(client, owner, version, packageKey, artifacts) {
4018
- const payload = buildWidgetPackageCompleteUploadPayload(packageKey, artifacts);
4150
+ /**
4151
+ * The whole deploy, as one function returning a result.
4152
+ *
4153
+ * A result rather than a throw because two of its outcomes — a key that belongs
4154
+ * to another package, a version already published — are refusals with a fix to
4155
+ * show, not exceptions.
4156
+ */
4157
+ async function runPortalDeploy(options) {
4019
4158
  try {
4020
- return normalizeWidgetPackageCompleteResponse(owner.kind === "company" ? await fluid_os_v0_complete_widget_package_version_upload(client, version, payload) : await client.post(getWidgetPackageCompleteUploadRoute(owner, version), payload), version);
4021
- } catch (err) {
4022
- if (err instanceof WidgetPackageUploadError) throw err;
4023
- throw new WidgetPackageUploadError("COMPLETE_UPLOAD_FAILED", `Failed to complete widget package upload: ${formatUnknownError(err)}`);
4024
- }
4025
- }
4026
- async function publishWidgetPackageVersion(options) {
4027
- const sessionPayload = buildWidgetPackageUploadSessionPayload(options.request);
4028
- if (options.dryRun) return {
4029
- dryRun: true,
4030
- sessionPayload
4031
- };
4032
- if (!options.client) throw new Error("A FetchClient is required when dryRun is false.");
4033
- const session = await requestWidgetPackageUploadSession(options.client, options.owner, options.request);
4034
- return {
4035
- dryRun: false,
4036
- sessionPayload,
4037
- session,
4038
- uploadedArtifacts: await uploadWidgetPackageArtifacts(session, options.request.artifacts, options.fetchImpl),
4039
- complete: await completeWidgetPackageUpload(options.client, options.owner, session.version, options.request.packageKey, options.request.artifacts)
4040
- };
4041
- }
4042
- function normalizeWidgetPackageUploadSession(response, requestedVersion, requestedArtifacts) {
4043
- const record = requireRecord(response, "INVALID_SESSION_RESPONSE", "Upload session response must be a JSON object.");
4044
- requireFiniteNumberProperty(record, "version_id", "INVALID_SESSION_RESPONSE");
4045
- requireStringProperty(record, "artifact_base_url", "INVALID_SESSION_RESPONSE");
4046
- const uploadValues = record["uploads"];
4047
- if (!Array.isArray(uploadValues)) throw new WidgetPackageUploadError("INVALID_SESSION_RESPONSE", "Upload session response must contain an \"uploads\" array.");
4048
- const requestedArtifactsByPath = new Map(requestedArtifacts.map((artifact) => [artifact.path, artifact]));
4049
- const seenUploadPaths = /* @__PURE__ */ new Set();
4050
- const uploads = uploadValues.map((value, index) => {
4051
- const upload = requireRecord(value, "INVALID_SESSION_RESPONSE", `Upload session uploads[${index}] must be a JSON object.`);
4052
- const path = requireStringProperty(upload, "path", "INVALID_SESSION_RESPONSE");
4053
- const url = requireStringProperty(upload, "upload_url", "INVALID_SESSION_RESPONSE");
4054
- requireStringProperty(upload, "public_url", "INVALID_SESSION_RESPONSE");
4055
- const requestedArtifact = requestedArtifactsByPath.get(path);
4056
- if (!requestedArtifact) throw new WidgetPackageUploadError("INVALID_SESSION_RESPONSE", `Upload session response included unexpected artifact "${path}".`);
4057
- if (seenUploadPaths.has(path)) throw new WidgetPackageUploadError("INVALID_SESSION_RESPONSE", `Upload session response included duplicate artifact "${path}".`);
4058
- seenUploadPaths.add(path);
4159
+ await assertStandalonePortalRemoteActionAllowed(options.projectDir, "portal deploy");
4160
+ } catch (error) {
4059
4161
  return {
4060
- path,
4061
- url,
4062
- contentType: requestedArtifact.contentType,
4063
- headers: {}
4162
+ ok: false,
4163
+ code: "STANDALONE_PORTAL",
4164
+ message: error instanceof Error ? error.message : String(error)
4064
4165
  };
4065
- });
4066
- const missingUploadPaths = requestedArtifacts.map((artifact) => artifact.path).filter((artifactPath) => !seenUploadPaths.has(artifactPath));
4067
- if (missingUploadPaths.length > 0) throw new WidgetPackageUploadError("INVALID_SESSION_RESPONSE", `Upload session response is missing signed URLs for artifact(s): ${missingUploadPaths.join(", ")}.`);
4068
- return {
4069
- version: requestedVersion,
4070
- uploads,
4071
- raw: response
4072
- };
4073
- }
4074
- function normalizeWidgetPackageCompleteResponse(response, requestedVersion) {
4075
- const record = requireRecord(response, "INVALID_COMPLETE_RESPONSE", "Complete upload response must be a JSON object.");
4076
- const responseVersion = requireStringProperty(record, "version", "INVALID_COMPLETE_RESPONSE");
4077
- if (responseVersion !== requestedVersion) throw new WidgetPackageUploadError("INVALID_COMPLETE_RESPONSE", `Complete upload response version "${responseVersion}" did not match requested version "${requestedVersion}".`);
4078
- const packageKey = requireStringProperty(record, "packageId", "INVALID_COMPLETE_RESPONSE");
4079
- const status = requireStringProperty(record, "status", "INVALID_COMPLETE_RESPONSE");
4080
- requireStringProperty(record, "scriptUrl", "INVALID_COMPLETE_RESPONSE");
4081
- requireStringProperty(record, "manifestUrl", "INVALID_COMPLETE_RESPONSE");
4082
- requireStringArrayProperty(record, "cssUrls", "INVALID_COMPLETE_RESPONSE");
4083
- return {
4084
- version: responseVersion,
4085
- status,
4086
- packageKey,
4087
- raw: response
4088
- };
4089
- }
4090
- function normalizeHeaderName(name) {
4091
- return name.toLowerCase();
4092
- }
4093
- function mergeUploadHeaders(headers, contentType) {
4094
- const merged = {};
4095
- for (const [key, value] of Object.entries(headers)) {
4096
- if (normalizeHeaderName(key) === "content-type") continue;
4097
- merged[key] = value;
4098
4166
  }
4099
- merged["Content-Type"] = contentType;
4100
- return merged;
4101
- }
4102
- function requireRecord(value, code, message) {
4103
- if (!isRecord$1(value) || Array.isArray(value)) throw new WidgetPackageUploadError(code, message);
4104
- return value;
4105
- }
4106
- function isRecord$1(value) {
4107
- return typeof value === "object" && value !== null;
4108
- }
4109
- function requireStringProperty(record, key, code) {
4110
- const value = record[key];
4111
- if (typeof value === "string" && value.trim().length > 0) return value;
4112
- throw new WidgetPackageUploadError(code, `Response field "${key}" must be a non-empty string.`);
4113
- }
4114
- function requireFiniteNumberProperty(record, key, code) {
4115
- const value = record[key];
4116
- if (typeof value === "number" && Number.isFinite(value)) return value;
4117
- throw new WidgetPackageUploadError(code, `Response field "${key}" must be a finite number.`);
4118
- }
4119
- function requireStringArrayProperty(record, key, code) {
4120
- const value = record[key];
4121
- if (Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.trim().length > 0)) return value;
4122
- throw new WidgetPackageUploadError(code, `Response field "${key}" must be an array of non-empty strings.`);
4123
- }
4124
- function formatUnknownError(err) {
4125
- const base = err instanceof Error ? err.message : String(err);
4126
- if (isRecord$1(err) && "data" in err) return `${base} — ${safeStringify(err.data)}`;
4127
- return base;
4128
- }
4129
- function safeStringify(value) {
4130
- const seen = /* @__PURE__ */ new WeakSet();
4131
- try {
4132
- return JSON.stringify(value, (_key, item) => {
4133
- if (typeof item === "bigint") return `${item}n`;
4134
- if (typeof item !== "object" || item === null) return item;
4135
- if (seen.has(item)) return "[Circular]";
4136
- seen.add(item);
4137
- return item;
4138
- }) ?? String(value);
4139
- } catch {
4140
- return "[unserializable error data]";
4167
+ let client;
4168
+ if (!options.dryRun) try {
4169
+ client = createAuthenticatedWidgetPackageClient();
4170
+ } catch (error) {
4171
+ return {
4172
+ ok: false,
4173
+ code: "NOT_AUTHENTICATED",
4174
+ message: "Not signed in to Fluid, so the widgets were not published.",
4175
+ ...error instanceof Error ? { hint: error.message } : {}
4176
+ };
4141
4177
  }
4142
- }
4143
- //#endregion
4144
- //#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;
4148
- const isDryRun = options.dryRun === true;
4149
- const client = options.client;
4150
- 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 {
4174
- if (!client) throw new Error("A FetchClient is required when dryRun is false.");
4175
- publish = await publishVersion({
4178
+ let preflight = {};
4179
+ if (client) {
4180
+ options.onProgress?.("Checking this company's widget package...");
4181
+ preflight = await preflightCompanyPackage({
4182
+ projectDir: options.projectDir,
4176
4183
  client,
4177
- owner: options.uploadOwner,
4178
- request
4184
+ readConfiguredPackage: async (dir) => {
4185
+ const { validated } = await loadAndValidateWidgetPackage({
4186
+ projectDir: dir,
4187
+ owner: "company"
4188
+ });
4189
+ return {
4190
+ packageId: validated.packageId,
4191
+ version: validated.version
4192
+ };
4193
+ }
4179
4194
  });
4180
- }
4181
- return {
4182
- build: buildResult.value,
4183
- request,
4184
- publish
4185
- };
4186
- }
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
- };
4211
- }
4212
- function createAuthenticatedWidgetPackageClient() {
4213
- const { profile, profileName, token } = resolveAuthenticatedProfile();
4214
- if (!token) {
4215
- if (!profileName) throw new Error("Not logged in. Run " + chalk.cyan("fluid login") + " first.");
4216
- throw new Error("No auth token found for profile " + chalk.cyan(profileName) + ". Run " + chalk.cyan("fluid login") + " to re-authenticate.");
4217
- }
4218
- return createFetchClient({
4219
- baseUrl: profile?.baseUrl ?? process.env["FLUID_API_BASE"] ?? "https://api.fluid.app",
4220
- getAuthToken: () => token
4221
- });
4222
- }
4223
- function resolveAuthenticatedProfile() {
4224
- const config = readConfig();
4225
- const projectConfig = findProjectConfig(process.cwd());
4226
- if (projectConfig?.profile) {
4227
- const profile = config.profiles[projectConfig.profile];
4228
- if (profile) return {
4229
- profile,
4230
- profileName: projectConfig.profile,
4231
- token: profile.token
4195
+ if (preflight.mismatch) return {
4196
+ ok: false,
4197
+ code: "PACKAGE_KEY_MISMATCH",
4198
+ message: "This project publishes under a different package key.",
4199
+ hint: preflight.mismatch
4232
4200
  };
4233
4201
  }
4234
- if (!config.activeProfile) return {};
4235
- const profile = config.profiles[config.activeProfile];
4236
- if (!profile) return {};
4237
- return {
4238
- profile,
4239
- profileName: config.activeProfile,
4240
- token: profile.token
4241
- };
4242
- }
4243
- function printWidgetPublishSummary(options) {
4244
- console.log();
4245
- console.log(chalk.green.bold(`${options.title} complete`));
4246
- console.log();
4247
- console.log(chalk.gray("Owner: ") + chalk.white(options.ownerLabel));
4248
- if (options.environment) console.log(chalk.gray("Environment: ") + chalk.white(options.environment));
4249
- console.log(chalk.gray("Package: ") + chalk.white(options.result.build.packageId));
4250
- console.log(chalk.gray("Version: ") + chalk.white(options.result.build.version));
4251
- console.log(chalk.gray("Output: ") + chalk.cyan(options.outDir));
4252
- console.log(chalk.gray("Artifacts: ") + chalk.white(String(options.result.request.artifacts.length)));
4253
- console.log();
4254
- }
4255
- function printRuntimeOnlyNotice() {
4256
- console.log(chalk.yellow("This publishes widget runtime artifacts only; it does not deploy the hosted portal shell."));
4257
- console.log();
4258
- }
4259
- function printDryRunSessionPayload(result) {
4260
- console.log(chalk.bold("Dry-run upload session payload:"));
4261
- console.log(JSON.stringify(result.publish.sessionPayload, null, 2));
4262
- console.log();
4263
- }
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
- //#endregion
4398
- //#region src/commands/deploy.ts
4399
- const DEFAULT_OUT_DIR$1 = ".fluid/widget-dist";
4400
- const DEFAULT_ENVIRONMENT = "production";
4401
- const deployCommand = new Command("deploy").description("Publish company-owned portal widget runtime artifacts").option("-e, --environment <name>", "Target environment label for output reporting", DEFAULT_ENVIRONMENT).option("-o, --out-dir <dir>", "Widget artifact output directory", DEFAULT_OUT_DIR$1).option("--dry-run", "Build and validate upload payload without publishing").action(async (options) => {
4402
- const environment = options.environment ?? DEFAULT_ENVIRONMENT;
4403
- const outDir = options.outDir ?? DEFAULT_OUT_DIR$1;
4404
- const dryRun = options.dryRun === true;
4405
- console.log();
4406
- console.log(chalk.blue.bold("Fluid Portal Deploy"));
4407
- console.log();
4408
- printRuntimeOnlyNotice();
4409
- console.log(chalk.gray("Environment: ") + chalk.white(environment));
4410
- console.log(chalk.gray("Output: ") + chalk.cyan(outDir));
4411
- if (dryRun) console.log(chalk.yellow("Dry run: no upload will be created."));
4412
- console.log();
4413
- const spinner = ora("Building company-owned widget package...").start();
4202
+ options.onProgress?.("Building company-owned widget package...");
4414
4203
  try {
4415
4204
  const result = await publishWidgetRuntimeArtifacts({
4416
- projectDir: process.cwd(),
4417
- outDir,
4205
+ projectDir: options.projectDir,
4206
+ outDir: options.outDir,
4418
4207
  buildOwner: "company",
4419
4208
  uploadOwner: { kind: "company" },
4420
- dryRun,
4421
- ...dryRun ? {} : { client: createAuthenticatedWidgetPackageClient() }
4422
- });
4423
- spinner.succeed("Built widget runtime artifacts");
4424
- if (dryRun) {
4425
- console.log(chalk.yellow("Dry run complete — upload session was not requested."));
4426
- console.log();
4427
- printDryRunSessionPayload(result);
4428
- } else console.log(chalk.green("Published widget package version."));
4429
- printWidgetPublishSummary({
4430
- title: dryRun ? "Portal deploy dry run" : "Portal deploy",
4431
- ownerLabel: "company",
4432
- environment,
4433
- outDir,
4434
- result
4209
+ dryRun: options.dryRun,
4210
+ ...preflight.versionOverride ? { version: preflight.versionOverride } : {},
4211
+ ...client ? { client } : {}
4435
4212
  });
4436
- } catch (err) {
4437
- spinner.fail("Portal deploy failed");
4438
- console.error(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
4439
- process.exit(1);
4213
+ return {
4214
+ ok: true,
4215
+ packageId: result.build.packageId,
4216
+ version: result.build.version,
4217
+ artifactCount: result.request.artifacts.length,
4218
+ ...preflight.versionOverride && preflight.configuredVersion ? { requestedVersion: preflight.configuredVersion } : {},
4219
+ sessionPayload: result.publish.sessionPayload
4220
+ };
4221
+ } catch (error) {
4222
+ return {
4223
+ ok: false,
4224
+ code: error instanceof Error && "code" in error ? String(error.code) : "PUBLISH_FAILED",
4225
+ message: error instanceof Error ? error.message : "Publishing the widgets failed.",
4226
+ ...error instanceof Error && "details" in error && typeof error.details === "string" && error.details.length > 0 ? { hint: error.details } : {}
4227
+ };
4440
4228
  }
4441
- });
4229
+ }
4442
4230
  //#endregion
4443
4231
  //#region src/commands/doctor.ts
4444
4232
  /** Files that are managed by the SDK and should match the canonical template. */
@@ -4613,9 +4401,9 @@ function requireToken() {
4613
4401
  return token;
4614
4402
  }
4615
4403
  function createClient(token) {
4616
- return createFetchClient({
4404
+ return createFluidOsClient({
4617
4405
  baseUrl: getApiBase(),
4618
- getAuthToken: () => token
4406
+ token
4619
4407
  });
4620
4408
  }
4621
4409
  async function requireDefinitionId() {
@@ -4628,13 +4416,14 @@ async function requireDefinitionId() {
4628
4416
  return mappings.definition.id;
4629
4417
  }
4630
4418
  const createVersionCommand = new Command("create").description("Create a new version (snapshot) of the portal definition").option("--activate", "Activate the version immediately after creation").action(async (options) => {
4419
+ await assertStandalonePortalRemoteActionAllowed(process.cwd(), "portal version create");
4631
4420
  const client = createClient(requireToken());
4632
4421
  const definitionId = await requireDefinitionId();
4633
4422
  console.log();
4634
4423
  console.log(chalk.bold("Creating version..."));
4635
4424
  let result;
4636
4425
  try {
4637
- result = await fluid_os_v0_create_fluid_osversion(client, definitionId);
4426
+ result = await fluidOsApi.createVersion(client, definitionId);
4638
4427
  } catch (err) {
4639
4428
  console.error(chalk.red("Error:") + " Failed to create version — " + (err instanceof Error ? err.message : String(err)));
4640
4429
  process.exit(1);
@@ -4652,7 +4441,7 @@ const createVersionCommand = new Command("create").description("Create a new ver
4652
4441
  if (options.activate) {
4653
4442
  console.log("Activating version...");
4654
4443
  try {
4655
- await fluid_os_v0_update_fluid_osversion(client, definitionId, version.id, { version: { active: true } });
4444
+ await fluidOsApi.updateVersion(client, definitionId, version.id, { version: { active: true } });
4656
4445
  } catch (err) {
4657
4446
  console.error(chalk.red("Error:") + " Failed to activate version — " + (err instanceof Error ? err.message : String(err)));
4658
4447
  process.exit(1);
@@ -4667,7 +4456,7 @@ const listVersionCommand = new Command("list").description("List all versions of
4667
4456
  const definitionId = await requireDefinitionId();
4668
4457
  let result;
4669
4458
  try {
4670
- result = await fluid_os_v0_list_fluid_osversions(client, definitionId);
4459
+ result = await fluidOsApi.listVersions(client, definitionId);
4671
4460
  } catch (err) {
4672
4461
  console.error(chalk.red("Error:") + " Failed to list versions — " + (err instanceof Error ? err.message : String(err)));
4673
4462
  process.exit(1);
@@ -4693,6 +4482,12 @@ const listVersionCommand = new Command("list").description("List all versions of
4693
4482
  console.log();
4694
4483
  });
4695
4484
  const activateVersionCommand = new Command("activate").description("Activate a specific version, making it the live version").argument("<version-id>", "The version ID to activate").option("-y, --yes", "Skip confirmation prompt").action(async (versionId, options) => {
4485
+ await assertStandalonePortalRemoteActionAllowed(process.cwd(), "portal version activate");
4486
+ const numericVersionId = Number(versionId);
4487
+ if (!Number.isSafeInteger(numericVersionId) || numericVersionId < 1) {
4488
+ console.error(chalk.red("Error:") + " Version ID must be a positive integer.");
4489
+ process.exit(1);
4490
+ }
4696
4491
  const client = createClient(requireToken());
4697
4492
  const definitionId = await requireDefinitionId();
4698
4493
  if (!options.yes) {
@@ -4710,7 +4505,7 @@ const activateVersionCommand = new Command("activate").description("Activate a s
4710
4505
  console.log();
4711
4506
  console.log(chalk.bold("Activating version..."));
4712
4507
  try {
4713
- await fluid_os_v0_update_fluid_osversion(client, definitionId, versionId, { version: { active: true } });
4508
+ await fluidOsApi.updateVersion(client, definitionId, numericVersionId, { version: { active: true } });
4714
4509
  } catch (err) {
4715
4510
  console.error(chalk.red("Error:") + " Failed to activate version — " + (err instanceof Error ? err.message : String(err)));
4716
4511
  process.exit(1);
@@ -4724,6 +4519,7 @@ const versionCommand = new Command("version").description("Manage portal definit
4724
4519
  //#region src/commands/widget-publish.ts
4725
4520
  const DEFAULT_OUT_DIR = ".fluid/widget-dist";
4726
4521
  const publishSubcommand = new Command("publish").description("Publish droplet-owned widget runtime artifacts").requiredOption("--droplet <uuid>", "Droplet UUID that owns the widget package").option("-o, --out-dir <dir>", "Widget artifact output directory", DEFAULT_OUT_DIR).option("--dry-run", "Build and validate upload payload without publishing").action(async (options) => {
4522
+ await assertStandalonePortalRemoteActionAllowed(process.cwd(), "portal widget publish");
4727
4523
  const outDir = options.outDir ?? DEFAULT_OUT_DIR;
4728
4524
  const dryRun = options.dryRun === true;
4729
4525
  console.log();
@@ -4751,17 +4547,19 @@ const publishSubcommand = new Command("publish").description("Publish droplet-ow
4751
4547
  if (dryRun) {
4752
4548
  console.log(chalk.yellow("Dry run complete — upload session was not requested."));
4753
4549
  console.log();
4754
- printDryRunSessionPayload(result);
4550
+ printDryRunSessionPayload(result.publish.sessionPayload);
4755
4551
  } else console.log(chalk.green("Published widget package version."));
4756
4552
  printWidgetPublishSummary({
4757
4553
  title: dryRun ? "Widget publish dry run" : "Widget publish",
4758
4554
  ownerLabel: `droplet ${options.droplet}`,
4759
4555
  outDir,
4760
- result
4556
+ packageId: result.build.packageId,
4557
+ version: result.build.version,
4558
+ artifactCount: result.request.artifacts.length
4761
4559
  });
4762
4560
  } catch (err) {
4763
4561
  spinner.fail("Widget publish failed");
4764
- console.error(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
4562
+ printWidgetPublishError(err);
4765
4563
  process.exit(1);
4766
4564
  }
4767
4565
  });
@@ -4793,6 +4591,6 @@ const plugin = {
4793
4591
  }
4794
4592
  };
4795
4593
  //#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 };
4594
+ 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, formatPackageKeyMismatch, getInstallCommand, getOriginUrl, getRunCommand, getSdkVersion, getSdkVersionSafe, getTemplatePaths, gitAheadBehindVsFetchHead, gitAutoCommit, gitClone, gitFetch, gitInit, gitMergeFastForward, gitMissingError, gitPush, hasUncommittedChanges, installDependencies, isGitAvailable, isGitRepo, pathExists, portalGitCredentialPath, preflightCompanyPackage, promptProjectConfig, publishWidgetRuntimeArtifacts, pullCommand, pushCommand, readCompanyWidgetPackage, readFileSafe, readMappings, readSnapshot, removeMapping, resolveIdToSlug, resolvePublishVersion, resolveSlugToId, rewriteProjectName, runPackageManager, runPortalGitSync, scaffoldFromGitHub, slugFromPath, topLevelWidgetCommand, transformNavigation, transformNavigationItems, transformProfile, transformScreen, transformTheme, updateMapping, validateCrossReferences, versionCommand, widgetCommand, writeFileSafe, writeFluidrcProfile, writeMappings, writeSnapshot };
4797
4595
 
4798
4596
  //# sourceMappingURL=index.mjs.map