@fluid-app/fluid-cli-theme-dev 0.1.46 → 0.1.48

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,12 +6,12 @@ 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 { execFileSync, spawn } from "node:child_process";
10
+ import { tmpdir } from "node:os";
9
11
  import net from "node:net";
10
12
  import chalk from "chalk";
11
13
  import prompts from "prompts";
12
14
  import ora from "ora";
13
- import { execFileSync, spawn } from "node:child_process";
14
- import { tmpdir } from "node:os";
15
15
  import { fileURLToPath } from "node:url";
16
16
  //#region ../../platform/api-client-core/src/api-error-shape.ts
17
17
  /**
@@ -1487,6 +1487,15 @@ var ThemeAssetManifest = class {
1487
1487
  entries() {
1488
1488
  return Object.entries(this.assets).map(([key, link]) => [key, copyLink(link)]);
1489
1489
  }
1490
+ /**
1491
+ * Stable digest of the URL references represented by this manifest.
1492
+ * Pull baselines exclude pending entries because those belong only to an
1493
+ * existing dev target and are deliberately absent from the pulled source.
1494
+ */
1495
+ fingerprint(opts = {}) {
1496
+ const entries = this.entries().filter(([, link]) => !opts.excludePending || !link.pending).toSorted(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
1497
+ return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
1498
+ }
1490
1499
  has(key) {
1491
1500
  return this.assets[key] !== void 0;
1492
1501
  }
@@ -2117,7 +2126,7 @@ var Syncer = class {
2117
2126
  const response = await deleteThemeResource(this.api, this.themeId, body);
2118
2127
  if (response.content_version_sha) this.lastKnownRemoteSha = response.content_version_sha;
2119
2128
  } catch (e) {
2120
- throw this.rethrowIfConflict(e);
2129
+ if (!isNotFoundError(e)) throw this.rethrowIfConflict(e);
2121
2130
  }
2122
2131
  this.removeRemoteResource(relativePath);
2123
2132
  }
@@ -2279,8 +2288,8 @@ var Syncer = class {
2279
2288
  return result;
2280
2289
  }
2281
2290
  }
2282
- await this.fetchChecksums();
2283
- await this.preflightPush(opts.baseSha);
2291
+ if (!opts.diff) await this.fetchChecksums();
2292
+ if (!opts.skipPreflight) await this.preflightPush(opts.baseSha);
2284
2293
  let baseSha = opts.baseSha ?? null;
