@fluid-app/fluid-cli-portal 0.1.8 → 0.1.9

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,1148 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { dirname, join, relative, sep } from "node:path";
5
+ import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
6
+ import prompts from "prompts";
7
+ import { existsSync } from "node:fs";
8
+ import { getActiveProfile, getAuthToken } from "@fluid-app/fluid-cli";
9
+ import pLimit from "p-limit";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ //#region \0rolldown/runtime.js
12
+ var __defProp = Object.defineProperty;
13
+ var __exportAll = (all, no_symbols) => {
14
+ let target = {};
15
+ for (var name in all) __defProp(target, name, {
16
+ get: all[name],
17
+ enumerable: true
18
+ });
19
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
20
+ return target;
21
+ };
22
+ //#endregion
23
+ //#region ../../platform/api-client-core/src/fetch-client.ts
24
+ /**
25
+ * API Error class compatible with fluid-admin's ApiError
26
+ */
27
+ var ApiError = class ApiError extends Error {
28
+ status;
29
+ data;
30
+ constructor(message, status, data) {
31
+ super(message);
32
+ this.name = "ApiError";
33
+ this.status = status;
34
+ this.data = data;
35
+ if ("captureStackTrace" in Error) Error.captureStackTrace(this, ApiError);
36
+ }
37
+ toJSON() {
38
+ return {
39
+ name: this.name,
40
+ message: this.message,
41
+ status: this.status,
42
+ data: this.data
43
+ };
44
+ }
45
+ };
46
+ /**
47
+ * Creates a configured fetch client instance
48
+ */
49
+ function createFetchClient(config) {
50
+ const { baseUrl, getAuthToken, onAuthError, defaultHeaders = {} } = config;
51
+ /**
52
+ * Build headers for a request
53
+ */
54
+ async function buildHeaders(customHeaders) {
55
+ const headers = {
56
+ Accept: "application/json",
57
+ "Content-Type": "application/json",
58
+ ...defaultHeaders,
59
+ ...customHeaders
60
+ };
61
+ if (getAuthToken) {
62
+ const token = await getAuthToken();
63
+ if (token) headers.Authorization = `Bearer ${token}`;
64
+ }
65
+ return headers;
66
+ }
67
+ /**
68
+ * Join baseUrl + endpoint via string concatenation (matches fetchApi).
69
+ * Using `new URL(endpoint, baseUrl)` would strip any path prefix from
70
+ * baseUrl (e.g. "/api") when the endpoint starts with "/".
71
+ */
72
+ function joinUrl(endpoint) {
73
+ return `${baseUrl}${endpoint}`;
74
+ }
75
+ /**
76
+ * Build URL with query parameters for GET requests
77
+ * Compatible with fluid-admin's query param handling
78
+ */
79
+ function buildUrl(endpoint, params) {
80
+ const fullUrl = joinUrl(endpoint);
81
+ if (!params || Object.keys(params).length === 0) return fullUrl;
82
+ const queryString = new URLSearchParams();
83
+ Object.entries(params).forEach(([key, value]) => {
84
+ if (value === void 0 || value === null) return;
85
+ if (Array.isArray(value)) value.forEach((item) => queryString.append(`${key}[]`, String(item)));
86
+ else if (typeof value === "object") Object.entries(value).forEach(([subKey, subValue]) => {
87
+ if (subValue === void 0 || subValue === null) return;
88
+ if (Array.isArray(subValue)) subValue.forEach((item) => queryString.append(`${key}[${subKey}][]`, String(item)));
89
+ else queryString.append(`${key}[${subKey}]`, String(subValue));
90
+ });
91
+ else queryString.append(key, String(value));
92
+ });
93
+ const qs = queryString.toString();
94
+ return qs ? `${fullUrl}?${qs}` : fullUrl;
95
+ }
96
+ /**
97
+ * Shared response handler for both JSON and FormData requests.
98
+ * Handles auth errors, non-OK responses, 204 No Content, and JSON parsing.
99
+ */
100
+ async function handleResponse(response, method, _url) {
101
+ if (response.status === 401 && onAuthError) onAuthError();
102
+ if (!response.ok) {
103
+ const errorText = await response.text().catch(() => "");
104
+ if (response.headers.get("content-type")?.includes("application/json")) {
105
+ let data;
106
+ try {
107
+ data = JSON.parse(errorText);
108
+ } catch {
109
+ throw new ApiError(errorText.slice(0, 200) || `${method} request failed with status ${response.status}`, response.status, null);
110
+ }
111
+ throw new ApiError(data.message || data.error_message || `${method} request failed`, response.status, data.errors || data);
112
+ } else throw new ApiError(`${method} request failed with status ${response.status}`, response.status, null);
113
+ }
114
+ if (response.status === 204 || response.headers.get("content-length") === "0") return null;
115
+ if (response.headers.get("content-type")?.includes("application/json")) try {
116
+ return await response.json();
117
+ } catch {
118
+ try {
119
+ return await response.text();
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * Main request function
128
+ */
129
+ async function request(endpoint, options = {}) {
130
+ const { method = "GET", headers: customHeaders, params, body, signal } = options;
131
+ const url = params ? buildUrl(endpoint, params) : joinUrl(endpoint);
132
+ const headers = await buildHeaders(customHeaders);
133
+ let response;
134
+ try {
135
+ const fetchOptions = {
136
+ method,
137
+ headers
138
+ };
139
+ const serializedBody = body && method !== "GET" ? JSON.stringify(body) : null;
140
+ if (serializedBody) fetchOptions.body = serializedBody;
141
+ if (signal) fetchOptions.signal = signal;
142
+ response = await fetch(url, fetchOptions);
143
+ } catch (networkError) {
144
+ throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
145
+ }
146
+ return handleResponse(response, method, url);
147
+ }
148
+ /**
149
+ * Request with FormData (for file uploads)
150
+ */
151
+ async function requestWithFormData(endpoint, formData, options = {}) {
152
+ const { method = "POST", headers: customHeaders, signal } = options;
153
+ const url = joinUrl(endpoint);
154
+ const headers = await buildHeaders(customHeaders);
155
+ delete headers["Content-Type"];
156
+ let response;
157
+ try {
158
+ const fetchOptions = {
159
+ method,
160
+ headers,
161
+ body: formData
162
+ };
163
+ if (signal) fetchOptions.signal = signal;
164
+ response = await fetch(url, fetchOptions);
165
+ } catch (networkError) {
166
+ throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
167
+ }
168
+ return handleResponse(response, method, url);
169
+ }
170
+ return {
171
+ request,
172
+ requestWithFormData,
173
+ get: (endpoint, params, options) => request(endpoint, {
174
+ ...options,
175
+ method: "GET",
176
+ ...params && { params }
177
+ }),
178
+ post: (endpoint, body, options) => request(endpoint, {
179
+ ...options,
180
+ method: "POST",
181
+ body
182
+ }),
183
+ put: (endpoint, body, options) => request(endpoint, {
184
+ ...options,
185
+ method: "PUT",
186
+ body
187
+ }),
188
+ patch: (endpoint, body, options) => request(endpoint, {
189
+ ...options,
190
+ method: "PATCH",
191
+ body
192
+ }),
193
+ delete: (endpoint, options) => request(endpoint, {
194
+ ...options,
195
+ method: "DELETE"
196
+ })
197
+ };
198
+ }
199
+ //#endregion
200
+ //#region ../../api-clients/fluidos/src/namespaces/fluid_os.ts
201
+ /**
202
+ * List Fluid OS definitions
203
+ * Retrieve a list of Fluid OS definitions for the current company
204
+ *
205
+ * @param client - Fetch client instance
206
+ * @param params? - params?
207
+ */
208
+ async function listFluidOSDefinitions(client, params) {
209
+ return client.get(`/api/company/fluid_os/definitions`, params);
210
+ }
211
+ /**
212
+ * List navigation items for a navigation
213
+ * Retrieve a list of navigation items for a specific navigation
214
+ *
215
+ * @param client - Fetch client instance
216
+ * @param definition_id - definition_id
217
+ * @param navigation_id - navigation_id
218
+ */
219
+ async function listFluidOSNavigationItems(client, definition_id, navigation_id) {
220
+ return client.get(`/api/company/fluid_os/definitions/${definition_id}/navigations/${navigation_id}/navigation_items`);
221
+ }
222
+ /**
223
+ * Create a navigation item
224
+ * Create a new navigation item for a navigation
225
+ *
226
+ * @param client - Fetch client instance
227
+ * @param definition_id - definition_id
228
+ * @param navigation_id - navigation_id
229
+ * @param body - body
230
+ */
231
+ async function createFluidOSNavigationItem(client, definition_id, navigation_id, body) {
232
+ return client.post(`/api/company/fluid_os/definitions/${definition_id}/navigations/${navigation_id}/navigation_items`, body);
233
+ }
234
+ /**
235
+ * Update a navigation item
236
+ * Update an existing navigation item
237
+ *
238
+ * @param client - Fetch client instance
239
+ * @param definition_id - definition_id
240
+ * @param navigation_id - navigation_id
241
+ * @param id - id
242
+ * @param body - body
243
+ */
244
+ async function updateFluidOSNavigationItem(client, definition_id, navigation_id, id, body) {
245
+ return client.put(`/api/company/fluid_os/definitions/${definition_id}/navigations/${navigation_id}/navigation_items/${id}`, body);
246
+ }
247
+ /**
248
+ * Delete a navigation item
249
+ * Delete a navigation item
250
+ *
251
+ * @param client - Fetch client instance
252
+ * @param definition_id - definition_id
253
+ * @param navigation_id - navigation_id
254
+ * @param id - id
255
+ */
256
+ async function deleteFluidOSNavigationItem(client, definition_id, navigation_id, id) {
257
+ return client.delete(`/api/company/fluid_os/definitions/${definition_id}/navigations/${navigation_id}/navigation_items/${id}`);
258
+ }
259
+ /**
260
+ * List navigations for a Fluid OS definition
261
+ * Retrieve a list of navigations for a specific Fluid OS definition
262
+ *
263
+ * @param client - Fetch client instance
264
+ * @param definition_id - definition_id
265
+ * @param params? - params?
266
+ */
267
+ async function listFluidOSNavigations(client, definition_id, params) {
268
+ return client.get(`/api/company/fluid_os/definitions/${definition_id}/navigations`, params);
269
+ }
270
+ /**
271
+ * Create a navigation for a Fluid OS definition
272
+ * Create a new navigation for a Fluid OS definition
273
+ *
274
+ * @param client - Fetch client instance
275
+ * @param definition_id - definition_id
276
+ * @param body - body
277
+ */
278
+ async function createFluidOSNavigation(client, definition_id, body) {
279
+ return client.post(`/api/company/fluid_os/definitions/${definition_id}/navigations`, body);
280
+ }
281
+ /**
282
+ * Update a navigation
283
+ * Update an existing navigation
284
+ *
285
+ * @param client - Fetch client instance
286
+ * @param definition_id - definition_id
287
+ * @param id - id
288
+ * @param body - body
289
+ */
290
+ async function updateFluidOSNavigation(client, definition_id, id, body) {
291
+ return client.put(`/api/company/fluid_os/definitions/${definition_id}/navigations/${id}`, body);
292
+ }
293
+ /**
294
+ * Delete a navigation
295
+ * Delete a navigation
296
+ *
297
+ * @param client - Fetch client instance
298
+ * @param definition_id - definition_id
299
+ * @param id - id
300
+ */
301
+ async function deleteFluidOSNavigation(client, definition_id, id) {
302
+ return client.delete(`/api/company/fluid_os/definitions/${definition_id}/navigations/${id}`);
303
+ }
304
+ /**
305
+ * List profiles for a Fluid OS definition
306
+ * Retrieve a list of profiles for a specific Fluid OS definition
307
+ *
308
+ * @param client - Fetch client instance
309
+ * @param definition_id - definition_id
310
+ * @param params? - params?
311
+ */
312
+ async function listFluidOSProfiles(client, definition_id, params) {
313
+ return client.get(`/api/company/fluid_os/definitions/${definition_id}/profiles`, params);
314
+ }
315
+ /**
316
+ * Create a profile for a Fluid OS definition
317
+ * Create a new profile for a Fluid OS definition
318
+ *
319
+ * @param client - Fetch client instance
320
+ * @param definition_id - definition_id
321
+ * @param body - body
322
+ */
323
+ async function createFluidOSProfile(client, definition_id, body) {
324
+ return client.post(`/api/company/fluid_os/definitions/${definition_id}/profiles`, body);
325
+ }
326
+ /**
327
+ * Update a profile
328
+ * Update an existing profile
329
+ *
330
+ * @param client - Fetch client instance
331
+ * @param definition_id - definition_id
332
+ * @param id - id
333
+ * @param body - body
334
+ */
335
+ async function updateFluidOSProfile(client, definition_id, id, body) {
336
+ return client.put(`/api/company/fluid_os/definitions/${definition_id}/profiles/${id}`, body);
337
+ }
338
+ /**
339
+ * Delete a profile
340
+ * Delete a profile
341
+ *
342
+ * @param client - Fetch client instance
343
+ * @param definition_id - definition_id
344
+ * @param id - id
345
+ */
346
+ async function deleteFluidOSProfile(client, definition_id, id) {
347
+ return client.delete(`/api/company/fluid_os/definitions/${definition_id}/profiles/${id}`);
348
+ }
349
+ /**
350
+ * List screens for a Fluid OS definition
351
+ * Retrieve a list of screens for a specific Fluid OS definition
352
+ *
353
+ * @param client - Fetch client instance
354
+ * @param definition_id - definition_id
355
+ * @param params? - params?
356
+ */
357
+ async function listFluidOSScreens(client, definition_id, params) {
358
+ return client.get(`/api/company/fluid_os/definitions/${definition_id}/screens`, params);
359
+ }
360
+ /**
361
+ * Create a screen for a Fluid OS definition
362
+ * Create a new screen for a Fluid OS definition
363
+ *
364
+ * @param client - Fetch client instance
365
+ * @param definition_id - definition_id
366
+ * @param body - body
367
+ */
368
+ async function createFluidOSScreen(client, definition_id, body) {
369
+ return client.post(`/api/company/fluid_os/definitions/${definition_id}/screens`, body);
370
+ }
371
+ /**
372
+ * Get a specific screen
373
+ * Retrieve a specific screen
374
+ *
375
+ * @param client - Fetch client instance
376
+ * @param definition_id - definition_id
377
+ * @param id - id
378
+ */
379
+ async function getFluidOSScreen(client, definition_id, id) {
380
+ return client.get(`/api/company/fluid_os/definitions/${definition_id}/screens/${id}`);
381
+ }
382
+ /**
383
+ * Update a screen
384
+ * Update an existing screen
385
+ *
386
+ * @param client - Fetch client instance
387
+ * @param definition_id - definition_id
388
+ * @param id - id
389
+ * @param body - body
390
+ */
391
+ async function updateFluidOSScreen(client, definition_id, id, body) {
392
+ return client.put(`/api/company/fluid_os/definitions/${definition_id}/screens/${id}`, body);
393
+ }
394
+ /**
395
+ * Delete a screen
396
+ * Delete a screen
397
+ *
398
+ * @param client - Fetch client instance
399
+ * @param definition_id - definition_id
400
+ * @param id - id
401
+ */
402
+ async function deleteFluidOSScreen(client, definition_id, id) {
403
+ return client.delete(`/api/company/fluid_os/definitions/${definition_id}/screens/${id}`);
404
+ }
405
+ /**
406
+ * List themes for a Fluid OS definition
407
+ * Retrieve a list of themes for a specific Fluid OS definition
408
+ *
409
+ * @param client - Fetch client instance
410
+ * @param definition_id - definition_id
411
+ * @param params? - params?
412
+ */
413
+ async function listFluidOSThemes(client, definition_id, params) {
414
+ return client.get(`/api/company/fluid_os/definitions/${definition_id}/themes`, params);
415
+ }
416
+ /**
417
+ * Create a theme for a Fluid OS definition
418
+ * Create a new theme for a Fluid OS definition
419
+ *
420
+ * @param client - Fetch client instance
421
+ * @param definition_id - definition_id
422
+ * @param body - body
423
+ */
424
+ async function createFluidOSTheme(client, definition_id, body) {
425
+ return client.post(`/api/company/fluid_os/definitions/${definition_id}/themes`, body);
426
+ }
427
+ /**
428
+ * Update a theme
429
+ * Update an existing theme
430
+ *
431
+ * @param client - Fetch client instance
432
+ * @param definition_id - definition_id
433
+ * @param id - id
434
+ * @param body - body
435
+ */
436
+ async function updateFluidOSTheme(client, definition_id, id, body) {
437
+ return client.put(`/api/company/fluid_os/definitions/${definition_id}/themes/${id}`, body);
438
+ }
439
+ /**
440
+ * Delete a theme
441
+ * Delete a theme
442
+ *
443
+ * @param client - Fetch client instance
444
+ * @param definition_id - definition_id
445
+ * @param id - id
446
+ */
447
+ async function deleteFluidOSTheme(client, definition_id, id) {
448
+ return client.delete(`/api/company/fluid_os/definitions/${definition_id}/themes/${id}`);
449
+ }
450
+ /**
451
+ * List versions for a Fluid OS definition
452
+ * Retrieve a list of published versions for a specific Fluid OS definition
453
+ *
454
+ * @param client - Fetch client instance
455
+ * @param definition_id - definition_id
456
+ * @param params? - params?
457
+ */
458
+ async function listFluidOSVersions(client, definition_id, params) {
459
+ return client.get(`/api/company/fluid_os/definitions/${definition_id}/versions`, params);
460
+ }
461
+ /**
462
+ * Publish a new version of a Fluid OS definition
463
+ * Publish a new version of the Fluid OS definition. This creates a snapshot of the current definition state including all screens, profiles, themes, and navigations. No request body is required - the manifest is built automatically from the current definition state.
464
+ *
465
+ * @param client - Fetch client instance
466
+ * @param definition_id - definition_id
467
+ */
468
+ async function createFluidOSVersion(client, definition_id) {
469
+ return client.post(`/api/company/fluid_os/definitions/${definition_id}/versions`);
470
+ }
471
+ /**
472
+ * Update a version
473
+ * Update a version. Currently only supports activating/deactivating a version.
474
+ *
475
+ * @param client - Fetch client instance
476
+ * @param definition_id - definition_id
477
+ * @param id - id
478
+ * @param body - body
479
+ */
480
+ async function updateFluidOSVersion(client, definition_id, id, body) {
481
+ return client.put(`/api/company/fluid_os/definitions/${definition_id}/versions/${id}`, body);
482
+ }
483
+ //#endregion
484
+ //#region src/utils/atomic-write.ts
485
+ /**
486
+ * Atomic file write utility.
487
+ *
488
+ * Uses a write-then-rename pattern so readers always see either the old
489
+ * file or the complete new file — never a partially-written state.
490
+ */
491
+ /**
492
+ * Write a file atomically using a write-then-rename pattern.
493
+ * Ensures readers always see either the old file or the complete new file.
494
+ *
495
+ * If `rename` fails after the temp file has been written, the temp file
496
+ * is cleaned up on a best-effort basis before re-throwing the error.
497
+ */
498
+ async function atomicWriteFile(filePath, data) {
499
+ const tmp = join(dirname(filePath), `.tmp-${randomBytes(6).toString("hex")}`);
500
+ try {
501
+ await writeFile(tmp, data, "utf-8");
502
+ await rename(tmp, filePath);
503
+ } catch (err) {
504
+ await unlink(tmp).catch(() => {});
505
+ throw err;
506
+ }
507
+ }
508
+ //#endregion
509
+ //#region src/utils/mappings.ts
510
+ /**
511
+ * Slug-to-ID mapping system for portal content sync.
512
+ *
513
+ * Maps human-readable slugs (derived from resource names) to API IDs,
514
+ * enabling the file system to use readable directory/file names while
515
+ * maintaining the link back to server-side resources.
516
+ *
517
+ * Persisted in `.portal-sync/mappings.json`.
518
+ */
519
+ /** Runtime check that a parsed value has the expected PortalMappings shape. */
520
+ function isMappings(value) {
521
+ if (!value || typeof value !== "object") return false;
522
+ const m = value;
523
+ const def = m.definition;
524
+ if (!def || typeof def !== "object") return false;
525
+ if (typeof def.name !== "string" || typeof def.id !== "number") return false;
526
+ return typeof m.screens === "object" && m.screens !== null && typeof m.themes === "object" && m.themes !== null && typeof m.navigations === "object" && m.navigations !== null && typeof m.profiles === "object" && m.profiles !== null && typeof m.countries === "object" && m.countries !== null && typeof m.ranks === "object" && m.ranks !== null;
527
+ }
528
+ const MAPPINGS_FILE = "mappings.json";
529
+ /**
530
+ * Read the mappings file from the `.portal-sync/` directory.
531
+ * Returns `null` if the file does not exist.
532
+ */
533
+ async function readMappings(portalSyncDir) {
534
+ try {
535
+ const filePath = join(portalSyncDir, MAPPINGS_FILE);
536
+ const content = await readFile(filePath, "utf-8");
537
+ const parsed = JSON.parse(content);
538
+ if (!isMappings(parsed)) throw new Error(`Malformed mappings file: ${filePath}`);
539
+ return parsed;
540
+ } catch (err) {
541
+ if (err.code === "ENOENT") return null;
542
+ throw err;
543
+ }
544
+ }
545
+ /**
546
+ * Write the mappings file to the `.portal-sync/` directory.
547
+ * Creates the directory if it does not exist.
548
+ */
549
+ async function writeMappings(portalSyncDir, mappings) {
550
+ const filePath = join(portalSyncDir, MAPPINGS_FILE);
551
+ await mkdir(dirname(filePath), { recursive: true });
552
+ await atomicWriteFile(filePath, JSON.stringify(mappings, null, 2) + "\n");
553
+ }
554
+ /**
555
+ * Derive a filesystem-safe slug from a resource name.
556
+ *
557
+ * - Lowercases the name
558
+ * - Replaces non-alphanumeric characters (except hyphens) with hyphens
559
+ * - Collapses consecutive hyphens
560
+ * - Trims leading/trailing hyphens
561
+ *
562
+ * If the derived slug collides with an existing slug in `existingSlugs`,
563
+ * a numeric suffix is appended (e.g., `home-2`, `home-3`).
564
+ */
565
+ function deriveSlug(name, existingSlugs = /* @__PURE__ */ new Set()) {
566
+ const base = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "unnamed";
567
+ if (!existingSlugs.has(base)) return base;
568
+ let counter = 2;
569
+ while (existingSlugs.has(`${base}-${counter}`)) counter++;
570
+ return `${base}-${counter}`;
571
+ }
572
+ /**
573
+ * Resolve a slug to its server-side ID.
574
+ * Returns `undefined` if the slug is not mapped.
575
+ */
576
+ function resolveSlugToId(mappings, resourceType, slug) {
577
+ return mappings[resourceType][slug];
578
+ }
579
+ /**
580
+ * Resolve a server-side ID back to its slug.
581
+ * Returns `undefined` if no slug maps to the given ID.
582
+ */
583
+ function resolveIdToSlug(mappings, resourceType, id) {
584
+ const entries = mappings[resourceType];
585
+ for (const [slug, mappedId] of Object.entries(entries)) if (mappedId === id) return slug;
586
+ }
587
+ /**
588
+ * Add or update a mapping entry for a given resource type.
589
+ * Returns a new `PortalMappings` object (immutable update).
590
+ */
591
+ function updateMapping(mappings, resourceType, slug, id) {
592
+ return {
593
+ ...mappings,
594
+ [resourceType]: {
595
+ ...mappings[resourceType],
596
+ [slug]: id
597
+ }
598
+ };
599
+ }
600
+ /**
601
+ * Remove a mapping entry for a given resource type.
602
+ * Returns a new `PortalMappings` object (immutable update).
603
+ * If the slug does not exist, the original mappings are returned unchanged.
604
+ */
605
+ function removeMapping(mappings, resourceType, slug) {
606
+ if (!(slug in mappings[resourceType])) return mappings;
607
+ const { [slug]: _removed, ...rest } = mappings[resourceType];
608
+ return {
609
+ ...mappings,
610
+ [resourceType]: rest
611
+ };
612
+ }
613
+ //#endregion
614
+ //#region src/utils/snapshot.ts
615
+ /**
616
+ * Hash-based snapshot system for portal content sync.
617
+ *
618
+ * Tracks file hashes to detect local changes since the last pull,
619
+ * enabling efficient push operations that only send modified files.
620
+ *
621
+ * Persisted in `.portal-sync/snapshot.json`.
622
+ */
623
+ /** Maximum number of concurrent file operations. */
624
+ const MAX_CONCURRENCY = 20;
625
+ /**
626
+ * Run async tasks with bounded concurrency to avoid exhausting file descriptors.
627
+ */
628
+ async function mapWithLimit(items, limit, fn) {
629
+ const results = new Array(items.length);
630
+ let index = 0;
631
+ async function worker() {
632
+ while (index < items.length) {
633
+ const i = index++;
634
+ results[i] = await fn(items[i]);
635
+ }
636
+ }
637
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
638
+ await Promise.all(workers);
639
+ return results;
640
+ }
641
+ /** Runtime check that a parsed value has the expected Snapshot shape. */
642
+ function isSnapshot(value) {
643
+ if (!value || typeof value !== "object") return false;
644
+ const s = value;
645
+ return typeof s.definition === "string" && typeof s.definition_id === "number" && typeof s.pulled_at === "string" && typeof s.files === "object" && s.files !== null;
646
+ }
647
+ /**
648
+ * Compute the SHA-256 hash of a file's contents.
649
+ * Returns the hex-encoded digest.
650
+ */
651
+ async function computeFileHash(filePath) {
652
+ const content = await readFile(filePath);
653
+ return createHash("sha256").update(content).digest("hex");
654
+ }
655
+ const SNAPSHOT_FILE = "snapshot.json";
656
+ /**
657
+ * Read the snapshot file from the `.portal-sync/` directory.
658
+ * Returns `null` if the file does not exist.
659
+ */
660
+ async function readSnapshot(portalSyncDir) {
661
+ try {
662
+ const filePath = join(portalSyncDir, SNAPSHOT_FILE);
663
+ const content = await readFile(filePath, "utf-8");
664
+ const parsed = JSON.parse(content);
665
+ if (!isSnapshot(parsed)) throw new Error(`Malformed snapshot file: ${filePath}`);
666
+ return parsed;
667
+ } catch (err) {
668
+ if (err.code === "ENOENT") return null;
669
+ throw err;
670
+ }
671
+ }
672
+ /**
673
+ * Write the snapshot file to the `.portal-sync/` directory.
674
+ * Creates the directory if it does not exist.
675
+ */
676
+ async function writeSnapshot(portalSyncDir, snapshot) {
677
+ const filePath = join(portalSyncDir, SNAPSHOT_FILE);
678
+ await mkdir(dirname(filePath), { recursive: true });
679
+ await atomicWriteFile(filePath, JSON.stringify(snapshot, null, 2) + "\n");
680
+ }
681
+ /**
682
+ * Recursively collect all files in a directory, returning paths
683
+ * relative to `baseDir`.
684
+ */
685
+ async function collectFiles(dir, baseDir = dir) {
686
+ const entries = await readdir(dir, { withFileTypes: true });
687
+ const files = [];
688
+ for (const entry of entries) {
689
+ const fullPath = join(dir, entry.name);
690
+ if (entry.name === ".portal-sync") continue;
691
+ if (entry.isSymbolicLink()) {
692
+ const realStat = await stat(fullPath);
693
+ if (realStat.isDirectory()) files.push(...await collectFiles(fullPath, baseDir));
694
+ else if (realStat.isFile()) files.push(relative(baseDir, fullPath).split(sep).join("/"));
695
+ } else if (entry.isDirectory()) files.push(...await collectFiles(fullPath, baseDir));
696
+ else files.push(relative(baseDir, fullPath).split(sep).join("/"));
697
+ }
698
+ return files;
699
+ }
700
+ /**
701
+ * Compare the current portal directory contents against a snapshot.
702
+ *
703
+ * Returns lists of new, changed, and deleted files (relative paths).
704
+ */
705
+ async function diffAgainstSnapshot(portalDir, snapshot) {
706
+ const currentFiles = await collectFiles(portalDir);
707
+ const snapshotPaths = new Set(Object.keys(snapshot.files));
708
+ const newFiles = [];
709
+ const changedFiles = [];
710
+ await mapWithLimit(currentFiles, MAX_CONCURRENCY, async (filePath) => {
711
+ const hash = await computeFileHash(join(portalDir, filePath));
712
+ if (!snapshotPaths.has(filePath)) newFiles.push(filePath);
713
+ else if (snapshot.files[filePath] !== hash) changedFiles.push(filePath);
714
+ });
715
+ const currentSet = new Set(currentFiles);
716
+ const deletedFiles = [...snapshotPaths].filter((filePath) => !currentSet.has(filePath));
717
+ return {
718
+ new: [...newFiles].sort(),
719
+ changed: [...changedFiles].sort(),
720
+ deleted: [...deletedFiles].sort()
721
+ };
722
+ }
723
+ /**
724
+ * Build a fresh snapshot from the current portal directory contents.
725
+ *
726
+ * Hashes every file in `portalDir` (excluding `.portal-sync/`)
727
+ * and records the definition metadata.
728
+ */
729
+ async function buildSnapshot(portalDir, definitionName, definitionId) {
730
+ const hashEntries = await mapWithLimit(await collectFiles(portalDir), MAX_CONCURRENCY, async (filePath) => {
731
+ return [filePath, await computeFileHash(join(portalDir, filePath))];
732
+ });
733
+ hashEntries.sort(([a], [b]) => a.localeCompare(b));
734
+ const fileHashes = {};
735
+ for (const [path, hash] of hashEntries) fileHashes[path] = hash;
736
+ return {
737
+ definition: definitionName,
738
+ definition_id: definitionId,
739
+ pulled_at: (/* @__PURE__ */ new Date()).toISOString(),
740
+ files: fileHashes
741
+ };
742
+ }
743
+ //#endregion
744
+ //#region src/utils/transform.ts
745
+ /**
746
+ * Transform an API screen response into the local file format.
747
+ * Strips server-only fields and normalizes `component_tree` to always be an array.
748
+ */
749
+ function transformScreen(screen) {
750
+ const name = screen.name ?? "";
751
+ const rawTree = screen.component_tree;
752
+ let componentTree;
753
+ if (rawTree == null) componentTree = [];
754
+ else if (Array.isArray(rawTree)) componentTree = rawTree;
755
+ else componentTree = [rawTree];
756
+ return {
757
+ name,
758
+ component_tree: componentTree
759
+ };
760
+ }
761
+ /**
762
+ * Derive a slug for a screen. Screens use the existing `slug` field from the API.
763
+ * Falls back to slugifying the name if no slug exists.
764
+ */
765
+ function deriveScreenSlug(screen, existingSlugs) {
766
+ if (screen.slug) {
767
+ const safe = screen.slug.replace(/[^a-z0-9_-]/gi, "-");
768
+ if (!existingSlugs.has(safe)) return safe;
769
+ return deriveSlug(safe, existingSlugs);
770
+ }
771
+ return deriveSlug(screen.name ?? "unnamed", existingSlugs);
772
+ }
773
+ /**
774
+ * Transform an API theme response into the local file format.
775
+ * Passes theme config through as-is (hex conversion is a separate ticket).
776
+ */
777
+ function transformTheme(theme) {
778
+ return {
779
+ name: theme.name ?? "",
780
+ config: theme.config ?? {},
781
+ active: theme.active ?? false
782
+ };
783
+ }
784
+ /**
785
+ * Build an ID → slug lookup map by inverting a slug → id mapping.
786
+ */
787
+ function buildIdToSlugMap(mappings) {
788
+ const map = /* @__PURE__ */ new Map();
789
+ for (const [slug, id] of Object.entries(mappings)) map.set(id, slug);
790
+ return map;
791
+ }
792
+ /**
793
+ * Transform API navigation items into local format.
794
+ * Converts `screen_id` to `screen` slug reference. Keeps `id` for sync.
795
+ */
796
+ function transformNavigationItems(items, screenIdToSlug) {
797
+ return items.map((item) => ({
798
+ id: item.id,
799
+ icon: item.icon ?? null,
800
+ label: item.label ?? null,
801
+ screen: item.screen_id ? screenIdToSlug.get(item.screen_id) ?? null : null,
802
+ slug: item.slug ?? null,
803
+ source: item.source,
804
+ position: item.position ?? null,
805
+ parent_id: item.parent_id ?? null,
806
+ children: transformNavigationItems(item.children ?? [], screenIdToSlug)
807
+ }));
808
+ }
809
+ /**
810
+ * Transform an API navigation with its items into the local file format.
811
+ */
812
+ function transformNavigation(nav, items, screenIdToSlug) {
813
+ return {
814
+ name: nav.name ?? "",
815
+ platform: nav.platform,
816
+ navigation_items: transformNavigationItems(items, screenIdToSlug)
817
+ };
818
+ }
819
+ /** Typed alias for call-site clarity. */
820
+ const buildNavigationIdToSlugMap = buildIdToSlugMap;
821
+ /** Typed alias for call-site clarity. */
822
+ const buildThemeIdToSlugMap = buildIdToSlugMap;
823
+ /**
824
+ * Transform an API profile response into the local file format.
825
+ * Converts navigation_id, mobile_navigation_id, and theme_ids to slug references.
826
+ *
827
+ * TODO (REP-842): Country/rank IDs in profile permissions need to be translated
828
+ * to human-readable names via the main Fluid API. For now, the permission arrays
829
+ * are written as-is with integer IDs.
830
+ */
831
+ function transformProfile(profile, navIdToSlug, themeIdToSlug) {
832
+ const navigation = profile.navigation ? navIdToSlug.get(profile.navigation.id) ?? null : null;
833
+ const mobileNavigation = profile.mobile_navigation ? navIdToSlug.get(profile.mobile_navigation.id) ?? null : null;
834
+ const themes = (profile.themes ?? []).map((t) => themeIdToSlug.get(t.id)).filter((slug) => slug != null);
835
+ const permissions = profile.permissions ?? {};
836
+ return {
837
+ name: profile.name ?? "",
838
+ default: profile.default ?? false,
839
+ navigation,
840
+ mobile_navigation: mobileNavigation,
841
+ themes,
842
+ permissions: {
843
+ ranks: permissions.ranks ?? [],
844
+ roles: permissions.roles ?? [],
845
+ platform: permissions.platform ?? [],
846
+ countries: permissions.countries ?? []
847
+ }
848
+ };
849
+ }
850
+ //#endregion
851
+ //#region src/commands/pull.ts
852
+ /**
853
+ * `fluid portal pull` command
854
+ *
855
+ * Pulls a Fluid OS definition's resources (screens, themes, navigations,
856
+ * profiles) from the API and writes them to the local `portal/` directory
857
+ * as JSON files, along with `.portal-sync/` metadata for push diffing.
858
+ */
859
+ var pull_exports = /* @__PURE__ */ __exportAll({ pullCommand: () => pullCommand });
860
+ const PORTAL_DIR = "portal";
861
+ const PORTAL_SYNC_DIR = ".portal-sync";
862
+ const PAGE_LIMIT = 500;
863
+ /**
864
+ * Create an authenticated FetchClient using the stored CLI profile.
865
+ */
866
+ function createClient() {
867
+ const token = getAuthToken();
868
+ if (!token) {
869
+ const profile = getActiveProfile();
870
+ if (!profile) throw new Error("Not logged in. Run " + chalk.cyan("fluid login") + " first.");
871
+ throw new Error("No auth token found for profile " + chalk.cyan(profile.name) + ". Run " + chalk.cyan("fluid login") + " to re-authenticate.");
872
+ }
873
+ return createFetchClient({
874
+ baseUrl: process.env["FLUID_API_BASE"] ?? "https://api.fluid.app",
875
+ getAuthToken: () => token
876
+ });
877
+ }
878
+ /**
879
+ * Select a definition interactively or by name.
880
+ */
881
+ async function fetchAllDefinitions(client) {
882
+ const all = [];
883
+ let page = 1;
884
+ while (true) {
885
+ const response = await listFluidOSDefinitions(client, {
886
+ page,
887
+ per_page: PAGE_LIMIT
888
+ });
889
+ const batch = response.definitions ?? [];
890
+ all.push(...batch);
891
+ const totalPages = response.meta?.total_pages ?? 1;
892
+ if (page >= totalPages || batch.length === 0) break;
893
+ page++;
894
+ }
895
+ return all;
896
+ }
897
+ async function selectDefinition(client, appName) {
898
+ const definitions = await fetchAllDefinitions(client);
899
+ if (definitions.length === 0) throw new Error("No Fluid OS definitions found. Create one in the admin dashboard first.");
900
+ if (appName) {
901
+ const match = definitions.find((d) => d.name?.toLowerCase() === appName.toLowerCase());
902
+ if (!match) {
903
+ const availableNames = definitions.map((d) => chalk.cyan(d.name ?? "(unnamed)")).join(", ");
904
+ throw new Error(`No definition found matching "${appName}". Available: ${availableNames}`);
905
+ }
906
+ return match;
907
+ }
908
+ const { definitionId } = await prompts({
909
+ type: "select",
910
+ name: "definitionId",
911
+ message: "Select a Fluid OS definition to pull",
912
+ choices: definitions.map((d) => ({
913
+ title: d.name ?? "(unnamed)",
914
+ value: d.id
915
+ }))
916
+ });
917
+ if (definitionId == null) throw new Error("No definition selected.");
918
+ const selected = definitions.find((d) => d.id === definitionId);
919
+ if (!selected) throw new Error("Selected definition not found.");
920
+ return selected;
921
+ }
922
+ /**
923
+ * Check for definition switching conflicts.
924
+ * Returns true if it's safe to proceed.
925
+ */
926
+ async function checkDefinitionSwitch(cwd, newDefinitionId, newDefinitionName, force) {
927
+ const portalDir = join(cwd, PORTAL_DIR);
928
+ const portalSyncDir = join(cwd, PORTAL_SYNC_DIR);
929
+ if (!existsSync(portalDir)) return true;
930
+ const snapshot = await readSnapshot(portalSyncDir);
931
+ if (!snapshot) return true;
932
+ if (snapshot.definition_id === newDefinitionId) return true;
933
+ const diff = await diffAgainstSnapshot(portalDir, snapshot);
934
+ if ((diff.new.length > 0 || diff.changed.length > 0 || diff.deleted.length > 0) && !force) {
935
+ console.log();
936
+ console.log(chalk.yellow("Warning:") + " Switching from " + chalk.cyan(snapshot.definition) + " to " + chalk.cyan(newDefinitionName) + " with unpushed local changes.");
937
+ console.log();
938
+ console.log(" Modified: " + diff.changed.length + " file(s)");
939
+ console.log(" New: " + diff.new.length + " file(s)");
940
+ console.log(" Deleted: " + diff.deleted.length + " file(s)");
941
+ console.log();
942
+ console.log("Use " + chalk.cyan("--force") + " to discard local changes and switch definitions.");
943
+ return false;
944
+ }
945
+ return true;
946
+ }
947
+ /**
948
+ * Write a JSON file to the portal directory, creating subdirectories as needed.
949
+ */
950
+ async function writePortalFile(portalDir, relativePath, data) {
951
+ const filePath = join(portalDir, relativePath);
952
+ await mkdir(join(filePath, ".."), { recursive: true });
953
+ await writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
954
+ }
955
+ 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) => {
956
+ const cwd = process.cwd();
957
+ console.log();
958
+ console.log(chalk.blue.bold("Fluid Portal Pull"));
959
+ console.log();
960
+ const spinner = ora();
961
+ spinner.start("Authenticating...");
962
+ let client;
963
+ try {
964
+ client = createClient();
965
+ spinner.succeed("Authenticated");
966
+ } catch (err) {
967
+ spinner.fail("Authentication failed");
968
+ console.log();
969
+ console.log(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
970
+ console.log();
971
+ process.exit(1);
972
+ }
973
+ let definition;
974
+ try {
975
+ definition = await selectDefinition(client, options.app);
976
+ } catch (err) {
977
+ console.log();
978
+ console.log(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
979
+ console.log();
980
+ process.exit(1);
981
+ }
982
+ const definitionId = definition.id;
983
+ const definitionName = definition.name ?? "(unnamed)";
984
+ console.log(chalk.gray("Definition: ") + chalk.white(definitionName) + chalk.gray(` (ID: ${definitionId})`));
985
+ console.log();
986
+ let canProceed;
987
+ try {
988
+ canProceed = await checkDefinitionSwitch(cwd, definitionId, definitionName, options.force ?? false);
989
+ } catch (err) {
990
+ console.log();
991
+ console.log(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
992
+ console.log();
993
+ process.exit(1);
994
+ }
995
+ if (!canProceed) process.exit(1);
996
+ spinner.start("Fetching resources...");
997
+ let screenBasics;
998
+ let themes;
999
+ let navigations;
1000
+ let profiles;
1001
+ let screens;
1002
+ const navigationItemsMap = /* @__PURE__ */ new Map();
1003
+ try {
1004
+ const [screensResponse, themesResponse, navigationsResponse, profilesResponse] = await Promise.all([
1005
+ listFluidOSScreens(client, definitionId, { per_page: PAGE_LIMIT }),
1006
+ listFluidOSThemes(client, definitionId, { per_page: PAGE_LIMIT }),
1007
+ listFluidOSNavigations(client, definitionId, { per_page: PAGE_LIMIT }),
1008
+ listFluidOSProfiles(client, definitionId, { per_page: PAGE_LIMIT })
1009
+ ]);
1010
+ screenBasics = screensResponse.screens ?? [];
1011
+ themes = themesResponse.themes ?? [];
1012
+ navigations = navigationsResponse.navigations ?? [];
1013
+ profiles = profilesResponse.profiles ?? [];
1014
+ if (screenBasics.length === PAGE_LIMIT) console.warn(chalk.yellow(`Warning: screen count hit the ${PAGE_LIMIT}-item limit; some screens may be missing.`));
1015
+ if (themes.length === PAGE_LIMIT) console.warn(chalk.yellow(`Warning: theme count hit the ${PAGE_LIMIT}-item limit; some themes may be missing.`));
1016
+ if (navigations.length === PAGE_LIMIT) console.warn(chalk.yellow(`Warning: navigation count hit the ${PAGE_LIMIT}-item limit; some navigations may be missing.`));
1017
+ if (profiles.length === PAGE_LIMIT) console.warn(chalk.yellow(`Warning: profile count hit the ${PAGE_LIMIT}-item limit; some profiles may be missing.`));
1018
+ spinner.text = "Fetching screen details...";
1019
+ const limit = pLimit(10);
1020
+ screens = await Promise.all(screenBasics.map((s) => limit(async () => {
1021
+ const res = await getFluidOSScreen(client, definitionId, s.id);
1022
+ if (!res.screen) throw new Error(`Failed to fetch details for screen ID ${s.id}`);
1023
+ return res.screen;
1024
+ })));
1025
+ spinner.text = "Fetching navigation items...";
1026
+ await Promise.all(navigations.map((nav) => limit(async () => {
1027
+ const res = await listFluidOSNavigationItems(client, definitionId, nav.id);
1028
+ navigationItemsMap.set(nav.id, res.navigation_items ?? []);
1029
+ })));
1030
+ spinner.succeed(`Fetched ${screens.length} screen(s), ${themes.length} theme(s), ${navigations.length} navigation(s), ${profiles.length} profile(s)`);
1031
+ } catch (err) {
1032
+ spinner.fail("Failed to fetch resources");
1033
+ console.log();
1034
+ console.log(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
1035
+ console.log();
1036
+ process.exit(1);
1037
+ }
1038
+ spinner.start("Transforming resources...");
1039
+ const screenSlugs = /* @__PURE__ */ new Set();
1040
+ const screenMappings = {};
1041
+ for (const screen of screens) {
1042
+ const slug = deriveScreenSlug(screen, screenSlugs);
1043
+ screenSlugs.add(slug);
1044
+ screenMappings[slug] = screen.id;
1045
+ }
1046
+ const themeSlugs = /* @__PURE__ */ new Set();
1047
+ const themeMappings = {};
1048
+ for (const theme of themes) {
1049
+ const slug = deriveSlug(theme.name ?? "unnamed", themeSlugs);
1050
+ themeSlugs.add(slug);
1051
+ themeMappings[slug] = theme.id;
1052
+ }
1053
+ const navSlugs = /* @__PURE__ */ new Set();
1054
+ const navMappings = {};
1055
+ for (const nav of navigations) {
1056
+ const slug = deriveSlug(nav.name ?? "unnamed", navSlugs);
1057
+ navSlugs.add(slug);
1058
+ navMappings[slug] = nav.id;
1059
+ }
1060
+ const profileSlugs = /* @__PURE__ */ new Set();
1061
+ const profileMappings = {};
1062
+ for (const profile of profiles) {
1063
+ const slug = deriveSlug(profile.name ?? "unnamed", profileSlugs);
1064
+ profileSlugs.add(slug);
1065
+ profileMappings[slug] = profile.id;
1066
+ }
1067
+ const screenIdToSlug = buildIdToSlugMap(screenMappings);
1068
+ const navIdToSlug = buildNavigationIdToSlugMap(navMappings);
1069
+ const themeIdToSlug = buildThemeIdToSlugMap(themeMappings);
1070
+ const profileIdToSlug = buildIdToSlugMap(profileMappings);
1071
+ spinner.succeed("Resources transformed");
1072
+ spinner.start("Writing files...");
1073
+ const portalDir = join(cwd, PORTAL_DIR);
1074
+ const portalSyncDir = join(cwd, PORTAL_SYNC_DIR);
1075
+ const tmpPortalDir = portalDir + ".tmp";
1076
+ try {
1077
+ if (existsSync(tmpPortalDir)) await rm(tmpPortalDir, { recursive: true });
1078
+ await mkdir(tmpPortalDir, { recursive: true });
1079
+ await writePortalFile(tmpPortalDir, "definition.json", { name: definitionName });
1080
+ for (const screen of screens) {
1081
+ const slug = screenIdToSlug.get(screen.id);
1082
+ const local = transformScreen(screen);
1083
+ await writePortalFile(tmpPortalDir, `screens/${slug}.json`, local);
1084
+ }
1085
+ for (const theme of themes) {
1086
+ const slug = themeIdToSlug.get(theme.id);
1087
+ const local = transformTheme(theme);
1088
+ await writePortalFile(tmpPortalDir, `themes/${slug}.json`, local);
1089
+ }
1090
+ for (const nav of navigations) {
1091
+ const slug = navIdToSlug.get(nav.id);
1092
+ const local = transformNavigation(nav, navigationItemsMap.get(nav.id) ?? [], screenIdToSlug);
1093
+ await writePortalFile(tmpPortalDir, `navigations/${slug}.json`, local);
1094
+ }
1095
+ for (const profile of profiles) {
1096
+ const slug = profileIdToSlug.get(profile.id);
1097
+ if (slug) {
1098
+ const local = transformProfile(profile, navIdToSlug, themeIdToSlug);
1099
+ await writePortalFile(tmpPortalDir, `profiles/${slug}.json`, local);
1100
+ }
1101
+ }
1102
+ if (existsSync(portalDir)) await rm(portalDir, { recursive: true });
1103
+ await rename(tmpPortalDir, portalDir);
1104
+ } catch (err) {
1105
+ if (existsSync(tmpPortalDir)) await rm(tmpPortalDir, { recursive: true }).catch(() => {});
1106
+ spinner.fail("Failed to write files");
1107
+ console.log();
1108
+ console.log(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
1109
+ console.log();
1110
+ process.exit(1);
1111
+ }
1112
+ try {
1113
+ await writeMappings(portalSyncDir, {
1114
+ definition: {
1115
+ name: definitionName,
1116
+ id: definitionId
1117
+ },
1118
+ screens: screenMappings,
1119
+ themes: themeMappings,
1120
+ navigations: navMappings,
1121
+ profiles: profileMappings,
1122
+ countries: {},
1123
+ ranks: {}
1124
+ });
1125
+ await writeSnapshot(portalSyncDir, await buildSnapshot(portalDir, definitionName, definitionId));
1126
+ spinner.succeed("Files written");
1127
+ } catch (err) {
1128
+ spinner.warn("Files written (metadata sync failed)");
1129
+ console.log();
1130
+ console.log(chalk.yellow("Warning:") + " Portal files were written successfully, but metadata sync failed. Run " + chalk.cyan("fluid portal pull") + " again to regenerate metadata.");
1131
+ console.log(chalk.gray(" Detail: ") + (err instanceof Error ? err.message : String(err)));
1132
+ console.log();
1133
+ }
1134
+ console.log();
1135
+ console.log(chalk.green.bold("Pull complete!"));
1136
+ console.log();
1137
+ console.log(chalk.gray(" portal/definition.json"));
1138
+ console.log(chalk.gray(` portal/screens/ `) + chalk.white(`${screens.length} file(s)`));
1139
+ console.log(chalk.gray(` portal/themes/ `) + chalk.white(`${themes.length} file(s)`));
1140
+ console.log(chalk.gray(` portal/navigations/ `) + chalk.white(`${navigations.length} file(s)`));
1141
+ console.log(chalk.gray(` portal/profiles/ `) + chalk.white(`${profiles.length} file(s)`));
1142
+ console.log(chalk.gray(` .portal-sync/ `) + chalk.white("mappings.json + snapshot.json"));
1143
+ console.log();
1144
+ });
1145
+ //#endregion
1146
+ export { deleteFluidOSNavigation as A, updateFluidOSScreen as B, writeMappings as C, createFluidOSScreen as D, createFluidOSProfile as E, listFluidOSNavigationItems as F, updateFluidOSVersion as H, listFluidOSVersions as I, updateFluidOSNavigation as L, deleteFluidOSProfile as M, deleteFluidOSScreen as N, createFluidOSTheme as O, deleteFluidOSTheme as P, updateFluidOSNavigationItem as R, updateMapping as S, createFluidOSNavigationItem as T, createFetchClient as U, updateFluidOSTheme as V, 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, deleteFluidOSNavigationItem as j, createFluidOSVersion 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, createFluidOSNavigation as w, resolveSlugToId as x, removeMapping as y, updateFluidOSProfile as z };
1147
+
1148
+ //# sourceMappingURL=pull-p1mSVa5W.mjs.map