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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,13 +1,14 @@
1
+ import { A as deleteFluidOSNavigation, B as updateFluidOSScreen, C as writeMappings, D as createFluidOSScreen, E as createFluidOSProfile, F as listFluidOSNavigationItems, H as updateFluidOSVersion, I as listFluidOSVersions, L as updateFluidOSNavigation, M as deleteFluidOSProfile, N as deleteFluidOSScreen, O as createFluidOSTheme, P as deleteFluidOSTheme, R as updateFluidOSNavigationItem, S as updateMapping, T as createFluidOSNavigationItem, U as createFetchClient, V as updateFluidOSTheme, _ 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 deleteFluidOSNavigationItem, k as createFluidOSVersion, 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 createFluidOSNavigation, x as resolveSlugToId, y as removeMapping, z as updateFluidOSProfile } from "./pull-p1mSVa5W.mjs";
1
2
  import { Command } from "commander";
2
3
  import chalk from "chalk";
3
4
  import ora from "ora";
4
- import path, { dirname, join, resolve } from "node:path";
5
+ import path, { basename, dirname, join, resolve } from "node:path";
5
6
  import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
6
7
  import prompts from "prompts";
7
- import { existsSync, readFileSync } from "node:fs";
8
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import Handlebars from "handlebars";
10
- import { failure, getErrorMessage, success } from "@fluid-app/fluid-cli";
11
+ import { failure, getActiveProfile, getAuthToken, getErrorMessage, success } from "@fluid-app/fluid-cli";
11
12
  import { execa } from "execa";
12
13
  import fs from "fs-extra";
13
14
  import path$1 from "path";
@@ -450,12 +451,48 @@ const createCommand = new Command("create").description("Create a new Fluid port
450
451
  process.exit(1);
451
452
  }
452
453
  });
453
- function registerCreateCommand(ctx) {
454
- ctx.program.addCommand(createCommand);
455
- }
456
454
  //#endregion
457
455
  //#region src/commands/dev.ts
458
- const devCommand = new Command("dev").description("Start the development server").option("-p, --port <port>", "Port to run the dev server on", "5173").option("--host", "Expose the dev server to the network").action(async (options) => {
456
+ /**
457
+ * `fluid portal dev` command
458
+ *
459
+ * Starts the Vite development server with the portal dev plugin,
460
+ * which intercepts manifest API requests and serves content from
461
+ * the local `portal/` directory.
462
+ *
463
+ * If no `portal/` directory exists, prompts the user to run `fluid portal pull`
464
+ * or auto-pulls if they are logged in.
465
+ */
466
+ const PORTAL_DIR$1 = "portal";
467
+ /**
468
+ * Check if the portal directory exists and has content files.
469
+ * Returns true if at minimum `portal/definition.json` exists.
470
+ */
471
+ function hasPortalContent(cwd) {
472
+ return existsSync(join(cwd, PORTAL_DIR$1, "definition.json"));
473
+ }
474
+ /**
475
+ * Attempt to auto-pull portal content by invoking the pull command's action.
476
+ * Falls back to a helpful error message if pull is not possible.
477
+ */
478
+ async function autoPull(cwd) {
479
+ console.log();
480
+ console.log(chalk.yellow("No portal/ directory found.") + " Attempting to pull content...");
481
+ console.log();
482
+ try {
483
+ const { pullCommand } = await import("./pull-p1mSVa5W.mjs").then((n) => n.n);
484
+ await pullCommand.parseAsync([], { from: "user" });
485
+ return hasPortalContent(cwd);
486
+ } catch (err) {
487
+ console.log();
488
+ console.log(chalk.red("Auto-pull failed: ") + (err instanceof Error ? err.message : String(err)));
489
+ console.log();
490
+ console.log("Run " + chalk.cyan("fluid portal pull") + " manually to set up local content.");
491
+ console.log();
492
+ return false;
493
+ }
494
+ }
495
+ const devCommand = new Command("dev").description("Start the development server with local portal content serving").option("-p, --port <port>", "Port to run the dev server on", "5173").option("--host", "Expose the dev server to the network").option("--skip-pull", "Skip auto-pull if portal/ directory is missing").action(async (options) => {
459
496
  const cwd = process.cwd();
460
497
  if (!existsSync(join(cwd, "package.json"))) {
461
498
  console.error(chalk.red("Error: No package.json found in current directory"));
@@ -467,6 +504,19 @@ const devCommand = new Command("dev").description("Start the development server"
467
504
  console.error(chalk.yellow("This command must be run from a Fluid project directory"));
468
505
  process.exit(1);
469
506
  }
507
+ if (!hasPortalContent(cwd) && !options.skipPull) {
508
+ if (!await autoPull(cwd)) {
509
+ console.error(chalk.red("Cannot start dev server without portal content."));
510
+ console.error(chalk.yellow("Run " + chalk.cyan("fluid portal pull") + " to download content first."));
511
+ process.exit(1);
512
+ }
513
+ }
514
+ if (hasPortalContent(cwd)) {
515
+ console.log();
516
+ console.log(chalk.green("Portal dev mode: ") + "local content from " + chalk.cyan("portal/") + " will be served");
517
+ console.log(chalk.gray(" Manifest requests intercepted at /api/fluid_os/definitions/active"));
518
+ console.log(chalk.gray(" File changes in portal/ will trigger a page reload"));
519
+ }
470
520
  const viteArgs = ["vite"];
471
521
  if (options.port) viteArgs.push("--port", String(options.port));
472
522
  if (options.host) viteArgs.push("--host");
@@ -484,9 +534,6 @@ const devCommand = new Command("dev").description("Start the development server"
484
534
  process.exit(1);
485
535
  }
486
536
  });
487
- function registerDevCommand(ctx) {
488
- ctx.program.addCommand(devCommand);
489
- }
490
537
  //#endregion
491
538
  //#region src/commands/build.ts
492
539
  const buildCommand = new Command("build").description("Build the application for production").option("-o, --out-dir <dir>", "Output directory", "dist").action(async (options) => {
@@ -524,9 +571,6 @@ const buildCommand = new Command("build").description("Build the application for
524
571
  process.exit(1);
525
572
  }
526
573
  });
527
- function registerBuildCommand(ctx) {
528
- ctx.program.addCommand(buildCommand);
529
- }
530
574
  //#endregion
531
575
  //#region src/utils/turso.ts
532
576
  const TURSO_API_BASE = "https://api.turso.tech/v1";
@@ -1929,9 +1973,6 @@ const deployCommand = new Command("deploy").description("Deploy the fullstack ap
1929
1973
  process.exit(1);
1930
1974
  }
1931
1975
  });
1932
- function registerDeployCommand(ctx) {
1933
- ctx.program.addCommand(deployCommand);
1934
- }
1935
1976
  //#endregion
1936
1977
  //#region src/commands/destroy.ts
1937
1978
  const destroyCommand = new Command("destroy").description("Tear down deployed Cloud Run service and Turso database").option("--region <region>", "Cloud Run region", "us-central1").option("--gcp-project <id>", "GCP project ID (default: from gcloud config)").option("-p, --project <name>", "Service name override (default: from package.json)").option("--turso-org <slug>", "Turso organization slug (skips interactive org selection)").option("--fluid-company-api-key <key>", "Fluid company API key (skips env var lookup and prompt)").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
@@ -2068,9 +2109,930 @@ const destroyCommand = new Command("destroy").description("Tear down deployed Cl
2068
2109
  else console.log(chalk.yellow.bold("Destroy completed with warnings.") + " Some resources may need manual cleanup.");
2069
2110
  console.log();
2070
2111
  });