2285
2294
  if (opts.linkManagedAssets) {
2286
2295
  result.linked = await this.linkManagedAssets(opts.linkManagedAssets);
@@ -2289,7 +2298,7 @@ var Syncer = class {
2289
2298
  this.assetManifest.reload();
2290
2299
  this.ensureManagedAssetsAreResolved();
2291
2300
  }
2292
- const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
2301
+ const toUpload = opts.diff?.changed ?? localFiles.filter((f) => f.exists && this.hasChanged(f));
2293
2302
  let done = 0;
2294
2303
  for (const file of toUpload) {
2295
2304
  try {
@@ -2303,9 +2312,11 @@ var Syncer = class {
2303
2312
  opts.onProgress?.(++done, toUpload.length);
2304
2313
  }
2305
2314
  if (opts.delete) {
2306
- const localPaths = new Set(localFiles.map((f) => f.relativePath));
2307
- for (const key of this.assetManifest.keys()) localPaths.add(key);
2308
- const toDelete = this.remoteKeys().filter((key) => !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
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
+ })();
2309
2320
  for (const key of toDelete) try {
2310
2321
  await this.deleteRemoteFile(key, baseSha);
2311
2322
  baseSha = this.lastKnownRemoteSha;
@@ -2459,1076 +2470,1158 @@ function isRecord(value) {
2459
2470
  return typeof value === "object" && value !== null && !Array.isArray(value);
2460
2471
  }
2461
2472
  //#endregion
2462
- //#region src/theme/liquid-delimiters.ts
2473
+ //#region src/theme/shadow-repo.ts
2463
2474
  /**
2464
- * Heuristic-only check for obviously unbalanced liquid delimiters
2465
- * (`{% %}` and `{{ }}`). This is NOT a liquid parser — it only tracks
2466
- * opening delimiters until they are closed. It exists to give watch-mode users a signal
2467
- * when a save is liquid-syntax-broken: the server accepts
2468
- * syntax-broken liquid silently on upload, and the storefront
2469
- * renderer then serves stale content for that section with no error
2470
- * anywhere else in the pipeline.
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.
2471
2482
  *
2472
- * Closing-looking tokens without a preceding Liquid opener are intentionally
2473
- * ignored. Liquid files commonly contain CSS such as `width:100%}` or adjacent
2474
- * block braces (`}}`), so treating every close token as Liquid creates noisy
2475
- * false warnings on valid theme files.
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/`.
2476
2487
  *
2477
- * Known false positive: delimiters written literally inside a
2478
- * `{% raw %}...{% endraw %}` block are still counted and can trip
2479
- * this check even though the liquid is valid. Acceptable for a
2480
- * warn-only heuristic — a real fix requires parsing liquid, which is
2481
- * out of scope here (see the server-side validation note in the PR).
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.
2482
2491
  */
2483
- function hasUnbalancedLiquidDelimiters(content) {
2484
- let unclosedTags = 0;
2485
- let unclosedOutputs = 0;
2486
- for (const token of content.matchAll(/\{%|%\}|\{\{|\}\}/g)) switch (token[0]) {
2487
- case "{%":
2488
- unclosedTags += 1;
2489
- break;
2490
- case "%}":
2491
- if (unclosedTags > 0) unclosedTags -= 1;
2492
- break;
2493
- case "{{":
2494
- unclosedOutputs += 1;
2495
- break;
2496
- case "}}":
2497
- if (unclosedOutputs > 0) unclosedOutputs -= 1;
2498
- break;
2492
+ var ShadowRepo = class ShadowRepo {
2493
+ headExists = void 0;
2494
+ constructor(themeRoot, gitDir) {
2495
+ this.themeRoot = themeRoot;
2496
+ this.gitDir = gitDir;
2499
2497
  }
2500
- return unclosedTags > 0 || unclosedOutputs > 0;
2501
- }
2502
- const BLOCK_TAGS = new Map([
2503
- ["capture", "endcapture"],
2504
- ["case", "endcase"],
2505
- ["comment", "endcomment"],
2506
- ["for", "endfor"],
2507
- ["form", "endform"],
2508
- ["if", "endif"],
2509
- ["ifchanged", "endifchanged"],
2510
- ["javascript", "endjavascript"],
2511
- ["paginate", "endpaginate"],
2512
- ["raw", "endraw"],
2513
- ["schema", "endschema"],
2514
- ["style", "endstyle"],
2515
- ["stylesheet", "endstylesheet"],
2516
- ["tablerow", "endtablerow"],
2517
- ["unless", "endunless"]
2518
- ]);
2519
- const CLOSING_TAGS = new Set(BLOCK_TAGS.values());
2520
- const OPAQUE_BLOCK_TAGS = new Set([
2521
- "comment",
2522
- "javascript",
2523
- "raw",
2524
- "schema",
2525
- "style",
2526
- "stylesheet"
2527
- ]);
2528
- /**
2529
- * Find structurally unbalanced Liquid block tags such as an `{% if %}` with
2530
- * no `{% endif %}`. This intentionally recognizes only established paired
2531
- * tags; custom and inline tags are ignored rather than guessed at.
2532
- *
2533
- * Content inside raw/comment/schema/style/javascript blocks is opaque to
2534
- * Liquid and therefore skipped until that block's matching close tag. This
2535
- * prevents CSS, JSON, and examples containing Liquid-looking text from
2536
- * producing false errors.
2537
- */
2538
- function findLiquidBlockTagDiagnostics(content) {
2539
- const stack = [];
2540
- const diagnostics = [];
2541
- let line = 1;
2542
- let previousTagIndex = 0;
2543
- const processTag = (name, tagLine) => {
2544
- const open = stack.at(-1);
2545
- if (open && OPAQUE_BLOCK_TAGS.has(open.name)) {
2546
- if (name === open.expectedClose) stack.pop();
2547
- return;
2548
- }
2549
- const expectedClose = BLOCK_TAGS.get(name);
2550
- if (expectedClose) {
2551
- stack.push({
2552
- name,
2553
- expectedClose,
2554
- line: tagLine
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
2555
2515
  });
2556
- return;
2557
2516
  }
2558
- if (!CLOSING_TAGS.has(name)) return;
2559
- if (!open) {
2560
- diagnostics.push({
2561
- severity: "error",
2562
- message: `Unexpected Liquid tag '{% ${name} %}' on line ${tagLine}; there is no open block to close.`
2563
- });
2564
- return;
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 });
2565
2527
  }
2566
- if (name !== open.expectedClose) {
2567
- diagnostics.push({
2568
- severity: "error",
2569
- message: `Mismatched Liquid tag '{% ${name} %}' on line ${tagLine}; '{% ${open.name} %}' from line ${open.line} must close with '{% ${open.expectedClose} %}'.`
2570
- });
2571
- return;
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;
2572
2546
  }
2573
- stack.pop();
2574
- };
2575
- for (const match of content.matchAll(/\{%-?\s*([a-zA-Z_][\w-]*)\b(?:(?!\{%)[\s\S])*?-?%\}/g)) {
2576
- const name = match[1]?.toLowerCase();
2577
- if (!name) continue;
2578
- const index = match.index ?? 0;
2579
- for (let cursor = previousTagIndex; cursor < index; cursor++) if (content.charCodeAt(cursor) === 10) line += 1;
2580
- previousTagIndex = index;
2581
- const open = stack.at(-1);
2582
- if (name === "liquid" && !(open && OPAQUE_BLOCK_TAGS.has(open.name))) {
2583
- const statements = match[0].replace(/^\{%-?\s*liquid\b/i, "").replace(/-?%\}$/, "").split("\n");
2584
- for (const [offset, statement] of statements.entries()) {
2585
- const statementName = /^\s*([a-zA-Z_][\w-]*)\b/.exec(statement)?.[1];
2586
- if (!statementName) continue;
2587
- processTag(statementName.toLowerCase(), line + offset);
2588
- }
2589
- } else processTag(name, line);
2547
+ return this.headExists;
2590
2548
  }
2591
- for (const open of stack.reverse()) diagnostics.push({
2592
- severity: "error",
2593
- message: `Unclosed Liquid tag '{% ${open.name} %}' on line ${open.line}; expected '{% ${open.expectedClose} %}'.`
2594
- });
2595
- return diagnostics;
2596
- }
2597
- //#endregion
2598
- //#region src/theme/dev-server/port-preflight.ts
2599
- /**
2600
- * The dev command does real work before it ever binds a port: it resolves
2601
- * (or creates) a server-side dev theme and runs a full initial sync, which
2602
- * can take minutes on a large theme. If the requested port is already taken
2603
- * — most commonly by another `fluid theme dev` or the Mist Desktop preview,
2604
- * which both default to 9292 — all of that work is wasted and the process
2605
- * used to die with a raw `EADDRINUSE` stack trace. Call this before any of
2606
- * that work starts so we fail fast with a clear message instead.
2607
- */
2608
- var PortInUseError = class extends Error {
2609
- constructor(host, port) {
2610
- super(formatPortConflictMessage(host, port));
2611
- this.host = host;
2612
- this.port = port;
2613
- this.name = "PortInUseError";
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);
2614
2564
  }
2615
- };
2616
- function formatPortConflictMessage(host, port) {
2617
- 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>.`;
2618
- }
2619
- /**
2620
- * Attempt to bind `host:port`, then immediately release it. Resolves if the
2621
- * port is free; rejects with `PortInUseError` on `EADDRINUSE`/`EACCES`, or
2622
- * the raw error for anything else unexpected.
2623
- */
2624
- function checkPortAvailable(host, port) {
2625
- return new Promise((resolve, reject) => {
2626
- const server = net.createServer();
2627
- server.once("error", (err) => {
2628
- if (err.code === "EADDRINUSE" || err.code === "EACCES") reject(new PortInUseError(host, port));
2629
- else reject(err);
2630
- });
2631
- server.once("listening", () => {
2632
- server.close(() => resolve());
2633
- });
2634
- server.listen(port, host);
2635
- });
2636
- }
2637
- //#endregion
2638
- //#region src/theme/dev-server/index.ts
2639
- function timestamp() {
2640
- return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
2641
- }
2642
- async function startDevServer(api, theme, themeRoot, opts, onReady) {
2643
- const sse = new SSEStream();
2644
- const syncer = new Syncer(api, theme.id, themeRoot);
2645
- const pendingUpdates = /* @__PURE__ */ new Set();
2646
- console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
2647
- const syncResult = await syncer.uploadTheme({
2648
- delete: true,
2649
- validate: opts.validate,
2650
- linkManagedAssets: { replace: true },
2651
- pendingBinaryAssets: true,
2652
- onProgress: (done, total) => {
2653
- process.stdout.write(`\r Uploading ${done}/${total} files…`);
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;
2654
2581
  }
2655
- });
2656
- process.stdout.write("\n");
2657
- if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
2658
- if (syncResult.validationFailed) {
2659
- console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
2660
- for (const e of syncResult.errors) console.error(` ${e}`);
2661
- process.exit(1);
2662
- } else if (syncResult.errors.length > 0) {
2663
- for (const e of syncResult.errors) console.error(` ${e}`);
2664
- if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
2665
2582
  }
2666
- const SYNC_IDLE_MS = 2e3;
2667
- let lastArrivedAt = 0;
2668
- let pendingSync = null;
2669
- let syncInFlight = Promise.resolve();
2670
- let askOwed = false;
2671
- const sendSync = () => {
2672
- syncInFlight = syncInFlight.then(async () => {
2673
- askOwed = !await syncer.requestSync();
2674
- });
2675
- };
2676
- const flushSyncNow = () => {
2677
- if (!pendingSync) return;
2678
- clearTimeout(pendingSync);
2679
- pendingSync = null;
2680
- sendSync();
2681
- };
2682
- const scheduleSync = () => {
2683
- if (pendingSync) clearTimeout(pendingSync);
2684
- pendingSync = setTimeout(() => {
2685
- pendingSync = null;
2686
- sendSync();
2687
- }, SYNC_IDLE_MS);
2688
- };
2689
- const stopWatcher = watchTheme(themeRoot, async (modified, added, removed, arrivedAt) => {
2690
- if (arrivedAt - lastArrivedAt > SYNC_IDLE_MS) flushSyncNow();
2691
- else if (pendingSync) {
2692
- clearTimeout(pendingSync);
2693
- pendingSync = null;
2694
- }
2695
- lastArrivedAt = arrivedAt;
2696
- await syncInFlight;
2697
- if (askOwed) {
2698
- sendSync();
2699
- await syncInFlight;
2700
- }
2701
- const changed = [...modified, ...added];
2702
- for (const file of changed) {
2703
- if (opts.validate && file.isLiquid) {
2704
- const diagnostics = file.validateSchema();
2705
- for (const d of diagnostics) {
2706
- const prefix = d.severity === "error" ? "Schema error" : "Schema warning";
2707
- console.warn(`\n[${prefix}] ${file.relativePath}: ${d.message}`);
2708
- }
2709
- }
2710
- pendingUpdates.add(file.relativePath);
2711
- try {
2712
- const uploadedContent = await syncer.uploadFile(file, void 0, { pendingAsset: true });
2713
- console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
2714
- if (file.isLiquid && uploadedContent !== null && hasUnbalancedLiquidDelimiters(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: unbalanced liquid delimiters — the storefront may silently serve stale content for this section`);
2715
- if (file.isLiquid && uploadedContent !== null) for (const diagnostic of findLiquidBlockTagDiagnostics(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: ${diagnostic.message}`);
2716
- } catch (e) {
2717
- console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
2718
- } finally {
2719
- pendingUpdates.delete(file.relativePath);
2720
- }
2721
- }
2722
- for (const file of removed) {
2723
- if (themeRoot.ignore.ignore(file.relativePath)) continue;
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 {
2724
2634
  try {
2725
- await syncer.deleteRemoteFile(file.relativePath);
2726
- console.log(` ✓ removed ${file.relativePath}`);
2635
+ rmSync(indexPath, { force: true });
2636
+ rmSync(indexPath.substring(0, indexPath.length - 6), {
2637
+ recursive: true,
2638
+ force: true
2639
+ });
2727
2640
  } catch {}
2728
2641
  }
2729
- if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
2730
- else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
2731
- scheduleSync();
2732
- });
2733
- const server = http.createServer(async (req, res) => {
2734
- if (req.url === "/hot-reload") {
2735
- sse.add(res);
2736
- return;
2737
- }
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");
2738
2665
  try {
2739
- await proxyRequest(req, res, {
2740
- company: theme.company,
2741
- themeId: theme.id,
2742
- reloadMode: opts.reloadMode,
2743
- pendingFiles: () => [...pendingUpdates].map((p) => themeRoot.file(p)).filter((f) => f.isText).map((f) => ({
2744
- relativePath: f.relativePath,
2745
- read: () => f.read()
2746
- }))
2747
- });
2748
- } catch (e) {
2749
- console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
2750
- if (!res.headersSent) {
2751
- const message = e instanceof Error ? e.message : String(e);
2752
- res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
2753
- 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.`);
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;
2754
2701
  }
2702
+ } finally {
2703
+ try {
2704
+ rmSync(dir, {
2705
+ recursive: true,
2706
+ force: true
2707
+ });
2708
+ } catch {}
2755
2709
  }
2756
- });
2757
- await new Promise((resolve, reject) => {
2758
- server.once("error", (err) => {
2759
- if (err.code === "EADDRINUSE" || err.code === "EACCES") {
2760
- console.error(formatPortConflictMessage(opts.host, opts.port));
2761
- process.exit(1);
2762
- }
2763
- reject(err);
2764
- });
2765
- server.listen(opts.port, opts.host, () => resolve());
2766
- });
2767
- const address = `http://${opts.host}:${opts.port}`;
2768
- onReady?.(address);
2769
- return function stop() {
2770
- sse.close();
2771
- stopWatcher();
2772
- server.close();
2773
- };
2774
- }
2775
- //#endregion
2776
- //#region src/theme-picker.ts
2777
- const PAGE_SIZE = 50;
2778
- const LOAD_MORE_VALUE = -1;
2779
- function themeLabel(t) {
2780
- const active = t.status === "active" ? ` ${chalk.green("[active]")}` : "";
2781
- return `${t.name} (#${t.id})${active}`;
2782
- }
2783
- function themeChoices(themeList, hasMore) {
2784
- const choices = themeList.map((t) => ({
2785
- title: themeLabel(t),
2786
- value: t.id
2787
- }));
2788
- if (hasMore) choices.push({
2789
- title: chalk.dim(`── Load more themes ──`),
2790
- value: LOAD_MORE_VALUE
2791
- });
2792
- return choices;
2793
- }
2794
- async function fetchThemesPage(api, page, searchQuery) {
2795
- const body = await listApplicationThemes(api, {
2796
- per_page: PAGE_SIZE,
2797
- page,
2798
- ...searchQuery ? { search_query: searchQuery } : {}
2799
- });
2800
- return {
2801
- themes: body.application_themes ?? [],
2802
- hasMore: page < (body.meta?.total_pages ?? 1)
2803
- };
2804
- }
2805
- async function selectTheme(api, message) {
2806
- const allThemes = [];
2807
- let page = 1;
2808
- let hasMore = true;
2809
- let initialIndex = 0;
2810
- let searchQuery = "";
2811
- let searchResults = [];
2812
- while (true) {
2813
- if (hasMore && allThemes.length < page * PAGE_SIZE) {
2814
- const result = await fetchThemesPage(api, page);
2815
- allThemes.push(...result.themes);
2816
- hasMore = result.hasMore;
2817
- }
2818
- if (!allThemes.length) {
2819
- console.error("No themes found.");
2820
- process.exit(1);
2821
- }
2822
- const choices = themeChoices(allThemes, hasMore);
2823
- const { id } = await prompts({
2824
- type: "autocomplete",
2825
- name: "id",
2826
- message,
2827
- initial: initialIndex,
2828
- choices,
2829
- suggest: async (input, choices) => {
2830
- if (!input) {
2831
- searchQuery = "";
2832
- searchResults = [];
2833
- return choices;
2834
- }
2835
- if (input !== searchQuery) {
2836
- searchQuery = input;
2837
- try {
2838
- searchResults = (await fetchThemesPage(api, 1, input)).themes;
2839
- } catch {
2840
- searchResults = [];
2841
- }
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);
2842
2765
  }
2843
- return searchResults.map((t) => ({
2844
- title: themeLabel(t),
2845
- value: t.id
2846
- }));
2847
- }
2848
- }, { onCancel: () => process.exit(130) });
2849
- if (id === LOAD_MORE_VALUE) {
2850
- initialIndex = allThemes.length;
2851
- page++;
2852
- continue;
2853
- }
2854
- if (!id) {
2855
- console.error("No theme selected.");
2856
- process.exit(1);
2857
- }
2858
- const found = allThemes.find((t) => t.id === id) ?? searchResults.find((t) => t.id === id);
2859
- if (found) return found;
2860
- return (await getApplicationTheme(api, id)).application_theme;
2766
+ });
2767
+ });
2861
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);
2862
2777
  }
