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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-twds-ZHy.mjs";
2
+ import { buildManifestFromLocalFiles } from "./build-manifest.mjs";
3
+ import { join, resolve, sep } from "node:path";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { getAuthToken, getFluidApiBase } from "@fluid-app/fluid-cli";
6
+ //#region src/vite-plugin/portal-dev-plugin.ts
7
+ /**
8
+ * Vite plugin for local portal content development.
9
+ *
10
+ * Intercepts the manifest API request and serves content assembled from the
11
+ * local `portal/` directory. Watches portal files for changes and triggers a
12
+ * full page reload.
13
+ *
14
+ * The manifest is intercepted on BOTH paths the SDK has used across versions
15
+ * (`/api/app/manifest` since portal-sdk 0.1.4xx, `/api/fluid_os/definitions/
16
+ * active` before that) so a newer SDK against an older-generation app — or
17
+ * vice versa — still gets the local draft.
18
+ */
19
+ var portal_dev_plugin_exports = /* @__PURE__ */ __exportAll({
20
+ portalDevPlugin: () => portalDevPlugin,
21
+ resolvePortalTenantApiTarget: () => resolvePortalTenantApiTarget
22
+ });
23
+ const DEBOUNCE_MS = 100;
24
+ /** Manifest endpoints across SDK generations — all serve the local draft. */
25
+ const MANIFEST_PATHS = ["/api/app/manifest", "/api/fluid_os/definitions/active"];
26
+ const DEFAULT_API_URL = "https://api.fluid.app";
27
+ const TENANT_LOOKUP_TIMEOUT_MS = 3e3;
28
+ const tenantTargetPromises = /* @__PURE__ */ new WeakMap();
29
+ /**
30
+ * Resolve the portal tenant BFF for the CLI's project-aware Fluid profile.
31
+ *
32
+ * Portal member APIs do not live on api.fluid.app or `<tenant>.fluid.app`.
33
+ * Their OpenAPI server is `<tenant>.portal.fluid.app`, and using either of
34
+ * the former hosts produces a wall of unrelated 404s in otherwise-valid
35
+ * local portals.
36
+ */
37
+ async function resolvePortalTenantApiTarget(dependencies = {}) {
38
+ const token = dependencies.token !== void 0 ? dependencies.token : getAuthToken();
39
+ if (!token) return void 0;
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), dependencies.timeoutMs ?? TENANT_LOOKUP_TIMEOUT_MS);
42
+ try {
43
+ const response = await (dependencies.fetchImpl ?? fetch)(`${(dependencies.apiBase ?? getFluidApiBase()).replace(/\/+$/, "")}/api/company/v1/companies/me`, {
44
+ headers: {
45
+ Authorization: `Bearer ${token}`,
46
+ Accept: "application/json"
47
+ },
48
+ signal: controller.signal
49
+ });
50
+ if (!response.ok) return void 0;
51
+ const subdomain = (await response.json()).data?.company?.subdomain;
52
+ if (typeof subdomain !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) return;
53
+ return `https://${subdomain}.portal.fluid.app`;
54
+ } catch {
55
+ return;
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+ /**
61
+ * Fallback .env reader for VITE_API_URL, used only when vite's `loadEnv`
62
+ * can't be imported (it's a declared dependency, so this is defensive — but
63
+ * a broken install must degrade to "wrong host" rather than "dev server
64
+ * won't boot"). Mirrors the basic lookup order: live env > .env.local >
65
+ * .env. Unlike loadEnv it ignores .env.[mode] files and variable expansion.
66
+ */
67
+ function readViteApiUrlFallback(root) {
68
+ if (process.env.VITE_API_URL) return process.env.VITE_API_URL;
69
+ if (process.env.NEXT_PUBLIC_API_BASE_URL) return process.env.NEXT_PUBLIC_API_BASE_URL;
70
+ for (const file of [".env.local", ".env"]) {
71
+ const path = join(root, file);
72
+ if (!existsSync(path)) continue;
73
+ for (const line of readFileSync(path, "utf8").split("\n")) {
74
+ const match = /^\s*VITE_API_URL\s*=\s*(?:"([^"]*)"|(.*?))\s*(?:#.*)?$/.exec(line);
75
+ const value = match?.[1] ?? match?.[2];
76
+ if (value) return value;
77
+ }
78
+ }
79
+ }
80
+ function storefrontSubdomainFromTarget(target) {
81
+ try {
82
+ const hostname = new URL(target).hostname;
83
+ const subdomain = /^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.fluid\.app$/.exec(hostname)?.[1];
84
+ if (!subdomain || [
85
+ "admin",
86
+ "api",
87
+ "app",
88
+ "docs",
89
+ "portal",
90
+ "upload"
91
+ ].includes(subdomain)) return;
92
+ return subdomain;
93
+ } catch {
94
+ return;
95
+ }
96
+ }
97
+ /**
98
+ * Resolve VITE_API_URL the way vite itself would — `loadEnv` honors
99
+ * .env.[mode], .env.[mode].local, envDir, and variable expansion, which the
100
+ * fallback parser does not (a mode-specific staging API must not silently
101
+ * fall back to production).
102
+ */
103
+ async function resolveViteApiUrl(root, mode, envDir) {
104
+ try {
105
+ const { loadEnv } = await import("vite");
106
+ const env = loadEnv(mode, envDir ?? root, ["VITE_", "NEXT_PUBLIC_"]);
107
+ return env.VITE_API_URL || process.env.VITE_API_URL || env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || void 0;
108
+ } catch {
109
+ return readViteApiUrlFallback(root);
110
+ }
111
+ }
112
+ function portalDevPlugin(options) {
113
+ let portalDir;
114
+ let cachedManifest = null;
115
+ let buildInFlight = null;
116
+ /** Bumped on every watched file change. A build carries the value it
117
+ * started under; if it no longer matches when the build resolves, the
118
+ * build read pre-edit state and must not be cached. */
119
+ let manifestGeneration = 0;
120
+ const resolveTenantTarget = options?.resolveTenantApiTarget ?? resolvePortalTenantApiTarget;
121
+ return {
122
+ name: "fluid-portal-dev",
123
+ enforce: "pre",
124
+ async config(config, env) {
125
+ if (env?.command !== "serve" || options?.sameOriginDev === false) return {};
126
+ const root = config.root ?? process.cwd();
127
+ const appDefinesProxy = config.server?.proxy?.["/api"] !== void 0;
128
+ const configuredTarget = options?.apiProxyTarget || appDefinesProxy ? void 0 : await resolveViteApiUrl(root, env?.mode ?? "development", config.envDir);
129
+ const tenantTarget = options?.apiProxyTarget || appDefinesProxy || configuredTarget ? void 0 : await (() => {
130
+ const existing = tenantTargetPromises.get(resolveTenantTarget);
131
+ if (existing) return existing;
132
+ const pending = resolveTenantTarget();
133
+ tenantTargetPromises.set(resolveTenantTarget, pending);
134
+ return pending;
135
+ })();
136
+ const target = options?.apiProxyTarget ?? configuredTarget ?? tenantTarget ?? DEFAULT_API_URL;
137
+ const storefrontSubdomain = storefrontSubdomainFromTarget(target);
138
+ if (tenantTarget) {
139
+ console.log(`[fluid-portal-dev] Tenant API proxy: ${tenantTarget}`);
140
+ console.log("[fluid-portal-dev] Member data still requires a portal handoff session; localhost requests may return 401.");
141
+ } else if (!options?.apiProxyTarget && !appDefinesProxy && !configuredTarget) console.warn(`[fluid-portal-dev] Could not resolve the company tenant API; falling back to ${DEFAULT_API_URL}. Built-in member screens may not load. Set VITE_API_URL explicitly or sign in with the project's Fluid profile.`);
142
+ else if (storefrontSubdomain) console.warn(`[fluid-portal-dev] ${target} is the storefront host, not the Portal tenant BFF. Built-in member APIs live at https://${storefrontSubdomain}.portal.fluid.app; remove the override to let the CLI resolve it.`);
143
+ const defines = {};
144
+ for (const key of ["import.meta.env.VITE_API_URL", "import.meta.env.NEXT_PUBLIC_API_BASE_URL"]) if (config.define?.[key] === void 0) defines[key] = JSON.stringify("");
145
+ return {
146
+ define: defines,
147
+ server: { proxy: !appDefinesProxy ? { "/api": {
148
+ target,
149
+ changeOrigin: true
150
+ } } : {} }
151
+ };
152
+ },
153
+ configResolved(config) {
154
+ const root = config.root ?? process.cwd();
155
+ portalDir = options?.portalDir ? resolve(root, options.portalDir) : join(root, "portal");
156
+ },
157
+ configureServer(server) {
158
+ if (server.__fluidPortalDevWired) return;
159
+ server.__fluidPortalDevWired = true;
160
+ const watchDir = portalDir;
161
+ if (existsSync(watchDir)) {
162
+ server.watcher.add(watchDir);
163
+ let reloadTimer = null;
164
+ const onFileChange = (filePath) => {
165
+ if (!filePath.startsWith(watchDir)) return;
166
+ cachedManifest = null;
167
+ buildInFlight = null;
168
+ manifestGeneration += 1;
169
+ console.log(`[fluid-portal-dev] File changed: ${filePath.replace(watchDir + sep, "")}`);
170
+ if (reloadTimer) clearTimeout(reloadTimer);
171
+ reloadTimer = setTimeout(() => {
172
+ reloadTimer = null;
173
+ server.ws.send({
174
+ type: "full-reload",
175
+ path: "*"
176
+ });
177
+ }, DEBOUNCE_MS);
178
+ };
179
+ server.watcher.on("change", onFileChange);
180
+ server.watcher.on("add", onFileChange);
181
+ server.watcher.on("unlink", onFileChange);
182
+ }
183
+ server.middlewares.use((req, res, next) => {
184
+ const url = req.url;
185
+ if (!url || !MANIFEST_PATHS.some((path) => url.includes(path))) {
186
+ next();
187
+ return;
188
+ }
189
+ (async () => {
190
+ try {
191
+ if (!existsSync(portalDir)) {
192
+ next();
193
+ return;
194
+ }
195
+ let manifestJson = cachedManifest;
196
+ if (manifestJson == null) {
197
+ if (!buildInFlight) {
198
+ const generation = manifestGeneration;
199
+ buildInFlight = buildManifestFromLocalFiles(portalDir).then((m) => {
200
+ const json = JSON.stringify(m);
201
+ if (generation === manifestGeneration) {
202
+ cachedManifest = json;
203
+ buildInFlight = null;
204
+ }
205
+ return json;
206
+ }).catch((err) => {
207
+ if (generation === manifestGeneration) buildInFlight = null;
208
+ throw err;
209
+ });
210
+ }
211
+ manifestJson = await buildInFlight;
212
+ }
213
+ res.writeHead(200, {
214
+ "Content-Type": "application/json",
215
+ "X-Portal-Dev-Mode": "true",
216
+ "Cache-Control": "no-store"
217
+ });
218
+ res.end(manifestJson);
219
+ } catch (err) {
220
+ console.error("[fluid-portal-dev] Error building manifest:", err);
221
+ res.writeHead(500, { "Content-Type": "application/json" });
222
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to build manifest from local files" }));
223
+ }
224
+ })();
225
+ });
226
+ }
227
+ };
228
+ }
229
+ //#endregion
230
+ export { portal_dev_plugin_exports as n, portalDevPlugin as t };
231
+
232
+ //# sourceMappingURL=portal-dev-plugin-ByYelgFg.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal-dev-plugin-ByYelgFg.mjs","names":[],"sources":["../src/vite-plugin/portal-dev-plugin.ts"],"sourcesContent":["/**\n * Vite plugin for local portal content development.\n *\n * Intercepts the manifest API request and serves content assembled from the\n * local `portal/` directory. Watches portal files for changes and triggers a\n * full page reload.\n *\n * The manifest is intercepted on BOTH paths the SDK has used across versions\n * (`/api/app/manifest` since portal-sdk 0.1.4xx, `/api/fluid_os/definitions/\n * active` before that) so a newer SDK against an older-generation app — or\n * vice versa — still gets the local draft.\n */\n\nimport { join, resolve, sep } from \"node:path\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { getAuthToken, getFluidApiBase } from \"@fluid-app/fluid-cli\";\nimport { buildManifestFromLocalFiles } from \"./build-manifest.js\";\n\n/** Options for the portal dev plugin. */\nexport interface PortalDevPluginOptions {\n portalDir?: string;\n /**\n * When true (default), dev-server requests are forced same-origin: the\n * plugin blanks `import.meta.env.VITE_API_URL` in serve mode and proxies\n * `/api` to the configured Fluid host instead. Without this, a `.env`\n * pointing VITE_API_URL at a remote API makes the SPA fetch its manifest\n * cross-origin — silently bypassing this plugin and rendering the remote\n * app instead of the local `portal/` draft. Set false to opt out and let\n * the SPA call the remote API directly.\n */\n sameOriginDev?: boolean;\n /**\n * Override the `/api` dev-proxy target. Defaults to VITE_API_URL from the\n * process env / .env files, then the authenticated company's\n * `<subdomain>.portal.fluid.app` tenant BFF, then https://api.fluid.app.\n */\n apiProxyTarget?: string;\n /**\n * Override tenant-target discovery. Primarily useful to keep embedded plugin\n * integrations and tests deterministic.\n */\n resolveTenantApiTarget?: () => Promise<string | undefined>;\n}\n\nconst DEBOUNCE_MS = 100;\n\n/** Manifest endpoints across SDK generations — all serve the local draft. */\nconst MANIFEST_PATHS = [\n \"/api/app/manifest\",\n \"/api/fluid_os/definitions/active\",\n];\n\nconst DEFAULT_API_URL = \"https://api.fluid.app\";\nconst TENANT_LOOKUP_TIMEOUT_MS = 3_000;\nconst tenantTargetPromises = new WeakMap<\n () => Promise<string | undefined>,\n Promise<string | undefined>\n>();\n\ninterface CompanyMeResponse {\n data?: {\n company?: {\n subdomain?: unknown;\n };\n };\n}\n\nexport interface TenantApiTargetDependencies {\n token?: string | null;\n apiBase?: string;\n fetchImpl?: typeof fetch;\n timeoutMs?: number;\n}\n\n/**\n * Resolve the portal tenant BFF for the CLI's project-aware Fluid profile.\n *\n * Portal member APIs do not live on api.fluid.app or `<tenant>.fluid.app`.\n * Their OpenAPI server is `<tenant>.portal.fluid.app`, and using either of\n * the former hosts produces a wall of unrelated 404s in otherwise-valid\n * local portals.\n */\nexport async function resolvePortalTenantApiTarget(\n dependencies: TenantApiTargetDependencies = {},\n): Promise<string | undefined> {\n const token =\n dependencies.token !== undefined ? dependencies.token : getAuthToken();\n if (!token) return undefined;\n\n const controller = new AbortController();\n const timer = setTimeout(\n () => controller.abort(),\n dependencies.timeoutMs ?? TENANT_LOOKUP_TIMEOUT_MS,\n );\n\n try {\n const response = await (dependencies.fetchImpl ?? fetch)(\n `${(dependencies.apiBase ?? getFluidApiBase()).replace(/\\/+$/, \"\")}/api/company/v1/companies/me`,\n {\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: \"application/json\",\n },\n signal: controller.signal,\n },\n );\n if (!response.ok) return undefined;\n\n const body = (await response.json()) as CompanyMeResponse;\n const subdomain = body.data?.company?.subdomain;\n if (\n typeof subdomain !== \"string\" ||\n !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)\n ) {\n return undefined;\n }\n\n return `https://${subdomain}.portal.fluid.app`;\n } catch {\n return undefined;\n } finally {\n clearTimeout(timer);\n }\n}\n\n/**\n * Fallback .env reader for VITE_API_URL, used only when vite's `loadEnv`\n * can't be imported (it's a declared dependency, so this is defensive — but\n * a broken install must degrade to \"wrong host\" rather than \"dev server\n * won't boot\"). Mirrors the basic lookup order: live env > .env.local >\n * .env. Unlike loadEnv it ignores .env.[mode] files and variable expansion.\n */\nfunction readViteApiUrlFallback(root: string): string | undefined {\n if (process.env.VITE_API_URL) return process.env.VITE_API_URL;\n if (process.env.NEXT_PUBLIC_API_BASE_URL) {\n return process.env.NEXT_PUBLIC_API_BASE_URL;\n }\n for (const file of [\".env.local\", \".env\"]) {\n const path = join(root, file);\n if (!existsSync(path)) continue;\n for (const line of readFileSync(path, \"utf8\").split(\"\\n\")) {\n // Quoted values stop at the closing `\"`; unquoted values stop before\n // an inline ` # comment` tail (dotenv semantics — a trailing comment\n // must not leak into the proxy target URL).\n const match =\n /^\\s*VITE_API_URL\\s*=\\s*(?:\"([^\"]*)\"|(.*?))\\s*(?:#.*)?$/.exec(line);\n const value = match?.[1] ?? match?.[2];\n if (value) return value;\n }\n }\n return undefined;\n}\n\nfunction storefrontSubdomainFromTarget(target: string): string | undefined {\n try {\n const hostname = new URL(target).hostname;\n const match = /^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\\.fluid\\.app$/.exec(\n hostname,\n );\n const subdomain = match?.[1];\n if (\n !subdomain ||\n [\"admin\", \"api\", \"app\", \"docs\", \"portal\", \"upload\"].includes(subdomain)\n ) {\n return undefined;\n }\n return subdomain;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Resolve VITE_API_URL the way vite itself would — `loadEnv` honors\n * .env.[mode], .env.[mode].local, envDir, and variable expansion, which the\n * fallback parser does not (a mode-specific staging API must not silently\n * fall back to production).\n */\nasync function resolveViteApiUrl(\n root: string,\n mode: string,\n envDir: string | undefined,\n): Promise<string | undefined> {\n try {\n const { loadEnv } = await import(\"vite\");\n const env = loadEnv(mode, envDir ?? root, [\"VITE_\", \"NEXT_PUBLIC_\"]);\n return (\n env.VITE_API_URL ||\n process.env.VITE_API_URL ||\n // Portals migrated from Next.js conventions configure the API host as\n // NEXT_PUBLIC_API_BASE_URL — honored by `fluid portal dev` since it\n // was introduced, so it stays a recognized source here.\n env.NEXT_PUBLIC_API_BASE_URL ||\n process.env.NEXT_PUBLIC_API_BASE_URL ||\n undefined\n );\n } catch {\n return readViteApiUrlFallback(root);\n }\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function portalDevPlugin(options?: PortalDevPluginOptions): any {\n let portalDir: string;\n let cachedManifest: string | null = null;\n let buildInFlight: Promise<string> | null = null;\n /** Bumped on every watched file change. A build carries the value it\n * started under; if it no longer matches when the build resolves, the\n * build read pre-edit state and must not be cached. */\n let manifestGeneration = 0;\n const resolveTenantTarget =\n options?.resolveTenantApiTarget ?? resolvePortalTenantApiTarget;\n\n return {\n name: \"fluid-portal-dev\",\n enforce: \"pre\" as const,\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n async config(config: any, env: any) {\n if (env?.command !== \"serve\" || options?.sameOriginDev === false) {\n return {};\n }\n\n const root: string = config.root ?? process.cwd();\n const appDefinesProxy = config.server?.proxy?.[\"/api\"] !== undefined;\n const configuredTarget =\n options?.apiProxyTarget || appDefinesProxy\n ? undefined\n : await resolveViteApiUrl(\n root,\n env?.mode ?? \"development\",\n config.envDir,\n );\n const tenantTarget =\n options?.apiProxyTarget || appDefinesProxy || configuredTarget\n ? undefined\n : await (() => {\n const existing = tenantTargetPromises.get(resolveTenantTarget);\n if (existing) return existing;\n\n const pending = resolveTenantTarget();\n tenantTargetPromises.set(resolveTenantTarget, pending);\n return pending;\n })();\n const target =\n options?.apiProxyTarget ??\n configuredTarget ??\n tenantTarget ??\n DEFAULT_API_URL;\n const storefrontSubdomain = storefrontSubdomainFromTarget(target);\n\n if (tenantTarget) {\n console.log(`[fluid-portal-dev] Tenant API proxy: ${tenantTarget}`);\n console.log(\n \"[fluid-portal-dev] Member data still requires a portal handoff session; localhost requests may return 401.\",\n );\n } else if (\n !options?.apiProxyTarget &&\n !appDefinesProxy &&\n !configuredTarget\n ) {\n console.warn(\n `[fluid-portal-dev] Could not resolve the company tenant API; falling back to ${DEFAULT_API_URL}. ` +\n \"Built-in member screens may not load. Set VITE_API_URL explicitly or sign in with the project's Fluid profile.\",\n );\n } else if (storefrontSubdomain) {\n console.warn(\n `[fluid-portal-dev] ${target} is the storefront host, not the Portal tenant BFF. ` +\n `Built-in member APIs live at https://${storefrontSubdomain}.portal.fluid.app; remove the override to let the CLI resolve it.`,\n );\n }\n\n // Vite merges a plugin's returned config OVER the user config, so an\n // app's own explicit define / /api proxy is only preserved by checking\n // for it and yielding here. BOTH API-host env vars must be blanked:\n // dev.ts's envPrefix exposes NEXT_PUBLIC_API_BASE_URL to the SPA too,\n // so leaving it defined would recreate the exact cross-origin bypass\n // this hook exists to prevent, just for Next.js-migrated portals.\n const defines: Record<string, string> = {};\n for (const key of [\n \"import.meta.env.VITE_API_URL\",\n \"import.meta.env.NEXT_PUBLIC_API_BASE_URL\",\n ]) {\n if (config.define?.[key] === undefined) {\n defines[key] = JSON.stringify(\"\");\n }\n }\n const proxy = !appDefinesProxy\n ? { \"/api\": { target, changeOrigin: true } }\n : {};\n\n return {\n define: defines,\n server: { proxy },\n };\n },\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n configResolved(config: any) {\n const root: string = config.root ?? process.cwd();\n portalDir = options?.portalDir\n ? resolve(root, options.portalDir)\n : join(root, \"portal\");\n },\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n configureServer(server: any) {\n // Idempotency: `fluid portal dev` injects this plugin, and a portal's\n // own vite.config may also wire it. Only the first instance registers\n // the watcher + middleware so we don't double-reload or double-serve.\n if (server.__fluidPortalDevWired) return;\n server.__fluidPortalDevWired = true;\n\n const watchDir = portalDir;\n\n if (existsSync(watchDir)) {\n server.watcher.add(watchDir);\n\n let reloadTimer: ReturnType<typeof setTimeout> | null = null;\n\n const onFileChange = (filePath: string) => {\n if (!filePath.startsWith(watchDir)) return;\n\n cachedManifest = null;\n buildInFlight = null;\n manifestGeneration += 1;\n\n console.log(\n `[fluid-portal-dev] File changed: ${filePath.replace(watchDir + sep, \"\")}`,\n );\n\n if (reloadTimer) clearTimeout(reloadTimer);\n reloadTimer = setTimeout(() => {\n reloadTimer = null;\n server.ws.send({ type: \"full-reload\", path: \"*\" });\n }, DEBOUNCE_MS);\n };\n\n server.watcher.on(\"change\", onFileChange);\n server.watcher.on(\"add\", onFileChange);\n server.watcher.on(\"unlink\", onFileChange);\n }\n\n // ── Middleware to intercept manifest requests ───────────────────\n // Every MANIFEST_PATHS route returns the SAME `{ manifest }` payload:\n // the portal-tenant BFF route the current SDK runtime fetches\n // (useFluidApp → app_manifest_show) and the legacy FluidOS route, so\n // the local portal/ draft feeds the running app regardless of which\n // runtime generation it uses (the SDK adapter reads\n // `response.manifest`, matching buildManifestFromLocalFiles' output).\n server.middlewares.use(\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (req: any, res: any, next: () => void) => {\n const url: string | undefined = req.url;\n\n if (!url || !MANIFEST_PATHS.some((path) => url.includes(path))) {\n next();\n return;\n }\n\n (async () => {\n try {\n if (!existsSync(portalDir)) {\n // Fall through to the Vite proxy so requests reach the real\n // API when local portal content is unavailable (e.g. --skip-pull).\n next();\n return;\n }\n\n let manifestJson = cachedManifest;\n if (manifestJson == null) {\n if (!buildInFlight) {\n // Stamp the build with the generation it was started\n // under. onFileChange bumps that counter, so a build\n // whose inputs changed underneath it can be recognized\n // when it finally resolves.\n const generation = manifestGeneration;\n buildInFlight = buildManifestFromLocalFiles(portalDir)\n .then((m) => {\n const json = JSON.stringify(m);\n // Only a build still current may populate the\n // cache. Caching a superseded one pinned the\n // pre-edit snapshot permanently: a non-null cache\n // means no later request ever rebuilds, so the\n // edit stayed invisible until some unrelated file\n // happened to change outside a build window.\n if (generation === manifestGeneration) {\n cachedManifest = json;\n buildInFlight = null;\n }\n return json;\n })\n .catch((err) => {\n // Same guard: a superseded build must not clear a\n // newer one's handle.\n if (generation === manifestGeneration) {\n buildInFlight = null; // allow retry on next request\n }\n throw err;\n });\n }\n // This response serves what was built for it, which is\n // correct even when superseded — the request predates\n // the edit. The point is that the NEXT one rebuilds.\n manifestJson = await buildInFlight;\n }\n\n res.writeHead(200, {\n \"Content-Type\": \"application/json\",\n \"X-Portal-Dev-Mode\": \"true\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(manifestJson);\n } catch (err) {\n console.error(\"[fluid-portal-dev] Error building manifest:\", err);\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error:\n err instanceof Error\n ? err.message\n : \"Failed to build manifest from local files\",\n }),\n );\n }\n })();\n },\n );\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AA4CA,MAAM,cAAc;;AAGpB,MAAM,iBAAiB,CACrB,qBACA,mCACD;AAED,MAAM,kBAAkB;AACxB,MAAM,2BAA2B;AACjC,MAAM,uCAAuB,IAAI,SAG9B;;;;;;;;;AAyBH,eAAsB,6BACpB,eAA4C,EAAE,EACjB;CAC7B,MAAM,QACJ,aAAa,UAAU,KAAA,IAAY,aAAa,QAAQ,cAAc;AACxE,KAAI,CAAC,MAAO,QAAO,KAAA;CAEnB,MAAM,aAAa,IAAI,iBAAiB;CACxC,MAAM,QAAQ,iBACN,WAAW,OAAO,EACxB,aAAa,aAAa,yBAC3B;AAED,KAAI;EACF,MAAM,WAAW,OAAO,aAAa,aAAa,OAChD,IAAI,aAAa,WAAW,iBAAiB,EAAE,QAAQ,QAAQ,GAAG,CAAC,+BACnE;GACE,SAAS;IACP,eAAe,UAAU;IACzB,QAAQ;IACT;GACD,QAAQ,WAAW;GACpB,CACF;AACD,MAAI,CAAC,SAAS,GAAI,QAAO,KAAA;EAGzB,MAAM,aADQ,MAAM,SAAS,MAAM,EACZ,MAAM,SAAS;AACtC,MACE,OAAO,cAAc,YACrB,CAAC,yCAAyC,KAAK,UAAU,CAEzD;AAGF,SAAO,WAAW,UAAU;SACtB;AACN;WACQ;AACR,eAAa,MAAM;;;;;;;;;;AAWvB,SAAS,uBAAuB,MAAkC;AAChE,KAAI,QAAQ,IAAI,aAAc,QAAO,QAAQ,IAAI;AACjD,KAAI,QAAQ,IAAI,yBACd,QAAO,QAAQ,IAAI;AAErB,MAAK,MAAM,QAAQ,CAAC,cAAc,OAAO,EAAE;EACzC,MAAM,OAAO,KAAK,MAAM,KAAK;AAC7B,MAAI,CAAC,WAAW,KAAK,CAAE;AACvB,OAAK,MAAM,QAAQ,aAAa,MAAM,OAAO,CAAC,MAAM,KAAK,EAAE;GAIzD,MAAM,QACJ,yDAAyD,KAAK,KAAK;GACrE,MAAM,QAAQ,QAAQ,MAAM,QAAQ;AACpC,OAAI,MAAO,QAAO;;;;AAMxB,SAAS,8BAA8B,QAAoC;AACzE,KAAI;EACF,MAAM,WAAW,IAAI,IAAI,OAAO,CAAC;EAIjC,MAAM,YAHQ,kDAAkD,KAC9D,SACD,GACyB;AAC1B,MACE,CAAC,aACD;GAAC;GAAS;GAAO;GAAO;GAAQ;GAAU;GAAS,CAAC,SAAS,UAAU,CAEvE;AAEF,SAAO;SACD;AACN;;;;;;;;;AAUJ,eAAe,kBACb,MACA,MACA,QAC6B;AAC7B,KAAI;EACF,MAAM,EAAE,YAAY,MAAM,OAAO;EACjC,MAAM,MAAM,QAAQ,MAAM,UAAU,MAAM,CAAC,SAAS,eAAe,CAAC;AACpE,SACE,IAAI,gBACJ,QAAQ,IAAI,gBAIZ,IAAI,4BACJ,QAAQ,IAAI,4BACZ,KAAA;SAEI;AACN,SAAO,uBAAuB,KAAK;;;AAKvC,SAAgB,gBAAgB,SAAuC;CACrE,IAAI;CACJ,IAAI,iBAAgC;CACpC,IAAI,gBAAwC;;;;CAI5C,IAAI,qBAAqB;CACzB,MAAM,sBACJ,SAAS,0BAA0B;AAErC,QAAO;EACL,MAAM;EACN,SAAS;EAGT,MAAM,OAAO,QAAa,KAAU;AAClC,OAAI,KAAK,YAAY,WAAW,SAAS,kBAAkB,MACzD,QAAO,EAAE;GAGX,MAAM,OAAe,OAAO,QAAQ,QAAQ,KAAK;GACjD,MAAM,kBAAkB,OAAO,QAAQ,QAAQ,YAAY,KAAA;GAC3D,MAAM,mBACJ,SAAS,kBAAkB,kBACvB,KAAA,IACA,MAAM,kBACJ,MACA,KAAK,QAAQ,eACb,OAAO,OACR;GACP,MAAM,eACJ,SAAS,kBAAkB,mBAAmB,mBAC1C,KAAA,IACA,aAAa;IACX,MAAM,WAAW,qBAAqB,IAAI,oBAAoB;AAC9D,QAAI,SAAU,QAAO;IAErB,MAAM,UAAU,qBAAqB;AACrC,yBAAqB,IAAI,qBAAqB,QAAQ;AACtD,WAAO;OACL;GACV,MAAM,SACJ,SAAS,kBACT,oBACA,gBACA;GACF,MAAM,sBAAsB,8BAA8B,OAAO;AAEjE,OAAI,cAAc;AAChB,YAAQ,IAAI,wCAAwC,eAAe;AACnE,YAAQ,IACN,6GACD;cAED,CAAC,SAAS,kBACV,CAAC,mBACD,CAAC,iBAED,SAAQ,KACN,gFAAgF,gBAAgB,kHAEjG;YACQ,oBACT,SAAQ,KACN,sBAAsB,OAAO,2FACa,oBAAoB,mEAC/D;GASH,MAAM,UAAkC,EAAE;AAC1C,QAAK,MAAM,OAAO,CAChB,gCACA,2CACD,CACC,KAAI,OAAO,SAAS,SAAS,KAAA,EAC3B,SAAQ,OAAO,KAAK,UAAU,GAAG;AAOrC,UAAO;IACL,QAAQ;IACR,QAAQ,EAAE,OANE,CAAC,kBACX,EAAE,QAAQ;KAAE;KAAQ,cAAc;KAAM,EAAE,GAC1C,EAAE,EAIa;IAClB;;EAIH,eAAe,QAAa;GAC1B,MAAM,OAAe,OAAO,QAAQ,QAAQ,KAAK;AACjD,eAAY,SAAS,YACjB,QAAQ,MAAM,QAAQ,UAAU,GAChC,KAAK,MAAM,SAAS;;EAI1B,gBAAgB,QAAa;AAI3B,OAAI,OAAO,sBAAuB;AAClC,UAAO,wBAAwB;GAE/B,MAAM,WAAW;AAEjB,OAAI,WAAW,SAAS,EAAE;AACxB,WAAO,QAAQ,IAAI,SAAS;IAE5B,IAAI,cAAoD;IAExD,MAAM,gBAAgB,aAAqB;AACzC,SAAI,CAAC,SAAS,WAAW,SAAS,CAAE;AAEpC,sBAAiB;AACjB,qBAAgB;AAChB,2BAAsB;AAEtB,aAAQ,IACN,oCAAoC,SAAS,QAAQ,WAAW,KAAK,GAAG,GACzE;AAED,SAAI,YAAa,cAAa,YAAY;AAC1C,mBAAc,iBAAiB;AAC7B,oBAAc;AACd,aAAO,GAAG,KAAK;OAAE,MAAM;OAAe,MAAM;OAAK,CAAC;QACjD,YAAY;;AAGjB,WAAO,QAAQ,GAAG,UAAU,aAAa;AACzC,WAAO,QAAQ,GAAG,OAAO,aAAa;AACtC,WAAO,QAAQ,GAAG,UAAU,aAAa;;AAU3C,UAAO,YAAY,KAEhB,KAAU,KAAU,SAAqB;IACxC,MAAM,MAA0B,IAAI;AAEpC,QAAI,CAAC,OAAO,CAAC,eAAe,MAAM,SAAS,IAAI,SAAS,KAAK,CAAC,EAAE;AAC9D,WAAM;AACN;;AAGF,KAAC,YAAY;AACX,SAAI;AACF,UAAI,CAAC,WAAW,UAAU,EAAE;AAG1B,aAAM;AACN;;MAGF,IAAI,eAAe;AACnB,UAAI,gBAAgB,MAAM;AACxB,WAAI,CAAC,eAAe;QAKlB,MAAM,aAAa;AACnB,wBAAgB,4BAA4B,UAAU,CACnD,MAAM,MAAM;SACX,MAAM,OAAO,KAAK,UAAU,EAAE;AAO9B,aAAI,eAAe,oBAAoB;AACrC,2BAAiB;AACjB,0BAAgB;;AAElB,gBAAO;UACP,CACD,OAAO,QAAQ;AAGd,aAAI,eAAe,mBACjB,iBAAgB;AAElB,eAAM;UACN;;AAKN,sBAAe,MAAM;;AAGvB,UAAI,UAAU,KAAK;OACjB,gBAAgB;OAChB,qBAAqB;OACrB,iBAAiB;OAClB,CAAC;AACF,UAAI,IAAI,aAAa;cACd,KAAK;AACZ,cAAQ,MAAM,+CAA+C,IAAI;AACjE,UAAI,UAAU,KAAK,EAAE,gBAAgB,oBAAoB,CAAC;AAC1D,UAAI,IACF,KAAK,UAAU,EACb,OACE,eAAe,QACX,IAAI,UACJ,6CACP,CAAC,CACH;;QAED;KAEP;;EAEJ"}
@@ -3,12 +3,43 @@ import { Command } from "commander";
3
3
  import chalk from "chalk";
