@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.
- package/README.md +3 -0
- package/dist/{backend-dev-plugin-B9-S4Is-.mjs → backend-dev-plugin-CY3yxgJ1.mjs} +1 -1
- package/dist/{backend-dev-plugin-B9-S4Is-.mjs.map → backend-dev-plugin-CY3yxgJ1.mjs.map} +1 -1
- package/dist/build-manifest.d.mts +91 -0
- package/dist/build-manifest.d.mts.map +1 -0
- package/dist/build-manifest.mjs +180 -0
- package/dist/build-manifest.mjs.map +1 -0
- package/dist/index.d.mts +657 -741
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1627 -1829
- package/dist/index.mjs.map +1 -1
- package/dist/portal-dev-plugin-CHlxpXc5.mjs +232 -0
- package/dist/portal-dev-plugin-CHlxpXc5.mjs.map +1 -0
- package/dist/portal-widget-dev-plugin-B53Lf9Ke.mjs +144 -0
- package/dist/portal-widget-dev-plugin-B53Lf9Ke.mjs.map +1 -0
- package/dist/pull-CUR8tbW9.mjs +1633 -0
- package/dist/pull-CUR8tbW9.mjs.map +1 -0
- package/dist/sdk.gen-DuPhfdxN.mjs +469 -0
- package/dist/sdk.gen-DuPhfdxN.mjs.map +1 -0
- package/dist/src-CpH5SpDH.mjs +2157 -0
- package/dist/src-CpH5SpDH.mjs.map +1 -0
- package/dist/vite-plugin.d.mts +15 -56
- package/dist/vite-plugin.d.mts.map +1 -1
- package/dist/vite-plugin.mjs +6 -3
- package/package.json +12 -3
- package/templates/base/.gitignore.template +6 -0
- package/templates/base/.oxlintrc.json +1 -2
- package/templates/base/AGENTS.md +4 -0
- package/templates/base/skills/fluid-portal-authoring/SKILL.md +238 -10
- package/templates/starter/.env.example +4 -3
- package/templates/starter/README.md.template +14 -1
- package/dist/portal-dev-plugin-BHE8Z4yr.mjs +0 -289
- package/dist/portal-dev-plugin-BHE8Z4yr.mjs.map +0 -1
- package/dist/pull-XOsFaqz2.mjs +0 -1204
- package/dist/pull-XOsFaqz2.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import {
|
|
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
|
|
8
|
-
import {
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
1131
|
+
await execa("git", [
|
|
1132
|
+
"reset",
|
|
1133
|
+
"-q",
|
|
1134
|
+
"--",
|
|
1135
|
+
":(top).mist",
|
|
1136
|
+
".mist"
|
|
1137
|
+
], { cwd });
|
|
825
1138
|
} catch {}
|
|
826
1139
|
try {
|
|
827
|
-
|
|
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 =
|
|
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
|
|
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-
|
|
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-
|
|
1260
|
-
const { backendDevPlugin } = await import("./backend-dev-plugin-
|
|
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: [
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
|
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
|
|
1506
|
-
|
|
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
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
|
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
|
|
2446
|
+
return createFluidOsClient({
|
|
1681
2447
|
baseUrl: process.env["FLUID_API_BASE"] ?? "https://api.fluid.app",
|
|
1682
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
1993
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2007
|
-
const newId = (await
|
|
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
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2064
|
-
await
|
|
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
|
|
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
|
-
|
|
2073
|
-
const
|
|
2074
|
-
|
|
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))
|
|
2086
|
-
|
|
2087
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
3289
|
+
spinner.start("Validating portal files...");
|
|
2366
3290
|
const validationErrors = await validateCrossReferences(portalDir, mappings, changes);
|
|
2367
3291
|
if (validationErrors.length > 0) {
|
|
2368
|
-
spinner.fail("
|
|
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("
|
|
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
|
|
2446
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
2474
|
-
* the
|
|
2475
|
-
*
|
|
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
|
|
3437
|
+
return createFluidOsClient({
|
|
2482
3438
|
baseUrl: getBaseUrl(),
|
|
2483
|
-
|
|
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
|
|
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
|
-
|
|
2503
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
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(
|
|
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 ${
|
|
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
|
|
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
|
|
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))
|
|
2703
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
4053
|
+
packageStableId: ${JSON.stringify(packageKey)},
|
|
2766
4054
|
version: "0.0.0-dev",
|
|
2767
4055
|
widgets: [${exportName}],
|
|
2768
4056
|
});
|
|
2769
4057
|
`;
|
|
2770
4058
|
}
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
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
|
-
|
|
2833
|
-
return await
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
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
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
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
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
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
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
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
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
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
|
-
|
|
4021
|
-
} catch (
|
|
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
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
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
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
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
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
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
|
-
|
|
4178
|
-
|
|
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
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
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
|
-
|
|
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:
|
|
4417
|
-
outDir,
|
|
4205
|
+
projectDir: options.projectDir,
|
|
4206
|
+
outDir: options.outDir,
|
|
4418
4207
|
buildOwner: "company",
|
|
4419
4208
|
uploadOwner: { kind: "company" },
|
|
4420
|
-
dryRun,
|
|
4421
|
-
...
|
|
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
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
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
|
|
4404
|
+
return createFluidOsClient({
|
|
4617
4405
|
baseUrl: getApiBase(),
|
|
4618
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|