2863
- async function findTheme(api, identifier) {
2864
- const idNum = Number(identifier);
2865
- if (Number.isInteger(idNum) && idNum > 0) try {
2866
- const body = await getApplicationTheme(api, idNum);
2867
- if (body.application_theme) return body.application_theme;
2868
- } catch {}
2869
- let page = 1;
2870
- let hasMore = true;
2871
- while (hasMore) {
2872
- const result = await fetchThemesPage(api, page, identifier);
2873
- const found = result.themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
2874
- if (found) return found;
2875
- hasMore = result.hasMore;
2876
- page++;
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;
2877
2784
  }
2878
- console.error(`No theme found with identifier: ${identifier}`);
2879
- process.exit(1);
2880
2785
  }
2881
- //#endregion
2882
- //#region src/workspace.ts
2883
- const WORKSPACE_FILE = ".fluid-workspace.json";
2884
2786
  /**
2885
- * Walk up from `startDir` looking for `.fluid-workspace.json`.
2886
- * Returns the workspace info if found, or `null` if not in a workspace.
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.
2887
2790
  */
2888
- function findWorkspace(startDir) {
2889
- let dir = resolve(startDir ?? process.cwd());
2890
- while (true) {
2891
- const candidate = join(dir, WORKSPACE_FILE);
2892
- if (existsSync(candidate)) try {
2893
- const raw = readFileSync(candidate, "utf-8");
2894
- const config = JSON.parse(raw);
2895
- return {
2896
- root: dir,
2897
- config
2898
- };
2899
- } catch {
2900
- return null;
2901
- }
2902
- const parent = dirname(dir);
2903
- if (parent === dir) break;
2904
- dir = parent;
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;
2905
2797
  }
2906
- return null;
2907
2798
  }
2908
2799
  /**
2909
- * If cwd is already inside `{workspace}/local/{company}/...`, return that
2910
- * theme root directory. Otherwise return null.
2911
- *
2912
- * Examples (workspace root = /code/fluid-theme-dev):
2913
- * cwd = /code/fluid-theme-dev/local/acme-co → /code/fluid-theme-dev/local/acme-co
2914
- * cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
2915
- * cwd = /code/fluid-theme-dev → null
2916
- * cwd = /code/fluid-theme-dev/local → null
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.
2917
2805
  */