2071
- function registerDestroyCommand(ctx) {
2072
- ctx.program.addCommand(destroyCommand);
2112
+ //#endregion
2113
+ //#region src/utils/push-validation.ts
2114
+ /**
2115
+ * Cross-reference validation and change categorization utilities for the push command.
2116
+ *
2117
+ * Extracted into a standalone utility so that pure logic can be tested
2118
+ * without pulling in CLI dependencies (ora, chalk, prompts, etc.).
2119
+ */
2120
+ /**
2121
+ * Extract the slug from a file path (e.g., "screens/home.json" -> "home").
2122
+ */
2123
+ function slugFromPath(filePath) {
2124
+ return basename(filePath, ".json");
2125
+ }
2126
+ /**
2127
+ * Extract the resource type directory from a file path (e.g., "screens/home.json" -> "screens").
2128
+ */
2129
+ function resourceTypeFromPath(filePath) {
2130
+ const dir = filePath.split("/")[0];
2131
+ if (dir === "screens" || dir === "themes" || dir === "navigations" || dir === "profiles") return dir;
2132
+ return null;
2133
+ }
2134
+ /**
2135
+ * Read and parse a JSON file from the portal directory.
2136
+ */
2137
+ async function readPortalFile$1(portalDir, relativePath) {
2138
+ const content = await readFile(join(portalDir, relativePath), "utf-8");
2139
+ return JSON.parse(content);
2140
+ }
2141
+ /**
2142
+ * Categorize a snapshot diff into resource-type-specific change lists.
2143
+ */
2144
+ function categorizeChanges(diff) {
2145
+ const result = {
2146
+ screens: {
2147
+ new: [],
2148
+ changed: [],
2149
+ deleted: []
2150
+ },
2151
+ themes: {
2152
+ new: [],
2153
+ changed: [],
2154
+ deleted: []
2155
+ },
2156
+ navigations: {
2157
+ new: [],
2158
+ changed: [],
2159
+ deleted: []
2160
+ },
2161
+ profiles: {
2162
+ new: [],
2163
+ changed: [],
2164
+ deleted: []
2165
+ }
2166
+ };
2167
+ for (const file of diff.new) {
2168
+ const type = resourceTypeFromPath(file);
2169
+ if (type) result[type].new.push(file);
2170
+ }
2171
+ for (const file of diff.changed) {
2172
+ const type = resourceTypeFromPath(file);
2173
+ if (type) result[type].changed.push(file);
2174
+ }
2175
+ for (const file of diff.deleted) {
2176
+ const type = resourceTypeFromPath(file);
2177
+ if (type) result[type].deleted.push(file);
2178
+ }
2179
+ return result;
2180
+ }
2181
+ /**
2182
+ * Validate that all cross-references between local portal files are valid.
2183
+ *
2184
+ * Checks:
2185
+ * - Navigation items' "screen" slugs reference existing screen files or mappings
2186
+ * - Profile "navigation" and "mobile_navigation" slugs reference existing nav files or mappings
2187
+ * - Profile "themes" slugs reference existing theme files or mappings
2188
+ */
2189
+ async function validateCrossReferences(portalDir, mappings, changes) {
2190
+ const errors = [];
2191
+ const validScreenSlugs = buildValidSlugsSet(portalDir, "screens", mappings);
2192
+ const validNavSlugs = buildValidSlugsSet(portalDir, "navigations", mappings);
2193
+ const validThemeSlugs = buildValidSlugsSet(portalDir, "themes", mappings);
2194
+ for (const file of changes.screens.deleted) validScreenSlugs.delete(slugFromPath(file));
2195
+ for (const file of changes.navigations.deleted) validNavSlugs.delete(slugFromPath(file));
2196
+ for (const file of changes.themes.deleted) validThemeSlugs.delete(slugFromPath(file));
2197
+ const navFilesToCheck = [...changes.navigations.new, ...changes.navigations.changed];
2198
+ for (const file of navFilesToCheck) try {
2199
+ validateNavigationItems((await readPortalFile$1(portalDir, file)).navigation_items, file, validScreenSlugs, errors);
2200
+ } catch {
2201
+ errors.push({
2202
+ file,
2203
+ message: "Failed to read navigation file"
2204
+ });
2205
+ }
2206
+ const profileFilesToCheck = [...changes.profiles.new, ...changes.profiles.changed];
2207
+ for (const file of profileFilesToCheck) try {
2208
+ const profile = await readPortalFile$1(portalDir, file);
2209
+ if (profile.navigation && !validNavSlugs.has(profile.navigation)) errors.push({
2210
+ file,
2211
+ message: `References navigation "${profile.navigation}" which does not exist`
2212
+ });
2213
+ if (profile.mobile_navigation && !validNavSlugs.has(profile.mobile_navigation)) errors.push({
2214
+ file,
2215
+ message: `References mobile_navigation "${profile.mobile_navigation}" which does not exist`
2216
+ });
2217
+ for (const themeSlug of profile.themes) if (!validThemeSlugs.has(themeSlug)) errors.push({
2218
+ file,
2219
+ message: `References theme "${themeSlug}" which does not exist`
2220
+ });
2221
+ } catch {
2222
+ errors.push({
2223
+ file,
2224
+ message: "Failed to read profile file"
2225
+ });
2226
+ }
2227
+ return errors;
2228
+ }
2229
+ /**
2230
+ * Build a set of valid slugs for a resource type by combining
2231
+ * existing mapping slugs with local file slugs on disk.
2232
+ */
2233
+ function buildValidSlugsSet(portalDir, resourceType, mappings) {
2234
+ const slugs = /* @__PURE__ */ new Set();
2235
+ for (const slug of Object.keys(mappings[resourceType])) slugs.add(slug);
2236
+ const dir = join(portalDir, resourceType);
2237
+ if (existsSync(dir)) try {
2238
+ const entries = readdirSync(dir);
2239
+ for (const entry of entries) if (entry.endsWith(".json")) slugs.add(basename(entry, ".json"));
2240
+ } catch {}
2241
+ return slugs;
2242
+ }
2243
+ /**
2244
+ * Recursively validate navigation item screen references.
2245
+ */
2246
+ function validateNavigationItems(items, file, validScreenSlugs, errors) {
2247
+ for (const item of items) {
2248
+ if (item.screen && !validScreenSlugs.has(item.screen)) errors.push({
2249
+ file,
2250
+ message: `Navigation item "${item.label ?? "(unlabeled)"}" references screen "${item.screen}" which does not exist`
2251
+ });
2252
+ if (item.children && item.children.length > 0) validateNavigationItems(item.children, file, validScreenSlugs, errors);
2253
+ }
2254
+ }
2255
+ //#endregion
2256
+ //#region src/commands/push.ts
2257
+ /**
2258
+ * `fluid portal push` command
2259
+ *
2260
+ * Pushes local portal content changes to the Fluid OS API.
2261
+ * Detects changes since the last pull/push via snapshot diffing,
2262
+ * validates cross-references, and pushes resources in dependency order.
2263
+ */
2264
+ const PORTAL_DIR = "portal";
2265
+ const PORTAL_SYNC_DIR$1 = ".portal-sync";
2266
+ /**
2267
+ * Convert the local array-form component_tree back to the object
2268
+ * the API expects. The pull command normalizes the API object into
2269
+ * an array for local convenience; this reverses that transformation.
2270
+ */
2271
+ function toApiComponentTree(tree) {
2272
+ if (tree.length === 0) return null;
2273
+ if (tree.length === 1) return tree[0];
2274
+ return { children: tree };
2275
+ }
2276
+ /**
2277
+ * Create an authenticated FetchClient using the stored CLI profile.
2278
+ */
2279
+ function createClient$1() {
2280
+ const token = getAuthToken();
2281
+ if (!token) {
2282
+ const profile = getActiveProfile();
2283
+ if (!profile) throw new Error("Not logged in. Run " + chalk.cyan("fluid login") + " first.");
2284
+ throw new Error("No auth token found for profile " + chalk.cyan(profile.name) + ". Run " + chalk.cyan("fluid login") + " to re-authenticate.");
2285
+ }
2286
+ return createFetchClient({
2287
+ baseUrl: process.env["FLUID_API_BASE"] ?? "https://api.fluid.app",
2288
+ getAuthToken: () => token
2289
+ });
2290
+ }
2291
+ /**
2292
+ * Extract an enriched error message from a caught value.
2293
+ * Includes structured API error data when available.
2294
+ */
2295
+ function enrichedErrorMessage(err) {
2296
+ let msg = err instanceof Error ? err.message : String(err);
2297
+ if (err && typeof err === "object" && "data" in err) msg += ` — ${JSON.stringify(err.data)}`;
2298
+ return msg;
2299
+ }
2300
+ /**
2301
+ * Read and parse a JSON file from the portal directory.
2302
+ */
2303
+ async function readPortalFile(portalDir, relativePath) {
2304
+ const content = await readFile(join(portalDir, relativePath), "utf-8");
2305
+ return JSON.parse(content);
2306
+ }
2307
+ /**
2308
+ * Push screen changes to the API.
2309
+ */
2310
+ async function pushScreens(client, defId, portalDir, changes, mappings) {
2311
+ const results = [];
2312
+ let currentMappings = mappings;
2313
+ for (const file of changes.new) {
2314
+ const slug = slugFromPath(file);
2315
+ try {
2316
+ const local = await readPortalFile(portalDir, file);
2317
+ const newId = (await createFluidOSScreen(client, defId, { screen: {
2318
+ name: local.name,
2319
+ slug,
2320
+ component_tree: toApiComponentTree(local.component_tree)
2321
+ } })).screen?.id;
2322
+ if (newId != null) currentMappings = updateMapping(currentMappings, "screens", slug, newId);
2323
+ results.push({
2324
+ file,
2325
+ action: "created",
2326
+ success: true
2327
+ });
2328
+ } catch (err) {
2329
+ results.push({
2330
+ file,
2331
+ action: "created",
2332
+ success: false,
2333
+ error: enrichedErrorMessage(err)
2334
+ });
2335
+ return {
2336
+ results,
2337
+ mappings: currentMappings
2338
+ };
2339
+ }
2340
+ }
2341
+ for (const file of changes.changed) {
2342
+ const slug = slugFromPath(file);
2343
+ const screenId = resolveSlugToId(currentMappings, "screens", slug);
2344
+ if (screenId == null) {
2345
+ results.push({
2346
+ file,
2347
+ action: "updated",
2348
+ success: false,
2349
+ error: `No mapping found for screen slug "${slug}"`
2350
+ });
2351
+ return {
2352
+ results,
2353
+ mappings: currentMappings
2354
+ };
2355
+ }
2356
+ try {
2357
+ const local = await readPortalFile(portalDir, file);
2358
+ await updateFluidOSScreen(client, defId, screenId, { screen: {
2359
+ name: local.name,
2360
+ slug,
2361
+ component_tree: toApiComponentTree(local.component_tree)
2362
+ } });
2363
+ results.push({
2364
+ file,
2365
+ action: "updated",
2366
+ success: true
2367
+ });
2368
+ } catch (err) {
2369
+ results.push({
2370
+ file,
2371
+ action: "updated",
2372
+ success: false,
2373
+ error: enrichedErrorMessage(err)
2374
+ });
2375
+ return {
2376
+ results,
2377
+ mappings: currentMappings
2378
+ };
2379
+ }
2380
+ }
2381
+ for (const file of changes.deleted) {
2382
+ const slug = slugFromPath(file);
2383
+ const screenId = resolveSlugToId(currentMappings, "screens", slug);
2384
+ if (screenId == null) {
2385
+ results.push({
2386
+ file,
2387
+ action: "deleted",
2388
+ success: false,
2389
+ error: `No mapping found for screen slug "${slug}"`
2390
+ });
2391
+ return {
2392
+ results,
2393
+ mappings: currentMappings
2394
+ };
2395
+ }
2396
+ try {
2397
+ await deleteFluidOSScreen(client, defId, screenId);
2398
+ currentMappings = removeMapping(currentMappings, "screens", slug);
2399
+ results.push({
2400
+ file,
2401
+ action: "deleted",
2402
+ success: true
2403
+ });
2404
+ } catch (err) {
2405
+ results.push({
2406
+ file,
2407
+ action: "deleted",
2408
+ success: false,
2409
+ error: enrichedErrorMessage(err)
2410
+ });
2411
+ return {
2412
+ results,
2413
+ mappings: currentMappings
2414
+ };
2415
+ }
2416
+ }
2417
+ return {
2418
+ results,
2419
+ mappings: currentMappings
2420
+ };
2421
+ }
2422
+ /**
2423
+ * Push theme changes to the API.
2424
+ */
2425
+ async function pushThemes(client, defId, portalDir, changes, mappings) {
2426
+ const results = [];
2427
+ let currentMappings = mappings;
2428
+ for (const file of changes.new) {
2429
+ const slug = slugFromPath(file);
2430
+ try {
2431
+ const local = await readPortalFile(portalDir, file);
2432
+ const newId = (await createFluidOSTheme(client, defId, { theme: {
2433
+ name: local.name,
2434
+ active: local.active,
2435
+ config: local.config
2436
+ } })).theme?.id;
2437
+ if (newId != null) currentMappings = updateMapping(currentMappings, "themes", slug, newId);
2438
+ results.push({
2439
+ file,
2440
+ action: "created",
2441
+ success: true
2442
+ });
2443
+ } catch (err) {
2444
+ results.push({
2445
+ file,
2446
+ action: "created",
2447
+ success: false,
2448
+ error: enrichedErrorMessage(err)
2449
+ });
2450
+ return {
2451
+ results,
2452
+ mappings: currentMappings
2453
+ };
2454
+ }
2455
+ }
2456
+ for (const file of changes.changed) {
2457
+ const slug = slugFromPath(file);
2458
+ const themeId = resolveSlugToId(currentMappings, "themes", slug);
2459
+ if (themeId == null) {
2460
+ results.push({
2461
+ file,
2462
+ action: "updated",
2463
+ success: false,
2464
+ error: `No mapping found for theme slug "${slug}"`
2465
+ });
2466
+ return {
2467
+ results,
2468
+ mappings: currentMappings
2469
+ };
2470
+ }
2471
+ try {
2472
+ const local = await readPortalFile(portalDir, file);
2473
+ await updateFluidOSTheme(client, defId, themeId, { theme: {
2474
+ name: local.name,
2475
+ active: local.active,
2476
+ config: local.config
2477
+ } });
2478
+ results.push({
2479
+ file,
2480
+ action: "updated",
2481
+ success: true
2482
+ });
2483
+ } catch (err) {
2484
+ results.push({
2485
+ file,
2486
+ action: "updated",
2487
+ success: false,
2488
+ error: enrichedErrorMessage(err)
2489
+ });
2490
+ return {
2491
+ results,
2492
+ mappings: currentMappings
2493
+ };
2494
+ }
2495
+ }
2496
+ for (const file of changes.deleted) {
2497
+ const slug = slugFromPath(file);
2498
+ const themeId = resolveSlugToId(currentMappings, "themes", slug);
2499
+ if (themeId == null) {
2500
+ results.push({
2501
+ file,
2502
+ action: "deleted",
2503
+ success: false,
2504
+ error: `No mapping found for theme slug "${slug}"`
2505
+ });
2506
+ return {
2507
+ results,
2508
+ mappings: currentMappings
2509
+ };
2510
+ }
2511
+ try {
2512
+ await deleteFluidOSTheme(client, defId, themeId);
2513
+ currentMappings = removeMapping(currentMappings, "themes", slug);
2514
+ results.push({
2515
+ file,
2516
+ action: "deleted",
2517
+ success: true
2518
+ });
2519
+ } catch (err) {
2520
+ results.push({
2521
+ file,
2522
+ action: "deleted",
2523
+ success: false,
2524
+ error: enrichedErrorMessage(err)
2525
+ });
2526
+ return {
2527
+ results,
2528
+ mappings: currentMappings
2529
+ };
2530
+ }
2531
+ }
2532
+ return {
2533
+ results,
2534
+ mappings: currentMappings
2535
+ };
2536
+ }
2537
+ /**
2538
+ * Resolve screen slug references to screen IDs in navigation items.
2539
+ * Returns a new tree with `screen_id` instead of `screen` slug,
2540
+ * shaped to match the FluidOSNavigationItemSyncItem schema.
2541
+ */
2542
+ function resolveNavigationItemScreenIds(items, mappings) {
2543
+ return items.map((item) => {
2544
+ const screenId = item.screen ? resolveSlugToId(mappings, "screens", item.screen) ?? void 0 : void 0;
2545
+ return {
2546
+ ...item.id ? { id: item.id } : {},
2547
+ label: item.label ?? "",
2548
+ position: item.position ?? 0,
2549
+ icon: item.icon,
2550
+ screen_id: screenId ?? null,
2551
+ slug: item.slug,
2552
+ source: item.source ?? "user",
2553
+ parent_id: item.parent_id,
2554
+ children: resolveNavigationItemScreenIds(item.children ?? [], mappings)
2555
+ };
2556
+ });
2557
+ }
2558
+ /**
2559
+ * Flatten a tree of navigation sync items into a flat list.
2560
+ * The API reconciliation logic requires a flat list to correctly
2561
+ * compare against the flat server response.
2562
+ */
2563
+ function flattenNavigationItems(items) {
2564
+ const flat = [];
2565
+ for (const item of items) {
2566
+ const { children, ...rest } = item;
2567
+ flat.push(rest);
2568
+ if (children && children.length > 0) flat.push(...flattenNavigationItems(children));
2569
+ }
2570
+ return flat;
2571
+ }
2572
+ /**
2573
+ * Push navigation changes to the API.
2574
+ */
2575
+ async function pushNavigations(client, defId, portalDir, changes, mappings) {
2576
+ const results = [];
2577
+ let currentMappings = mappings;
2578
+ for (const file of changes.new) {
2579
+ const slug = slugFromPath(file);
2580
+ try {
2581
+ const local = await readPortalFile(portalDir, file);
2582
+ const newId = (await createFluidOSNavigation(client, defId, { navigation: {
2583
+ name: local.name,
2584
+ platform: local.platform
2585
+ } })).navigation?.id;
2586
+ if (newId != null) {
2587
+ currentMappings = updateMapping(currentMappings, "navigations", slug, newId);
2588
+ const resolvedItems = flattenNavigationItems(resolveNavigationItemScreenIds(local.navigation_items, currentMappings));
2589
+ const localToServerId = /* @__PURE__ */ new Map();
2590
+ for (const item of resolvedItems) {
2591
+ const resolvedParentId = item.parent_id != null ? localToServerId.get(item.parent_id) ?? item.parent_id : void 0;
2592
+ const created = await createFluidOSNavigationItem(client, defId, newId, { navigation_item: {
2593
+ label: item.label ?? "",
2594
+ position: item.position ?? 0,
2595
+ icon: item.icon ?? void 0,
2596
+ screen_id: item.screen_id ?? void 0,
2597
+ slug: item.slug ?? void 0,
2598
+ source: item.source ?? void 0,
2599
+ parent_id: resolvedParentId
2600
+ } });
2601
+ if (item.id != null && created.navigation_item?.id != null) localToServerId.set(item.id, created.navigation_item.id);
2602
+ }
2603
+ }
2604
+ results.push({
2605
+ file,
2606
+ action: "created",
2607
+ success: true
2608
+ });
2609
+ } catch (err) {
2610
+ results.push({
2611
+ file,
2612
+ action: "created",
2613
+ success: false,
2614
+ error: enrichedErrorMessage(err)
2615
+ });
2616
+ return {
2617
+ results,
2618
+ mappings: currentMappings
2619
+ };
2620
+ }
2621
+ }
2622
+ for (const file of changes.changed) {
2623
+ const slug = slugFromPath(file);
2624
+ const navId = resolveSlugToId(currentMappings, "navigations", slug);
2625
+ if (navId == null) {
2626
+ results.push({
2627
+ file,
2628
+ action: "updated",
2629
+ success: false,
2630
+ error: `No mapping found for navigation slug "${slug}"`
2631
+ });
2632
+ return {
2633
+ results,
2634
+ mappings: currentMappings
2635
+ };
2636
+ }
2637
+ try {
2638
+ const local = await readPortalFile(portalDir, file);
2639
+ await updateFluidOSNavigation(client, defId, navId, { navigation: {
2640
+ name: local.name,
2641
+ platform: local.platform
2642
+ } });
2643
+ const resolvedItems = flattenNavigationItems(resolveNavigationItemScreenIds(local.navigation_items, currentMappings));
2644
+ const serverItems = (await listFluidOSNavigationItems(client, defId, navId)).navigation_items ?? [];
2645
+ const serverById = new Map(serverItems.map((s) => [s.id, s]));
2646
+ const localIds = new Set(resolvedItems.filter((i) => i.id).map((i) => i.id));
2647
+ for (const serverItem of serverItems) if (!localIds.has(serverItem.id)) await deleteFluidOSNavigationItem(client, defId, navId, serverItem.id);
2648
+ const localToServerId = /* @__PURE__ */ new Map();
2649
+ for (const item of resolvedItems) {
2650
+ const resolvedParentId = item.parent_id != null ? localToServerId.get(item.parent_id) ?? item.parent_id : void 0;
2651
+ const body = {
2652
+ label: item.label,
2653
+ position: item.position,
2654
+ icon: item.icon ?? void 0,
2655
+ screen_id: item.screen_id ?? void 0,
2656
+ slug: item.slug ?? void 0,
2657
+ source: item.source ?? void 0,
2658
+ parent_id: resolvedParentId
2659
+ };
2660
+ if (item.id && serverById.has(item.id)) await updateFluidOSNavigationItem(client, defId, navId, item.id, { navigation_item: body });
2661
+ else {
2662
+ const created = await createFluidOSNavigationItem(client, defId, navId, { navigation_item: {
2663
+ ...body,
2664
+ label: body.label ?? "",
2665
+ position: body.position ?? 0
2666
+ } });
2667
+ if (item.id != null && created.navigation_item?.id != null) localToServerId.set(item.id, created.navigation_item.id);
2668
+ }
2669
+ }
2670
+ results.push({
2671
+ file,
2672
+ action: "updated",
2673
+ success: true
2674
+ });
2675
+ } catch (err) {
2676
+ results.push({
2677
+ file,
2678
+ action: "updated",
2679
+ success: false,
2680
+ error: enrichedErrorMessage(err)
2681
+ });
2682
+ return {
2683
+ results,
2684
+ mappings: currentMappings
2685
+ };
2686
+ }
2687
+ }
2688
+ for (const file of changes.deleted) {
2689
+ const slug = slugFromPath(file);
2690
+ const navId = resolveSlugToId(currentMappings, "navigations", slug);
2691
+ if (navId == null) {
2692
+ results.push({
2693
+ file,
2694
+ action: "deleted",
2695
+ success: false,
2696
+ error: `No mapping found for navigation slug "${slug}"`
2697
+ });
2698
+ return {
2699
+ results,
2700
+ mappings: currentMappings
2701
+ };
2702
+ }
2703
+ try {
2704
+ await deleteFluidOSNavigation(client, defId, navId);
2705
+ currentMappings = removeMapping(currentMappings, "navigations", slug);
2706
+ results.push({
2707
+ file,
2708
+ action: "deleted",
2709
+ success: true
2710
+ });
2711
+ } catch (err) {
2712
+ results.push({
2713
+ file,
2714
+ action: "deleted",
2715
+ success: false,
2716
+ error: enrichedErrorMessage(err)
2717
+ });
2718
+ return {
2719
+ results,
2720
+ mappings: currentMappings
2721
+ };
2722
+ }
2723
+ }
2724
+ return {
2725
+ results,
2726
+ mappings: currentMappings
2727
+ };
2728
+ }
2729
+ /**
2730
+ * Push profile changes to the API.
2731
+ */
2732
+ async function pushProfiles(client, defId, portalDir, changes, mappings) {
2733
+ const results = [];
2734
+ let currentMappings = mappings;
2735
+ for (const file of changes.new) {
2736
+ const slug = slugFromPath(file);
2737
+ try {
2738
+ const newId = (await createFluidOSProfile(client, defId, { profile: resolveProfileBody(await readPortalFile(portalDir, file), currentMappings) })).profile?.id;
2739
+ if (newId != null) currentMappings = updateMapping(currentMappings, "profiles", slug, newId);
2740
+ results.push({
2741
+ file,
2742
+ action: "created",
2743
+ success: true
2744
+ });
2745
+ } catch (err) {
2746
+ results.push({
2747
+ file,
2748
+ action: "created",
2749
+ success: false,
2750
+ error: enrichedErrorMessage(err)
2751
+ });
2752
+ return {
2753
+ results,
2754
+ mappings: currentMappings
2755
+ };
2756
+ }
2757
+ }
2758
+ for (const file of changes.changed) {
2759
+ const slug = slugFromPath(file);
2760
+ const profileId = resolveSlugToId(currentMappings, "profiles", slug);
2761
+ if (profileId == null) {
2762
+ results.push({
2763
+ file,
2764
+ action: "updated",
2765
+ success: false,
2766
+ error: `No mapping found for profile slug "${slug}"`
2767
+ });
2768
+ return {
2769
+ results,
2770
+ mappings: currentMappings
2771
+ };
2772
+ }
2773
+ try {
2774
+ await updateFluidOSProfile(client, defId, profileId, { profile: resolveProfileBody(await readPortalFile(portalDir, file), currentMappings) });
2775
+ results.push({
2776
+ file,
2777
+ action: "updated",
2778
+ success: true
2779
+ });
2780
+ } catch (err) {
2781
+ results.push({
2782
+ file,
2783
+ action: "updated",
2784
+ success: false,
2785
+ error: enrichedErrorMessage(err)
2786
+ });
2787
+ return {
2788
+ results,
2789
+ mappings: currentMappings
2790
+ };
2791
+ }
2792
+ }
2793
+ for (const file of changes.deleted) {
2794
+ const slug = slugFromPath(file);
2795
+ const profileId = resolveSlugToId(currentMappings, "profiles", slug);
2796
+ if (profileId == null) {
2797
+ results.push({
2798
+ file,
2799
+ action: "deleted",
2800
+ success: false,
2801
+ error: `No mapping found for profile slug "${slug}"`
2802
+ });
2803
+ return {
2804
+ results,
2805
+ mappings: currentMappings
2806
+ };
2807
+ }
2808
+ try {
2809
+ await deleteFluidOSProfile(client, defId, profileId);
2810
+ currentMappings = removeMapping(currentMappings, "profiles", slug);
2811
+ results.push({
2812
+ file,
2813
+ action: "deleted",
2814
+ success: true
2815
+ });
2816
+ } catch (err) {
2817
+ results.push({
2818
+ file,
2819
+ action: "deleted",
2820
+ success: false,
2821
+ error: enrichedErrorMessage(err)
2822
+ });
2823
+ return {
2824
+ results,
2825
+ mappings: currentMappings
2826
+ };
2827
+ }
2828
+ }
2829
+ return {
2830
+ results,
2831
+ mappings: currentMappings
2832
+ };
2073
2833
  }
2834
+ /**
2835
+ * Resolve profile slug references to API IDs for create/update request body.
2836
+ */
2837
+ function resolveProfileBody(local, mappings) {
2838
+ const body = {
2839
+ name: local.name,
2840
+ default: local.default,
2841
+ permissions: local.permissions
2842
+ };
2843
+ if (local.navigation) {
2844
+ const navId = resolveSlugToId(mappings, "navigations", local.navigation);
2845
+ if (navId != null) body.navigation_id = navId;
2846
+ }
2847
+ if (local.mobile_navigation) {
2848
+ const mobileNavId = resolveSlugToId(mappings, "navigations", local.mobile_navigation);
2849
+ if (mobileNavId != null) body.mobile_navigation_id = mobileNavId;
2850
+ }
2851
+ body.theme_ids = local.themes.map((slug) => resolveSlugToId(mappings, "themes", slug)).filter((id) => id != null);
2852
+ return body;
2853
+ }
2854
+ function printChangesSummary(diff, definitionName) {
2855
+ console.log(chalk.blue("Changes to push for ") + chalk.white.bold(`"${definitionName}"`) + chalk.blue(":"));
2856
+ console.log();
2857
+ if (diff.new.length > 0) console.log(chalk.green(" New: ") + diff.new.join(", "));
2858
+ if (diff.changed.length > 0) console.log(chalk.yellow(" Changed: ") + diff.changed.join(", "));
2859
+ if (diff.deleted.length > 0) console.log(chalk.red(" Deleted: ") + diff.deleted.join(", "));
2860
+ console.log();
2861
+ }
2862
+ function printPushReport(results, skippedPhases) {
2863
+ const succeeded = results.filter((r) => r.success);
2864
+ const failed = results.filter((r) => !r.success);
2865
+ if (succeeded.length > 0) {
2866
+ console.log(chalk.green.bold("Succeeded:"));
2867
+ for (const r of succeeded) console.log(chalk.green(" " + r.action + ": ") + r.file);
2868
+ }
2869
+ if (failed.length > 0) {
2870
+ console.log();
2871
+ console.log(chalk.red.bold("Failed:"));
2872
+ for (const r of failed) console.log(chalk.red(" " + r.action + ": ") + r.file + chalk.gray(" — " + (r.error ?? "Unknown error")));
2873
+ }
2874
+ if (skippedPhases.length > 0) {
2875
+ console.log();
2876
+ console.log(chalk.yellow.bold("Skipped phases:"));
2877
+ for (const phase of skippedPhases) console.log(chalk.yellow(" " + phase));
2878
+ }
2879
+ }
2880
+ const pushCommand = new Command("push").description("Push local portal content changes to the Fluid OS API").option("--yes", "Skip confirmation prompt").action(async (options) => {
2881
+ const cwd = process.cwd();
2882
+ const portalDir = join(cwd, PORTAL_DIR);
2883
+ const portalSyncDir = join(cwd, PORTAL_SYNC_DIR$1);
2884
+ console.log();
2885
+ console.log(chalk.blue.bold("Fluid Portal Push"));
2886
+ console.log();
2887
+ if (!existsSync(portalDir)) {
2888
+ console.log(chalk.red("Error:") + " No portal/ directory found. Run " + chalk.cyan("fluid portal pull") + " first.");
2889
+ console.log();
2890
+ process.exit(1);
2891
+ }
2892
+ const snapshot = await readSnapshot(portalSyncDir);
2893
+ if (!snapshot) {
2894
+ console.log(chalk.red("Error:") + " No snapshot found in .portal-sync/. Run " + chalk.cyan("fluid portal pull") + " first.");
2895
+ console.log();
2896
+ process.exit(1);
2897
+ }
2898
+ const mappings = await readMappings(portalSyncDir);
2899
+ if (!mappings) {
2900
+ console.log(chalk.red("Error:") + " No mappings found in .portal-sync/. Run " + chalk.cyan("fluid portal pull") + " first.");
2901
+ console.log();
2902
+ process.exit(1);
2903
+ }
2904
+ const definitionId = snapshot.definition_id;
2905
+ const definitionName = snapshot.definition;
2906
+ console.log(chalk.gray("Definition: ") + chalk.white(definitionName) + chalk.gray(` (ID: ${definitionId})`));
2907
+ console.log();
2908
+ const spinner = ora();
2909
+ spinner.start("Detecting changes...");
2910
+ const diff = await diffAgainstSnapshot(portalDir, snapshot);
2911
+ const totalChanges = diff.new.length + diff.changed.length + diff.deleted.length;
2912
+ if (totalChanges === 0) {
2913
+ spinner.succeed("Nothing to push.");
2914
+ console.log();
2915
+ return;
2916
+ }
2917
+ spinner.succeed(`Found ${totalChanges} change(s)`);
2918
+ console.log();
2919
+ printChangesSummary(diff, definitionName);
2920
+ if (!options.yes) {
2921
+ const { confirmed } = await prompts({
2922
+ type: "confirm",
2923
+ name: "confirmed",
2924
+ message: `Push ${totalChanges} change(s) to Fluid OS?`,
2925
+ initial: false
2926
+ });
2927
+ if (!confirmed) {
2928
+ console.log();
2929
+ console.log(chalk.gray("Push cancelled."));
2930
+ console.log();
2931
+ return;
2932
+ }
2933
+ console.log();
2934
+ }
2935
+ const changes = categorizeChanges(diff);
2936
+ spinner.start("Validating cross-references...");
2937
+ const validationErrors = await validateCrossReferences(portalDir, mappings, changes);
2938
+ if (validationErrors.length > 0) {
2939
+ spinner.fail("Cross-reference validation failed");
2940
+ console.log();
2941
+ for (const err of validationErrors) console.log(chalk.red(" " + err.file + ": ") + err.message);
2942
+ console.log();
2943
+ process.exit(1);
2944
+ }
2945
+ spinner.succeed("Cross-references valid");
2946
+ spinner.start("Authenticating...");
2947
+ let client;
2948
+ try {
2949
+ client = createClient$1();
2950
+ spinner.succeed("Authenticated");
2951
+ } catch (err) {
2952
+ spinner.fail("Authentication failed");
2953
+ console.log();
2954
+ console.log(chalk.red("Error:") + " " + (err instanceof Error ? err.message : String(err)));
2955
+ console.log();
2956
+ process.exit(1);
2957
+ }
2958
+ const allResults = [];
2959
+ const skippedPhases = [];
2960
+ let currentMappings = mappings;
2961
+ let aborted = false;
2962
+ const hasScreenChanges = changes.screens.new.length > 0 || changes.screens.changed.length > 0 || changes.screens.deleted.length > 0;
2963
+ const hasThemeChanges = changes.themes.new.length > 0 || changes.themes.changed.length > 0 || changes.themes.deleted.length > 0;
2964
+ if (hasScreenChanges || hasThemeChanges) {
2965
+ spinner.start("Phase 1: Pushing screens and themes...");
2966
+ const phase1Tasks = [];
2967
+ if (hasScreenChanges) phase1Tasks.push(pushScreens(client, definitionId, portalDir, changes.screens, currentMappings));
2968
+ if (hasThemeChanges) phase1Tasks.push(pushThemes(client, definitionId, portalDir, changes.themes, currentMappings));
2969
+ const phase1Results = await Promise.all(phase1Tasks);
2970
+ for (const result of phase1Results) allResults.push(...result.results);
2971
+ if (hasScreenChanges) currentMappings = {
2972
+ ...currentMappings,
2973
+ screens: phase1Results[0].mappings.screens
2974
+ };
2975
+ if (hasThemeChanges) {
2976
+ const idx = hasScreenChanges ? 1 : 0;
2977
+ currentMappings = {
2978
+ ...currentMappings,
2979
+ themes: phase1Results[idx].mappings.themes
2980
+ };
2981
+ }
2982
+ if (phase1Results.some((r) => r.results.some((res) => !res.success))) {
2983
+ spinner.fail("Phase 1 failed");
2984
+ aborted = true;
2985
+ skippedPhases.push("Phase 2: Navigations", "Phase 3: Profiles");
2986
+ } else spinner.succeed("Phase 1 complete");
2987
+ }
2988
+ const hasNavChanges = changes.navigations.new.length > 0 || changes.navigations.changed.length > 0 || changes.navigations.deleted.length > 0;
2989
+ if (!aborted && hasNavChanges) {
2990
+ spinner.start("Phase 2: Pushing navigations...");
2991
+ const navResult = await pushNavigations(client, definitionId, portalDir, changes.navigations, currentMappings);
2992
+ allResults.push(...navResult.results);
2993
+ currentMappings = {
2994
+ ...currentMappings,
2995
+ navigations: navResult.mappings.navigations
2996
+ };
2997
+ if (navResult.results.some((r) => !r.success)) {
2998
+ spinner.fail("Phase 2 failed");
2999
+ aborted = true;
3000
+ skippedPhases.push("Phase 3: Profiles");
3001
+ } else spinner.succeed("Phase 2 complete");
3002
+ } else if (aborted && hasNavChanges) {}
3003
+ const hasProfileChanges = changes.profiles.new.length > 0 || changes.profiles.changed.length > 0 || changes.profiles.deleted.length > 0;
3004
+ if (!aborted && hasProfileChanges) {
3005
+ spinner.start("Phase 3: Pushing profiles...");
3006
+ const profileResult = await pushProfiles(client, definitionId, portalDir, changes.profiles, currentMappings);
3007
+ allResults.push(...profileResult.results);
3008
+ currentMappings = {
3009
+ ...currentMappings,
3010
+ profiles: profileResult.mappings.profiles
3011
+ };
3012
+ if (profileResult.results.some((r) => !r.success)) spinner.fail("Phase 3 failed");
3013
+ else spinner.succeed("Phase 3 complete");
3014
+ }
3015
+ await writeMappings(portalSyncDir, currentMappings);
3016
+ const successfulFiles = new Set(allResults.filter((r) => r.success).map((r) => r.file));
3017
+ if (successfulFiles.size > 0) {
3018
+ const updatedHashes = { ...snapshot.files };
3019
+ for (const file of successfulFiles) {
3020
+ const fullPath = join(portalDir, file);
3021
+ if (existsSync(fullPath)) updatedHashes[file] = await computeFileHash(fullPath);
3022
+ else delete updatedHashes[file];
3023
+ }
3024
+ await writeSnapshot(portalSyncDir, {
3025
+ ...snapshot,
3026
+ files: updatedHashes
3027
+ });
3028
+ }
3029
+ console.log();
3030
+ if (allResults.every((r) => r.success) && !aborted) console.log(chalk.green.bold("Push complete!"));
3031
+ else console.log(chalk.yellow.bold("Push completed with issues:"));
3032
+ console.log();
3033
+ printPushReport(allResults, skippedPhases);
3034
+ console.log();
3035
+ });
2074
3036
  //#endregion
2075
3037
  //#region src/utils/widget-helpers.ts
2076
3038
  /**
@@ -2138,7 +3100,7 @@ function insertImport(source, importLine) {
2138
3100
  */
2139
3101
  function insertIntoCustomWidgets(source, camelName) {
2140
3102
  let matched = false;
2141
- const result = source.replace(/export const customWidgets:\s*WidgetManifest\[\]\s*=\s*\[([^\]]*)\]/, (_match, inner) => {
3103
+ const result = source.replace(/(export const customWidgets(?::\s*WidgetManifest\[\])?)\s*=\s*\[([^\]]*)\]/, (_match, declaration, inner) => {
2142
3104
  matched = true;
2143
3105
  const lines = inner.split("\n");
2144
3106
  const existingEntries = [];
@@ -2148,7 +3110,7 @@ function insertIntoCustomWidgets(source, camelName) {
2148
3110
  }
2149
3111
  if (existingEntries.includes(camelName)) return _match;
2150
3112
  const commentLines = lines.filter((line) => line.trim().startsWith("//")).map((line) => line.trimEnd());
2151
- return `export const customWidgets: WidgetManifest[] = [${commentLines.length > 0 ? "\n" + commentLines.join("\n") : ""}\n${[...existingEntries, camelName].map((e) => ` ${e},`).join("\n")}\n]`;
3113
+ return `${declaration} = [${commentLines.length > 0 ? "\n" + commentLines.join("\n") : ""}\n${[...existingEntries, camelName].map((e) => ` ${e},`).join("\n")}\n]`;
2152
3114
  });
2153
3115
  return matched ? result : null;
2154
3116
  }
@@ -2251,12 +3213,9 @@ export { manifest } from "./manifest";
2251
3213
  console.log(chalk.yellow("Next steps:"));
2252
3214
  console.log(` 1. Edit the component in ${chalk.cyan(`src/widgets/${name}/component.tsx`)}`);
2253
3215
  console.log(` 2. Customize the manifest fields in ${chalk.cyan(`src/widgets/${name}/manifest.ts`)}`);
2254
- console.log(` 3. Run ${chalk.cyan("fluid dev")} to preview in the builder`);
3216
+ console.log(` 3. Run ${chalk.cyan("fluid portal dev")} to preview in the builder`);
2255
3217
  });
