@fluid-app/fluid-cli-theme-dev 0.1.48 → 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
@@ -6,9 +6,9 @@ import { createHash, randomBytes } from "node:crypto";
6
6
  import http from "node:http";
7
7
  import https from "node:https";
8
8
  import chokidar from "chokidar";
9
+ import net from "node:net";
9
10
  import { execFileSync, spawn } from "node:child_process";
10
11
  import { tmpdir } from "node:os";
11
- import net from "node:net";
12
12
  import chalk from "chalk";
13
13
  import prompts from "prompts";
14
14
  import ora from "ora";
@@ -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;
@@ -1534,16 +1556,16 @@ var ThemeAssetManifest = class {
1534
1556
  function readDocument(path) {
1535
1557
  if (!existsSync(path)) return emptyDocument();
1536
1558
  try {
1537
- return parseDocument(JSON.parse(readFileSync(path, "utf-8")));
1559
+ return parseDocument$1(JSON.parse(readFileSync(path, "utf-8")));
1538
1560
  } catch (error) {
1539
1561
  const message = error instanceof Error ? error.message : String(error);
1540
1562
  throw new Error(`Could not read ${MANIFEST_FILE}: ${message}`);
1541
1563
  }
1542
1564
  }
1543
- function parseDocument(value) {
1544
- 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}`);
1545
1567
  const rawAssets = value["assets"];
1546
- if (!isRecord$1(rawAssets)) throw new Error("expected an assets object");
1568
+ if (!isRecord$2(rawAssets)) throw new Error("expected an assets object");
1547
1569
  const assets = {};
1548
1570
  for (const [key, rawLink] of Object.entries(rawAssets)) {
1549
1571
  if (!isThemeAssetKey(key) || !isThemeAssetLink(rawLink)) throw new Error(`invalid asset entry for ${key}`);
@@ -1578,14 +1600,14 @@ function copyLink(link) {
1578
1600
  };
1579
1601
  }
1580
1602
  function isThemeAssetLink(value) {
1581
- 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);
1582
1604
  }
1583
1605
  function isThemeAssetKey(key) {
1584
1606
  if (key.includes("\\") || key.includes("\0")) return false;
1585
1607
  const segments = key.split("/");
1586
1608
  return segments[0] === "assets" && segments.length === 2 && segments[1] !== void 0 && segments[1].length > 0 && segments[1] !== "." && segments[1] !== "..";
1587
1609
  }
1588
- function isRecord$1(value) {
1610
+ function isRecord$2(value) {
1589
1611
  return typeof value === "object" && value !== null && !Array.isArray(value);
1590
1612
  }
1591
1613
  //#endregion
@@ -1619,6 +1641,7 @@ var Syncer = class {
1619
1641
  remoteResourceGroups = /* @__PURE__ */ new Map();
1620
1642
  remoteResourceIndex = /* @__PURE__ */ new Map();
1621
1643
  remoteIndexesDirty = false;
1644
+ remoteResourcesLoaded = false;
1622
1645
  lastKnownRemoteSha = null;
1623
1646
  assetManifestInstance;
1624
1647
  constructor(api, themeId, themeRoot, assetManifest) {
@@ -1635,6 +1658,7 @@ var Syncer = class {
1635
1658
  const body = await listThemeResources(this.api, this.themeId);
1636
1659
  this.updateChecksums(body.application_theme_resources ?? []);
1637
1660
  this.lastKnownRemoteSha = body.content_version_sha ?? null;
1661
+ this.remoteResourcesLoaded = true;
1638
1662
  }
1639
1663
  /**
1640
1664
  * Server's `content_version_sha` captured on the last `fetchChecksums()`
@@ -1644,6 +1668,7 @@ var Syncer = class {
1644
1668
  return this.lastKnownRemoteSha;
1645
1669
  }
1646
1670
  updateChecksums(resources) {
1671
+ assertNoCaseCollisions(resources.flatMap((resource) => resource.key ? [resource.key] : []));
1647
1672
  this.rawRemoteResources.clear();
1648
1673
  this.remoteResourceGroups.clear();
1649
1674
  for (const resource of resources) {
@@ -1690,6 +1715,11 @@ var Syncer = class {
1690
1715
  remoteKeys() {
1691
1716
  return [...this.remoteResources.keys()];
1692
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
+ }
1693
1723
  /** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
1694
1724
  remoteChecksums() {
1695
1725
  return Object.fromEntries(this.checksums);
@@ -1704,13 +1734,32 @@ var Syncer = class {
1704
1734
  }
1705
1735
  return urls;
1706
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
+ }
1707
1756
  /**
1708
1757
  * Adds URL-backed FileResources for manifest assets without transferring
1709
1758
  * their bytes. The target stores the source asset's ImageKit URL.
1710
1759
  */
1711
1760
  async linkManagedAssets(opts = {}) {
1712
1761
  this.assetManifest.reload();
1713
- await this.fetchChecksums();
1762
+ await this.ensureRemoteResourcesLoaded();
1714
1763
  const plans = [];
1715
1764
  for (const [key, link] of this.assetManifest.entries()) {
1716
1765
  if (this.themeRoot.ignore.ignore(key)) continue;
@@ -1770,7 +1819,7 @@ var Syncer = class {
1770
1819
  }
1771
1820
  async fetchThemeAssetMetadata(sourceThemeId) {
1772
1821
  const body = await getThemeAssets(this.api, sourceThemeId);
1773
- 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");
1774
1823
  const assets = /* @__PURE__ */ new Map();
1775
1824
  for (const value of body["file_resources"]) {
1776
1825
  const asset = parseThemeAssetMetadata(value);
@@ -1925,7 +1974,12 @@ var Syncer = class {
1925
1974
  key: file.relativePath,
1926
1975
  content
1927
1976
  }, baseSha);
1928
- this.setRemoteResource(resource);
1977
+ this.setRemoteResource({
1978
+ ...resource,
1979
+ key: file.relativePath,
1980
+ content,
1981
+ checksum: resource.checksum ?? file.checksum()
1982
+ });
1929
1983
  return content;
1930
1984
  }
1931
1985
  if (isNestedBinaryThemeAsset(file)) throw new Error(`Binary assets must be directly inside assets/: ${file.relativePath}`);
@@ -2135,6 +2189,7 @@ var Syncer = class {
2135
2189
  const resources = body.application_theme_resources ?? [];
2136
2190
  this.updateChecksums(resources);
2137
2191
  this.lastKnownRemoteSha = body.content_version_sha ?? null;
2192
+ this.remoteResourcesLoaded = true;
2138
2193
  return resources;
2139
2194
  }
2140
2195
  async downloadBinaryAsset(url) {
@@ -2269,6 +2324,7 @@ var Syncer = class {
2269
2324
  }
2270
2325
  async uploadTheme(opts = {}) {
2271
2326
  const localFiles = this.themeRoot.files();
2327
+ assertNoCaseCollisions(localFiles.map((file) => file.relativePath));
2272
2328
  const result = {
2273
2329
  uploaded: 0,
2274
2330
  deleted: 0,
@@ -2288,7 +2344,8 @@ var Syncer = class {
2288
2344
  return result;
2289
2345
  }
2290
2346
  }
2291
- if (!opts.diff) await this.fetchChecksums();
2347
+ if (opts.remoteState) this.useDevRemoteState(opts.remoteState);
2348
+ else await this.fetchChecksums();
2292
2349
  if (!opts.skipPreflight) await this.preflightPush(opts.baseSha);
2293
2350
  let baseSha = opts.baseSha ?? null;
2294
2351
  if (opts.linkManagedAssets) {
@@ -2298,7 +2355,7 @@ var Syncer = class {
2298
2355
  this.assetManifest.reload();
2299
2356
  this.ensureManagedAssetsAreResolved();
2300
2357
  }
2301
- const toUpload = opts.diff?.changed ?? localFiles.filter((f) => f.exists && this.hasChanged(f));
2358
+ const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
2302
2359
  let done = 0;
2303
2360
  for (const file of toUpload) {
2304
2361
  try {
@@ -2312,11 +2369,9 @@ var Syncer = class {
2312
2369
  opts.onProgress?.(++done, toUpload.length);
2313
2370
  }
2314
2371
  if (opts.delete) {
2315
- const toDelete = opts.diff?.deleted ?? (() => {
2316
- const localPaths = new Set(localFiles.map((f) => f.relativePath));
2317
- for (const key of this.assetManifest.keys()) localPaths.add(key);
2318
- return this.remoteKeys().filter((key) => !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
2319
- })();
2372
+ const localPaths = new Set(localFiles.map((f) => f.relativePath));
2373
+ for (const key of this.assetManifest.keys()) localPaths.add(key);
2374
+ const toDelete = this.remoteKeys().filter((key) => this.canDeleteRemoteResource(key) && !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
2320
2375
  for (const key of toDelete) try {
2321
2376
  await this.deleteRemoteFile(key, baseSha);
2322
2377
  baseSha = this.lastKnownRemoteSha;
@@ -2394,6 +2449,26 @@ function isNestedBinaryThemeAsset(file) {
2394
2449
  function isManagedAssetResource(resource) {
2395
2450
  return resource?.resource_type === "FileResource" && typeof resource.url === "string" && resource.url.length > 0;
2396
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
+ }
2397
2472
  function isNotFoundError(error) {
2398
2473
  return isApiError(error) && error.status === 404;
2399
2474
  }
@@ -2429,7 +2504,7 @@ function uploadedAssetMetadata(file, resource) {
2429
2504
  };
2430
2505
  }
2431
2506
  function parseThemeAssetMetadata(value) {
2432
- if (!isRecord(value)) return void 0;
2507
+ if (!isRecord$1(value)) return void 0;
2433
2508
  const filename = nonEmptyString(value["filename"]);
2434
2509
  const url = nonEmptyString(value["url"]);
2435
2510
  const contentType = nonEmptyString(value["content_type"]);
@@ -2451,7 +2526,7 @@ function parseThemeAssetMetadata(value) {
2451
2526
  };
2452
2527
  }
2453
2528
  function createdFileResourceId(value) {
2454
- if (!isRecord(value) || !isRecord(value["file_resource"])) return;
2529
+ if (!isRecord$1(value) || !isRecord$1(value["file_resource"])) return;
2455
2530
  return positiveInteger(value["file_resource"]["id"]);
2456
2531
  }
2457
2532
  function positiveInteger(value) {
@@ -2466,840 +2541,882 @@ function nonEmptyString(value) {
2466
2541
  function optionalString(value) {
2467
2542
  return typeof value === "string" ? value : void 0;
2468
2543
  }
2469
- function isRecord(value) {
2544
+ function isRecord$1(value) {
2470
2545
  return typeof value === "object" && value !== null && !Array.isArray(value);
2471
2546
  }
2472
2547
  //#endregion
2473
- //#region src/theme/shadow-repo.ts
2548
+ //#region src/theme/liquid-delimiters.ts
2474
2549
  /**
2475
- * A bare git repo hidden under `.fluid-theme/repo` that stands in for
2476
- * git's index+HEAD when talking to the Fluid server. Every pull commits
2477
- * the incoming remote state onto a single branch (`refs/heads/main`);
2478
- * every successful push commits the outgoing local state onto the same
2479
- * branch. HEAD therefore represents "the last state the CLI and server
2480
- * agreed on", and its tree is the natural three-way-merge base for the
2481
- * next pull.
2550
+ * Heuristic-only check for obviously unbalanced liquid delimiters
2551
+ * (`{% %}` and `{{ }}`). This is NOT a liquid parser — it only tracks
2552
+ * opening delimiters until they are closed. It exists to give watch-mode users a signal
2553
+ * when a save is liquid-syntax-broken: the server accepts
2554
+ * syntax-broken liquid silently on upload, and the storefront
2555
+ * renderer then serves stale content for that section with no error
2556
+ * anywhere else in the pipeline.
2482
2557
  *
2483
- * We stay in git's plumbing layer no working tree, no index file
2484
- * next to the theme content so the shadow repo cannot interfere
2485
- * with the user's own git repo (if they have one) around the theme
2486
- * dir. All state lives inside `.fluid-theme/`.
2558
+ * Closing-looking tokens without a preceding Liquid opener are intentionally
2559
+ * ignored. Liquid files commonly contain CSS such as `width:100%}` or adjacent
2560
+ * block braces (`}}`), so treating every close token as Liquid creates noisy
2561
+ * false warnings on valid theme files.
2487
2562
  *
2488
- * The class only wraps the small handful of plumbing commands we
2489
- * actually need: hash-object, cat-file, write-tree (via a temp index),
2490
- * commit-tree, update-ref, and merge-file. Everything else stays out.
2563
+ * Known false positive: delimiters written literally inside a
2564
+ * `{% raw %}...{% endraw %}` block are still counted and can trip
2565
+ * this check even though the liquid is valid. Acceptable for a
2566
+ * warn-only heuristic — a real fix requires parsing liquid, which is
2567
+ * out of scope here (see the server-side validation note in the PR).
2491
2568
  */
2492
- var ShadowRepo = class ShadowRepo {
2493
- headExists = void 0;
2494
- constructor(themeRoot, gitDir) {
2495
- this.themeRoot = themeRoot;
2496
- this.gitDir = gitDir;
2569
+ function hasUnbalancedLiquidDelimiters(content) {
2570
+ let unclosedTags = 0;
2571
+ let unclosedOutputs = 0;
2572
+ for (const token of content.matchAll(/\{%|%\}|\{\{|\}\}/g)) switch (token[0]) {
2573
+ case "{%":
2574
+ unclosedTags += 1;
2575
+ break;
2576
+ case "%}":
2577
+ if (unclosedTags > 0) unclosedTags -= 1;
2578
+ break;
2579
+ case "{{":
2580
+ unclosedOutputs += 1;
2581
+ break;
2582
+ case "}}":
2583
+ if (unclosedOutputs > 0) unclosedOutputs -= 1;
2584
+ break;
2497
2585
  }
2498
- /**
2499
- * Return a ShadowRepo bound to `themeId` for the given theme root.
2500
- * The shadow is initialized on first use and re-initialized when
2501
- * the caller passes a themeId different from the one previously
2502
- * recorded — the merge base is only meaningful for the theme it
2503
- * was captured against, so a cross-theme operation (pull A → push
2504
- * B, or two pulls of different themes into one dir) starts from a
2505
- * clean slate rather than pretending B's state matches A's HEAD.
2506
- */
2507
- static async open(themeRoot, themeId) {
2508
- const shadowDir = join(themeRoot, ".fluid-theme");
2509
- const gitDir = join(shadowDir, "repo");
2510
- const themeIdFile = join(shadowDir, "theme-id");
2511
- if (existsSync(gitDir)) {
2512
- if (readStoredThemeId(themeIdFile) !== themeId) rmSync(gitDir, {
2513
- recursive: true,
2514
- force: true
2586
+ return unclosedTags > 0 || unclosedOutputs > 0;
2587
+ }
2588
+ const BLOCK_TAGS = new Map([
2589
+ ["capture", "endcapture"],
2590
+ ["case", "endcase"],
2591
+ ["comment", "endcomment"],
2592
+ ["for", "endfor"],
2593
+ ["form", "endform"],
2594
+ ["if", "endif"],
2595
+ ["ifchanged", "endifchanged"],
2596
+ ["javascript", "endjavascript"],
2597
+ ["paginate", "endpaginate"],
2598
+ ["raw", "endraw"],
2599
+ ["schema", "endschema"],
2600
+ ["style", "endstyle"],
2601
+ ["stylesheet", "endstylesheet"],
2602
+ ["tablerow", "endtablerow"],
2603
+ ["unless", "endunless"]
2604
+ ]);
2605
+ const CLOSING_TAGS = new Set(BLOCK_TAGS.values());
2606
+ const OPAQUE_BLOCK_TAGS = new Set([
2607
+ "comment",
2608
+ "javascript",
2609
+ "raw",
2610
+ "schema",
2611
+ "style",
2612
+ "stylesheet"
2613
+ ]);
2614
+ /**
2615
+ * Find structurally unbalanced Liquid block tags such as an `{% if %}` with
2616
+ * no `{% endif %}`. This intentionally recognizes only established paired
2617
+ * tags; custom and inline tags are ignored rather than guessed at.
2618
+ *
2619
+ * Content inside raw/comment/schema/style/javascript blocks is opaque to
2620
+ * Liquid and therefore skipped until that block's matching close tag. This
2621
+ * prevents CSS, JSON, and examples containing Liquid-looking text from
2622
+ * producing false errors.
2623
+ */
2624
+ function findLiquidBlockTagDiagnostics(content) {
2625
+ const stack = [];
2626
+ const diagnostics = [];
2627
+ let line = 1;
2628
+ let previousTagIndex = 0;
2629
+ const processTag = (name, tagLine) => {
2630
+ const open = stack.at(-1);
2631
+ if (open && OPAQUE_BLOCK_TAGS.has(open.name)) {
2632
+ if (name === open.expectedClose) stack.pop();
2633
+ return;
2634
+ }
2635
+ const expectedClose = BLOCK_TAGS.get(name);
2636
+ if (expectedClose) {
2637
+ stack.push({
2638
+ name,
2639
+ expectedClose,
2640
+ line: tagLine
2515
2641
  });
2642
+ return;
2516
2643
  }
2517
- const repo = new ShadowRepo(themeRoot, gitDir);
2518
- if (!existsSync(gitDir)) {
2519
- mkdirSync(shadowDir, { recursive: true });
2520
- await repo.git([
2521
- "init",
2522
- "--bare",
2523
- "-b",
2524
- "main",
2525
- gitDir
2526
- ], { cwd: themeRoot });
2644
+ if (!CLOSING_TAGS.has(name)) return;
2645
+ if (!open) {
2646
+ diagnostics.push({
2647
+ severity: "error",
2648
+ message: `Unexpected Liquid tag '{% ${name} %}' on line ${tagLine}; there is no open block to close.`
2649
+ });
2650
+ return;
2527
2651
  }
2528
- writeFileSync(themeIdFile, `${themeId}\n`, "utf-8");
2529
- const shadowIgnore = join(shadowDir, ".gitignore");
2530
- if (!existsSync(shadowIgnore)) writeFileSync(shadowIgnore, "# Fluid CLI shadow repo — internal state, not for version control.\n*\n");
2531
- await ensureRootGitignoreHidesShadow(themeRoot);
2532
- return repo;
2533
- }
2534
- /** True when the repo has at least one commit on `refs/heads/main`. */
2535
- async hasHead() {
2536
- if (this.headExists !== void 0) return this.headExists;
2537
- try {
2538
- await this.git([
2539
- "rev-parse",
2540
- "--verify",
2541
- "HEAD"
2542
- ]);
2543
- this.headExists = true;
2544
- } catch {
2545
- this.headExists = false;
2652
+ if (name !== open.expectedClose) {
2653
+ diagnostics.push({
2654
+ severity: "error",
2655
+ message: `Mismatched Liquid tag '{% ${name} %}' on line ${tagLine}; '{% ${open.name} %}' from line ${open.line} must close with '{% ${open.expectedClose} %}'.`
2656
+ });
2657
+ return;
2546
2658
  }
2547
- return this.headExists;
2659
+ stack.pop();
2660
+ };
2661
+ for (const match of content.matchAll(/\{%-?\s*([a-zA-Z_][\w-]*)\b(?:(?!\{%)[\s\S])*?-?%\}/g)) {
2662
+ const name = match[1]?.toLowerCase();
2663
+ if (!name) continue;
2664
+ const index = match.index ?? 0;
2665
+ for (let cursor = previousTagIndex; cursor < index; cursor++) if (content.charCodeAt(cursor) === 10) line += 1;
2666
+ previousTagIndex = index;
2667
+ const open = stack.at(-1);
2668
+ if (name === "liquid" && !(open && OPAQUE_BLOCK_TAGS.has(open.name))) {
2669
+ const statements = match[0].replace(/^\{%-?\s*liquid\b/i, "").replace(/-?%\}$/, "").split("\n");
2670
+ for (const [offset, statement] of statements.entries()) {
2671
+ const statementName = /^\s*([a-zA-Z_][\w-]*)\b/.exec(statement)?.[1];
2672
+ if (!statementName) continue;
2673
+ processTag(statementName.toLowerCase(), line + offset);
2674
+ }
2675
+ } else processTag(name, line);
2548
2676
  }
2549
- /**
2550
- * Every path recorded under HEAD's tree, recursively. Callers use
2551
- * this to detect local deletions (paths in HEAD, absent from the
2552
- * working tree). Returns [] when HEAD has never been committed.
2553
- */
2554
- async headPaths() {
2555
- if (!await this.hasHead()) return [];
2556
- const { stdout } = await this.git([
2557
- "ls-tree",
2558
- "-r",
2559
- "-z",
2560
- "HEAD",
2561
- "--name-only"
2562
- ]);
2563
- return stdout.toString("utf8").split("\0").filter((line) => line.length > 0);
2677
+ for (const open of stack.reverse()) diagnostics.push({
2678
+ severity: "error",
2679
+ message: `Unclosed Liquid tag '{% ${open.name} %}' on line ${open.line}; expected '{% ${open.expectedClose} %}'.`
2680
+ });
2681
+ return diagnostics;
2682
+ }
2683
+ //#endregion
2684
+ //#region src/theme/dev-server/port-preflight.ts
2685
+ /**
2686
+ * The dev command does real work before it ever binds a port: it resolves
2687
+ * (or creates) a server-side dev theme and runs a full initial sync, which
2688
+ * can take minutes on a large theme. If the requested port is already taken
2689
+ * — most commonly by another `fluid theme dev` or the Mist Desktop preview,
2690
+ * which both default to 9292 — all of that work is wasted and the process
2691
+ * used to die with a raw `EADDRINUSE` stack trace. Call this before any of
2692
+ * that work starts so we fail fast with a clear message instead.
2693
+ */
2694
+ var PortInUseError = class extends Error {
2695
+ constructor(host, port) {
2696
+ super(formatPortConflictMessage(host, port));
2697
+ this.host = host;
2698
+ this.port = port;
2699
+ this.name = "PortInUseError";
2564
2700
  }
2565
- /**
2566
- * The content of `path` in HEAD's tree, or null when the path does
2567
- * not exist there. Callers use this as the merge base for pull
2568
- * conflict resolution.
2569
- */
2570
- async blobAtHead(path) {
2571
- if (!await this.hasHead()) return null;
2572
- try {
2573
- const { stdout } = await this.git([
2574
- "cat-file",
2575
- "-p",
2576
- `HEAD:${path}`
2577
- ]);
2578
- return stdout;
2579
- } catch {
2580
- return null;
2581
- }
2582
- }
2583
- /**
2584
- * Write `content` as a blob in the shadow repo and return its sha.
2585
- * Used by `commitState` to stage each file's content before the
2586
- * `write-tree` call.
2587
- */
2588
- async writeBlob(content) {
2589
- const buf = typeof content === "string" ? Buffer.from(content) : content;
2590
- const { stdout } = await this.git([
2591
- "hash-object",
2592
- "-w",
2593
- "--stdin"
2594
- ], { input: buf });
2595
- return stdout.toString("utf8").trim();
2596
- }
2597
- /**
2598
- * Commit `files` as HEAD's new tree, threaded onto the current HEAD
2599
- * as the parent. Uses a per-call temp index so a partial run can't
2600
- * corrupt anything reachable from HEAD; the previous commit stays
2601
- * intact until `update-ref` at the end.
2602
- *
2603
- * Returns the new commit sha.
2604
- */
2605
- async commitState(files, message) {
2606
- const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
2607
- try {
2608
- const indexArgs = ["update-index", "--add"];
2609
- for (const { path, sha } of files) indexArgs.push("--cacheinfo", `100644,${sha},${path}`);
2610
- if (files.length > 0) await this.git(indexArgs, { env: { GIT_INDEX_FILE: indexPath } });
2611
- const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
2612
- const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
2613
- const commitArgs = [
2614
- "commit-tree",
2615
- treeSha,
2616
- "-m",
2617
- message
2618
- ];
2619
- if (parent) commitArgs.push("-p", parent);
2620
- const commitSha = (await this.git(commitArgs, { env: {
2621
- GIT_AUTHOR_NAME: "Fluid CLI",
2622
- GIT_AUTHOR_EMAIL: "cli@fluid.app",
2623
- GIT_COMMITTER_NAME: "Fluid CLI",
2624
- GIT_COMMITTER_EMAIL: "cli@fluid.app"
2625
- } })).stdout.toString("utf8").trim();
2626
- await this.git([
2627
- "update-ref",
2628
- "refs/heads/main",
2629
- commitSha
2630
- ]);
2631
- this.headExists = true;
2632
- return commitSha;
2633
- } finally {
2634
- try {
2635
- rmSync(indexPath, { force: true });
2636
- rmSync(indexPath.substring(0, indexPath.length - 6), {
2637
- recursive: true,
2638
- force: true
2639
- });
2640
- } catch {}
2641
- }
2642
- }
2643
- /**
2644
- * Three-way merge of `local` against `remote` with `base` as the
2645
- * common ancestor. Returns the merged bytes and a flag when
2646
- * `git merge-file` reported unresolved conflicts (i.e. the output
2647
- * contains `<<<<<<<` markers for the reader to resolve).
2648
- *
2649
- * `base` is null when HEAD has never seen this path; we merge
2650
- * against an empty base, which is what git itself does for a new
2651
- * file added on both sides.
2652
- *
2653
- * `favor` maps to `git merge-file`'s `--ours` / `--theirs`: instead
2654
- * of emitting `<<<<<<<` markers, conflicting hunks are resolved to
2655
- * the local (`"local"` → `--ours`, local is file1) or remote
2656
- * (`"remote"` → `--theirs`) side. The output then never contains
2657
- * markers, so the result is reported conflict-free even when
2658
- * merge-file's exit code still counts the auto-resolved hunks.
2659
- */
2660
- async merge3(base, local, remote, favor) {
2661
- const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
2662
- const localPath = join(dir, "local");
2663
- const basePath = join(dir, "base");
2664
- const remotePath = join(dir, "remote");
2665
- try {
2666
- writeFileSync(localPath, local);
2667
- writeFileSync(basePath, base ?? Buffer.alloc(0));
2668
- writeFileSync(remotePath, remote);
2669
- try {
2670
- const { stdout } = await this.git([
2671
- "merge-file",
2672
- "-p",
2673
- ...favor === "local" ? ["--ours"] : favor === "remote" ? ["--theirs"] : [],
2674
- "-L",
2675
- "local",
2676
- "-L",
2677
- "base",
2678
- "-L",
2679
- "remote",
2680
- localPath,
2681
- basePath,
2682
- remotePath
2683
- ]);
2684
- return {
2685
- merged: stdout,
2686
- hasConflicts: false
2687
- };
2688
- } catch (err) {
2689
- const e = err;
2690
- const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
2691
- const inConflictRange = typeof e.code === "number" && e.code >= 1 && e.code <= 127;
2692
- if (inConflictRange && favor) return {
2693
- merged,
2694
- hasConflicts: false
2695
- };
2696
- if (inConflictRange && merged.length > 0) return {
2697
- merged,
2698
- hasConflicts: true
2699
- };
2700
- throw err;
2701
- }
2702
- } finally {
2703
- try {
2704
- rmSync(dir, {
2705
- recursive: true,
2706
- force: true
2707
- });
2708
- } catch {}
2709
- }
2710
- }
2711
- /**
2712
- * Snapshot the working-tree copy of `paths` into HEAD as a single
2713
- * commit. Intended for the migration path: on the first pull with
2714
- * the new CLI (no shadow repo yet, but a `.fluid-theme.json` with
2715
- * checksums exists) we seed HEAD with whatever is on disk before
2716
- * running the merge, so unmodified files fast-forward cleanly and
2717
- * modified files show a diff.
2718
- */
2719
- async seedFromWorkingTree(files, message) {
2720
- const entries = [];
2721
- for (const { path, content } of files) entries.push({
2722
- path,
2723
- sha: await this.writeBlob(content)
2724
- });
2725
- await this.commitState(entries, message);
2726
- }
2727
- async git(args, opts = {}) {
2728
- const child = spawn("git", args[0] === "init" ? args : [
2729
- "--git-dir",
2730
- this.gitDir,
2731
- ...args
2732
- ], {
2733
- cwd: opts.cwd ?? this.themeRoot,
2734
- env: {
2735
- ...process.env,
2736
- ...opts.env
2737
- },
2738
- stdio: [
2739
- "pipe",
2740
- "pipe",
2741
- "pipe"
2742
- ]
2743
- });
2744
- if (opts.input) child.stdin.write(opts.input);
2745
- child.stdin.end();
2746
- return new Promise((resolve, reject) => {
2747
- const stdout = [];
2748
- const stderr = [];
2749
- child.stdout.on("data", (chunk) => stdout.push(chunk));
2750
- child.stderr.on("data", (chunk) => stderr.push(chunk));
2751
- child.on("error", reject);
2752
- child.on("close", (code) => {
2753
- const out = Buffer.concat(stdout);
2754
- const err = Buffer.concat(stderr);
2755
- if (code === 0) resolve({
2756
- stdout: out,
2757
- stderr: err
2758
- });
2759
- else {
2760
- const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
2761
- e.code = code ?? -1;
2762
- e.stdout = out;
2763
- e.stderr = err;
2764
- reject(e);
2765
- }
2766
- });
2767
- });
2768
- }
2769
- };
2770
- /**
2771
- * Content-type check the pull command uses to decide whether a file
2772
- * is safe to run through `merge3` (line-based) or must fall back to
2773
- * whole-file "either/or" resolution (binary).
2774
- */
2775
- function looksBinary(content) {
2776
- return content.subarray(0, Math.min(content.length, 8e3)).includes(0);
2777
- }
2778
- /** Best-effort readFile that returns null when the file does not exist. */
2779
- function readIfExists(path) {
2780
- try {
2781
- return readFileSync(path);
2782
- } catch {
2783
- return null;
2784
- }
2785
- }
2786
- /**
2787
- * Parse the numeric theme id from `.fluid-theme/theme-id`. Returns
2788
- * null when the file is missing or the contents don't parse cleanly;
2789
- * `open` treats that as "unknown theme" and rebuilds the shadow.
2790
- */
2791
- function readStoredThemeId(themeIdFile) {
2792
- try {
2793
- const stored = parseInt(readFileSync(themeIdFile, "utf-8").trim(), 10);
2794
- return Number.isFinite(stored) ? stored : null;
2795
- } catch {
2796
- return null;
2797
- }
2798
- }
2799
- /**
2800
- * Append `.fluid-theme/` to the theme root's `.gitignore` when the
2801
- * theme dir sits inside a git working tree and the entry isn't
2802
- * already there. Skipped when the user isn't in a git repo — no
2803
- * point manufacturing a `.gitignore` for someone who doesn't use
2804
- * git. Idempotent — a second call is a no-op.
2805
- */
2806
- async function ensureRootGitignoreHidesShadow(themeRoot) {
2807
- if (!await new Promise((resolve) => {
2808
- const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
2809
- cwd: themeRoot,
2810
- stdio: [
2811
- "ignore",
2812
- "pipe",
2813
- "pipe"
2814
- ]
2815
- });
2816
- child.on("close", (code) => resolve(code === 0));
2817
- child.on("error", () => resolve(false));
2818
- })) return;
2819
- const gitignorePath = join(themeRoot, ".gitignore");
2820
- let existing = "";
2821
- try {
2822
- existing = readFileSync(gitignorePath, "utf-8");
2823
- } catch {
2824
- existing = "";
2825
- }
2826
- const lines = existing.split("\n").map((line) => line.trim());
2827
- if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
2828
- const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
2829
- writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
2830
- }
2831
- //#endregion
2832
- //#region src/theme/merge-push.ts
2833
- /**
2834
- * Compute what changed locally since the last time the shadow repo
2835
- * committed a state. Replaces the sha256 `checksums` map: shadow HEAD
2836
- * is the source of truth for "what the CLI last saw the server have".
2837
- *
2838
- * Files whose local bytes are byte-identical to their HEAD blob are
2839
- * skipped; anything else — new, modified, or a locally-deleted path
2840
- * that HEAD still has — is included.
2841
- */
2842
- async function diffAgainstShadow(themeRoot, shadow) {
2843
- const changed = [];
2844
- const deleted = [];
2845
- const localFiles = themeRoot.files();
2846
- const localByKey = /* @__PURE__ */ new Map();
2847
- const assetManifest = new ThemeAssetManifest(themeRoot.root);
2848
- for (const file of localFiles) {
2849
- if (!file.exists) continue;
2850
- localByKey.set(file.relativePath, file);
2851
- const headBlob = await shadow.blobAtHead(file.relativePath);
2852
- const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
2853
- if (headBlob && headBlob.equals(localBuf)) continue;
2854
- changed.push(file);
2855
- }
2856
- if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
2857
- if (localByKey.has(key)) continue;
2858
- if (themeRoot.ignore.ignore(key)) continue;
2859
- if (assetManifest.has(key)) continue;
2860
- if (isStylesheetKey(key)) continue;
2861
- deleted.push(key);
2862
- }
2863
- return {
2864
- changed,
2865
- deleted
2866
- };
2867
- }
2868
- /**
2869
- * Read every path recorded under HEAD's tree. Used to detect local
2870
- * deletions (paths in HEAD, absent from the working tree). Implemented
2871
- * with `git ls-tree -r HEAD --name-only` piped through the shadow
2872
- * repo's plumbing.
2873
- */
2874
- async function listHeadPaths(shadow) {
2875
- return shadow.headPaths();
2876
- }
2877
- /**
2878
- * Refuse a push when any working file still contains a conflict marker
2879
- * from a previous pull. Mirrors git's "you have unresolved conflicts;
2880
- * fix them and re-run" behavior — the whole point of writing markers
2881
- * on pull was to hand resolution to the user, so we can't send them
2882
- * upstream.
2883
- */
2884
- function findUnresolvedConflicts(files) {
2885
- const flagged = [];
2886
- for (const file of files) {
2887
- if (!file.isText) continue;
2888
- const buf = readIfExists(file.absolutePath);
2889
- if (!buf) continue;
2890
- if (containsConflictMarker(buf)) flagged.push(file.relativePath);
2891
- }
2892
- return flagged;
2893
- }
2894
- /**
2895
- * Stable, machine-readable one-liner for non-interactive callers
2896
- * (Mist Desktop's publish flow parses push output). Uploading marker-
2897
- * bearing files to a live theme is never acceptable, so `--auto-
2898
- * baseline` pushes still refuse — but they emit this line so the
2899
- * desktop can surface WHICH files block the publish instead of a
2900
- * dead-end wall of prose. Format:
2901
- *
2902
- * FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=a.liquid,b.json
2903
- */
2904
- function conflictMarkerBlockLine(files) {
2905
- return `FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=${files.join(",")}`;
2906
- }
2907
- const CONFLICT_START = Buffer.from("<<<<<<<");
2908
- const CONFLICT_MID = Buffer.from("=======");
2909
- const CONFLICT_END = Buffer.from(">>>>>>>");
2910
- /**
2911
- * A file counts as unresolved when it contains all three marker
2912
- * shapes: `<<<<<<<`, `=======`, and `>>>>>>>`. Requiring all three
2913
- * avoids false positives — a line of equals signs alone (e.g. inside
2914
- * an ASCII table in a template comment) doesn't trip the guard.
2915
- */
2916
- function containsConflictMarker(buf) {
2917
- return buf.includes(CONFLICT_START) && buf.includes(CONFLICT_MID) && buf.includes(CONFLICT_END);
2701
+ };
2702
+ function formatPortConflictMessage(host, port) {
2703
+ return `Port ${port} on ${host} is already in use likely another \`fluid theme dev\` or the Mist Desktop preview. Stop the other server or pass --port <number>.`;
2918
2704
  }