2918
- function resolveThemeRootFromCwd(workspace) {
2919
- const cwd = resolve(process.cwd());
2920
- const localDir = join(workspace.root, "local");
2921
- const rel = relative(localDir, cwd);
2922
- if (rel.startsWith("..") || rel === ".") return null;
2923
- const firstSegment = rel.split(sep)[0];
2924
- if (!firstSegment) return null;
2925
- return join(localDir, firstSegment);
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");
2926
2830
  }
2927
2831
  //#endregion
2928
- //#region src/commands/dev.ts
2832
+ //#region src/theme/merge-push.ts
2929
2833
  /**
2930
- * Create the isolated theme used by `theme dev`.
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".
2931
2837
  *
2932
- * A checkout from `theme pull` has a source theme id. New servers clone that
2933
- * source by reference, preserving its DAM/ImageKit assets without moving
2934
- * bytes. A 404/405 keeps older deployments compatible with the established
2935
- * empty-theme flow; other failures must remain visible to the developer.
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.
2936
2841
  */
2937
- async function createDevelopmentTheme(api, sourceThemeId, name) {
2938
- if (sourceThemeId !== void 0) try {
2939
- return (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme;
2940
- } catch (error) {
2941
- if (!isApiError(error) || error.status !== 404 && error.status !== 405) throw error;
2942
- console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
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);
2943
2855
  }
2944
- return (await createApplicationTheme(api, { application_theme: {
2945
- name,
2946
- status: "development"
2947
- } })).application_theme;
2948
- }
2949
- async function ensureDevTheme(api, projectKey, identifier, sourceThemeId) {
2950
- if (identifier) {
2951
- const theme = await findTheme(api, identifier);
2952
- setLastDevThemeId(theme.id);
2953
- return theme;
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);
2954
2862
  }
2955
- const stored = getDevTheme(projectKey);
2956
- if (stored && stored.sourceThemeId === sourceThemeId) {
2957
- try {
2958
- const existing = (await getApplicationTheme(api, stored.id)).application_theme;
2959
- if (existing && existing.status === "development") {
2960
- console.log(`Using existing dev theme #${existing.id}`);
2961
- setDevTheme(projectKey, {
2962
- id: existing.id,
2963
- name: existing.name,
2964
- ...sourceThemeId === void 0 ? {} : { sourceThemeId }
2965
- });
2966
- return existing;
2967
- }
2968
- } catch {}
2969
- clearDevTheme(projectKey);
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);
2970
2891
  }
2971
- const { hostname } = await import("node:os");
2972
- const theme = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
2973
- setDevTheme(projectKey, {
2974
- id: theme.id,
2975
- name: theme.name,
2976
- ...sourceThemeId === void 0 ? {} : { sourceThemeId }
2977
- });
2978
- console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
2979
- return theme;
2892
+ return flagged;
2980
2893
  }
2981
- function createDevCommand() {
2982
- return new Command("dev").description("Start the theme dev server with hot reload").option("--host <host>", "Local server host", "127.0.0.1").option("--port <port>", "Local server port", "9292").option("-t, --theme <name-or-id>", "Use an existing theme instead of dev theme").option("-f, --force", "Skip schema validation on upload").option("--live-reload <mode>", "Reload mode: full-page | off", "full-page").option("--navigate", "Open browser navigator after server starts").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
2983
- requireToken();
2984
- let rootPath = opts.root;
2985
- if (rootPath === ".") {
2986
- const workspace = findWorkspace();
2987
- if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
2988
- }
2989
- const themeRoot = new ThemeRoot(rootPath);
2990
- if (!themeRoot.isValid()) {
2991
- console.error(`'${rootPath}' does not look like a theme directory.`);
2992
- process.exit(1);
2993
- }
2994
- const port = Number(opts.port);
2995
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
2996
- console.error(`Invalid port: '${opts.port}'. Must be an integer between 1 and 65535.`);
2997
- process.exit(1);
2998
- }
2999
- try {
3000
- await checkPortAvailable(opts.host, port);
3001
- } catch (e) {
3002
- if (e instanceof PortInUseError) console.error(e.message);
3003
- else console.error(`Failed to check port availability: ${e}`);
3004
- process.exit(1);
3005
- }
3006
- const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
3007
- const api = createApiClient();
3008
- const config = readThemeConfig(themeRoot.root);
3009
- let company;
3010
- if (config?.company) company = config.company;
3011
- else {
3012
- company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain ?? "";
3013
- if (!company) {
3014
- console.error("Could not determine company subdomain. Make sure your token is valid.");
3015
- process.exit(1);
3016
- }
3017
- }
3018
- const projectKey = devThemeKey(company, themeRoot.root);
3019
- const theme = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId);
3020
- const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
3021
- let stop;
3022
- const cleanup = () => {
3023
- stop?.();
3024
- process.exit(0);
3025
- };
3026
- process.on("SIGINT", cleanup);
3027
- process.on("SIGTERM", cleanup);
3028
- stop = await startDevServer(api, {
3029
- id: theme.id,
3030
- name: theme.name,
3031
- company,
3032
- editorUrl
3033
- }, themeRoot, {
3034
- host: opts.host,
3035
- port,
3036
- reloadMode,
3037
- validate: !opts.force
3038
- }, (address) => {
3039
- console.log(`\n Dev server: ${address}`);
3040
- console.log(` Web editor: ${editorUrl}`);
3041
- console.log("\n Watching for file changes…\n");
3042
- if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
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);
2918
+ }
2919
+ /**
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.
2923
+ */
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)
3043
2935
  });
3044
- await new Promise(() => {});
3045
- });
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
2944
+ });
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()}`);
3046
2957
  }
3047
2958
  //#endregion
3048
- //#region src/theme/shadow-repo.ts
2959
+ //#region src/theme/liquid-delimiters.ts
3049
2960
  /**
3050
- * A bare git repo hidden under `.fluid-theme/repo` that stands in for
3051
- * git's index+HEAD when talking to the Fluid server. Every pull commits
3052
- * the incoming remote state onto a single branch (`refs/heads/main`);
3053
- * every successful push commits the outgoing local state onto the same
3054
- * branch. HEAD therefore represents "the last state the CLI and server
3055
- * agreed on", and its tree is the natural three-way-merge base for the
3056
- * next pull.
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.
3057
2968
  *
3058
- * We stay in git's plumbing layer no working tree, no index file
3059
- * next to the theme content so the shadow repo cannot interfere
3060
- * with the user's own git repo (if they have one) around the theme
3061
- * dir. All state lives inside `.fluid-theme/`.
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.
3062
2973
  *
3063
- * The class only wraps the small handful of plumbing commands we
3064
- * actually need: hash-object, cat-file, write-tree (via a temp index),
3065
- * commit-tree, update-ref, and merge-file. Everything else stays out.
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).
3066
2979
  */
3067
- var ShadowRepo = class ShadowRepo {
3068
- headExists = void 0;
3069
- constructor(themeRoot, gitDir) {
3070
- this.themeRoot = themeRoot;
3071
- this.gitDir = gitDir;
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;
3072
2996
  }
3073
- /**
3074
- * Return a ShadowRepo bound to `themeId` for the given theme root.
3075
- * The shadow is initialized on first use and re-initialized when
3076
- * the caller passes a themeId different from the one previously
3077
- * recorded — the merge base is only meaningful for the theme it
3078
- * was captured against, so a cross-theme operation (pull A → push
3079
- * B, or two pulls of different themes into one dir) starts from a
3080
- * clean slate rather than pretending B's state matches A's HEAD.
3081
- */
3082
- static async open(themeRoot, themeId) {
3083
- const shadowDir = join(themeRoot, ".fluid-theme");
3084
- const gitDir = join(shadowDir, "repo");
3085
- const themeIdFile = join(shadowDir, "theme-id");
3086
- if (existsSync(gitDir)) {
3087
- if (readStoredThemeId(themeIdFile) !== themeId) rmSync(gitDir, {
3088
- recursive: true,
3089
- force: true
2997
+ return unclosedTags > 0 || unclosedOutputs > 0;
2998
+ }
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;
3045
+ }
3046
+ const expectedClose = BLOCK_TAGS.get(name);
3047
+ if (expectedClose) {
3048
+ stack.push({
3049
+ name,
3050
+ expectedClose,
3051
+ line: tagLine
3090
3052
  });
3053
+ return;
3091
3054
  }
3092
- const repo = new ShadowRepo(themeRoot, gitDir);
3093
- if (!existsSync(gitDir)) {
3094
- mkdirSync(shadowDir, { recursive: true });
3095
- await repo.git([
3096
- "init",
3097
- "--bare",
3098
- "-b",
3099
- "main",
3100
- gitDir
3101
- ], { cwd: themeRoot });
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
+ });
3061
+ return;
3102
3062
  }
3103
- writeFileSync(themeIdFile, `${themeId}\n`, "utf-8");
3104
- const shadowIgnore = join(shadowDir, ".gitignore");
3105
- if (!existsSync(shadowIgnore)) writeFileSync(shadowIgnore, "# Fluid CLI shadow repo — internal state, not for version control.\n*\n");
3106
- await ensureRootGitignoreHidesShadow(themeRoot);
3107
- return repo;
3108
- }
3109
- /** True when the repo has at least one commit on `refs/heads/main`. */
3110
- async hasHead() {
3111
- if (this.headExists !== void 0) return this.headExists;
3112
- try {
3113
- await this.git([
3114
- "rev-parse",
3115
- "--verify",
3116
- "HEAD"
3117
- ]);
3118
- this.headExists = true;
3119
- } catch {
3120
- this.headExists = false;
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} %}'.`
3067
+ });
3068
+ return;
3121
3069
  }