2256
3218
  const widgetCommand = new Command("widget").description("Manage custom portal widgets").addCommand(createSubcommand);
2257
- function registerWidgetCommand(ctx) {
2258
- ctx.program.addCommand(widgetCommand);
2259
- }
2260
3219
  //#endregion
2261
3220
  //#region src/commands/doctor.ts
2262
3221
  /** Files that are managed by the SDK and should match the canonical template. */
@@ -2416,25 +3375,155 @@ const doctorCommand = new Command("doctor").description("Check portal for scaffo
2416
3375
  console.log(` ${parts.join(", ")}\n`);
2417
3376
  if (errors.length > 0) process.exit(1);
2418
3377
  });
2419
- function registerDoctorCommand(ctx) {
2420
- ctx.program.addCommand(doctorCommand);
3378
+ //#endregion
3379
+ //#region src/commands/version.ts
3380
+ const PORTAL_SYNC_DIR = ".portal-sync";
3381
+ function getApiBase() {
3382
+ return process.env["FLUID_API_BASE"] ?? "https://api.fluid.app";
3383
+ }
3384
+ function requireToken() {
3385
+ const token = getAuthToken();
3386
+ if (!token) {
3387
+ console.error(chalk.red("Error:") + " Not logged in. Run " + chalk.cyan("`fluid login`") + " first.");
3388
+ process.exit(1);
3389
+ }
3390
+ return token;
3391
+ }
3392
+ function createClient(token) {
3393
+ return createFetchClient({
3394
+ baseUrl: getApiBase(),
3395
+ getAuthToken: () => token
3396
+ });
2421
3397
  }
3398
+ async function requireDefinitionId() {
3399
+ const cwd = process.cwd();
3400
+ const mappings = await readMappings(path.join(cwd, PORTAL_SYNC_DIR));
3401
+ if (!mappings) {
3402
+ console.error(chalk.red("Error:") + " No definition pulled. Run " + chalk.cyan("`fluid portal pull`") + " first.");
3403
+ process.exit(1);
3404
+ }
3405
+ return mappings.definition.id;
3406
+ }
3407
+ 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) => {
3408
+ const client = createClient(requireToken());
3409
+ const definitionId = await requireDefinitionId();
3410
+ console.log();
3411
+ console.log(chalk.bold("Creating version..."));
3412
+ let result;
3413
+ try {
3414
+ result = await createFluidOSVersion(client, definitionId);
3415
+ } catch (err) {
3416
+ console.error(chalk.red("Error:") + " Failed to create version — " + (err instanceof Error ? err.message : String(err)));
3417
+ process.exit(1);
3418
+ }
3419
+ const version = result.version;
3420
+ if (!version?.id) {
3421
+ console.error(chalk.red("Error:") + " Failed to create version — unexpected response.");
3422
+ process.exit(1);
3423
+ }
3424
+ console.log();
3425
+ console.log(chalk.green("Version created successfully."));
3426
+ console.log();
3427
+ console.log(chalk.gray("Version ID: ") + chalk.white(version.id));
3428
+ let active = version.active ?? false;
3429
+ if (options.activate) {
3430
+ console.log("Activating version...");
3431
+ try {
3432
+ await updateFluidOSVersion(client, definitionId, version.id, { version: { active: true } });
3433
+ } catch (err) {
3434
+ console.error(chalk.red("Error:") + " Failed to activate version — " + (err instanceof Error ? err.message : String(err)));
3435
+ process.exit(1);
3436
+ }
3437
+ active = true;
3438
+ }
3439
+ console.log(chalk.gray("Active: ") + (active ? chalk.green("yes") : chalk.gray("no")));
3440
+ console.log();
3441
+ });
3442
+ const listVersionCommand = new Command("list").description("List all versions of the portal definition").action(async () => {
3443
+ const client = createClient(requireToken());
3444
+ const definitionId = await requireDefinitionId();
3445
+ let result;
3446
+ try {
3447
+ result = await listFluidOSVersions(client, definitionId);
3448
+ } catch (err) {
3449
+ console.error(chalk.red("Error:") + " Failed to list versions — " + (err instanceof Error ? err.message : String(err)));
3450
+ process.exit(1);
3451
+ }
3452
+ const versions = result.version;
3453
+ if (!Array.isArray(versions) || versions.length === 0) {
3454
+ console.log();
3455
+ console.log(chalk.yellow("No versions found."));
3456
+ console.log("Run " + chalk.cyan("`fluid portal version create`") + " to publish a version.");
3457
+ console.log();
3458
+ return;
3459
+ }
3460
+ console.log();
3461
+ const COL_ID = "Version ID".padEnd(36);
3462
+ const COL_ACT = "Active".padEnd(6);
3463
+ console.log(chalk.gray(` ${COL_ID} ${COL_ACT} Published`));
3464
+ for (const v of versions) {
3465
+ const id = String(v.id).padEnd(36);
3466
+ const active = v.active ? chalk.green("✓") + " " : " ";
3467
+ const published = v.published_at ? new Date(v.published_at).toLocaleString() : "—";
3468
+ console.log(` ${id} ${active} ${published}`);
3469
+ }
3470
+ console.log();
3471
+ });
3472
+ 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) => {
3473
+ const client = createClient(requireToken());
3474
+ const definitionId = await requireDefinitionId();
3475
+ if (!options.yes) {
3476
+ const { confirm } = await prompts({
3477
+ type: "confirm",
3478
+ name: "confirm",
3479
+ message: `Activate version ${versionId}? This will make it the live version.`,
3480
+ initial: false
3481
+ });
3482
+ if (!confirm) {
3483
+ console.log(chalk.yellow("Aborted."));
3484
+ return;
3485
+ }
3486
+ }
3487
+ console.log();
3488
+ console.log(chalk.bold("Activating version..."));
3489
+ try {
3490
+ await updateFluidOSVersion(client, definitionId, versionId, { version: { active: true } });
3491
+ } catch (err) {
3492
+ console.error(chalk.red("Error:") + " Failed to activate version — " + (err instanceof Error ? err.message : String(err)));
3493
+ process.exit(1);
3494
+ }
3495
+ console.log();
3496
+ console.log(chalk.green("Version " + versionId + " is now active."));
3497
+ console.log();
3498
+ });
3499
+ const versionCommand = new Command("version").description("Manage portal definition versions").addCommand(createVersionCommand).addCommand(listVersionCommand).addCommand(activateVersionCommand);
2422
3500
  //#endregion