2919
2705
  /**
2920
- * Commit the current working-tree state to shadow HEAD after a
2921
- * successful push. Ensures the next pull's merge base is the state
2922
- * we know the server just accepted.
2706
+ * Attempt to bind `host:port`, then immediately release it. Resolves if the
2707
+ * port is free; rejects with `PortInUseError` on `EADDRINUSE`/`EACCES`, or
2708
+ * the raw error for anything else unexpected.
2923
2709
  */
2924
- async function commitPushedState(themeRoot, shadow, message) {
2925
- const entries = [];
2926
- const assetManifest = new ThemeAssetManifest(themeRoot.root);
2927
- const localKeys = /* @__PURE__ */ new Set();
2928
- for (const file of themeRoot.files()) {
2929
- if (!file.exists) continue;
2930
- localKeys.add(file.relativePath);
2931
- const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
2932
- entries.push({
2933
- path: file.relativePath,
2934
- sha: await shadow.writeBlob(buf)
2710
+ function checkPortAvailable(host, port) {
2711
+ return new Promise((resolve, reject) => {
2712
+ const server = net.createServer();
2713
+ server.once("error", (err) => {
2714
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") reject(new PortInUseError(host, port));
2715
+ else reject(err);
2935
2716
  });
2936
- }
2937
- let managedAssetSentinelSha;
2938
- for (const key of assetManifest.keys()) {
2939
- if (localKeys.has(key)) continue;
2940
- managedAssetSentinelSha ??= await shadow.writeBlob(MANAGED_ASSET_SHADOW_SENTINEL);
2941
- entries.push({
2942
- path: key,
2943
- sha: managedAssetSentinelSha
2717
+ server.once("listening", () => {
2718
+ server.close(() => resolve());
2944
2719
  });
2945
- }
2946
- if (entries.length > 0 || await shadow.hasHead()) await shadow.commitState(entries, message);
2947
- }
2948
- /**
2949
- * A target can already have every URL-backed FileResource (for example from a
2950
- * reference clone), while this checkout's manifest still names its old source
2951
- * theme. Adopt the target and clear any legacy binary from shadow even though
2952
- * no remote write was necessary.
2953
- */
2954
- async function finalizeManifestOnlyPush(syncer, themeRoot, shadow) {
2955
- syncer.repointManagedAssetsToCurrentTheme();
2956
- await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
2720
+ server.listen(port, host);
2721
+ });
2957
2722
  }
2958
2723
  //#endregion
2959
- //#region src/theme/liquid-delimiters.ts
2960
- /**
2961
- * Heuristic-only check for obviously unbalanced liquid delimiters
2962
- * (`{% %}` and `{{ }}`). This is NOT a liquid parser — it only tracks
2963
- * opening delimiters until they are closed. It exists to give watch-mode users a signal
2964
- * when a save is liquid-syntax-broken: the server accepts
2965
- * syntax-broken liquid silently on upload, and the storefront
2966
- * renderer then serves stale content for that section with no error
2967
- * anywhere else in the pipeline.
2968
- *
2969
- * Closing-looking tokens without a preceding Liquid opener are intentionally
2970
- * ignored. Liquid files commonly contain CSS such as `width:100%}` or adjacent
2971
- * block braces (`}}`), so treating every close token as Liquid creates noisy
2972
- * false warnings on valid theme files.
2973
- *
2974
- * Known false positive: delimiters written literally inside a
2975
- * `{% raw %}...{% endraw %}` block are still counted and can trip
2976
- * this check even though the liquid is valid. Acceptable for a
2977
- * warn-only heuristic — a real fix requires parsing liquid, which is
2978
- * out of scope here (see the server-side validation note in the PR).
2979
- */
2980
- function hasUnbalancedLiquidDelimiters(content) {
2981
- let unclosedTags = 0;
2982
- let unclosedOutputs = 0;
2983
- for (const token of content.matchAll(/\{%|%\}|\{\{|\}\}/g)) switch (token[0]) {
2984
- case "{%":
2985
- unclosedTags += 1;
2986
- break;
2987
- case "%}":
2988
- if (unclosedTags > 0) unclosedTags -= 1;
2989
- break;
2990
- case "{{":
2991
- unclosedOutputs += 1;
2992
- break;
2993
- case "}}":
2994
- if (unclosedOutputs > 0) unclosedOutputs -= 1;
2995
- break;
2996
- }
2997
- return unclosedTags > 0 || unclosedOutputs > 0;
2724
+ //#region src/theme/dev-server/index.ts
2725
+ function timestamp() {
2726
+ return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
2998
2727
  }
2999
- const BLOCK_TAGS = new Map([
3000
- ["capture", "endcapture"],
3001
- ["case", "endcase"],
3002
- ["comment", "endcomment"],
3003
- ["for", "endfor"],
3004
- ["form", "endform"],
3005
- ["if", "endif"],
3006
- ["ifchanged", "endifchanged"],
3007
- ["javascript", "endjavascript"],
3008
- ["paginate", "endpaginate"],
3009
- ["raw", "endraw"],
3010
- ["schema", "endschema"],
3011
- ["style", "endstyle"],
3012
- ["stylesheet", "endstylesheet"],
3013
- ["tablerow", "endtablerow"],
3014
- ["unless", "endunless"]
3015
- ]);
3016
- const CLOSING_TAGS = new Set(BLOCK_TAGS.values());
3017
- const OPAQUE_BLOCK_TAGS = new Set([
3018
- "comment",
3019
- "javascript",
3020
- "raw",
3021
- "schema",
3022
- "style",
3023
- "stylesheet"
3024
- ]);
3025
- /**
3026
- * Find structurally unbalanced Liquid block tags such as an `{% if %}` with
3027
- * no `{% endif %}`. This intentionally recognizes only established paired
3028
- * tags; custom and inline tags are ignored rather than guessed at.
3029
- *
3030
- * Content inside raw/comment/schema/style/javascript blocks is opaque to
3031
- * Liquid and therefore skipped until that block's matching close tag. This
3032
- * prevents CSS, JSON, and examples containing Liquid-looking text from
3033
- * producing false errors.
3034
- */
3035
- function findLiquidBlockTagDiagnostics(content) {
3036
- const stack = [];
3037
- const diagnostics = [];
3038
- let line = 1;
3039
- let previousTagIndex = 0;
3040
- const processTag = (name, tagLine) => {
3041
- const open = stack.at(-1);
3042
- if (open && OPAQUE_BLOCK_TAGS.has(open.name)) {
3043
- if (name === open.expectedClose) stack.pop();
3044
- return;
2728
+ async function startDevServer(api, theme, themeRoot, opts, onReady) {
2729
+ const sse = new SSEStream();
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
+ }
3045
2748
  }
3046
- const expectedClose = BLOCK_TAGS.get(name);
3047
- if (expectedClose) {
3048
- stack.push({
3049
- name,
3050
- expectedClose,
3051
- line: tagLine
3052
- });
2749
+ };
2750
+ const pendingUpdates = /* @__PURE__ */ new Set();
2751
+ console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
2752
+ const progress = (done, total) => {
2753
+ process.stdout.write(`\r Uploading ${done}/${total} files…`);
2754
+ };
2755
+ const uploadFromRemoteIndex = () => syncer.uploadTheme({
2756
+ delete: true,
2757
+ validate: opts.validate,
2758
+ linkManagedAssets: { replace: true },
2759
+ pendingBinaryAssets: true,
2760
+ onProgress: progress
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();
2783
+ process.stdout.write("\n");
2784
+ if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
2785
+ if (syncResult.validationFailed) {
2786
+ console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
2787
+ for (const e of syncResult.errors) console.error(` ${e}`);
2788
+ process.exit(1);
2789
+ } else if (syncResult.errors.length > 0) {
2790
+ invalidateRemoteState();
2791
+ for (const e of syncResult.errors) console.error(` ${e}`);
2792
+ if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
2793
+ }
2794
+ if (syncResult.errors.length === 0) recordRemoteState();
2795
+ const SYNC_IDLE_MS = 2e3;
2796
+ let lastArrivedAt = 0;
2797
+ let pendingSync = null;
2798
+ let syncInFlight = Promise.resolve();
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
+ };
2807
+ const sendSync = () => {
2808
+ syncInFlight = syncInFlight.then(async () => {
2809
+ const accepted = await syncer.requestSync();
2810
+ askOwed = !accepted;
2811
+ if (accepted) recordRemoteState();
2812
+ });
2813
+ };
2814
+ const flushSyncNow = () => {
2815
+ if (!pendingSync) return;
2816
+ clearTimeout(pendingSync);
2817
+ pendingSync = null;
2818
+ sendSync();
2819
+ };
2820
+ const scheduleSync = () => {
2821
+ if (pendingSync) clearTimeout(pendingSync);
2822
+ pendingSync = setTimeout(() => {
2823
+ pendingSync = null;
2824
+ sendSync();
2825
+ }, SYNC_IDLE_MS);
2826
+ };
2827
+ const stopWatcher = watchTheme(themeRoot, async (modified, added, removed, arrivedAt) => {
2828
+ if (arrivedAt - lastArrivedAt > SYNC_IDLE_MS) flushSyncNow();
2829
+ else if (pendingSync) {
2830
+ clearTimeout(pendingSync);
2831
+ pendingSync = null;
2832
+ }
2833
+ lastArrivedAt = arrivedAt;
2834
+ await syncInFlight;
2835
+ if (askOwed) {
2836
+ sendSync();
2837
+ await syncInFlight;
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)}`);
3053
2844
  return;
3054
2845
  }
3055
- if (!CLOSING_TAGS.has(name)) return;
3056
- if (!open) {
3057
- diagnostics.push({
3058
- severity: "error",
3059
- message: `Unexpected Liquid tag '{% ${name} %}' on line ${tagLine}; there is no open block to close.`
3060
- });
2846
+ const changed = [...modified, ...added];
2847
+ let wroteRemote = false;
2848
+ for (const file of changed) {
2849
+ if (opts.validate && file.isLiquid) {
2850
+ const diagnostics = file.validateSchema();
2851
+ for (const d of diagnostics) {
2852
+ const prefix = d.severity === "error" ? "Schema error" : "Schema warning";
2853
+ console.warn(`\n[${prefix}] ${file.relativePath}: ${d.message}`);
2854
+ }
2855
+ }
2856
+ pendingUpdates.add(file.relativePath);
2857
+ try {
2858
+ const uploadedContent = await syncer.uploadFile(file, syncer.remoteSha(), { pendingAsset: true });
2859
+ wroteRemote = true;
2860
+ console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
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`);
2862
+ if (file.isLiquid && uploadedContent !== null) for (const diagnostic of findLiquidBlockTagDiagnostics(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: ${diagnostic.message}`);
2863
+ } catch (e) {
2864
+ if (e instanceof PushConflictError) {
2865
+ blockRemoteWrites(e);
2866
+ break;
2867
+ }
2868
+ invalidateRemoteState();
2869
+ console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
2870
+ } finally {
2871
+ pendingUpdates.delete(file.relativePath);
2872
+ }
2873
+ }
2874
+ if (remoteWritesBlocked) return;
2875
+ for (const file of removed) {
2876
+ if (themeRoot.ignore.ignore(file.relativePath)) continue;
2877
+ try {
2878
+ await syncer.deleteRemoteFile(file.relativePath, syncer.remoteSha());
2879
+ wroteRemote = true;
2880
+ console.log(` ✓ removed ${file.relativePath}`);
2881
+ } catch (error) {
2882
+ if (error instanceof PushConflictError) {
2883
+ blockRemoteWrites(error);
2884
+ break;
2885
+ }
2886
+ invalidateRemoteState();
2887
+ }
2888
+ }
2889
+ if (remoteWritesBlocked) return;
2890
+ if (wroteRemote) recordRemoteState();
2891
+ if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
2892
+ else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
2893
+ scheduleSync();
2894
+ });
2895
+ const server = http.createServer(async (req, res) => {
2896
+ if (req.url === "/hot-reload") {
2897
+ sse.add(res);
3061
2898
  return;
3062
2899
  }
3063
- if (name !== open.expectedClose) {
3064
- diagnostics.push({
3065
- severity: "error",
3066
- message: `Mismatched Liquid tag '{% ${name} %}' on line ${tagLine}; '{% ${open.name} %}' from line ${open.line} must close with '{% ${open.expectedClose} %}'.`
2900
+ try {
2901
+ await proxyRequest(req, res, {
2902
+ company: theme.company,
2903
+ themeId: theme.id,
2904
+ reloadMode: opts.reloadMode,
2905
+ pendingFiles: () => [...pendingUpdates].map((p) => themeRoot.file(p)).filter((f) => f.isText).map((f) => ({
2906
+ relativePath: f.relativePath,
2907
+ read: () => f.read()
2908
+ }))
3067
2909
  });
3068
- return;
2910
+ } catch (e) {
2911
+ console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
2912
+ if (!res.headersSent) {
2913
+ const message = e instanceof Error ? e.message : String(e);
2914
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
2915
+ res.end(`Bad Gateway — the local preview could not reach ${theme.company}.fluid.app: ${message}\nThis is the dev machine's network path to Fluid, not the theme. Common causes: TLS-inspecting security software (its root CA is in the OS keychain, which Node does not read — set NODE_EXTRA_CA_CERTS to its certificate), DNS, or a proxy. The same error is logged by the theme dev server process.`);
2916
+ }
3069
2917
  }
3070
- stack.pop();
3071
- };
3072
- for (const match of content.matchAll(/\{%-?\s*([a-zA-Z_][\w-]*)\b(?:(?!\{%)[\s\S])*?-?%\}/g)) {
3073
- const name = match[1]?.toLowerCase();
3074
- if (!name) continue;
3075
- const index = match.index ?? 0;
3076
- for (let cursor = previousTagIndex; cursor < index; cursor++) if (content.charCodeAt(cursor) === 10) line += 1;
3077
- previousTagIndex = index;
3078
- const open = stack.at(-1);
3079
- if (name === "liquid" && !(open && OPAQUE_BLOCK_TAGS.has(open.name))) {
3080
- const statements = match[0].replace(/^\{%-?\s*liquid\b/i, "").replace(/-?%\}$/, "").split("\n");
3081
- for (const [offset, statement] of statements.entries()) {
3082
- const statementName = /^\s*([a-zA-Z_][\w-]*)\b/.exec(statement)?.[1];
3083
- if (!statementName) continue;
3084
- processTag(statementName.toLowerCase(), line + offset);
2918
+ });
2919
+ await new Promise((resolve, reject) => {
2920
+ server.once("error", (err) => {
2921
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") {
2922
+ console.error(formatPortConflictMessage(opts.host, opts.port));
2923
+ process.exit(1);
3085
2924
  }
3086
- } else processTag(name, line);
3087
- }
3088
- for (const open of stack.reverse()) diagnostics.push({
3089
- severity: "error",
3090
- message: `Unclosed Liquid tag '{% ${open.name} %}' on line ${open.line}; expected '{% ${open.expectedClose} %}'.`
2925
+ reject(err);
2926
+ });
2927
+ server.listen(opts.port, opts.host, () => resolve());
3091
2928
  });
3092
- return diagnostics;
2929
+ const address = `http://${opts.host}:${opts.port}`;
2930
+ onReady?.(address);
2931
+ return function stop() {
2932
+ sse.close();
2933
+ stopWatcher();
2934
+ server.close();
2935
+ };
3093
2936
  }
3094
2937
  //#endregion
3095
- //#region src/theme/dev-server/port-preflight.ts
3096
- /**
3097
- * The dev command does real work before it ever binds a port: it resolves
3098
- * (or creates) a server-side dev theme and runs a full initial sync, which
3099
- * can take minutes on a large theme. If the requested port is already taken
3100
- * most commonly by another `fluid theme dev` or the Mist Desktop preview,
3101
- * which both default to 9292 — all of that work is wasted and the process
3102
- * used to die with a raw `EADDRINUSE` stack trace. Call this before any of
3103
- * that work starts so we fail fast with a clear message instead.
3104
- */
3105
- var PortInUseError = class extends Error {
3106
- constructor(host, port) {
3107
- super(formatPortConflictMessage(host, port));
3108
- this.host = host;
3109
- this.port = port;
3110
- this.name = "PortInUseError";
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;
3111
2949
  }
3112
- };
3113
- function formatPortConflictMessage(host, port) {
3114
- return `Port ${port} on ${host} is already in use — likely another \`fluid theme dev\` or the Mist Desktop preview. Stop the other server or pass --port <number>.`;
3115
2950
  }
3116
- /**
3117
- * Attempt to bind `host:port`, then immediately release it. Resolves if the
3118
- * port is free; rejects with `PortInUseError` on `EADDRINUSE`/`EACCES`, or
3119
- * the raw error for anything else unexpected.
3120
- */
3121
- function checkPortAvailable(host, port) {
3122
- return new Promise((resolve, reject) => {
3123
- const server = net.createServer();
3124
- server.once("error", (err) => {
3125
- if (err.code === "EADDRINUSE" || err.code === "EACCES") reject(new PortInUseError(host, port));
3126
- else reject(err);
3127
- });
3128
- server.once("listening", () => {
3129
- server.close(() => resolve());
2951
+ function writeDevRemoteBaseline(themeRoot, state) {
2952
+ const document = {
2953
+ version: BASELINE_VERSION,
2954
+ ...state
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
3130
2964
  });
3131
- server.listen(port, host);
3132
- });
2965
+ renameSync(tempPath, path);
2966
+ } catch (error) {
2967
+ rmSync(tempPath, { force: true });
2968
+ throw error;
2969
+ }
3133
2970
  }
3134
- //#endregion
3135
- //#region src/theme/dev-server/index.ts
3136
- function timestamp() {
3137
- return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
2971
+ function removeDevRemoteBaseline(themeRoot) {
2972
+ rmSync(join(themeRoot, BASELINE_FILE), { force: true });
3138
2973
  }
3139
- async function startDevServer(api, theme, themeRoot, opts, onReady) {
3140
- const sse = new SSEStream();
3141
- const syncer = new Syncer(api, theme.id, themeRoot);
3142
- const recordRemoteSha = () => {
3143
- const remoteSha = syncer.remoteSha();
3144
- if (remoteSha) opts.onRemoteSha?.(remoteSha);
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;
2993
+ }
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
+ });
3000
+ }
3001
+ return {
3002
+ themeId,
3003
+ remoteSha,
3004
+ assetManifestSha: assetManifest.fingerprint(),
3005
+ resources
3006
+ };
3007
+ } catch {
3008
+ return null;
3009
+ }
3010
+ }
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
3145
3028
  };
3146
- const pendingUpdates = /* @__PURE__ */ new Set();
3147
- console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
3148
- const progress = (done, total) => {
3149
- process.stdout.write(`\r Uploading ${done}/${total} files…`);
3029
+ }
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
3150
3051
  };
3151
- const uploadFromRemoteIndex = () => syncer.uploadTheme({
3152
- delete: true,
3153
- validate: opts.validate,
3154
- linkManagedAssets: { replace: true },
3155
- pendingBinaryAssets: true,
3156
- onProgress: progress
3157
- });
3158
- let syncResult;
3159
- if (opts.initialSync) {
3160
- let shadowIsTrusted = false;
3052
+ }
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;
3061
+ }
3062
+ //#endregion
3063
+ //#region src/theme/shadow-repo.ts
3064
+ /**
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.
3072
+ *
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.
3081
+ */
3082
+ var ShadowRepo = class ShadowRepo {
3083
+ headExists = void 0;
3084
+ constructor(themeRoot, gitDir) {
3085
+ this.themeRoot = themeRoot;
3086
+ this.gitDir = gitDir;
3087
+ }
3088
+ /**
3089
+ * Return a ShadowRepo bound to `themeId` for the given theme root.
3090
+ * The shadow is initialized on first use and re-initialized when
3091
+ * the caller passes a themeId different from the one previously
3092
+ * recorded — the merge base is only meaningful for the theme it
3093
+ * was captured against, so a cross-theme operation (pull A → push
3094
+ * B, or two pulls of different themes into one dir) starts from a
3095
+ * clean slate rather than pretending B's state matches A's HEAD.
3096
+ */
3097
+ static async open(themeRoot, themeId) {
3098
+ const shadowDir = join(themeRoot, ".fluid-theme");
3099
+ const gitDir = join(shadowDir, "repo");
3100
+ const themeIdFile = join(shadowDir, "theme-id");
3101
+ if (existsSync(gitDir)) {
3102
+ if (readStoredThemeId(themeIdFile) !== themeId) rmSync(gitDir, {
3103
+ recursive: true,
3104
+ force: true
3105
+ });
3106
+ }
3107
+ const repo = new ShadowRepo(themeRoot, gitDir);
3108
+ if (!existsSync(gitDir)) {
3109
+ mkdirSync(shadowDir, { recursive: true });
3110
+ await repo.git([
3111
+ "init",
3112
+ "--bare",
3113
+ "-b",
3114
+ "main",
3115
+ gitDir
3116
+ ], { cwd: themeRoot });
3117
+ }
3118
+ writeFileSync(themeIdFile, `${themeId}\n`, "utf-8");
3119
+ const shadowIgnore = join(shadowDir, ".gitignore");
3120
+ if (!existsSync(shadowIgnore)) writeFileSync(shadowIgnore, "# Fluid CLI shadow repo — internal state, not for version control.\n*\n");
3121
+ await ensureRootGitignoreHidesShadow(themeRoot);
3122
+ return repo;
3123
+ }
3124
+ /** True when the repo has at least one commit on `refs/heads/main`. */
3125
+ async hasHead() {
3126
+ if (this.headExists !== void 0) return this.headExists;
3127
+ try {
3128
+ await this.git([
3129
+ "rev-parse",
3130
+ "--verify",
3131
+ "HEAD"
3132
+ ]);
3133
+ this.headExists = true;
3134
+ } catch {
3135
+ this.headExists = false;
3136
+ }
3137
+ return this.headExists;
3138
+ }
3139
+ /**
3140
+ * Every path recorded under HEAD's tree, recursively. Callers use
3141
+ * this to detect local deletions (paths in HEAD, absent from the
3142
+ * working tree). Returns [] when HEAD has never been committed.
3143
+ */
3144
+ async headPaths() {
3145
+ if (!await this.hasHead()) return [];
3146
+ const { stdout } = await this.git([
3147
+ "ls-tree",
3148
+ "-r",
3149
+ "-z",
3150
+ "HEAD",
3151
+ "--name-only"
3152
+ ]);
3153
+ return stdout.toString("utf8").split("\0").filter((line) => line.length > 0);
3154
+ }
3155
+ /**
3156
+ * The content of `path` in HEAD's tree, or null when the path does
3157
+ * not exist there. Callers use this as the merge base for pull
3158
+ * conflict resolution.
3159
+ */
3160
+ async blobAtHead(path) {
3161
+ if (!await this.hasHead()) return null;
3161
3162
  try {
3162
- await syncer.preflightPush(opts.initialSync.remoteSha);
3163
- shadowIsTrusted = true;
3164
- } catch (error) {
3165
- if (!(error instanceof PushConflictError)) throw error;
3163
+ const { stdout } = await this.git([
3164
+ "cat-file",
3165
+ "-p",
3166
+ `HEAD:${path}`
3167
+ ]);
3168
+ return stdout;
3169
+ } catch {
3170
+ return null;
3166
3171
  }
3167
- if (shadowIsTrusted) {
3168
- const diff = await diffAgainstShadow(themeRoot, opts.initialSync.shadow);
3169
- syncResult = await syncer.uploadTheme({
3170
- delete: true,
3171
- validate: opts.validate,
3172
- pendingBinaryAssets: true,
3173
- baseSha: syncer.remoteSha(),
3174
- diff,
3175
- skipPreflight: true,
3176
- onProgress: progress
3177
- });
3178
- } else syncResult = await uploadFromRemoteIndex();
3179
- } else syncResult = await uploadFromRemoteIndex();
3180
- process.stdout.write("\n");
3181
- if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
3182
- if (syncResult.validationFailed) {
3183
- console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
3184
- for (const e of syncResult.errors) console.error(` ${e}`);
3185
- process.exit(1);
3186
- } else if (syncResult.errors.length > 0) {
3187
- for (const e of syncResult.errors) console.error(` ${e}`);
3188
- if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
3189
3172
  }
3190
- if (syncResult.errors.length === 0) recordRemoteSha();
3191
- const SYNC_IDLE_MS = 2e3;
3192
- let lastArrivedAt = 0;
3193
- let pendingSync = null;
3194
- let syncInFlight = Promise.resolve();
3195
- let askOwed = false;
3196
- const sendSync = () => {
3197
- syncInFlight = syncInFlight.then(async () => {
3198
- askOwed = !await syncer.requestSync();
3199
- });
3200
- };
3201
- const flushSyncNow = () => {
3202
- if (!pendingSync) return;
3203
- clearTimeout(pendingSync);
3204
- pendingSync = null;
3205
- sendSync();
3206
- };
3207
- const scheduleSync = () => {
3208
- if (pendingSync) clearTimeout(pendingSync);
3209
- pendingSync = setTimeout(() => {
3210
- pendingSync = null;
3211
- sendSync();
3212
- }, SYNC_IDLE_MS);
3213
- };
3214
- const stopWatcher = watchTheme(themeRoot, async (modified, added, removed, arrivedAt) => {
3215
- if (arrivedAt - lastArrivedAt > SYNC_IDLE_MS) flushSyncNow();
3216
- else if (pendingSync) {
3217
- clearTimeout(pendingSync);
3218
- pendingSync = null;
3219
- }
3220
- lastArrivedAt = arrivedAt;
3221
- await syncInFlight;
3222
- if (askOwed) {
3223
- sendSync();
3224
- await syncInFlight;
3173
+ /**
3174
+ * Write `content` as a blob in the shadow repo and return its sha.
3175
+ * Used by `commitState` to stage each file's content before the
3176
+ * `write-tree` call.
3177
+ */
3178
+ async writeBlob(content) {
3179
+ const buf = typeof content === "string" ? Buffer.from(content) : content;
3180
+ const { stdout } = await this.git([
3181
+ "hash-object",
3182
+ "-w",
3183
+ "--stdin"
3184
+ ], { input: buf });
3185
+ return stdout.toString("utf8").trim();
3186
+ }
3187
+ /**
3188
+ * Commit `files` as HEAD's new tree, threaded onto the current HEAD
3189
+ * as the parent. Uses a per-call temp index so a partial run can't
3190
+ * corrupt anything reachable from HEAD; the previous commit stays
3191
+ * intact until `update-ref` at the end.
3192
+ *
3193
+ * Returns the new commit sha.
3194
+ */
3195
+ async commitState(files, message) {
3196
+ const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
3197
+ try {
3198
+ const indexArgs = ["update-index", "--add"];
3199
+ for (const { path, sha } of files) indexArgs.push("--cacheinfo", `100644,${sha},${path}`);
3200
+ if (files.length > 0) await this.git(indexArgs, { env: { GIT_INDEX_FILE: indexPath } });
3201
+ const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
3202
+ const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
3203
+ const commitArgs = [
3204
+ "commit-tree",
3205
+ treeSha,
3206
+ "-m",
3207
+ message
3208
+ ];
3209
+ if (parent) commitArgs.push("-p", parent);
3210
+ const commitSha = (await this.git(commitArgs, { env: {
3211
+ GIT_AUTHOR_NAME: "Fluid CLI",
3212
+ GIT_AUTHOR_EMAIL: "cli@fluid.app",
3213
+ GIT_COMMITTER_NAME: "Fluid CLI",
3214
+ GIT_COMMITTER_EMAIL: "cli@fluid.app"
3215
+ } })).stdout.toString("utf8").trim();
3216
+ await this.git([
3217
+ "update-ref",
3218
+ "refs/heads/main",
3219
+ commitSha
3220
+ ]);
3221
+ this.headExists = true;
3222
+ return commitSha;
3223
+ } finally {
3224
+ try {
3225
+ rmSync(indexPath, { force: true });
3226
+ rmSync(indexPath.substring(0, indexPath.length - 6), {
3227
+ recursive: true,
3228
+ force: true
3229
+ });
3230
+ } catch {}
3225
3231
  }
3226
- const changed = [...modified, ...added];
3227
- let wroteRemote = false;
3228
- for (const file of changed) {
3229
- if (opts.validate && file.isLiquid) {
3230
- const diagnostics = file.validateSchema();
3231
- for (const d of diagnostics) {
3232
- const prefix = d.severity === "error" ? "Schema error" : "Schema warning";
3233
- console.warn(`\n[${prefix}] ${file.relativePath}: ${d.message}`);
3234
- }
3235
- }
3236
- pendingUpdates.add(file.relativePath);
3232
+ }
3233
+ /**
3234
+ * Three-way merge of `local` against `remote` with `base` as the
3235
+ * common ancestor. Returns the merged bytes and a flag when
3236
+ * `git merge-file` reported unresolved conflicts (i.e. the output
3237
+ * contains `<<<<<<<` markers for the reader to resolve).
3238
+ *
3239
+ * `base` is null when HEAD has never seen this path; we merge
3240
+ * against an empty base, which is what git itself does for a new
3241
+ * file added on both sides.
3242
+ *
3243
+ * `favor` maps to `git merge-file`'s `--ours` / `--theirs`: instead
3244
+ * of emitting `<<<<<<<` markers, conflicting hunks are resolved to
3245
+ * the local (`"local"` → `--ours`, local is file1) or remote
3246
+ * (`"remote"` → `--theirs`) side. The output then never contains
3247
+ * markers, so the result is reported conflict-free even when
3248
+ * merge-file's exit code still counts the auto-resolved hunks.
3249
+ */
3250
+ async merge3(base, local, remote, favor) {
3251
+ const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
3252
+ const localPath = join(dir, "local");
3253
+ const basePath = join(dir, "base");
3254
+ const remotePath = join(dir, "remote");
3255
+ try {
3256
+ writeFileSync(localPath, local);
3257
+ writeFileSync(basePath, base ?? Buffer.alloc(0));
3258
+ writeFileSync(remotePath, remote);
3237
3259
  try {
3238
- const uploadedContent = await syncer.uploadFile(file, void 0, { pendingAsset: true });
3239
- wroteRemote = true;
3240
- console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
3241
- if (file.isLiquid && uploadedContent !== null && hasUnbalancedLiquidDelimiters(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: unbalanced liquid delimiters the storefront may silently serve stale content for this section`);
3242
- if (file.isLiquid && uploadedContent !== null) for (const diagnostic of findLiquidBlockTagDiagnostics(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: ${diagnostic.message}`);
3243
- } catch (e) {
3244
- console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
3245
- } finally {
3246
- pendingUpdates.delete(file.relativePath);
3260
+ const { stdout } = await this.git([
3261
+ "merge-file",
3262
+ "-p",
3263
+ ...favor === "local" ? ["--ours"] : favor === "remote" ? ["--theirs"] : [],
3264
+ "-L",
3265
+ "local",
3266
+ "-L",
3267
+ "base",
3268
+ "-L",
3269
+ "remote",
3270
+ localPath,
3271
+ basePath,
3272
+ remotePath
3273
+ ]);
3274
+ return {
3275
+ merged: stdout,
3276
+ hasConflicts: false
3277
+ };
3278
+ } catch (err) {
3279
+ const e = err;
3280
+ const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
3281
+ const inConflictRange = typeof e.code === "number" && e.code >= 1 && e.code <= 127;
3282
+ if (inConflictRange && favor) return {
3283
+ merged,
3284
+ hasConflicts: false
3285
+ };
3286
+ if (inConflictRange && merged.length > 0) return {
3287
+ merged,
3288
+ hasConflicts: true
3289
+ };
3290
+ throw err;
3247
3291
  }
3248
- }
3249
- for (const file of removed) {
3250
- if (themeRoot.ignore.ignore(file.relativePath)) continue;
3292
+ } finally {
3251
3293
  try {
3252
- await syncer.deleteRemoteFile(file.relativePath);
3253
- wroteRemote = true;
3254
- console.log(` ✓ removed ${file.relativePath}`);
3294
+ rmSync(dir, {
3295
+ recursive: true,
3296
+ force: true
3297
+ });
3255
3298
  } catch {}
3256
3299
  }
3257
- if (wroteRemote) recordRemoteSha();
3258
- if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
3259
- else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
3260
- scheduleSync();
3261
- });
3262
- const server = http.createServer(async (req, res) => {
3263
- if (req.url === "/hot-reload") {
3264
- sse.add(res);
3265
- return;
3266
- }
3267
- try {
3268
- await proxyRequest(req, res, {
3269
- company: theme.company,
3270
- themeId: theme.id,
3271
- reloadMode: opts.reloadMode,
3272
- pendingFiles: () => [...pendingUpdates].map((p) => themeRoot.file(p)).filter((f) => f.isText).map((f) => ({
3273
- relativePath: f.relativePath,
3274
- read: () => f.read()
3275
- }))
3300
+ }
3301
+ /**
3302
+ * Snapshot the working-tree copy of `paths` into HEAD as a single
3303
+ * commit. Intended for the migration path: on the first pull with
3304
+ * the new CLI (no shadow repo yet, but a `.fluid-theme.json` with
3305
+ * checksums exists) we seed HEAD with whatever is on disk before
3306
+ * running the merge, so unmodified files fast-forward cleanly and
3307
+ * modified files show a diff.
3308
+ */
3309
+ async seedFromWorkingTree(files, message) {
3310
+ const entries = [];
3311
+ for (const { path, content } of files) entries.push({
3312
+ path,
3313
+ sha: await this.writeBlob(content)
3314
+ });
3315
+ await this.commitState(entries, message);
3316
+ }
3317
+ async git(args, opts = {}) {
3318
+ const child = spawn("git", args[0] === "init" ? args : [
3319
+ "--git-dir",
3320
+ this.gitDir,
3321
+ ...args
3322
+ ], {
3323
+ cwd: opts.cwd ?? this.themeRoot,
3324
+ env: {
3325
+ ...process.env,
3326
+ ...opts.env
3327
+ },
3328
+ stdio: [
3329
+ "pipe",
3330
+ "pipe",
3331
+ "pipe"
3332
+ ]
3333
+ });
3334
+ if (opts.input) child.stdin.write(opts.input);
3335
+ child.stdin.end();
3336
+ return new Promise((resolve, reject) => {
3337
+ const stdout = [];
3338
+ const stderr = [];
3339
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
3340
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
3341
+ child.on("error", reject);
3342
+ child.on("close", (code) => {
3343
+ const out = Buffer.concat(stdout);
3344
+ const err = Buffer.concat(stderr);
3345
+ if (code === 0) resolve({
3346
+ stdout: out,
3347
+ stderr: err
3348
+ });
3349
+ else {
3350
+ const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
3351
+ e.code = code ?? -1;
3352
+ e.stdout = out;
3353
+ e.stderr = err;
3354
+ reject(e);
3355
+ }
3276
3356
  });
3277
- } catch (e) {
3278
- console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
3279
- if (!res.headersSent) {
3280
- const message = e instanceof Error ? e.message : String(e);
3281
- res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
3282
- res.end(`Bad Gateway — the local preview could not reach ${theme.company}.fluid.app: ${message}\nThis is the dev machine's network path to Fluid, not the theme. Common causes: TLS-inspecting security software (its root CA is in the OS keychain, which Node does not read — set NODE_EXTRA_CA_CERTS to its certificate), DNS, or a proxy. The same error is logged by the theme dev server process.`);
3283
- }
3284
- }
3285
- });
3286
- await new Promise((resolve, reject) => {
3287
- server.once("error", (err) => {
3288
- if (err.code === "EADDRINUSE" || err.code === "EACCES") {
3289
- console.error(formatPortConflictMessage(opts.host, opts.port));
3290
- process.exit(1);
3291
- }
3292
- reject(err);
3293
3357
  });
3294
- server.listen(opts.port, opts.host, () => resolve());
3295
- });
3296
- const address = `http://${opts.host}:${opts.port}`;
3297
- onReady?.(address);
3298
- return function stop() {
3299
- sse.close();
3300
- stopWatcher();
3301
- server.close();
3302
- };
3358
+ }
3359
+ };
3360
+ /**
3361
+ * Content-type check the pull command uses to decide whether a file
3362
+ * is safe to run through `merge3` (line-based) or must fall back to
3363
+ * whole-file "either/or" resolution (binary).
3364
+ */
3365
+ function looksBinary(content) {
3366
+ return content.subarray(0, Math.min(content.length, 8e3)).includes(0);
3367
+ }
3368
+ /** Best-effort readFile that returns null when the file does not exist. */
3369
+ function readIfExists(path) {
3370
+ try {
3371
+ return readFileSync(path);
3372
+ } catch {
3373
+ return null;
3374
+ }
3375
+ }
3376
+ /**
3377
+ * Parse the numeric theme id from `.fluid-theme/theme-id`. Returns
3378
+ * null when the file is missing or the contents don't parse cleanly;
3379
+ * `open` treats that as "unknown theme" and rebuilds the shadow.
3380
+ */
3381
+ function readStoredThemeId(themeIdFile) {
3382
+ try {
3383
+ const stored = parseInt(readFileSync(themeIdFile, "utf-8").trim(), 10);
3384
+ return Number.isFinite(stored) ? stored : null;
3385
+ } catch {
3386
+ return null;
3387
+ }
3388
+ }
3389
+ /**
3390
+ * Append `.fluid-theme/` to the theme root's `.gitignore` when the
3391
+ * theme dir sits inside a git working tree and the entry isn't
3392
+ * already there. Skipped when the user isn't in a git repo — no
3393
+ * point manufacturing a `.gitignore` for someone who doesn't use
3394
+ * git. Idempotent — a second call is a no-op.
3395
+ */
3396
+ async function ensureRootGitignoreHidesShadow(themeRoot) {
3397
+ if (!await new Promise((resolve) => {
3398
+ const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
3399
+ cwd: themeRoot,
3400
+ stdio: [
3401
+ "ignore",
3402
+ "pipe",
3403
+ "pipe"
3404
+ ]
3405
+ });
3406
+ child.on("close", (code) => resolve(code === 0));
3407
+ child.on("error", () => resolve(false));
3408
+ })) return;
3409
+ const gitignorePath = join(themeRoot, ".gitignore");
3410
+ let existing = "";
3411
+ try {
3412
+ existing = readFileSync(gitignorePath, "utf-8");
3413
+ } catch {
3414
+ existing = "";
3415
+ }
3416
+ const lines = existing.split("\n").map((line) => line.trim());
3417
+ if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
3418
+ const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
3419
+ writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
3303
3420
  }
3304
3421
  //#endregion
3305
3422
  //#region src/theme-picker.ts
@@ -3455,22 +3572,9 @@ function resolveThemeRootFromCwd(workspace) {
3455
3572
  }
3456
3573
  //#endregion
3457
3574
  //#region src/commands/dev.ts
3458
- /**
3459
- * Return the state needed to trust the source shadow for a dev sync.
3460
- *
3461
- * All three values must still describe the same world: the checkout has not
3462
- * pulled a newer source, the dev theme has not changed outside this CLI, and
3463
- * managed ImageKit references have not changed outside the shadow's file tree.
3464
- */
3465
- function devShadowTrust(config, devTheme, assetManifestSha) {
3466
- if (!config?.baseSha || !devTheme?.remoteSha || !devTheme.sourceBaseSha || !devTheme.assetManifestSha) return null;
3467
- if (devTheme.sourceThemeId !== config.themeId) return null;
3468
- if (devTheme.sourceBaseSha !== config.baseSha) return null;
3469
- if (devTheme.assetManifestSha !== assetManifestSha) return null;
3470
- return {
3471
- sourceThemeId: config.themeId,
3472
- remoteSha: devTheme.remoteSha
3473
- };
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";
3474
3578
  }
3475
3579
  /**
3476
3580
  * Create the isolated theme used by `theme dev`.
@@ -3482,21 +3586,30 @@ function devShadowTrust(config, devTheme, assetManifestSha) {
3482
3586
  */
3483
3587
  async function createDevelopmentTheme(api, sourceThemeId, name) {
3484
3588
  if (sourceThemeId !== void 0) try {
3485
- return (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme;
3589
+ return {
3590
+ theme: (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme,
3591
+ referenceCloned: true
3592
+ };
3486
3593
  } catch (error) {
3487
3594
  if (!isApiError(error) || error.status !== 404 && error.status !== 405) throw error;
3488
3595
  console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
3489
3596
  }
3490
- return (await createApplicationTheme(api, { application_theme: {
3491
- name,
3492
- status: "development"
3493
- } })).application_theme;
3597
+ return {
3598
+ theme: (await createApplicationTheme(api, { application_theme: {
3599
+ name,
3600
+ status: "development"
3601
+ } })).application_theme,
3602
+ referenceCloned: false
3603
+ };
3494
3604
  }
3495
- async function ensureDevTheme(api, projectKey, identifier, sourceThemeId, sourceBaseSha, sourceAssetManifestSha) {
3605
+ async function ensureDevTheme(api, projectKey, identifier, sourceThemeId) {
3496
3606
  if (identifier) {
3497
3607
  const theme = await findTheme(api, identifier);
3498
3608
  setLastDevThemeId(theme.id);
3499
- return theme;
3609
+ return {
3610
+ theme,
3611
+ referenceCloned: false
3612
+ };
3500
3613
  }
3501
3614
  const stored = getDevTheme(projectKey);
3502
3615
  if (stored && stored.sourceThemeId === sourceThemeId) {
@@ -3510,25 +3623,24 @@ async function ensureDevTheme(api, projectKey, identifier, sourceThemeId, source
3510
3623
  name: existing.name,
3511
3624
  ...sourceThemeId === void 0 ? {} : { sourceThemeId }
3512
3625
  });
3513
- return existing;
3626
+ return {
3627
+ theme: existing,
3628
+ referenceCloned: false
3629
+ };
3514
3630
  }
3515
3631
  } catch {}
3516
3632
  clearDevTheme(projectKey);
3517
3633
  }
3518
3634
  const { hostname } = await import("node:os");
3519
- const theme = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
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;
3520
3637
  setDevTheme(projectKey, {
3521
3638
  id: theme.id,
3522
3639
  name: theme.name,
3523
- ...sourceThemeId === void 0 ? {} : { sourceThemeId },
3524
- ...sourceThemeId !== void 0 && sourceBaseSha ? {
3525
- sourceBaseSha,
3526
- remoteSha: sourceBaseSha
3527
- } : {},
3528
- ...sourceAssetManifestSha ? { assetManifestSha: sourceAssetManifestSha } : {}
3640
+ ...sourceThemeId === void 0 ? {} : { sourceThemeId }
3529
3641
  });
3530
3642
  console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
3531
- return theme;
3643
+ return creation;
3532
3644
  }
3533
3645
  function createDevCommand() {
3534
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) => {
@@ -3568,31 +3680,16 @@ function createDevCommand() {
3568
3680
  }
3569
3681
  }
3570
3682
  const projectKey = devThemeKey(company, themeRoot.root);
3571
- const theme = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId, config?.baseSha, config?.assetManifestSha);
3572
- const devTheme = opts.theme ? void 0 : getDevTheme(projectKey);
3683
+ const devTarget = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId);
3684
+ const { theme } = devTarget;
3573
3685
  const assetManifest = new ThemeAssetManifest(themeRoot.root);
3574
- const trust = opts.theme ? null : devShadowTrust(config, devTheme, assetManifest.fingerprint());
3575
- let initialSync;
3576
- if (trust) {
3577
- const shadow = await ShadowRepo.open(themeRoot.root, trust.sourceThemeId);
3578
- if (await shadow.hasHead()) initialSync = {
3579
- shadow,
3580
- remoteSha: trust.remoteSha
3581
- };
3582
- }
3583
- let rememberRemoteSha;
3584
- if (!opts.theme && config?.baseSha && devTheme) {
3585
- const sourceBaseSha = config.baseSha;
3586
- let rememberedDevTheme = devTheme;
3587
- rememberRemoteSha = (remoteSha) => {
3588
- rememberedDevTheme = {
3589
- ...rememberedDevTheme,
3590
- sourceBaseSha,
3591
- remoteSha,
3592
- assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint()
3593
- };
3594
- setDevTheme(projectKey, rememberedDevTheme);
3595
- };
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;
3596
3693
  }
3597
3694
  const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
3598
3695
  let stop;
@@ -3612,8 +3709,11 @@ function createDevCommand() {
3612
3709
  port,
3613
3710
  reloadMode,
3614
3711
  validate: !opts.force,
3615
- ...initialSync ? { initialSync } : {},
3616
- ...rememberRemoteSha ? { onRemoteSha: rememberRemoteSha } : {}
3712
+ ...initialRemoteState ? { initialSync: initialRemoteState } : {},
3713
+ ...baselineEnabled ? {
3714
+ onRemoteState: (state) => writeDevRemoteBaseline(themeRoot.root, state),
3715
+ onRemoteStateInvalidated: () => removeDevRemoteBaseline(themeRoot.root)
3716
+ } : {}
3617
3717
  }, (address) => {
3618
3718
  console.log(`\n Dev server: ${address}`);
3619
3719
  console.log(` Web editor: ${editorUrl}`);
@@ -3624,6 +3724,133 @@ function createDevCommand() {
3624
3724
  });
3625
3725
  }
3626
3726
  //#endregion
3727
+ //#region src/theme/merge-push.ts
3728
+ /**
3729
+ * Compute what changed locally since the last time the shadow repo
3730
+ * committed a state. Replaces the sha256 `checksums` map: shadow HEAD
3731
+ * is the source of truth for "what the CLI last saw the server have".
3732
+ *
3733
+ * Files whose local bytes are byte-identical to their HEAD blob are
3734
+ * skipped; anything else — new, modified, or a locally-deleted path
3735
+ * that HEAD still has — is included.
3736
+ */
3737
+ async function diffAgainstShadow(themeRoot, shadow) {
3738
+ const changed = [];
3739
+ const deleted = [];
3740
+ const localFiles = themeRoot.files();
3741
+ const localByKey = /* @__PURE__ */ new Map();
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);
3751
+ for (const file of localFiles) {
3752
+ if (!file.exists) continue;
3753
+ localByKey.set(file.relativePath, file);
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}`);
3756
+ const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
3757
+ if (headBlob && headBlob.equals(localBuf)) continue;
3758
+ changed.push(file);
3759
+ }
3760
+ for (const key of headPaths) {
3761
+ if (localByKey.has(key)) continue;
3762
+ if (themeRoot.ignore.ignore(key)) continue;
3763
+ if (assetManifest.has(key)) continue;
3764
+ if (isStylesheetKey(key)) continue;
3765
+ deleted.push(key);
3766
+ }
3767
+ return {
3768
+ changed,
3769
+ deleted
3770
+ };
3771
+ }
3772
+ /**
3773
+ * Refuse a push when any working file still contains a conflict marker
3774
+ * from a previous pull. Mirrors git's "you have unresolved conflicts;
3775
+ * fix them and re-run" behavior — the whole point of writing markers
3776
+ * on pull was to hand resolution to the user, so we can't send them
3777
+ * upstream.
3778
+ */
3779
+ function findUnresolvedConflicts(files) {
3780
+ const flagged = [];
3781
+ for (const file of files) {
3782
+ if (!file.isText) continue;
3783
+ const buf = readIfExists(file.absolutePath);
3784
+ if (!buf) continue;
3785
+ if (containsConflictMarker(buf)) flagged.push(file.relativePath);
3786
+ }
3787
+ return flagged;
3788
+ }
3789
+ /**
3790
+ * Stable, machine-readable one-liner for non-interactive callers
3791
+ * (Mist Desktop's publish flow parses push output). Uploading marker-
3792
+ * bearing files to a live theme is never acceptable, so `--auto-
3793
+ * baseline` pushes still refuse — but they emit this line so the
3794
+ * desktop can surface WHICH files block the publish instead of a
3795
+ * dead-end wall of prose. Format:
3796
+ *
3797
+ * FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=a.liquid,b.json
3798
+ */
3799
+ function conflictMarkerBlockLine(files) {
3800
+ return `FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=${files.join(",")}`;
3801
+ }
3802
+ const CONFLICT_START = Buffer.from("<<<<<<<");
3803
+ const CONFLICT_MID = Buffer.from("=======");
3804
+ const CONFLICT_END = Buffer.from(">>>>>>>");
3805
+ /**
3806
+ * A file counts as unresolved when it contains all three marker
3807
+ * shapes: `<<<<<<<`, `=======`, and `>>>>>>>`. Requiring all three
3808
+ * avoids false positives — a line of equals signs alone (e.g. inside
3809
+ * an ASCII table in a template comment) doesn't trip the guard.
3810
+ */
3811
+ function containsConflictMarker(buf) {
3812
+ return buf.includes(CONFLICT_START) && buf.includes(CONFLICT_MID) && buf.includes(CONFLICT_END);
3813
+ }
3814
+ /**
3815
+ * Commit the current working-tree state to shadow HEAD after a
3816
+ * successful push. Ensures the next pull's merge base is the state
3817
+ * we know the server just accepted.
3818
+ */
3819
+ async function commitPushedState(themeRoot, shadow, message) {
3820
+ const entries = [];
3821
+ const assetManifest = new ThemeAssetManifest(themeRoot.root);
3822
+ const localKeys = /* @__PURE__ */ new Set();
3823
+ for (const file of themeRoot.files()) {
3824
+ if (!file.exists) continue;
3825
+ localKeys.add(file.relativePath);
3826
+ const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
3827
+ entries.push({
3828
+ path: file.relativePath,
3829
+ sha: await shadow.writeBlob(buf)
3830
+ });
3831
+ }
3832
+ let managedAssetSentinelSha;
3833
+ for (const key of assetManifest.keys()) {
3834
+ if (localKeys.has(key)) continue;
3835
+ managedAssetSentinelSha ??= await shadow.writeBlob(MANAGED_ASSET_SHADOW_SENTINEL);
3836
+ entries.push({
3837
+ path: key,
3838
+ sha: managedAssetSentinelSha
3839
+ });
3840
+ }
3841
+ if (entries.length > 0 || await shadow.hasHead()) await shadow.commitState(entries, message);
3842
+ }
3843
+ /**
3844
+ * A target can already have every URL-backed FileResource (for example from a
3845
+ * reference clone), while this checkout's manifest still names its old source
3846
+ * theme. Adopt the target and clear any legacy binary from shadow even though
3847
+ * no remote write was necessary.
3848
+ */
3849
+ async function finalizeManifestOnlyPush(syncer, themeRoot, shadow) {
3850
+ syncer.repointManagedAssetsToCurrentTheme();
3851
+ await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
3852
+ }
3853
+ //#endregion
3627
3854
  //#region src/theme/auto-baseline.ts
3628
3855
  /**
3629
3856
  * `fluid theme push --auto-baseline`: when a theme directory has no
@@ -4305,7 +4532,11 @@ function createPullCommand() {
4305
4532
  const syncer = new Syncer(api, theme.id, themeRoot);
4306
4533
  const actorPromise = fetchSyncActor(api);
4307
4534
  const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
4308
- 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
+ });
4309
4540
  const externalizedAssets = await syncer.externalizePulledAssets(resources, { delete: !opts.nodelete });
4310
4541
  const result = await mergePull({
4311
4542
  themeRoot,