3122
- return this.headExists;
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);
3085
+ }
3086
+ } else processTag(name, line);
3123
3087
  }
3124
- /**
3125
- * Every path recorded under HEAD's tree, recursively. Callers use
3126
- * this to detect local deletions (paths in HEAD, absent from the
3127
- * working tree). Returns [] when HEAD has never been committed.
3128
- */
3129
- async headPaths() {
3130
- if (!await this.hasHead()) return [];
3131
- const { stdout } = await this.git([
3132
- "ls-tree",
3133
- "-r",
3134
- "-z",
3135
- "HEAD",
3136
- "--name-only"
3137
- ]);
3138
- return stdout.toString("utf8").split("\0").filter((line) => line.length > 0);
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} %}'.`
3091
+ });
3092
+ return diagnostics;
3093
+ }
3094
+ //#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";
3139
3111
  }
3140
- /**
3141
- * The content of `path` in HEAD's tree, or null when the path does
3142
- * not exist there. Callers use this as the merge base for pull
3143
- * conflict resolution.
3144
- */
3145
- async blobAtHead(path) {
3146
- if (!await this.hasHead()) return null;
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
+ }
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());
3130
+ });
3131
+ server.listen(port, host);
3132
+ });
3133
+ }
3134
+ //#endregion
3135
+ //#region src/theme/dev-server/index.ts
3136
+ function timestamp() {
3137
+ return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
3138
+ }
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);
3145
+ };
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…`);
3150
+ };
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;
3147
3161
  try {
3148
- const { stdout } = await this.git([
3149
- "cat-file",
3150
- "-p",
3151
- `HEAD:${path}`
3152
- ]);
3153
- return stdout;
3154
- } catch {
3155
- return null;
3162
+ await syncer.preflightPush(opts.initialSync.remoteSha);
3163
+ shadowIsTrusted = true;
3164
+ } catch (error) {
3165
+ if (!(error instanceof PushConflictError)) throw error;
3156
3166
  }
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);
3157
3189
  }
3158
- /**
3159
- * Write `content` as a blob in the shadow repo and return its sha.
3160
- * Used by `commitState` to stage each file's content before the
3161
- * `write-tree` call.
3162
- */
3163
- async writeBlob(content) {
3164
- const buf = typeof content === "string" ? Buffer.from(content) : content;
3165
- const { stdout } = await this.git([
3166
- "hash-object",
3167
- "-w",
3168
- "--stdin"
3169
- ], { input: buf });
3170
- return stdout.toString("utf8").trim();
3171
- }
3172
- /**
3173
- * Commit `files` as HEAD's new tree, threaded onto the current HEAD
3174
- * as the parent. Uses a per-call temp index so a partial run can't
3175
- * corrupt anything reachable from HEAD; the previous commit stays
3176
- * intact until `update-ref` at the end.
3177
- *
3178
- * Returns the new commit sha.
3179
- */
3180
- async commitState(files, message) {
3181
- const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
3182
- try {
3183
- const indexArgs = ["update-index", "--add"];
3184
- for (const { path, sha } of files) indexArgs.push("--cacheinfo", `100644,${sha},${path}`);
3185
- if (files.length > 0) await this.git(indexArgs, { env: { GIT_INDEX_FILE: indexPath } });
3186
- const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
3187
- const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
3188
- const commitArgs = [
3189
- "commit-tree",
3190
- treeSha,
3191
- "-m",
3192
- message
3193
- ];
3194
- if (parent) commitArgs.push("-p", parent);
3195
- const commitSha = (await this.git(commitArgs, { env: {
3196
- GIT_AUTHOR_NAME: "Fluid CLI",
3197
- GIT_AUTHOR_EMAIL: "cli@fluid.app",
3198
- GIT_COMMITTER_NAME: "Fluid CLI",
3199
- GIT_COMMITTER_EMAIL: "cli@fluid.app"
3200
- } })).stdout.toString("utf8").trim();
3201
- await this.git([
3202
- "update-ref",
3203
- "refs/heads/main",
3204
- commitSha
3205
- ]);
3206
- this.headExists = true;
3207
- return commitSha;
3208
- } finally {
3209
- try {
3210
- rmSync(indexPath, { force: true });
3211
- rmSync(indexPath.substring(0, indexPath.length - 6), {
3212
- recursive: true,
3213
- force: true
3214
- });
3215
- } catch {}
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;
3216
3219
  }
3217
- }
3218
- /**
3219
- * Three-way merge of `local` against `remote` with `base` as the
3220
- * common ancestor. Returns the merged bytes and a flag when
3221
- * `git merge-file` reported unresolved conflicts (i.e. the output
3222
- * contains `<<<<<<<` markers for the reader to resolve).
3223
- *
3224
- * `base` is null when HEAD has never seen this path; we merge
3225
- * against an empty base, which is what git itself does for a new
3226
- * file added on both sides.
3227
- *
3228
- * `favor` maps to `git merge-file`'s `--ours` / `--theirs`: instead
3229
- * of emitting `<<<<<<<` markers, conflicting hunks are resolved to
3230
- * the local (`"local"` → `--ours`, local is file1) or remote
3231
- * (`"remote"` → `--theirs`) side. The output then never contains
3232
- * markers, so the result is reported conflict-free even when
3233
- * merge-file's exit code still counts the auto-resolved hunks.
3234
- */
3235
- async merge3(base, local, remote, favor) {
3236
- const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
3237
- const localPath = join(dir, "local");
3238
- const basePath = join(dir, "base");
3239
- const remotePath = join(dir, "remote");
3240
- try {
3241
- writeFileSync(localPath, local);
3242
- writeFileSync(basePath, base ?? Buffer.alloc(0));
3243
- writeFileSync(remotePath, remote);
3220
+ lastArrivedAt = arrivedAt;
3221
+ await syncInFlight;
3222
+ if (askOwed) {
3223
+ sendSync();
3224
+ await syncInFlight;
3225
+ }
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);
3244
3237
  try {
3245
- const { stdout } = await this.git([
3246
- "merge-file",
3247
- "-p",
3248
- ...favor === "local" ? ["--ours"] : favor === "remote" ? ["--theirs"] : [],
3249
- "-L",
3250
- "local",
3251
- "-L",
3252
- "base",
3253
- "-L",
3254
- "remote",
3255
- localPath,
3256
- basePath,
3257
- remotePath
3258
- ]);
3259
- return {
3260
- merged: stdout,
3261
- hasConflicts: false
3262
- };
3263
- } catch (err) {
3264
- const e = err;
3265
- const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
3266
- const inConflictRange = typeof e.code === "number" && e.code >= 1 && e.code <= 127;
3267
- if (inConflictRange && favor) return {
3268
- merged,
3269
- hasConflicts: false
3270
- };
3271
- if (inConflictRange && merged.length > 0) return {
3272
- merged,
3273
- hasConflicts: true
3274
- };
3275
- throw err;
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);
3276
3247
  }
3277
- } finally {
3248
+ }
3249
+ for (const file of removed) {
3250
+ if (themeRoot.ignore.ignore(file.relativePath)) continue;
3278
3251
  try {
3279
- rmSync(dir, {
3280
- recursive: true,
3281
- force: true
3282
- });
3252
+ await syncer.deleteRemoteFile(file.relativePath);
3253
+ wroteRemote = true;
3254
+ console.log(` ✓ removed ${file.relativePath}`);
3283
3255
  } catch {}
3284
3256
  }
3285
- }
3286
- /**
3287
- * Snapshot the working-tree copy of `paths` into HEAD as a single
3288
- * commit. Intended for the migration path: on the first pull with
3289
- * the new CLI (no shadow repo yet, but a `.fluid-theme.json` with
3290
- * checksums exists) we seed HEAD with whatever is on disk before
3291
- * running the merge, so unmodified files fast-forward cleanly and
3292
- * modified files show a diff.
3293
- */
3294
- async seedFromWorkingTree(files, message) {
3295
- const entries = [];
3296
- for (const { path, content } of files) entries.push({
3297
- path,
3298
- sha: await this.writeBlob(content)
3299
- });
3300
- await this.commitState(entries, message);
3301
- }
3302
- async git(args, opts = {}) {
3303
- const child = spawn("git", args[0] === "init" ? args : [
3304
- "--git-dir",
3305
- this.gitDir,
3306
- ...args
3307
- ], {
3308
- cwd: opts.cwd ?? this.themeRoot,
3309
- env: {
3310
- ...process.env,
3311
- ...opts.env
3312
- },
3313
- stdio: [
3314
- "pipe",
3315
- "pipe",
3316
- "pipe"
3317
- ]
3318
- });
3319
- if (opts.input) child.stdin.write(opts.input);
3320
- child.stdin.end();
3321
- return new Promise((resolve, reject) => {
3322
- const stdout = [];
3323
- const stderr = [];
3324
- child.stdout.on("data", (chunk) => stdout.push(chunk));
3325
- child.stderr.on("data", (chunk) => stderr.push(chunk));
3326
- child.on("error", reject);
3327
- child.on("close", (code) => {
3328
- const out = Buffer.concat(stdout);
3329
- const err = Buffer.concat(stderr);
3330
- if (code === 0) resolve({
3331
- stdout: out,
3332
- stderr: err
3333
- });
3334
- else {
3335
- const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
3336
- e.code = code ?? -1;
3337
- e.stdout = out;
3338
- e.stderr = err;
3339
- reject(e);
3340
- }
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
+ }))
3341
3276
  });
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);
3342
3293
  });
3343
- }
3344
- };
3345
- /**
3346
- * Content-type check the pull command uses to decide whether a file
3347
- * is safe to run through `merge3` (line-based) or must fall back to
3348
- * whole-file "either/or" resolution (binary).
3349
- */
3350
- function looksBinary(content) {
3351
- return content.subarray(0, Math.min(content.length, 8e3)).includes(0);
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
+ };
3352
3303
  }
3353
- /** Best-effort readFile that returns null when the file does not exist. */
3354
- function readIfExists(path) {
3355
- try {
3356
- return readFileSync(path);
3357
- } catch {
3358
- return null;
3359
- }
3304
+ //#endregion
3305
+ //#region src/theme-picker.ts
3306
+ const PAGE_SIZE = 50;
3307
+ const LOAD_MORE_VALUE = -1;
3308
+ function themeLabel(t) {
3309
+ const active = t.status === "active" ? ` ${chalk.green("[active]")}` : "";
3310
+ return `${t.name} (#${t.id})${active}`;
3360
3311
  }