2423
3501
  //#region src/index.ts
3502
+ /**
3503
+ * @fluid-app/fluid-cli-portal
3504
+ *
3505
+ * Fluid CLI plugin for building and deploying portal applications.
3506
+ * Auto-discovered by @fluid-app/fluid-cli via the fluid-cli-* naming convention.
3507
+ */
2424
3508
  const plugin = {
2425
3509
  name: "fluid-cli-portal",
2426
3510
  version: "0.1.0",
2427
3511
  async register(ctx) {
2428
- registerCreateCommand(ctx);
2429
- registerDevCommand(ctx);
2430
- registerBuildCommand(ctx);
2431
- registerDeployCommand(ctx);
2432
- registerDestroyCommand(ctx);
2433
- registerWidgetCommand(ctx);
2434
- registerDoctorCommand(ctx);
3512
+ const portal = new Command("portal").description("Build, develop, and deploy portal applications");
3513
+ portal.addCommand(createCommand);
3514
+ portal.addCommand(devCommand);
3515
+ portal.addCommand(buildCommand);
3516
+ portal.addCommand(deployCommand);
3517
+ portal.addCommand(destroyCommand);
3518
+ portal.addCommand(pullCommand);
3519
+ portal.addCommand(pushCommand);
3520
+ portal.addCommand(widgetCommand);
3521
+ portal.addCommand(doctorCommand);
3522
+ portal.addCommand(versionCommand);
3523
+ ctx.program.addCommand(portal);
2435
3524
  }
2436
3525
  };
