@fluid-app/fluid-cli-theme-dev 0.1.47 → 0.1.49

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
@@ -7,11 +7,11 @@ import http from "node:http";
7
7
  import https from "node:https";
8
8
  import chokidar from "chokidar";
9
9
  import net from "node:net";
10
+ import { execFileSync, spawn } from "node:child_process";
11
+ import { tmpdir } from "node:os";
10
12
  import chalk from "chalk";
11
13
  import prompts from "prompts";
12
14
  import ora from "ora";
13
- import { execFileSync, spawn } from "node:child_process";
14
- import { tmpdir } from "node:os";
15
15
  import { fileURLToPath } from "node:url";
16
16
  //#region ../../platform/api-client-core/src/api-error-shape.ts
17
17
  /**
@@ -1455,6 +1455,28 @@ function watchTheme(root, handler) {
1455
1455
  return () => watcher.close();
1456
1456
  }
1457
1457
  //#endregion
1458
+ //#region src/theme/case-collisions.ts
1459
+ var CaseCollisionError = class extends Error {
1460
+ constructor(collisions) {
1461
+ super(`Theme contains paths that differ only by letter case and cannot be synchronized safely:\n${collisions.map((paths) => ` ${paths.join(", ")}`).join("\n")}`);
1462
+ this.collisions = collisions;
1463
+ this.name = "CaseCollisionError";
1464
+ }
1465
+ };
1466
+ /** Refuse paths a case-insensitive checkout cannot represent independently. */
1467
+ function assertNoCaseCollisions(paths) {
1468
+ const pathsByFoldedKey = /* @__PURE__ */ new Map();
1469
+ for (const rawPath of paths) {
1470
+ const path = normalizeThemeResourceKey(rawPath);
1471
+ const foldedKey = path.toLowerCase();
1472
+ const matchingPaths = pathsByFoldedKey.get(foldedKey) ?? /* @__PURE__ */ new Set();
1473
+ matchingPaths.add(path);
1474
+ pathsByFoldedKey.set(foldedKey, matchingPaths);
1475
+ }
1476
+ const collisions = [...pathsByFoldedKey.values()].filter((matchingPaths) => matchingPaths.size > 1).map((matchingPaths) => [...matchingPaths].sort()).sort(([left = ""], [right = ""]) => left.localeCompare(right));
1477
+ if (collisions.length > 0) throw new CaseCollisionError(collisions);
1478
+ }
1479
+ //#endregion
1458
1480
  //#region src/theme/asset-manifest.ts
1459
1481
  const MANIFEST_FILE = ".fluid-assets.json";
1460
1482
  const MANIFEST_VERSION = 1;