3361
- /**
3362
- * Parse the numeric theme id from `.fluid-theme/theme-id`. Returns
3363
- * null when the file is missing or the contents don't parse cleanly;
3364
- * `open` treats that as "unknown theme" and rebuilds the shadow.
3365
- */
3366
- function readStoredThemeId(themeIdFile) {
3367
- try {
3368
- const stored = parseInt(readFileSync(themeIdFile, "utf-8").trim(), 10);
3369
- return Number.isFinite(stored) ? stored : null;
3370
- } catch {
3371
- return null;
3372
- }
3312
+ function themeChoices(themeList, hasMore) {
3313
+ const choices = themeList.map((t) => ({
3314
+ title: themeLabel(t),
3315
+ value: t.id
3316
+ }));
3317
+ if (hasMore) choices.push({
3318
+ title: chalk.dim(`── Load more themes ──`),
3319
+ value: LOAD_MORE_VALUE
3320
+ });
3321
+ return choices;
3373
3322
  }
3374
- /**
3375
- * Append `.fluid-theme/` to the theme root's `.gitignore` when the
3376
- * theme dir sits inside a git working tree and the entry isn't
3377
- * already there. Skipped when the user isn't in a git repo — no
3378
- * point manufacturing a `.gitignore` for someone who doesn't use
3379
- * git. Idempotent — a second call is a no-op.
3380
- */
3381
- async function ensureRootGitignoreHidesShadow(themeRoot) {
3382
- if (!await new Promise((resolve) => {
3383
- const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
3384
- cwd: themeRoot,
3385
- stdio: [
3386
- "ignore",
3387
- "pipe",
3388
- "pipe"
3389
- ]
3390
- });
3391
- child.on("close", (code) => resolve(code === 0));
3392
- child.on("error", () => resolve(false));
3393
- })) return;
3394
- const gitignorePath = join(themeRoot, ".gitignore");
3395
- let existing = "";
3396
- try {
3397
- existing = readFileSync(gitignorePath, "utf-8");
3398
- } catch {
3399
- existing = "";
3400
- }
3401
- const lines = existing.split("\n").map((line) => line.trim());
3402
- if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
3403
- const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
3404
- writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
3323
+ async function fetchThemesPage(api, page, searchQuery) {
3324
+ const body = await listApplicationThemes(api, {
3325
+ per_page: PAGE_SIZE,
3326
+ page,
3327
+ ...searchQuery ? { search_query: searchQuery } : {}
3328
+ });
3329
+ return {
3330
+ themes: body.application_themes ?? [],
3331
+ hasMore: page < (body.meta?.total_pages ?? 1)
3332
+ };
3405
3333
  }