2437
3526
  //#endregion
2438
- export { CLOUD_RUN_ERRORS, FILE_SYSTEM_ERRORS, FLUID_API_ERROR, TEMPLATES, TURSO_ERROR, copyTemplate, copyTemplateSafe, createCommand, createDatabase, createDatabaseToken, createDirectory, createDirectorySafe, plugin as default, deleteCloudRunService, deleteDatabase, deployToCloudRun, destroyCommand, directoryExists, doctorCommand, ensureGroup, fetchLocations, fileExists, getGcpProject, getInstallCommand, getRunCommand, getSdkVersion, getSdkVersionSafe, getTemplatePaths, installDependencies, isTemplateName, parseOrgList, pathExists, promptProjectConfig, provisionDatabase, readFileSafe, resolveFluidApiKey, resolveTursoConfig, runPackageManager, validateFluidApiKey, validateGcloudAuth, validateGcloudInstalled, validateLocation, validateTursoConfig, widgetCommand, writeFileSafe };
3527
+ export { CLOUD_RUN_ERRORS, FILE_SYSTEM_ERRORS, FLUID_API_ERROR, TEMPLATES, TURSO_ERROR, buildIdToSlugMap, buildNavigationIdToSlugMap, buildSnapshot, buildThemeIdToSlugMap, categorizeChanges, computeFileHash, copyTemplate, copyTemplateSafe, createCommand, createDatabase, createDatabaseToken, createDirectory, createDirectorySafe, plugin as default, deleteCloudRunService, deleteDatabase, deployToCloudRun, deriveScreenSlug, deriveSlug, destroyCommand, diffAgainstSnapshot, directoryExists, doctorCommand, ensureGroup, fetchLocations, fileExists, getGcpProject, getInstallCommand, getRunCommand, getSdkVersion, getSdkVersionSafe, getTemplatePaths, installDependencies, isTemplateName, parseOrgList, pathExists, promptProjectConfig, provisionDatabase, pullCommand, pushCommand, readFileSafe, readMappings, readSnapshot, removeMapping, resolveFluidApiKey, resolveIdToSlug, resolveSlugToId, resolveTursoConfig, runPackageManager, slugFromPath, transformNavigation, transformNavigationItems, transformProfile, transformScreen, transformTheme, updateMapping, validateCrossReferences, validateFluidApiKey, validateGcloudAuth, validateGcloudInstalled, validateLocation, validateTursoConfig, versionCommand, widgetCommand, writeFileSafe, writeMappings, writeSnapshot };
2439
3528
 
2440
3529
  //# sourceMappingURL=index.mjs.map