4
4
  import ora from "ora";
5
5
  import { dirname, join, relative, sep } from "node:path";
6
+ import { existsSync } from "node:fs";
6
7
  import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
7
8
  import { getActiveProfile, getAuthToken } from "@fluid-app/fluid-cli";
8
9
  import prompts from "prompts";
9
- import { existsSync } from "node:fs";
10
- import pLimit from "p-limit";
11
10
  import { createHash, randomBytes } from "node:crypto";
11
+ import pLimit from "p-limit";
12
+ //#region src/commands/pull-definition.ts
13
+ /** Pick a definition by explicit id, else by name.
14
+ *
15
+ * Mirrors clone.ts's resolver of the same name, and for the same
16
+ * reason: definition NAMES REPEAT. clone's `--id` help says so
17
+ * outright ("avoids ambiguity when names repeat") and clone.test.ts
18
+ * pins it. pull only ever looked up by name — first match wins — so a
19
+ * caller holding an unambiguous id had no way to use it, and Mist
20
+ * called pull by name immediately after cloning by id.
21
+ *
22
+ * Returns null when neither is supplied, so the caller falls through
23
+ * to the interactive selector. */
24
+ function resolveDefinition(definitions, appName, rawDefinitionId) {
25
+ if (rawDefinitionId !== void 0) {
26
+ if (!/^[1-9]\d*$/.test(rawDefinitionId)) throw new Error("--id must be a positive integer");
27
+ const definitionId = Number.parseInt(rawDefinitionId, 10);
28
+ const match = definitions.find((d) => d.id === definitionId);
29
+ if (!match) throw new Error(`No definition found with id ${definitionId}.`);
30
+ return match;
31
+ }
32
+ if (appName) {
33
+ const match = definitions.find((d) => d.name?.toLowerCase() === appName.toLowerCase());
34
+ if (!match) {
35
+ const availableNames = definitions.map((d) => chalk.cyan(d.name ?? "(unnamed)")).join(", ");
36
+ throw new Error(`No definition found matching "${appName}". Available: ${availableNames}`);
37
+ }
38
+ return match;
39
+ }
40
+ return null;
41
+ }
42
+ //#endregion
12
43
  //#region ../../platform/api-client-core/src/fetch-client.ts