@@ -1487,6 +1509,15 @@ var ThemeAssetManifest = class {
1487
1509
  entries() {
1488
1510
  return Object.entries(this.assets).map(([key, link]) => [key, copyLink(link)]);
1489
1511
  }
1512
+ /**
1513
+ * Stable digest of the URL references represented by this manifest.
1514
+ * Pull baselines exclude pending entries because those belong only to an
1515
+ * existing dev target and are deliberately absent from the pulled source.
1516
+ */
1517
+ fingerprint(opts = {}) {
1518
+ const entries = this.entries().filter(([, link]) => !opts.excludePending || !link.pending).toSorted(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
1519
+ return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
1520
+ }
1490
1521
  has(key) {
1491
1522
  return this.assets[key] !== void 0;
1492
1523
  }
@@ -1525,16 +1556,16 @@ var ThemeAssetManifest = class {
1525
1556
  function readDocument(path) {
1526
1557
  if (!existsSync(path)) return emptyDocument();
1527
1558
  try {
1528
- return parseDocument(JSON.parse(readFileSync(path, "utf-8")));
1559
+ return parseDocument$1(JSON.parse(readFileSync(path, "utf-8")));
1529
1560
  } catch (error) {
1530
1561
  const message = error instanceof Error ? error.message : String(error);
1531
1562
  throw new Error(`Could not read ${MANIFEST_FILE}: ${message}`);
1532
1563
  }
1533
1564
  }
1534
- function parseDocument(value) {
1535
- if (!isRecord$1(value) || value["version"] !== MANIFEST_VERSION) throw new Error(`expected version ${MANIFEST_VERSION}`);
1565
+ function parseDocument$1(value) {
1566
+ if (!isRecord$2(value) || value["version"] !== MANIFEST_VERSION) throw new Error(`expected version ${MANIFEST_VERSION}`);
1536
1567
  const rawAssets = value["assets"];
1537
- if (!isRecord$1(rawAssets)) throw new Error("expected an assets object");
1568
+ if (!isRecord$2(rawAssets)) throw new Error("expected an assets object");
1538
1569
  const assets = {};
1539
1570
  for (const [key, rawLink] of Object.entries(rawAssets)) {
1540
1571
  if (!isThemeAssetKey(key) || !isThemeAssetLink(rawLink)) throw new Error(`invalid asset entry for ${key}`);
@@ -1569,14 +1600,14 @@ function copyLink(link) {
1569
1600
  };
1570
1601
  }
1571
1602
  function isThemeAssetLink(value) {
1572
- return isRecord$1(value) && typeof value["sourceThemeId"] === "number" && Number.isInteger(value["sourceThemeId"]) && value["sourceThemeId"] > 0 && (value["checksum"] === void 0 || typeof value["checksum"] === "string" && value["checksum"].length > 0) && (value["url"] === void 0 || typeof value["url"] === "string" && value["url"].length > 0) && (value["contentType"] === void 0 || typeof value["contentType"] === "string" && value["contentType"].length > 0) && (value["contentSize"] === void 0 || typeof value["contentSize"] === "number" && Number.isInteger(value["contentSize"]) && value["contentSize"] > 0) && (value["previewImageUrl"] === void 0 || typeof value["previewImageUrl"] === "string" && value["previewImageUrl"].length > 0) && (value["altText"] === void 0 || typeof value["altText"] === "string") && (value["handle"] === void 0 || typeof value["handle"] === "string" && value["handle"].length > 0) && (value["pending"] === void 0 || typeof value["pending"] === "boolean") && (value["damAssetCode"] === void 0 || typeof value["damAssetCode"] === "string" && value["damAssetCode"].length > 0);
1603
+ return isRecord$2(value) && typeof value["sourceThemeId"] === "number" && Number.isInteger(value["sourceThemeId"]) && value["sourceThemeId"] > 0 && (value["checksum"] === void 0 || typeof value["checksum"] === "string" && value["checksum"].length > 0) && (value["url"] === void 0 || typeof value["url"] === "string" && value["url"].length > 0) && (value["contentType"] === void 0 || typeof value["contentType"] === "string" && value["contentType"].length > 0) && (value["contentSize"] === void 0 || typeof value["contentSize"] === "number" && Number.isInteger(value["contentSize"]) && value["contentSize"] > 0) && (value["previewImageUrl"] === void 0 || typeof value["previewImageUrl"] === "string" && value["previewImageUrl"].length > 0) && (value["altText"] === void 0 || typeof value["altText"] === "string") && (value["handle"] === void 0 || typeof value["handle"] === "string" && value["handle"].length > 0) && (value["pending"] === void 0 || typeof value["pending"] === "boolean") && (value["damAssetCode"] === void 0 || typeof value["damAssetCode"] === "string" && value["damAssetCode"].length > 0);
1573
1604
  }
1574
1605
  function isThemeAssetKey(key) {
1575
1606
  if (key.includes("\\") || key.includes("\0")) return false;
1576
1607
  const segments = key.split("/");
1577
1608
  return segments[0] === "assets" && segments.length === 2 && segments[1] !== void 0 && segments[1].length > 0 && segments[1] !== "." && segments[1] !== "..";
1578
1609
  }
1579
- function isRecord$1(value) {
1610
+ function isRecord$2(value) {
1580
1611
  return typeof value === "object" && value !== null && !Array.isArray(value);
1581
1612
  }
1582
1613
  //#endregion
@@ -1610,6 +1641,7 @@ var Syncer = class {
1610
1641
  remoteResourceGroups = /* @__PURE__ */ new Map();
1611
1642
  remoteResourceIndex = /* @__PURE__ */ new Map();
1612
1643
  remoteIndexesDirty = false;
1644
+ remoteResourcesLoaded = false;
1613
1645
  lastKnownRemoteSha = null;
1614
1646
  assetManifestInstance;
1615
1647
  constructor(api, themeId, themeRoot, assetManifest) {
@@ -1626,6 +1658,7 @@ var Syncer = class {
1626
1658
  const body = await listThemeResources(this.api, this.themeId);
1627
1659
  this.updateChecksums(body.application_theme_resources ?? []);
1628
1660
  this.lastKnownRemoteSha = body.content_version_sha ?? null;
1661
+ this.remoteResourcesLoaded = true;
1629
1662
  }
1630
1663
  /**
1631
1664
  * Server's `content_version_sha` captured on the last `fetchChecksums()`
@@ -1635,6 +1668,7 @@ var Syncer = class {
1635
1668
  return this.lastKnownRemoteSha;
1636
1669
  }
1637
1670
  updateChecksums(resources) {
1671
+ assertNoCaseCollisions(resources.flatMap((resource) => resource.key ? [resource.key] : []));
1638
1672
  this.rawRemoteResources.clear();
1639
1673
  this.remoteResourceGroups.clear();
1640
1674
  for (const resource of resources) {
@@ -1681,6 +1715,11 @@ var Syncer = class {
1681
1715
  remoteKeys() {
1682
1716
  return [...this.remoteResources.keys()];
1683
1717
  }
1718
+ /** A null-content resource has no possible working-tree counterpart. */
1719
+ canDeleteRemoteResource(key) {
1720
+ const resource = this.remoteResources.get(key);
1721
+ return resource?.content != null || isManagedAssetResource(resource);
1722
+ }
1684
1723
  /** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
1685
1724
  remoteChecksums() {
1686
1725
  return Object.fromEntries(this.checksums);
@@ -1695,13 +1734,32 @@ var Syncer = class {
1695
1734
  }
1696
1735
  return urls;
1697
1736
  }
1737
+ /** Compact, complete resource state paired with its acknowledged dev SHA. */
1738
+ devRemoteState(assetManifestSha) {
1739
+ if (!this.remoteResourcesLoaded || !this.lastKnownRemoteSha) return null;
1740
+ return {
1741
+ themeId: this.themeId,
1742
+ remoteSha: this.lastKnownRemoteSha,
1743
+ assetManifestSha,
1744
+ resources: [...this.remoteResourceGroups.values()].flat().map(remoteResourceState)
1745
+ };
1746
+ }
1747
+ useDevRemoteState(state) {
1748
+ if (state.themeId !== this.themeId) throw new Error(`Dev remote state belongs to theme #${state.themeId}, not #${this.themeId}`);
1749
+ this.updateChecksums(state.resources.map(remoteResourceFromState));
1750
+ this.lastKnownRemoteSha = state.remoteSha;
1751
+ this.remoteResourcesLoaded = true;
1752
+ }
1753
+ async ensureRemoteResourcesLoaded() {
1754
+ if (!this.remoteResourcesLoaded) await this.fetchChecksums();
1755
+ }
1698
1756
  /**
1699
1757
  * Adds URL-backed FileResources for manifest assets without transferring
1700
1758
  * their bytes. The target stores the source asset's ImageKit URL.
1701
1759
  */
1702
1760
  async linkManagedAssets(opts = {}) {
1703
1761
  this.assetManifest.reload();
1704
- await this.fetchChecksums();
1762
+ await this.ensureRemoteResourcesLoaded();
1705
1763
  const plans = [];
1706
1764
  for (const [key, link] of this.assetManifest.entries()) {
1707
1765
  if (this.themeRoot.ignore.ignore(key)) continue;
@@ -1761,7 +1819,7 @@ var Syncer = class {
1761
1819
  }
1762
1820
  async fetchThemeAssetMetadata(sourceThemeId) {
1763
1821
  const body = await getThemeAssets(this.api, sourceThemeId);
1764
- if (!isRecord(body) || !Array.isArray(body["file_resources"])) throw new Error("Theme assets response did not include file_resources");
1822
+ if (!isRecord$1(body) || !Array.isArray(body["file_resources"])) throw new Error("Theme assets response did not include file_resources");
1765
1823
  const assets = /* @__PURE__ */ new Map();
1766
1824
  for (const value of body["file_resources"]) {
1767
1825
  const asset = parseThemeAssetMetadata(value);
@@ -1916,7 +1974,12 @@ var Syncer = class {
1916
1974
  key: file.relativePath,
1917
1975
  content
1918
1976
  }, baseSha);
1919
- this.setRemoteResource(resource);
1977
+ this.setRemoteResource({
1978
+ ...resource,
1979
+ key: file.relativePath,
1980
+ content,
1981
+ checksum: resource.checksum ?? file.checksum()
1982
+ });
1920
1983
  return content;
1921
1984
  }
1922
1985
  if (isNestedBinaryThemeAsset(file)) throw new Error(`Binary assets must be directly inside assets/: ${file.relativePath}`);
@@ -2117,7 +2180,7 @@ var Syncer = class {
2117
2180
  const response = await deleteThemeResource(this.api, this.themeId, body);
2118
2181
  if (response.content_version_sha) this.lastKnownRemoteSha = response.content_version_sha;
2119
2182
  } catch (e) {
2120
- throw this.rethrowIfConflict(e);
2183
+ if (!isNotFoundError(e)) throw this.rethrowIfConflict(e);
2121
2184
  }
2122
2185
  this.removeRemoteResource(relativePath);
2123
2186
  }
@@ -2126,6 +2189,7 @@ var Syncer = class {
2126
2189
  const resources = body.application_theme_resources ?? [];
2127
2190
  this.updateChecksums(resources);
2128
2191
  this.lastKnownRemoteSha = body.content_version_sha ?? null;
2192
+ this.remoteResourcesLoaded = true;
2129
2193
  return resources;
2130
2194
  }
2131
2195
  async downloadBinaryAsset(url) {
@@ -2260,6 +2324,7 @@ var Syncer = class {
2260
2324
  }
2261
2325
  async uploadTheme(opts = {}) {
2262
2326
  const localFiles = this.themeRoot.files();
2327
+ assertNoCaseCollisions(localFiles.map((file) => file.relativePath));
2263
2328
  const result = {
2264
2329
  uploaded: 0,
2265
2330
  deleted: 0,
@@ -2279,8 +2344,9 @@ var Syncer = class {
2279
2344
  return result;
2280
2345
  }
2281
2346
  }
2282
- await this.fetchChecksums();
2283
- await this.preflightPush(opts.baseSha);
2347
+ if (opts.remoteState) this.useDevRemoteState(opts.remoteState);
2348
+ else await this.fetchChecksums();
2349
+ if (!opts.skipPreflight) await this.preflightPush(opts.baseSha);
2284
2350
  let baseSha = opts.baseSha ?? null;
2285
2351
  if (opts.linkManagedAssets) {
2286
2352
  result.linked = await this.linkManagedAssets(opts.linkManagedAssets);
@@ -2305,7 +2371,7 @@ var Syncer = class {
2305
2371
  if (opts.delete) {
2306
2372
  const localPaths = new Set(localFiles.map((f) => f.relativePath));
2307
2373
  for (const key of this.assetManifest.keys()) localPaths.add(key);
2308
- const toDelete = this.remoteKeys().filter((key) => !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
2374
+ const toDelete = this.remoteKeys().filter((key) => this.canDeleteRemoteResource(key) && !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
2309
2375
  for (const key of toDelete) try {
2310
2376
  await this.deleteRemoteFile(key, baseSha);
2311
2377
  baseSha = this.lastKnownRemoteSha;
@@ -2383,6 +2449,26 @@ function isNestedBinaryThemeAsset(file) {
2383
2449
  function isManagedAssetResource(resource) {
2384
2450
  return resource?.resource_type === "FileResource" && typeof resource.url === "string" && resource.url.length > 0;
2385
2451
  }
2452
+ function remoteResourceFromState(state) {
2453
+ return {
2454
+ key: state.key,
2455
+ checksum: state.checksum,
2456
+ content: state.contentPresent ? "" : null,
2457
+ resource_type: state.resourceType,
2458
+ resource_id: state.resourceId,
2459
+ url: state.url
2460
+ };
2461
+ }
2462
+ function remoteResourceState(resource) {
2463
+ return {
2464
+ key: resource.key,
2465
+ checksum: resource.checksum,
2466
+ contentPresent: resource.content != null,
2467
+ resourceType: resource.resource_type,
2468
+ resourceId: resource.resource_id,
2469
+ url: resource.url
2470
+ };
2471
+ }
2386
2472
  function isNotFoundError(error) {
2387
2473
  return isApiError(error) && error.status === 404;
2388
2474
  }
@@ -2418,7 +2504,7 @@ function uploadedAssetMetadata(file, resource) {
2418
2504
  };
2419
2505
  }
2420
2506
  function parseThemeAssetMetadata(value) {
2421
- if (!isRecord(value)) return void 0;
2507
+ if (!isRecord$1(value)) return void 0;
2422
2508
  const filename = nonEmptyString(value["filename"]);
2423
2509
  const url = nonEmptyString(value["url"]);
2424
2510
  const contentType = nonEmptyString(value["content_type"]);
@@ -2440,7 +2526,7 @@ function parseThemeAssetMetadata(value) {
2440
2526
  };
2441
2527
  }
2442
2528
  function createdFileResourceId(value) {
2443
- if (!isRecord(value) || !isRecord(value["file_resource"])) return;
2529
+ if (!isRecord$1(value) || !isRecord$1(value["file_resource"])) return;
2444
2530
  return positiveInteger(value["file_resource"]["id"]);
2445
2531
  }
2446
2532
  function positiveInteger(value) {
@@ -2455,7 +2541,7 @@ function nonEmptyString(value) {
2455
2541
  function optionalString(value) {
2456
2542
  return typeof value === "string" ? value : void 0;
2457
2543
  }
2458
- function isRecord(value) {
2544
+ function isRecord$1(value) {
2459
2545
  return typeof value === "object" && value !== null && !Array.isArray(value);
2460
2546
  }
2461
2547
  //#endregion
@@ -2642,17 +2728,58 @@ function timestamp() {
2642
2728
  async function startDevServer(api, theme, themeRoot, opts, onReady) {
2643
2729
  const sse = new SSEStream();
2644
2730
  const syncer = new Syncer(api, theme.id, themeRoot);
2731
+ let remoteStateSafe = true;
2732
+ const invalidateRemoteState = () => {
2733
+ if (!remoteStateSafe) return;
2734
+ remoteStateSafe = false;
2735
+ try {
2736
+ opts.onRemoteStateInvalidated?.();
2737
+ } catch {}
2738
+ };
2739
+ const recordRemoteState = () => {
2740
+ if (!remoteStateSafe) return;
2741
+ if (opts.onRemoteState) {
2742
+ const state = syncer.devRemoteState(new ThemeAssetManifest(themeRoot.root).fingerprint());
2743
+ if (state) try {
2744
+ opts.onRemoteState(state);
2745
+ } catch {
2746
+ invalidateRemoteState();
2747
+ }
2748
+ }
2749
+ };
2645
2750
  const pendingUpdates = /* @__PURE__ */ new Set();
2646
2751
  console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
2647
- const syncResult = await syncer.uploadTheme({
2752
+ const progress = (done, total) => {
2753
+ process.stdout.write(`\r Uploading ${done}/${total} files…`);
2754
+ };
2755
+ const uploadFromRemoteIndex = () => syncer.uploadTheme({
2648
2756
  delete: true,
2649
2757
  validate: opts.validate,
2650
2758
  linkManagedAssets: { replace: true },
2651
2759
  pendingBinaryAssets: true,
2652
- onProgress: (done, total) => {
2653
- process.stdout.write(`\r Uploading ${done}/${total} files…`);
2654
- }
2760
+ onProgress: progress
2655
2761
  });
2762
+ let syncResult;
2763
+ if (opts.initialSync) {
2764
+ let remoteStateIsTrusted = false;
2765
+ try {
2766
+ syncer.useDevRemoteState(opts.initialSync);
2767
+ await syncer.preflightPush(opts.initialSync.remoteSha);
2768
+ remoteStateIsTrusted = true;
2769
+ } catch (error) {
2770
+ if (!(error instanceof PushConflictError)) throw error;
2771
+ }
2772
+ syncResult = remoteStateIsTrusted ? await syncer.uploadTheme({
2773
+ delete: true,
2774
+ validate: opts.validate,
2775
+ linkManagedAssets: { replace: true },
2776
+ pendingBinaryAssets: true,
2777
+ baseSha: syncer.remoteSha(),
2778
+ remoteState: opts.initialSync,
2779
+ skipPreflight: true,
2780
+ onProgress: progress
2781
+ }) : await uploadFromRemoteIndex();
2782
+ } else syncResult = await uploadFromRemoteIndex();
2656
2783
  process.stdout.write("\n");
2657
2784
  if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
2658
2785
  if (syncResult.validationFailed) {
@@ -2660,17 +2787,28 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
2660
2787
  for (const e of syncResult.errors) console.error(` ${e}`);
2661
2788
  process.exit(1);
2662
2789
  } else if (syncResult.errors.length > 0) {
2790
+ invalidateRemoteState();
2663
2791
  for (const e of syncResult.errors) console.error(` ${e}`);
2664
2792
  if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
2665
2793
  }
2794
+ if (syncResult.errors.length === 0) recordRemoteState();
2666
2795
  const SYNC_IDLE_MS = 2e3;
2667
2796
  let lastArrivedAt = 0;
2668
2797
  let pendingSync = null;
2669
2798
  let syncInFlight = Promise.resolve();
2670
2799
  let askOwed = false;
2800
+ let remoteWritesBlocked = false;
2801
+ const blockRemoteWrites = (error) => {
2802
+ if (remoteWritesBlocked) return;
2803
+ remoteWritesBlocked = true;
2804
+ invalidateRemoteState();
2805
+ console.error(`\n[Watcher] Remote theme changed outside this dev session (${error.message}). Restart theme dev to compare the current remote state before writing again.`);
2806
+ };
2671
2807
  const sendSync = () => {
2672
2808
  syncInFlight = syncInFlight.then(async () => {
2673
- askOwed = !await syncer.requestSync();
2809
+ const accepted = await syncer.requestSync();
2810
+ askOwed = !accepted;
2811
+ if (accepted) recordRemoteState();
2674
2812
  });
2675
2813
  };
2676
2814
  const flushSyncNow = () => {
@@ -2698,7 +2836,15 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
2698
2836
  sendSync();
2699
2837
  await syncInFlight;
2700
2838
  }
2839
+ if (remoteWritesBlocked) return;
2840
+ try {
2841
+ assertNoCaseCollisions(themeRoot.files().map((file) => file.relativePath));
2842
+ } catch (error) {
2843
+ console.error(`\n[Watcher] Sync blocked: ${String(error)}`);
2844
+ return;
2845
+ }
2701
2846
  const changed = [...modified, ...added];
2847
+ let wroteRemote = false;
2702
2848
  for (const file of changed) {
2703
2849
  if (opts.validate && file.isLiquid) {
2704
2850
  const diagnostics = file.validateSchema();
@@ -2709,23 +2855,39 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
2709
2855
  }
2710
2856
  pendingUpdates.add(file.relativePath);
2711
2857
  try {
2712
- const uploadedContent = await syncer.uploadFile(file, void 0, { pendingAsset: true });
2858
+ const uploadedContent = await syncer.uploadFile(file, syncer.remoteSha(), { pendingAsset: true });
2859
+ wroteRemote = true;
2713
2860
  console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
2714
2861
  if (file.isLiquid && uploadedContent !== null && hasUnbalancedLiquidDelimiters(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: unbalanced liquid delimiters — the storefront may silently serve stale content for this section`);
2715
2862
  if (file.isLiquid && uploadedContent !== null) for (const diagnostic of findLiquidBlockTagDiagnostics(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: ${diagnostic.message}`);
2716
2863
  } catch (e) {
2864
+ if (e instanceof PushConflictError) {
2865
+ blockRemoteWrites(e);
2866
+ break;
2867
+ }
2868
+ invalidateRemoteState();
2717
2869
  console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
2718
2870
  } finally {
2719
2871
  pendingUpdates.delete(file.relativePath);
2720
2872
  }
2721
2873
  }
2874
+ if (remoteWritesBlocked) return;
2722
2875
  for (const file of removed) {
2723
2876
  if (themeRoot.ignore.ignore(file.relativePath)) continue;
2724
2877
  try {
2725
- await syncer.deleteRemoteFile(file.relativePath);
2878
+ await syncer.deleteRemoteFile(file.relativePath, syncer.remoteSha());
2879
+ wroteRemote = true;
2726
2880
  console.log(` ✓ removed ${file.relativePath}`);
2727
- } catch {}
2881
+ } catch (error) {
2882
+ if (error instanceof PushConflictError) {
2883
+ blockRemoteWrites(error);
2884
+ break;
2885
+ }
2886
+ invalidateRemoteState();
2887
+ }
2728
2888
  }
2889
+ if (remoteWritesBlocked) return;
2890
+ if (wroteRemote) recordRemoteState();
2729
2891
  if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
2730
2892
  else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
2731
2893
  scheduleSync();
@@ -2773,302 +2935,155 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
2773
2935
  };
2774
2936
  }
2775
2937
  //#endregion
2776
- //#region src/theme-picker.ts
2777
- const PAGE_SIZE = 50;
2778
- const LOAD_MORE_VALUE = -1;
2779
- function themeLabel(t) {
2780
- const active = t.status === "active" ? ` ${chalk.green("[active]")}` : "";
2781
- return `${t.name} (#${t.id})${active}`;
2782
- }
2783
- function themeChoices(themeList, hasMore) {
2784
- const choices = themeList.map((t) => ({
2785
- title: themeLabel(t),
2786
- value: t.id
2787
- }));
2788
- if (hasMore) choices.push({
2789
- title: chalk.dim(`── Load more themes ──`),
2790
- value: LOAD_MORE_VALUE
2791
- });
2792
- return choices;
2938
+ //#region src/theme/dev-remote-baseline.ts
2939
+ const BASELINE_VERSION = 1;
2940
+ const BASELINE_FILE = join(".fluid-theme", "dev-baseline.json");
2941
+ function readDevRemoteBaseline(themeRoot, themeId, assetManifestSha) {
2942
+ try {
2943
+ const state = parseDocument(JSON.parse(readFileSync(join(themeRoot, BASELINE_FILE), "utf-8")));
2944
+ if (state.themeId !== themeId) return null;
2945
+ if (state.assetManifestSha !== assetManifestSha) return null;
2946
+ return state;
2947
+ } catch {
2948
+ return null;
2949
+ }
2793
2950
  }
2794
- async function fetchThemesPage(api, page, searchQuery) {
2795
- const body = await listApplicationThemes(api, {
2796
- per_page: PAGE_SIZE,
2797
- page,
2798
- ...searchQuery ? { search_query: searchQuery } : {}
2799
- });
2800
- return {
2801
- themes: body.application_themes ?? [],
2802
- hasMore: page < (body.meta?.total_pages ?? 1)
2951
+ function writeDevRemoteBaseline(themeRoot, state) {
2952
+ const document = {
2953
+ version: BASELINE_VERSION,
2954
+ ...state
2803
2955
  };
2956
+ parseDocument(document);
2957
+ const path = join(themeRoot, BASELINE_FILE);
2958
+ const tempPath = `${path}.${randomBytes(6).toString("hex")}.tmp`;
2959
+ try {
2960
+ mkdirSync(dirname(path), { recursive: true });
2961
+ writeFileSync(tempPath, `${JSON.stringify(document)}\n`, {
2962
+ encoding: "utf-8",
2963
+ mode: 384
2964
+ });
2965
+ renameSync(tempPath, path);
2966
+ } catch (error) {
2967
+ rmSync(tempPath, { force: true });
2968
+ throw error;
2969
+ }
2804
2970
  }
2805
- async function selectTheme(api, message) {
2806
- const allThemes = [];
2807
- let page = 1;
2808
- let hasMore = true;
2809
- let initialIndex = 0;
2810
- let searchQuery = "";
2811
- let searchResults = [];
2812
- while (true) {
2813
- if (hasMore && allThemes.length < page * PAGE_SIZE) {
2814
- const result = await fetchThemesPage(api, page);
2815
- allThemes.push(...result.themes);
2816
- hasMore = result.hasMore;
2817
- }
2818
- if (!allThemes.length) {
2819
- console.error("No themes found.");
2820
- process.exit(1);
2821
- }
2822
- const choices = themeChoices(allThemes, hasMore);
2823
- const { id } = await prompts({
2824
- type: "autocomplete",
2825
- name: "id",
2826
- message,
2827
- initial: initialIndex,
2828
- choices,
2829
- suggest: async (input, choices) => {
2830
- if (!input) {
2831
- searchQuery = "";
2832
- searchResults = [];
2833
- return choices;
2834
- }
2835
- if (input !== searchQuery) {
2836
- searchQuery = input;
2837
- try {
2838
- searchResults = (await fetchThemesPage(api, 1, input)).themes;
2839
- } catch {
2840
- searchResults = [];
2841
- }
2842
- }
2843
- return searchResults.map((t) => ({
2844
- title: themeLabel(t),
2845
- value: t.id
2846
- }));
2971
+ function removeDevRemoteBaseline(themeRoot) {
2972
+ rmSync(join(themeRoot, BASELINE_FILE), { force: true });
2973
+ }
2974
+ async function devRemoteStateFromSourceShadow(themeRoot, shadow, themeId, remoteSha) {
2975
+ try {
2976
+ if (!await shadow.hasHead()) return null;
2977
+ const assetManifest = new ThemeAssetManifest(themeRoot.root);
2978
+ const resources = [];
2979
+ for (const key of await shadow.headPaths()) {
2980
+ const content = await shadow.blobAtHead(key);
2981
+ if (!content) return null;
2982
+ const asset = assetManifest.get(key);
2983
+ if (asset) {
2984
+ if (asset.pending || !asset.url || !content.equals(Buffer.from("fluid-managed-asset\n"))) return null;
2985
+ resources.push({
2986
+ key,
2987
+ checksum: asset.checksum ?? null,
2988
+ contentPresent: false,
2989
+ resourceType: "FileResource",
2990
+ url: asset.url
2991
+ });
2992
+ continue;
2847
2993
  }
2848
- }, { onCancel: () => process.exit(130) });
2849
- if (id === LOAD_MORE_VALUE) {
2850
- initialIndex = allThemes.length;
2851
- page++;
2852
- continue;
2853
- }
2854
- if (!id) {
2855
- console.error("No theme selected.");
2856
- process.exit(1);
2994
+ if (content.equals(Buffer.from("fluid-managed-asset\n"))) return null;
2995
+ resources.push({
2996
+ key,
2997
+ checksum: createHash("sha256").update(content).digest("hex"),
2998
+ contentPresent: true
2999
+ });
2857
3000
  }
2858
- const found = allThemes.find((t) => t.id === id) ?? searchResults.find((t) => t.id === id);
2859
- if (found) return found;
2860
- return (await getApplicationTheme(api, id)).application_theme;
3001
+ return {
3002
+ themeId,
3003
+ remoteSha,
3004
+ assetManifestSha: assetManifest.fingerprint(),
3005
+ resources
3006
+ };
3007
+ } catch {
3008
+ return null;
2861
3009
  }
2862
3010
  }
2863
- async function findTheme(api, identifier) {
2864
- const idNum = Number(identifier);
2865
- if (Number.isInteger(idNum) && idNum > 0) try {
2866
- const body = await getApplicationTheme(api, idNum);
2867
- if (body.application_theme) return body.application_theme;
2868
- } catch {}
2869
- let page = 1;
2870
- let hasMore = true;
2871
- while (hasMore) {
2872
- const result = await fetchThemesPage(api, page, identifier);
2873
- const found = result.themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
2874
- if (found) return found;
2875
- hasMore = result.hasMore;
2876
- page++;
2877
- }
2878
- console.error(`No theme found with identifier: ${identifier}`);
2879
- process.exit(1);
3011
+ function parseDocument(document) {
3012
+ if (!isRecord(document) || document["version"] !== BASELINE_VERSION) throw new Error(`expected version ${BASELINE_VERSION}`);
3013
+ const themeId = document["themeId"];
3014
+ const remoteSha = document["remoteSha"];
3015
+ const assetManifestSha = document["assetManifestSha"];
3016
+ const rawResources = document["resources"];
3017
+ if (!isPositiveInteger(themeId)) throw new Error("invalid theme id");
3018
+ if (!isNonEmptyString(remoteSha)) throw new Error("invalid remote sha");
3019
+ if (!isNonEmptyString(assetManifestSha)) throw new Error("invalid asset manifest sha");
3020
+ if (!Array.isArray(rawResources)) throw new Error("invalid resources");
3021
+ const resources = rawResources.map(parseResource);
3022
+ assertNoCaseCollisions(resources.map((resource) => resource.key));
3023
+ return {
3024
+ themeId,
3025
+ remoteSha,
3026
+ assetManifestSha,
3027
+ resources
3028
+ };
2880
3029
  }
2881
- //#endregion
2882
- //#region src/workspace.ts
2883
- const WORKSPACE_FILE = ".fluid-workspace.json";
2884
- /**
2885
- * Walk up from `startDir` looking for `.fluid-workspace.json`.
2886
- * Returns the workspace info if found, or `null` if not in a workspace.
2887
- */
2888
- function findWorkspace(startDir) {
2889
- let dir = resolve(startDir ?? process.cwd());
2890
- while (true) {
2891
- const candidate = join(dir, WORKSPACE_FILE);
2892
- if (existsSync(candidate)) try {
2893
- const raw = readFileSync(candidate, "utf-8");
2894
- const config = JSON.parse(raw);
2895
- return {
2896
- root: dir,
2897
- config
2898
- };
2899
- } catch {
2900
- return null;
2901
- }
2902
- const parent = dirname(dir);
2903
- if (parent === dir) break;
2904
- dir = parent;
2905
- }
2906
- return null;
3030
+ function parseResource(resource) {
3031
+ if (!isRecord(resource)) throw new Error("invalid resource");
3032
+ const key = resource["key"];
3033
+ const checksum = resource["checksum"];
3034
+ const contentPresent = resource["contentPresent"];
3035
+ const resourceType = resource["resourceType"];
3036
+ const resourceId = resource["resourceId"];
3037
+ const url = resource["url"];
3038
+ if (!isNonEmptyString(key) || key.includes("\0")) throw new Error("invalid resource key");
3039
+ if (checksum !== null && !isNonEmptyString(checksum)) throw new Error("invalid resource checksum");
3040
+ if (typeof contentPresent !== "boolean") throw new Error("invalid resource content marker");
3041
+ if (resourceType !== void 0 && resourceType !== null && !isNonEmptyString(resourceType)) throw new Error("invalid resource type");
3042
+ if (resourceId !== void 0 && resourceId !== null && !isPositiveInteger(resourceId)) throw new Error("invalid resource id");
3043
+ if (url !== void 0 && url !== null && !isNonEmptyString(url)) throw new Error("invalid resource url");
3044
+ return {
3045
+ key,
3046
+ checksum,
3047
+ contentPresent,
3048
+ resourceType,
3049
+ resourceId,
3050
+ url
3051
+ };
2907
3052
  }
2908
- /**
2909
- * If cwd is already inside `{workspace}/local/{company}/...`, return that
2910
- * theme root directory. Otherwise return null.
2911
- *
2912
- * Examples (workspace root = /code/fluid-theme-dev):
2913
- * cwd = /code/fluid-theme-dev/local/acme-co → /code/fluid-theme-dev/local/acme-co
2914
- * cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
2915
- * cwd = /code/fluid-theme-dev → null
2916
- * cwd = /code/fluid-theme-dev/local → null
2917
- */
2918
- function resolveThemeRootFromCwd(workspace) {
2919
- const cwd = resolve(process.cwd());
2920
- const localDir = join(workspace.root, "local");
2921
- const rel = relative(localDir, cwd);
2922
- if (rel.startsWith("..") || rel === ".") return null;
2923
- const firstSegment = rel.split(sep)[0];
2924
- if (!firstSegment) return null;
2925
- return join(localDir, firstSegment);
3053
+ function isRecord(candidate) {
3054
+ return typeof candidate === "object" && candidate !== null;
3055
+ }
3056
+ function isNonEmptyString(candidate) {
3057
+ return typeof candidate === "string" && candidate.length > 0;
3058
+ }
3059
+ function isPositiveInteger(candidate) {
3060
+ return typeof candidate === "number" && Number.isInteger(candidate) && candidate > 0;
2926
3061
  }
2927
3062
  //#endregion
2928
- //#region src/commands/dev.ts
3063
+ //#region src/theme/shadow-repo.ts
2929
3064
  /**
2930
- * Create the isolated theme used by `theme dev`.
3065
+ * A bare git repo hidden under `.fluid-theme/repo` that stands in for
3066
+ * git's index+HEAD when talking to the Fluid server. Every pull commits
3067
+ * the incoming remote state onto a single branch (`refs/heads/main`);
3068
+ * every successful push commits the outgoing local state onto the same
3069
+ * branch. HEAD therefore represents "the last state the CLI and server
3070
+ * agreed on", and its tree is the natural three-way-merge base for the
3071
+ * next pull.
2931
3072
  *
2932
- * A checkout from `theme pull` has a source theme id. New servers clone that
2933
- * source by reference, preserving its DAM/ImageKit assets without moving
2934
- * bytes. A 404/405 keeps older deployments compatible with the established
2935
- * empty-theme flow; other failures must remain visible to the developer.
3073
+ * We stay in git's plumbing layer no working tree, no index file
3074
+ * next to the theme content so the shadow repo cannot interfere
3075
+ * with the user's own git repo (if they have one) around the theme
3076
+ * dir. All state lives inside `.fluid-theme/`.
3077
+ *
3078
+ * The class only wraps the small handful of plumbing commands we
3079
+ * actually need: hash-object, cat-file, write-tree (via a temp index),
3080
+ * commit-tree, update-ref, and merge-file. Everything else stays out.
2936
3081
  */
2937
- async function createDevelopmentTheme(api, sourceThemeId, name) {
2938
- if (sourceThemeId !== void 0) try {
2939
- return (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme;
2940
- } catch (error) {
2941
- if (!isApiError(error) || error.status !== 404 && error.status !== 405) throw error;
2942
- console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
2943
- }
2944
- return (await createApplicationTheme(api, { application_theme: {
2945
- name,
2946
- status: "development"
2947
- } })).application_theme;
2948
- }
2949
- async function ensureDevTheme(api, projectKey, identifier, sourceThemeId) {
2950
- if (identifier) {
2951
- const theme = await findTheme(api, identifier);
2952
- setLastDevThemeId(theme.id);
2953
- return theme;
2954
- }
2955
- const stored = getDevTheme(projectKey);
2956
- if (stored && stored.sourceThemeId === sourceThemeId) {
2957
- try {
2958
- const existing = (await getApplicationTheme(api, stored.id)).application_theme;
2959
- if (existing && existing.status === "development") {
2960
- console.log(`Using existing dev theme #${existing.id}`);
2961
- setDevTheme(projectKey, {
2962
- id: existing.id,
2963
- name: existing.name,
2964
- ...sourceThemeId === void 0 ? {} : { sourceThemeId }
2965
- });
2966
- return existing;
2967
- }
2968
- } catch {}
2969
- clearDevTheme(projectKey);
2970
- }
2971
- const { hostname } = await import("node:os");
2972
- const theme = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
2973
- setDevTheme(projectKey, {
2974
- id: theme.id,
2975
- name: theme.name,
2976
- ...sourceThemeId === void 0 ? {} : { sourceThemeId }
2977
- });
2978
- console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
2979
- return theme;
2980
- }
2981
- function createDevCommand() {
2982
- return new Command("dev").description("Start the theme dev server with hot reload").option("--host <host>", "Local server host", "127.0.0.1").option("--port <port>", "Local server port", "9292").option("-t, --theme <name-or-id>", "Use an existing theme instead of dev theme").option("-f, --force", "Skip schema validation on upload").option("--live-reload <mode>", "Reload mode: full-page | off", "full-page").option("--navigate", "Open browser navigator after server starts").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
2983
- requireToken();
2984
- let rootPath = opts.root;
2985
- if (rootPath === ".") {
2986
- const workspace = findWorkspace();
2987
- if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
2988
- }
2989
- const themeRoot = new ThemeRoot(rootPath);
2990
- if (!themeRoot.isValid()) {
2991
- console.error(`'${rootPath}' does not look like a theme directory.`);
2992
- process.exit(1);
2993
- }
2994
- const port = Number(opts.port);
2995
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
2996
- console.error(`Invalid port: '${opts.port}'. Must be an integer between 1 and 65535.`);
2997
- process.exit(1);
2998
- }
2999
- try {
3000
- await checkPortAvailable(opts.host, port);
3001
- } catch (e) {
3002
- if (e instanceof PortInUseError) console.error(e.message);
3003
- else console.error(`Failed to check port availability: ${e}`);
3004
- process.exit(1);
3005
- }
3006
- const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
3007
- const api = createApiClient();
3008
- const config = readThemeConfig(themeRoot.root);
3009
- let company;
3010
- if (config?.company) company = config.company;
3011
- else {
3012
- company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain ?? "";
3013
- if (!company) {
3014
- console.error("Could not determine company subdomain. Make sure your token is valid.");
3015
- process.exit(1);
3016
- }
3017
- }
3018
- const projectKey = devThemeKey(company, themeRoot.root);
3019
- const theme = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId);
3020
- const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
3021
- let stop;
3022
- const cleanup = () => {
3023
- stop?.();
3024
- process.exit(0);
3025
- };
3026
- process.on("SIGINT", cleanup);
3027
- process.on("SIGTERM", cleanup);
3028
- stop = await startDevServer(api, {
3029
- id: theme.id,
3030
- name: theme.name,
3031
- company,
3032
- editorUrl
3033
- }, themeRoot, {
3034
- host: opts.host,
3035
- port,
3036
- reloadMode,
3037
- validate: !opts.force
3038
- }, (address) => {
3039
- console.log(`\n Dev server: ${address}`);
3040
- console.log(` Web editor: ${editorUrl}`);
3041
- console.log("\n Watching for file changes…\n");
3042
- if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
3043
- });
3044
- await new Promise(() => {});
3045
- });
3046
- }
3047
- //#endregion
3048
- //#region src/theme/shadow-repo.ts
3049
- /**
3050
- * A bare git repo hidden under `.fluid-theme/repo` that stands in for
3051
- * git's index+HEAD when talking to the Fluid server. Every pull commits
3052
- * the incoming remote state onto a single branch (`refs/heads/main`);
3053
- * every successful push commits the outgoing local state onto the same
3054
- * branch. HEAD therefore represents "the last state the CLI and server
3055
- * agreed on", and its tree is the natural three-way-merge base for the
3056
- * next pull.
3057
- *
3058
- * We stay in git's plumbing layer — no working tree, no index file
3059
- * next to the theme content — so the shadow repo cannot interfere
3060
- * with the user's own git repo (if they have one) around the theme
3061
- * dir. All state lives inside `.fluid-theme/`.
3062
- *
3063
- * The class only wraps the small handful of plumbing commands we
3064
- * actually need: hash-object, cat-file, write-tree (via a temp index),
3065
- * commit-tree, update-ref, and merge-file. Everything else stays out.
3066
- */
3067
- var ShadowRepo = class ShadowRepo {
3068
- headExists = void 0;
3069
- constructor(themeRoot, gitDir) {
3070
- this.themeRoot = themeRoot;
3071
- this.gitDir = gitDir;
3082
+ var ShadowRepo = class ShadowRepo {
3083
+ headExists = void 0;
3084
+ constructor(themeRoot, gitDir) {
3085
+ this.themeRoot = themeRoot;
3086
+ this.gitDir = gitDir;
3072
3087
  }
3073
3088
  /**
3074
3089
  * Return a ShadowRepo bound to `themeId` for the given theme root.
@@ -3404,6 +3419,311 @@ async function ensureRootGitignoreHidesShadow(themeRoot) {
3404
3419
  writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
3405
3420
  }
3406
3421
  //#endregion
3422
+ //#region src/theme-picker.ts
3423
+ const PAGE_SIZE = 50;
3424
+ const LOAD_MORE_VALUE = -1;
3425
+ function themeLabel(t) {
3426
+ const active = t.status === "active" ? ` ${chalk.green("[active]")}` : "";
3427
+ return `${t.name} (#${t.id})${active}`;
3428
+ }
3429
+ function themeChoices(themeList, hasMore) {
3430
+ const choices = themeList.map((t) => ({
3431
+ title: themeLabel(t),
3432
+ value: t.id
3433
+ }));
3434
+ if (hasMore) choices.push({
3435
+ title: chalk.dim(`── Load more themes ──`),
3436
+ value: LOAD_MORE_VALUE
3437
+ });
3438
+ return choices;
3439
+ }
3440
+ async function fetchThemesPage(api, page, searchQuery) {
3441
+ const body = await listApplicationThemes(api, {
3442
+ per_page: PAGE_SIZE,
3443
+ page,
3444
+ ...searchQuery ? { search_query: searchQuery } : {}
3445
+ });
3446
+ return {
3447
+ themes: body.application_themes ?? [],
3448
+ hasMore: page < (body.meta?.total_pages ?? 1)
3449
+ };
3450
+ }
3451
+ async function selectTheme(api, message) {
3452
+ const allThemes = [];
3453
+ let page = 1;
3454
+ let hasMore = true;
3455
+ let initialIndex = 0;
3456
+ let searchQuery = "";
3457
+ let searchResults = [];
3458
+ while (true) {
3459
+ if (hasMore && allThemes.length < page * PAGE_SIZE) {
3460
+ const result = await fetchThemesPage(api, page);
3461
+ allThemes.push(...result.themes);
3462
+ hasMore = result.hasMore;
3463
+ }
3464
+ if (!allThemes.length) {
3465
+ console.error("No themes found.");
3466
+ process.exit(1);
3467
+ }
3468
+ const choices = themeChoices(allThemes, hasMore);
3469
+ const { id } = await prompts({
3470
+ type: "autocomplete",
3471
+ name: "id",
3472
+ message,
3473
+ initial: initialIndex,
3474
+ choices,
3475
+ suggest: async (input, choices) => {
3476
+ if (!input) {
3477
+ searchQuery = "";
3478
+ searchResults = [];
3479
+ return choices;
3480
+ }
3481
+ if (input !== searchQuery) {
3482
+ searchQuery = input;
3483
+ try {
3484
+ searchResults = (await fetchThemesPage(api, 1, input)).themes;
3485
+ } catch {
3486
+ searchResults = [];
3487
+ }
3488
+ }
3489
+ return searchResults.map((t) => ({
3490
+ title: themeLabel(t),
3491
+ value: t.id
3492
+ }));
3493
+ }
3494
+ }, { onCancel: () => process.exit(130) });
3495
+ if (id === LOAD_MORE_VALUE) {
3496
+ initialIndex = allThemes.length;
3497
+ page++;
3498
+ continue;
3499
+ }
3500
+ if (!id) {
3501
+ console.error("No theme selected.");
3502
+ process.exit(1);
3503
+ }
3504
+ const found = allThemes.find((t) => t.id === id) ?? searchResults.find((t) => t.id === id);
3505
+ if (found) return found;
3506
+ return (await getApplicationTheme(api, id)).application_theme;
3507
+ }
3508
+ }
3509
+ async function findTheme(api, identifier) {
3510
+ const idNum = Number(identifier);
3511
+ if (Number.isInteger(idNum) && idNum > 0) try {
3512
+ const body = await getApplicationTheme(api, idNum);
3513
+ if (body.application_theme) return body.application_theme;
3514
+ } catch {}
3515
+ let page = 1;
3516
+ let hasMore = true;
3517
+ while (hasMore) {
3518
+ const result = await fetchThemesPage(api, page, identifier);
3519
+ const found = result.themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
3520
+ if (found) return found;
3521
+ hasMore = result.hasMore;
3522
+ page++;
3523
+ }
3524
+ console.error(`No theme found with identifier: ${identifier}`);
3525
+ process.exit(1);
3526
+ }
3527
+ //#endregion
3528
+ //#region src/workspace.ts
3529
+ const WORKSPACE_FILE = ".fluid-workspace.json";
3530
+ /**
3531
+ * Walk up from `startDir` looking for `.fluid-workspace.json`.
3532
+ * Returns the workspace info if found, or `null` if not in a workspace.
3533
+ */
3534
+ function findWorkspace(startDir) {
3535
+ let dir = resolve(startDir ?? process.cwd());
3536
+ while (true) {
3537
+ const candidate = join(dir, WORKSPACE_FILE);
3538
+ if (existsSync(candidate)) try {
3539
+ const raw = readFileSync(candidate, "utf-8");
3540
+ const config = JSON.parse(raw);
3541
+ return {
3542
+ root: dir,
3543
+ config
3544
+ };
3545
+ } catch {
3546
+ return null;
3547
+ }
3548
+ const parent = dirname(dir);
3549
+ if (parent === dir) break;
3550
+ dir = parent;
3551
+ }
3552
+ return null;
3553
+ }
3554
+ /**
3555
+ * If cwd is already inside `{workspace}/local/{company}/...`, return that
3556
+ * theme root directory. Otherwise return null.
3557
+ *
3558
+ * Examples (workspace root = /code/fluid-theme-dev):
3559
+ * cwd = /code/fluid-theme-dev/local/acme-co → /code/fluid-theme-dev/local/acme-co
3560
+ * cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
3561
+ * cwd = /code/fluid-theme-dev → null
3562
+ * cwd = /code/fluid-theme-dev/local → null
3563
+ */
3564
+ function resolveThemeRootFromCwd(workspace) {
3565
+ const cwd = resolve(process.cwd());
3566
+ const localDir = join(workspace.root, "local");
3567
+ const rel = relative(localDir, cwd);
3568
+ if (rel.startsWith("..") || rel === ".") return null;
3569
+ const firstSegment = rel.split(sep)[0];
3570
+ if (!firstSegment) return null;
3571
+ return join(localDir, firstSegment);
3572
+ }
3573
+ //#endregion
3574
+ //#region src/commands/dev.ts
3575
+ /** Whether this invocation may read and persist the dev remote baseline. */
3576
+ function devRemoteBaselineEnabled(explicitTheme) {
3577
+ return !explicitTheme && process.env["FLUID_THEME_DEV_DISABLE_SHADOW_SYNC"] !== "1";
3578
+ }
3579
+ /**
3580
+ * Create the isolated theme used by `theme dev`.
3581
+ *
3582
+ * A checkout from `theme pull` has a source theme id. New servers clone that
3583
+ * source by reference, preserving its DAM/ImageKit assets without moving
3584
+ * bytes. A 404/405 keeps older deployments compatible with the established
3585
+ * empty-theme flow; other failures must remain visible to the developer.
3586
+ */
3587
+ async function createDevelopmentTheme(api, sourceThemeId, name) {
3588
+ if (sourceThemeId !== void 0) try {
3589
+ return {
3590
+ theme: (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme,
3591
+ referenceCloned: true
3592
+ };
3593
+ } catch (error) {
3594
+ if (!isApiError(error) || error.status !== 404 && error.status !== 405) throw error;
3595
+ console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
3596
+ }
3597
+ return {
3598
+ theme: (await createApplicationTheme(api, { application_theme: {
3599
+ name,
3600
+ status: "development"
3601
+ } })).application_theme,
3602
+ referenceCloned: false
3603
+ };
3604
+ }
3605
+ async function ensureDevTheme(api, projectKey, identifier, sourceThemeId) {
3606
+ if (identifier) {
3607
+ const theme = await findTheme(api, identifier);
3608
+ setLastDevThemeId(theme.id);
3609
+ return {
3610
+ theme,
3611
+ referenceCloned: false
3612
+ };
3613
+ }
3614
+ const stored = getDevTheme(projectKey);
3615
+ if (stored && stored.sourceThemeId === sourceThemeId) {
3616
+ try {
3617
+ const existing = (await getApplicationTheme(api, stored.id)).application_theme;
3618
+ if (existing && existing.status === "development") {
3619
+ console.log(`Using existing dev theme #${existing.id}`);
3620
+ setDevTheme(projectKey, {
3621
+ ...stored,
3622
+ id: existing.id,
3623
+ name: existing.name,
3624
+ ...sourceThemeId === void 0 ? {} : { sourceThemeId }
3625
+ });
3626
+ return {
3627
+ theme: existing,
3628
+ referenceCloned: false
3629
+ };
3630
+ }
3631
+ } catch {}
3632
+ clearDevTheme(projectKey);
3633
+ }
3634
+ const { hostname } = await import("node:os");
3635
+ const creation = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
3636
+ const { theme } = creation;
3637
+ setDevTheme(projectKey, {
3638
+ id: theme.id,
3639
+ name: theme.name,
3640
+ ...sourceThemeId === void 0 ? {} : { sourceThemeId }
3641
+ });
3642
+ console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
3643
+ return creation;
3644
+ }
3645
+ function createDevCommand() {
3646
+ return new Command("dev").description("Start the theme dev server with hot reload").option("--host <host>", "Local server host", "127.0.0.1").option("--port <port>", "Local server port", "9292").option("-t, --theme <name-or-id>", "Use an existing theme instead of dev theme").option("-f, --force", "Skip schema validation on upload").option("--live-reload <mode>", "Reload mode: full-page | off", "full-page").option("--navigate", "Open browser navigator after server starts").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
3647
+ requireToken();
3648
+ let rootPath = opts.root;
3649
+ if (rootPath === ".") {
3650
+ const workspace = findWorkspace();
3651
+ if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
3652
+ }
3653
+ const themeRoot = new ThemeRoot(rootPath);
3654
+ if (!themeRoot.isValid()) {
3655
+ console.error(`'${rootPath}' does not look like a theme directory.`);
3656
+ process.exit(1);
3657
+ }
3658
+ const port = Number(opts.port);
3659
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
3660
+ console.error(`Invalid port: '${opts.port}'. Must be an integer between 1 and 65535.`);
3661
+ process.exit(1);
3662
+ }
3663
+ try {
3664
+ await checkPortAvailable(opts.host, port);
3665
+ } catch (e) {
3666
+ if (e instanceof PortInUseError) console.error(e.message);
3667
+ else console.error(`Failed to check port availability: ${e}`);
3668
+ process.exit(1);
3669
+ }
3670
+ const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
3671
+ const api = createApiClient();
3672
+ const config = readThemeConfig(themeRoot.root);
3673
+ let company;
3674
+ if (config?.company) company = config.company;
3675
+ else {
3676
+ company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain ?? "";
3677
+ if (!company) {
3678
+ console.error("Could not determine company subdomain. Make sure your token is valid.");
3679
+ process.exit(1);
3680
+ }
3681
+ }
3682
+ const projectKey = devThemeKey(company, themeRoot.root);
3683
+ const devTarget = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId);
3684
+ const { theme } = devTarget;
3685
+ const assetManifest = new ThemeAssetManifest(themeRoot.root);
3686
+ const baselineEnabled = devRemoteBaselineEnabled(Boolean(opts.theme));
3687
+ let initialRemoteState = baselineEnabled ? readDevRemoteBaseline(themeRoot.root, theme.id, assetManifest.fingerprint()) : null;
3688
+ if (!initialRemoteState && baselineEnabled && devTarget.referenceCloned && config?.themeId && config.baseSha) try {
3689
+ initialRemoteState = await devRemoteStateFromSourceShadow(themeRoot, await ShadowRepo.open(themeRoot.root, config.themeId), theme.id, config.baseSha);
3690
+ if (initialRemoteState) writeDevRemoteBaseline(themeRoot.root, initialRemoteState);
3691
+ } catch {
3692
+ initialRemoteState = null;
3693
+ }
3694
+ const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
3695
+ let stop;
3696
+ const cleanup = () => {
3697
+ stop?.();
3698
+ process.exit(0);
3699
+ };
3700
+ process.on("SIGINT", cleanup);
3701
+ process.on("SIGTERM", cleanup);
3702
+ stop = await startDevServer(api, {
3703
+ id: theme.id,
3704
+ name: theme.name,
3705
+ company,
3706
+ editorUrl
3707
+ }, themeRoot, {
3708
+ host: opts.host,
3709
+ port,
3710
+ reloadMode,
3711
+ validate: !opts.force,
3712
+ ...initialRemoteState ? { initialSync: initialRemoteState } : {},
3713
+ ...baselineEnabled ? {
3714
+ onRemoteState: (state) => writeDevRemoteBaseline(themeRoot.root, state),
3715
+ onRemoteStateInvalidated: () => removeDevRemoteBaseline(themeRoot.root)
3716
+ } : {}
3717
+ }, (address) => {
3718
+ console.log(`\n Dev server: ${address}`);
3719
+ console.log(` Web editor: ${editorUrl}`);
3720
+ console.log("\n Watching for file changes…\n");
3721
+ if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
3722
+ });
3723
+ await new Promise(() => {});
3724
+ });
3725
+ }
3726
+ //#endregion
3407
3727
  //#region src/theme/merge-push.ts
3408
3728
  /**
3409
3729
  * Compute what changed locally since the last time the shadow repo
@@ -3420,15 +3740,24 @@ async function diffAgainstShadow(themeRoot, shadow) {
3420
3740
  const localFiles = themeRoot.files();
3421
3741
  const localByKey = /* @__PURE__ */ new Map();
3422
3742
  const assetManifest = new ThemeAssetManifest(themeRoot.root);
3743
+ let headPaths;
3744
+ try {
3745
+ headPaths = await shadow.headPaths();
3746
+ } catch (error) {
3747
+ throw new Error("Could not read the local theme shadow", { cause: error });
3748
+ }
3749
+ assertNoCaseCollisions([...localFiles.map((file) => file.relativePath), ...headPaths]);
3750
+ const headPathSet = new Set(headPaths);
3423
3751
  for (const file of localFiles) {
3424
3752
  if (!file.exists) continue;
3425
3753
  localByKey.set(file.relativePath, file);
3426
3754
  const headBlob = await shadow.blobAtHead(file.relativePath);
3755
+ if (!headBlob && headPathSet.has(file.relativePath)) throw new Error(`Could not read the local theme shadow: ${file.relativePath}`);
3427
3756
  const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
3428
3757
  if (headBlob && headBlob.equals(localBuf)) continue;
3429
3758
  changed.push(file);
3430
3759
  }
3431
- if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
3760
+ for (const key of headPaths) {
3432
3761
  if (localByKey.has(key)) continue;
3433
3762
  if (themeRoot.ignore.ignore(key)) continue;
3434
3763
  if (assetManifest.has(key)) continue;
@@ -3441,15 +3770,6 @@ async function diffAgainstShadow(themeRoot, shadow) {
3441
3770
  };
3442
3771
  }
3443
3772
  /**
3444
- * Read every path recorded under HEAD's tree. Used to detect local
3445
- * deletions (paths in HEAD, absent from the working tree). Implemented
3446
- * with `git ls-tree -r HEAD --name-only` piped through the shadow
3447
- * repo's plumbing.
3448
- */
3449
- async function listHeadPaths(shadow) {
3450
- return shadow.headPaths();
3451
- }
3452
- /**
3453
3773
  * Refuse a push when any working file still contains a conflict marker
3454
3774
  * from a previous pull. Mirrors git's "you have unresolved conflicts;
3455
3775
  * fix them and re-run" behavior — the whole point of writing markers
@@ -3917,7 +4237,8 @@ function createPushCommand() {
3917
4237
  themeId: theme.id,
3918
4238
  themeName: theme.name,
3919
4239
  company: config.company,
3920
- baseSha: baseSha ?? void 0
4240
+ baseSha: baseSha ?? void 0,
4241
+ assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint({ excludePending: true })
3921
4242
  });
3922
4243
  return;
3923
4244
  }
@@ -3929,7 +4250,8 @@ function createPushCommand() {
3929
4250
  themeId: theme.id,
3930
4251
  themeName: theme.name,
3931
4252
  company: subdomain,
3932
- baseSha: baseSha ?? void 0
4253
+ baseSha: baseSha ?? void 0,
4254
+ assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint({ excludePending: true })
3933
4255
  });
3934
4256
  } catch {}
3935
4257
  }
@@ -4210,7 +4532,11 @@ function createPullCommand() {
4210
4532
  const syncer = new Syncer(api, theme.id, themeRoot);
4211
4533
  const actorPromise = fetchSyncActor(api);
4212
4534
  const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
4213
- const resources = await syncer.downloadAll();
4535
+ const resources = await syncer.downloadAll().catch((error) => {
4536
+ if (!(error instanceof CaseCollisionError)) throw error;
4537
+ spinner.fail(error.message);
4538
+ process.exit(1);
4539
+ });
4214
4540
  const externalizedAssets = await syncer.externalizePulledAssets(resources, { delete: !opts.nodelete });
4215
4541
  const result = await mergePull({
4216
4542
  themeRoot,
@@ -4252,7 +4578,8 @@ function createPullCommand() {
4252
4578
  themeId: theme.id,
4253
4579
  themeName: theme.name,
4254
4580
  company: subdomain,
4255
- baseSha: remoteSha ?? existingConfig?.baseSha
4581
+ baseSha: remoteSha ?? existingConfig?.baseSha,
4582
+ assetManifestSha: new ThemeAssetManifest(absoluteRoot).fingerprint({ excludePending: true })
4256
4583
  });
4257
4584
  if (result.conflicts.length > 0) process.exit(1);
4258
4585
  });