3406
- //#endregion
3407
- //#region src/theme/merge-push.ts
3408
- /**
3409
- * Compute what changed locally since the last time the shadow repo
3410
- * committed a state. Replaces the sha256 `checksums` map: shadow HEAD
3411
- * is the source of truth for "what the CLI last saw the server have".
3412
- *
3413
- * Files whose local bytes are byte-identical to their HEAD blob are
3414
- * skipped; anything else new, modified, or a locally-deleted path
3415
- * that HEAD still has — is included.
3416
- */
3417
- async function diffAgainstShadow(themeRoot, shadow) {
3418
- const changed = [];
3419
- const deleted = [];
3420
- const localFiles = themeRoot.files();
3421
- const localByKey = /* @__PURE__ */ new Map();
3422
- const assetManifest = new ThemeAssetManifest(themeRoot.root);
3423
- for (const file of localFiles) {
3424
- if (!file.exists) continue;
3425
- localByKey.set(file.relativePath, file);
3426
- const headBlob = await shadow.blobAtHead(file.relativePath);
3427
- const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
3428
- if (headBlob && headBlob.equals(localBuf)) continue;
3429
- changed.push(file);
3430
- }
3431
- if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
3432
- if (localByKey.has(key)) continue;
3433
- if (themeRoot.ignore.ignore(key)) continue;
3434
- if (assetManifest.has(key)) continue;
3435
- if (isStylesheetKey(key)) continue;
3436
- deleted.push(key);
3334
+ async function selectTheme(api, message) {
3335
+ const allThemes = [];
3336
+ let page = 1;
3337
+ let hasMore = true;
3338
+ let initialIndex = 0;
3339
+ let searchQuery = "";
3340
+ let searchResults = [];
3341
+ while (true) {
3342
+ if (hasMore && allThemes.length < page * PAGE_SIZE) {
3343
+ const result = await fetchThemesPage(api, page);
3344
+ allThemes.push(...result.themes);
3345
+ hasMore = result.hasMore;
3346
+ }
3347
+ if (!allThemes.length) {
3348
+ console.error("No themes found.");
3349
+ process.exit(1);
3350
+ }
3351
+ const choices = themeChoices(allThemes, hasMore);
3352
+ const { id } = await prompts({
3353
+ type: "autocomplete",
3354
+ name: "id",
3355
+ message,
3356
+ initial: initialIndex,
3357
+ choices,
3358
+ suggest: async (input, choices) => {
3359
+ if (!input) {
3360
+ searchQuery = "";
3361
+ searchResults = [];
3362
+ return choices;
3363
+ }
3364
+ if (input !== searchQuery) {
3365
+ searchQuery = input;
3366
+ try {
3367
+ searchResults = (await fetchThemesPage(api, 1, input)).themes;
3368
+ } catch {
3369
+ searchResults = [];
3370
+ }
3371
+ }
3372
+ return searchResults.map((t) => ({
3373
+ title: themeLabel(t),
3374
+ value: t.id
3375
+ }));
3376
+ }
3377
+ }, { onCancel: () => process.exit(130) });
3378
+ if (id === LOAD_MORE_VALUE) {
3379
+ initialIndex = allThemes.length;
3380
+ page++;
3381
+ continue;
3382
+ }
3383
+ if (!id) {
3384
+ console.error("No theme selected.");
3385
+ process.exit(1);
3386
+ }
3387
+ const found = allThemes.find((t) => t.id === id) ?? searchResults.find((t) => t.id === id);
3388
+ if (found) return found;
3389
+ return (await getApplicationTheme(api, id)).application_theme;
3437
3390
  }
3438
- return {
3439
- changed,
3440
- deleted
3441
- };
3442
3391
  }
3443
- /**
3444
- * Read every path recorded under HEAD's tree. Used to detect local
3445
- * deletions (paths in HEAD, absent from the working tree). Implemented
3446
- * with `git ls-tree -r HEAD --name-only` piped through the shadow
3447
- * repo's plumbing.
3448
- */
3449
- async function listHeadPaths(shadow) {
3450
- return shadow.headPaths();
3392
+ async function findTheme(api, identifier) {
3393
+ const idNum = Number(identifier);
3394
+ if (Number.isInteger(idNum) && idNum > 0) try {
3395
+ const body = await getApplicationTheme(api, idNum);
3396
+ if (body.application_theme) return body.application_theme;
3397
+ } catch {}
3398
+ let page = 1;
3399
+ let hasMore = true;
3400
+ while (hasMore) {
3401
+ const result = await fetchThemesPage(api, page, identifier);
3402
+ const found = result.themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
3403
+ if (found) return found;
3404
+ hasMore = result.hasMore;
3405
+ page++;
3406
+ }
3407
+ console.error(`No theme found with identifier: ${identifier}`);
3408
+ process.exit(1);
3451
3409
  }
3410
+ //#endregion
3411
+ //#region src/workspace.ts
3412
+ const WORKSPACE_FILE = ".fluid-workspace.json";
3452
3413
  /**
3453
- * Refuse a push when any working file still contains a conflict marker
3454
- * from a previous pull. Mirrors git's "you have unresolved conflicts;
3455
- * fix them and re-run" behavior — the whole point of writing markers
3456
- * on pull was to hand resolution to the user, so we can't send them
3457
- * upstream.
3414
+ * Walk up from `startDir` looking for `.fluid-workspace.json`.
3415
+ * Returns the workspace info if found, or `null` if not in a workspace.
3458
3416
  */
3459
- function findUnresolvedConflicts(files) {
3460
- const flagged = [];
3461
- for (const file of files) {
3462
- if (!file.isText) continue;
3463
- const buf = readIfExists(file.absolutePath);
3464
- if (!buf) continue;
3465
- if (containsConflictMarker(buf)) flagged.push(file.relativePath);
3417
+ function findWorkspace(startDir) {
3418
+ let dir = resolve(startDir ?? process.cwd());
3419
+ while (true) {
3420
+ const candidate = join(dir, WORKSPACE_FILE);
3421
+ if (existsSync(candidate)) try {
3422
+ const raw = readFileSync(candidate, "utf-8");
3423
+ const config = JSON.parse(raw);
3424
+ return {
3425
+ root: dir,
3426
+ config
3427
+ };
3428
+ } catch {
3429
+ return null;
3430
+ }
3431
+ const parent = dirname(dir);
3432
+ if (parent === dir) break;
3433
+ dir = parent;
3466
3434
  }
3467
- return flagged;
3435
+ return null;
3468
3436
  }
3469
3437
  /**
3470
- * Stable, machine-readable one-liner for non-interactive callers
3471
- * (Mist Desktop's publish flow parses push output). Uploading marker-
3472
- * bearing files to a live theme is never acceptable, so `--auto-
3473
- * baseline` pushes still refuse — but they emit this line so the
3474
- * desktop can surface WHICH files block the publish instead of a
3475
- * dead-end wall of prose. Format:
3438
+ * If cwd is already inside `{workspace}/local/{company}/...`, return that
3439
+ * theme root directory. Otherwise return null.
3476
3440
  *
3477
- * FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=a.liquid,b.json
3441
+ * Examples (workspace root = /code/fluid-theme-dev):
3442
+ * cwd = /code/fluid-theme-dev/local/acme-co → /code/fluid-theme-dev/local/acme-co
3443
+ * cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
3444
+ * cwd = /code/fluid-theme-dev → null
3445
+ * cwd = /code/fluid-theme-dev/local → null
3478
3446
  */
3479
- function conflictMarkerBlockLine(files) {
3480
- return `FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=${files.join(",")}`;
3447
+ function resolveThemeRootFromCwd(workspace) {
3448
+ const cwd = resolve(process.cwd());
3449
+ const localDir = join(workspace.root, "local");
3450
+ const rel = relative(localDir, cwd);
3451
+ if (rel.startsWith("..") || rel === ".") return null;
3452
+ const firstSegment = rel.split(sep)[0];
3453
+ if (!firstSegment) return null;
3454
+ return join(localDir, firstSegment);
3481
3455
  }
3482
- const CONFLICT_START = Buffer.from("<<<<<<<");
3483
- const CONFLICT_MID = Buffer.from("=======");
3484
- const CONFLICT_END = Buffer.from(">>>>>>>");
3456
+ //#endregion
3457
+ //#region src/commands/dev.ts
3485
3458
  /**
3486
- * A file counts as unresolved when it contains all three marker
3487
- * shapes: `<<<<<<<`, `=======`, and `>>>>>>>`. Requiring all three
3488
- * avoids false positives a line of equals signs alone (e.g. inside
3489
- * an ASCII table in a template comment) doesn't trip the guard.
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.
3490
3464
  */
3491
- function containsConflictMarker(buf) {
3492
- return buf.includes(CONFLICT_START) && buf.includes(CONFLICT_MID) && buf.includes(CONFLICT_END);
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
+ };
3493
3474
  }
3494
3475
  /**
3495
- * Commit the current working-tree state to shadow HEAD after a
3496
- * successful push. Ensures the next pull's merge base is the state
3497
- * we know the server just accepted.
3476
+ * Create the isolated theme used by `theme dev`.
3477
+ *
3478
+ * A checkout from `theme pull` has a source theme id. New servers clone that
3479
+ * source by reference, preserving its DAM/ImageKit assets without moving
3480
+ * bytes. A 404/405 keeps older deployments compatible with the established
3481
+ * empty-theme flow; other failures must remain visible to the developer.
3498
3482
  */
3499
- async function commitPushedState(themeRoot, shadow, message) {
3500
- const entries = [];
3501
- const assetManifest = new ThemeAssetManifest(themeRoot.root);
3502
- const localKeys = /* @__PURE__ */ new Set();
3503
- for (const file of themeRoot.files()) {
3504
- if (!file.exists) continue;
3505
- localKeys.add(file.relativePath);
3506
- const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
3507
- entries.push({
3508
- path: file.relativePath,
3509
- sha: await shadow.writeBlob(buf)
3510
- });
3483
+ async function createDevelopmentTheme(api, sourceThemeId, name) {
3484
+ if (sourceThemeId !== void 0) try {
3485
+ return (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme;
3486
+ } catch (error) {
3487
+ if (!isApiError(error) || error.status !== 404 && error.status !== 405) throw error;
3488
+ console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
3511
3489
  }
3512
- let managedAssetSentinelSha;
3513
- for (const key of assetManifest.keys()) {
3514
- if (localKeys.has(key)) continue;
3515
- managedAssetSentinelSha ??= await shadow.writeBlob(MANAGED_ASSET_SHADOW_SENTINEL);
3516
- entries.push({
3517
- path: key,
3518
- sha: managedAssetSentinelSha
3519
- });
3490
+ return (await createApplicationTheme(api, { application_theme: {
3491
+ name,
3492
+ status: "development"
3493
+ } })).application_theme;
3494
+ }
3495
+ async function ensureDevTheme(api, projectKey, identifier, sourceThemeId, sourceBaseSha, sourceAssetManifestSha) {
3496
+ if (identifier) {
3497
+ const theme = await findTheme(api, identifier);
3498
+ setLastDevThemeId(theme.id);
3499
+ return theme;
3520
3500
  }
3521
- if (entries.length > 0 || await shadow.hasHead()) await shadow.commitState(entries, message);
3501
+ const stored = getDevTheme(projectKey);
3502
+ if (stored && stored.sourceThemeId === sourceThemeId) {
3503
+ try {
3504
+ const existing = (await getApplicationTheme(api, stored.id)).application_theme;
3505
+ if (existing && existing.status === "development") {
3506
+ console.log(`Using existing dev theme #${existing.id}`);
3507
+ setDevTheme(projectKey, {
3508
+ ...stored,
3509
+ id: existing.id,
3510
+ name: existing.name,
3511
+ ...sourceThemeId === void 0 ? {} : { sourceThemeId }
3512
+ });
3513
+ return existing;
3514
+ }
3515
+ } catch {}
3516
+ clearDevTheme(projectKey);
3517
+ }
3518
+ 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));
3520
+ setDevTheme(projectKey, {
3521
+ id: theme.id,
3522
+ name: theme.name,
3523
+ ...sourceThemeId === void 0 ? {} : { sourceThemeId },
3524
+ ...sourceThemeId !== void 0 && sourceBaseSha ? {
3525
+ sourceBaseSha,
3526
+ remoteSha: sourceBaseSha
3527
+ } : {},
3528
+ ...sourceAssetManifestSha ? { assetManifestSha: sourceAssetManifestSha } : {}
3529
+ });
3530
+ console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
3531
+ return theme;
3522
3532
  }
3523
- /**
3524
- * A target can already have every URL-backed FileResource (for example from a
3525
- * reference clone), while this checkout's manifest still names its old source
3526
- * theme. Adopt the target and clear any legacy binary from shadow even though
3527
- * no remote write was necessary.
3528
- */
3529
- async function finalizeManifestOnlyPush(syncer, themeRoot, shadow) {
3530
- syncer.repointManagedAssetsToCurrentTheme();
3531
- await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
3533
+ function createDevCommand() {
3534
+ 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) => {
3535
+ requireToken();
3536
+ let rootPath = opts.root;
3537
+ if (rootPath === ".") {
3538
+ const workspace = findWorkspace();
3539
+ if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
3540
+ }
3541
+ const themeRoot = new ThemeRoot(rootPath);
3542
+ if (!themeRoot.isValid()) {
3543
+ console.error(`'${rootPath}' does not look like a theme directory.`);
3544
+ process.exit(1);
3545
+ }
3546
+ const port = Number(opts.port);
3547
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
3548
+ console.error(`Invalid port: '${opts.port}'. Must be an integer between 1 and 65535.`);
3549
+ process.exit(1);
3550
+ }
3551
+ try {
3552
+ await checkPortAvailable(opts.host, port);
3553
+ } catch (e) {
3554
+ if (e instanceof PortInUseError) console.error(e.message);
3555
+ else console.error(`Failed to check port availability: ${e}`);
3556
+ process.exit(1);
3557
+ }
3558
+ const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
3559
+ const api = createApiClient();
3560
+ const config = readThemeConfig(themeRoot.root);
3561
+ let company;
3562
+ if (config?.company) company = config.company;
3563
+ else {
3564
+ company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain ?? "";
3565
+ if (!company) {
3566
+ console.error("Could not determine company subdomain. Make sure your token is valid.");
3567
+ process.exit(1);
3568
+ }
3569
+ }
3570
+ 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);
3573
+ 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
+ };
3596
+ }
3597
+ const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
3598
+ let stop;
3599
+ const cleanup = () => {
3600
+ stop?.();
3601
+ process.exit(0);
3602
+ };
3603
+ process.on("SIGINT", cleanup);
3604
+ process.on("SIGTERM", cleanup);
3605
+ stop = await startDevServer(api, {
3606
+ id: theme.id,
3607
+ name: theme.name,
3608
+ company,
3609
+ editorUrl
3610
+ }, themeRoot, {
3611
+ host: opts.host,
3612
+ port,
3613
+ reloadMode,
3614
+ validate: !opts.force,
3615
+ ...initialSync ? { initialSync } : {},
3616
+ ...rememberRemoteSha ? { onRemoteSha: rememberRemoteSha } : {}
3617
+ }, (address) => {
3618
+ console.log(`\n Dev server: ${address}`);
3619
+ console.log(` Web editor: ${editorUrl}`);
3620
+ console.log("\n Watching for file changes…\n");
3621
+ if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
3622
+ });
3623
+ await new Promise(() => {});
3624
+ });
3532
3625
  }
3533
3626
  //#endregion
3534
3627
  //#region src/theme/auto-baseline.ts
@@ -3917,7 +4010,8 @@ function createPushCommand() {
3917
4010
  themeId: theme.id,
3918
4011
  themeName: theme.name,
3919
4012
  company: config.company,
3920
- baseSha: baseSha ?? void 0
4013
+ baseSha: baseSha ?? void 0,
4014
+ assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint({ excludePending: true })
3921
4015
  });
3922
4016
  return;
3923
4017
  }
@@ -3929,7 +4023,8 @@ function createPushCommand() {
3929
4023
  themeId: theme.id,
3930
4024
  themeName: theme.name,
3931
4025
  company: subdomain,
3932
- baseSha: baseSha ?? void 0
4026
+ baseSha: baseSha ?? void 0,
4027
+ assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint({ excludePending: true })
3933
4028
  });
3934
4029
  } catch {}
3935
4030
  }
@@ -4252,7 +4347,8 @@ function createPullCommand() {
4252
4347
  themeId: theme.id,
4253
4348
  themeName: theme.name,
4254
4349
  company: subdomain,
4255
- baseSha: remoteSha ?? existingConfig?.baseSha
4350
+ baseSha: remoteSha ?? existingConfig?.baseSha,
4351
+ assetManifestSha: new ThemeAssetManifest(absoluteRoot).fingerprint({ excludePending: true })
4256
4352
  });
4257
4353
  if (result.conflicts.length > 0) process.exit(1);
4258
4354
  });