13
44
  /**
14
45
  * API Error class compatible with fluid-admin's ApiError
@@ -118,7 +149,10 @@ function createFetchClient(config) {
118
149
  throw new ApiError(errorText.slice(0, 200) || `${method} request failed with status ${response.status}`, response.status, null, headerRequestId);
119
150
  }
120
151
  const nestedError = typeof data.error === "object" && data.error !== null ? data.error.message : void 0;
121
- throw new ApiError(data.message || data.error_message || (typeof nestedError === "string" ? nestedError : void 0) || `${method} request failed`, response.status, data.errors || data, headerRequestId ?? getRequestIdFromJsonBody(data));
152
+ const directError = typeof data.error === "string" ? data.error : void 0;
153
+ const message = typeof data.message === "string" ? data.message : void 0;
154
+ const errorMessage = typeof data.error_message === "string" ? data.error_message : void 0;
155
+ throw new ApiError(message || errorMessage || directError || (typeof nestedError === "string" ? nestedError : void 0) || `${method} request failed with status ${response.status}`, response.status, data.errors || data, headerRequestId ?? getRequestIdFromJsonBody(data));
122
156
  } else throw new ApiError(`${method} request failed with status ${response.status}`, response.status, null, headerRequestId);
123
157
  }
124
158
  if (response.status === 204 || response.headers.get("content-length") === "0") return null;
@@ -950,17 +984,11 @@ async function fetchAllDefinitions(client) {
950
984
  }
951
985
  return all;
952
986
  }
953
- async function selectDefinition(client, appName) {
987
+ async function selectDefinition(client, appName, rawDefinitionId) {
954
988
  const definitions = await fetchAllDefinitions(client);
955
989
  if (definitions.length === 0) throw new Error("No Fluid OS definitions found. Create one in the admin dashboard first.");
956
- if (appName) {
957
- const match = definitions.find((d) => d.name?.toLowerCase() === appName.toLowerCase());
958
- if (!match) {
959
- const availableNames = definitions.map((d) => chalk.cyan(d.name ?? "(unnamed)")).join(", ");
960
- throw new Error(`No definition found matching "${appName}". Available: ${availableNames}`);
961
- }
962
- return match;
963
- }
990
+ const resolved = resolveDefinition(definitions, appName, rawDefinitionId);
991
+ if (resolved) return resolved;
964
992
  const { definitionId } = await prompts({
965
993
  type: "select",
966
994
  name: "definitionId",
@@ -1008,7 +1036,7 @@ async function writePortalFile(portalDir, relativePath, data) {
1008
1036
  await mkdir(join(filePath, ".."), { recursive: true });
1009
1037
  await writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
1010
1038
  }
1011
- const pullCommand = new Command("pull").description("Pull a Fluid OS definition's resources to the local portal/ directory").option("--app <name>", "Definition name (skips interactive selector)").option("--force", "Overwrite local changes when switching definitions").action(async (options) => {
1039
+ const pullCommand = new Command("pull").description("Pull a Fluid OS definition's resources to the local portal/ directory").option("--app <name>", "Definition name (skips interactive selector)").option("--id <id>", "Definition id (avoids ambiguity when names repeat)").option("--force", "Overwrite local changes when switching definitions").action(async (options) => {
1012
1040
  const cwd = process.cwd();
1013
1041
  console.log();
1014
1042
  console.log(chalk.blue.bold("Fluid Portal Pull"));
@@ -1028,7 +1056,7 @@ const pullCommand = new Command("pull").description("Pull a Fluid OS definition'
1028
1056
  }
1029
1057
  let definition;
1030
1058
  try {
1031
- definition = await selectDefinition(client, options.app);
1059
+ definition = await selectDefinition(client, options.app, options.id);
1032
1060
  } catch (err) {
1033
1061
  console.log();
1034
1062
  console.log(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
@@ -1199,6 +1227,6 @@ const pullCommand = new Command("pull").description("Pull a Fluid OS definition'
1199
1227
  console.log();
1200
1228
  });
1201
1229
  //#endregion
1202
- export { fluid_os_v0_create_fluid_osversion as A, fluid_os_v0_update_fluid_osnavigation as B, writeMappings as C, fluid_os_v0_create_fluid_osprofile as D, fluid_os_v0_create_fluid_osnavigation_item as E, fluid_os_v0_delete_fluid_osscreen as F, fluid_os_v0_update_fluid_osversion as G, fluid_os_v0_update_fluid_osprofile as H, fluid_os_v0_delete_fluid_ostheme as I, createFetchClient as K, fluid_os_v0_list_fluid_osdefinitions as L, fluid_os_v0_delete_fluid_osnavigation as M, fluid_os_v0_delete_fluid_osnavigation_item as N, fluid_os_v0_create_fluid_osscreen as O, fluid_os_v0_delete_fluid_osprofile as P, fluid_os_v0_list_fluid_osnavigation_items as R, updateMapping as S, fluid_os_v0_create_fluid_osnavigation as T, fluid_os_v0_update_fluid_osscreen as U, fluid_os_v0_update_fluid_osnavigation_item as V, fluid_os_v0_update_fluid_ostheme as W, deriveSlug as _, buildThemeIdToSlugMap as a, resolveIdToSlug as b, transformNavigationItems as c, transformTheme as d, buildSnapshot as f, writeSnapshot as g, readSnapshot as h, buildNavigationIdToSlugMap as i, fluid_os_v0_create_widget_package_version as j, fluid_os_v0_create_fluid_ostheme as k, transformProfile as l, diffAgainstSnapshot as m, pull_exports as n, deriveScreenSlug as o, computeFileHash as p, buildIdToSlugMap as r, transformNavigation as s, pullCommand as t, transformScreen as u, readMappings as v, fluid_os_v0_complete_widget_package_version_upload as w, resolveSlugToId as x, removeMapping as y, fluid_os_v0_list_fluid_osversions as z };
1230
+ export { fluid_os_v0_create_fluid_ostheme as A, fluid_os_v0_list_fluid_osversions as B, writeMappings as C, fluid_os_v0_create_fluid_osnavigation_item as D, fluid_os_v0_create_fluid_osnavigation as E, fluid_os_v0_delete_fluid_osprofile as F, fluid_os_v0_update_fluid_ostheme as G, fluid_os_v0_update_fluid_osnavigation_item as H, fluid_os_v0_delete_fluid_osscreen as I, fluid_os_v0_update_fluid_osversion as K, fluid_os_v0_delete_fluid_ostheme as L, fluid_os_v0_create_widget_package_version as M, fluid_os_v0_delete_fluid_osnavigation as N, fluid_os_v0_create_fluid_osprofile as O, fluid_os_v0_delete_fluid_osnavigation_item as P, fluid_os_v0_list_fluid_osdefinitions as R, updateMapping as S, fluid_os_v0_complete_widget_package_version_upload as T, fluid_os_v0_update_fluid_osprofile as U, fluid_os_v0_update_fluid_osnavigation as V, fluid_os_v0_update_fluid_osscreen as W, deriveSlug as _, buildThemeIdToSlugMap as a, resolveIdToSlug as b, transformNavigationItems as c, transformTheme as d, buildSnapshot as f, writeSnapshot as g, readSnapshot as h, buildNavigationIdToSlugMap as i, fluid_os_v0_create_fluid_osversion as j, fluid_os_v0_create_fluid_osscreen as k, transformProfile as l, diffAgainstSnapshot as m, pull_exports as n, deriveScreenSlug as o, computeFileHash as p, createFetchClient as q, buildIdToSlugMap as r, transformNavigation as s, pullCommand as t, transformScreen as u, readMappings as v, atomicWriteFile as w, resolveSlugToId as x, removeMapping as y, fluid_os_v0_list_fluid_osnavigation_items as z };
1203
1231
 
1204
- //# sourceMappingURL=pull-XOsFaqz2.mjs.map
1232
+ //# sourceMappingURL=pull-C3yj9L07.mjs.map