@fluid-app/fluid-cli-theme-dev 0.1.37 → 0.1.38

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
@@ -1156,45 +1156,6 @@ function readBody(req) {
1156
1156
  });
1157
1157
  }
1158
1158
  //#endregion
1159
- //#region src/theme/dev-server/watcher.ts
1160
- function watchTheme(root, handler) {
1161
- const watcher = chokidar.watch(root.root, {
1162
- ignoreInitial: true,
1163
- ignored: (filePath) => {
1164
- if (filePath.includes("node_modules")) return true;
1165
- try {
1166
- const rel = relative(root.root, filePath);
1167
- return (rel.split(/[\\/]/).pop() ?? "").startsWith(".") || root.ignore.ignore(rel);
1168
- } catch {
1169
- return false;
1170
- }
1171
- },
1172
- persistent: true,
1173
- awaitWriteFinish: {
1174
- stabilityThreshold: 50,
1175
- pollInterval: 10
1176
- }
1177
- });
1178
- let pending = Promise.resolve();
1179
- const enqueue = (fn) => {
1180
- pending = pending.then(fn).catch(() => {});
1181
- };
1182
- watcher.on("change", (filePath) => {
1183
- const rel = relative(root.root, filePath);
1184
- if (root.ignore.ignore(rel)) return;
1185
- enqueue(() => handler([root.file(filePath)], [], []));
1186
- });
1187
- watcher.on("add", (filePath) => {
1188
- const rel = relative(root.root, filePath);
1189
- if (root.ignore.ignore(rel)) return;
1190
- enqueue(() => handler([], [root.file(filePath)], []));
1191
- });
1192
- watcher.on("unlink", (filePath) => {
1193
- enqueue(() => handler([], [], [root.file(filePath)]));
1194
- });
1195
- return () => watcher.close();
1196
- }
1197
- //#endregion
1198
1159
  //#region ../../api-clients/themes/src/namespaces/v0.ts
1199
1160
  /**
1200
1161
  * List application themes
@@ -1281,12 +1242,6 @@ async function deleteThemeResource(client, application_theme_id, body) {
1281
1242
  return client.delete(`/api/application_themes/${application_theme_id}/resources`, { body });
1282
1243
  }
1283
1244
  //#endregion
1284
- //#region src/theme/stylesheet-keys.ts
1285
- const STYLESHEET_KEY_PATTERN = /^(styles\.css|global_styles\.css|[^/]+\/[^/]+\/styles\.css)$/;
1286
- function isStylesheetKey(key) {
1287
- return STYLESHEET_KEY_PATTERN.test(key);
1288
- }
1289
- //#endregion
1290
1245
  //#region src/theme/format-error.ts
1291
1246
  const STYLESHEET_MIGRATION_SKILL = "template-stylesheet-to-asset-migration";
1292
1247
  function isStylesheetKeyRejection(error) {
@@ -1320,6 +1275,53 @@ function formatError(e) {
1320
1275
  return String(e);
1321
1276
  }
1322
1277
  //#endregion
1278
+ //#region src/theme/dev-server/watcher.ts
1279
+ function watchTheme(root, handler) {
1280
+ const watcher = chokidar.watch(root.root, {
1281
+ ignoreInitial: true,
1282
+ ignored: (filePath) => {
1283
+ if (filePath.includes("node_modules")) return true;
1284
+ try {
1285
+ const rel = relative(root.root, filePath);
1286
+ return (rel.split(/[\\/]/).pop() ?? "").startsWith(".") || root.ignore.ignore(rel);
1287
+ } catch {
1288
+ return false;
1289
+ }
1290
+ },
1291
+ persistent: true,
1292
+ awaitWriteFinish: {
1293
+ stabilityThreshold: 50,
1294
+ pollInterval: 10
1295
+ }
1296
+ });
1297
+ let pending = Promise.resolve();
1298
+ const enqueue = (fn) => {
1299
+ pending = pending.then(fn).catch((e) => {
1300
+ console.error(` [Watcher] change handling failed: ${formatError(e)}`);
1301
+ });
1302
+ };
1303
+ watcher.on("change", (filePath) => {
1304
+ const rel = relative(root.root, filePath);
1305
+ if (root.ignore.ignore(rel)) return;
1306
+ enqueue(() => handler([root.file(filePath)], [], []));
1307
+ });
1308
+ watcher.on("add", (filePath) => {
1309
+ const rel = relative(root.root, filePath);
1310
+ if (root.ignore.ignore(rel)) return;
1311
+ enqueue(() => handler([], [root.file(filePath)], []));
1312
+ });
1313
+ watcher.on("unlink", (filePath) => {
1314
+ enqueue(() => handler([], [], [root.file(filePath)]));
1315
+ });
1316
+ return () => watcher.close();
1317
+ }
1318
+ //#endregion
1319
+ //#region src/theme/stylesheet-keys.ts
1320
+ const STYLESHEET_KEY_PATTERN = /^(styles\.css|global_styles\.css|[^/]+\/[^/]+\/styles\.css)$/;
1321
+ function isStylesheetKey(key) {
1322
+ return STYLESHEET_KEY_PATTERN.test(key);
1323
+ }
1324
+ //#endregion
1323
1325
  //#region src/theme/syncer.ts
1324
1326
  /**
1325
1327
  * Server rejected the push because the CLI's `base_sha` no longer
@@ -1371,12 +1373,23 @@ var Syncer = class {
1371
1373
  remoteChecksums() {
1372
1374
  return Object.fromEntries(this.checksums);
1373
1375
  }
1376
+ /**
1377
+ * Uploads one file. Resolves with the exact text content that was
1378
+ * sent to the server (null for binary files) so callers can run
1379
+ * diagnostics against the same bytes instead of re-reading a file
1380
+ * that may have changed on disk while the request was in flight.
1381
+ */
1374
1382
  async uploadFile(file, baseSha) {
1375
- if (file.isText) await this.putResource({
1376
- key: file.relativePath,
1377
- content: file.read()
1378
- }, baseSha);
1379
- else await this.uploadBinaryFile(file, baseSha);
1383
+ if (file.isText) {
1384
+ const content = file.read();
1385
+ await this.putResource({
1386
+ key: file.relativePath,
1387
+ content
1388
+ }, baseSha);
1389
+ return content;
1390
+ }
1391
+ await this.uploadBinaryFile(file, baseSha);
1392
+ return null;
1380
1393
  }
1381
1394
  /**
1382
1395
  * Wraps the generated `updateThemeResource` client with the two
@@ -1622,6 +1635,30 @@ var Syncer = class {
1622
1635
  }
1623
1636
  };
1624
1637
  //#endregion
1638
+ //#region src/theme/liquid-delimiters.ts
1639
+ /**
1640
+ * Heuristic-only check for obviously unbalanced liquid delimiters
1641
+ * (`{% %}` and `{{ }}`). This is NOT a liquid parser — it just counts
1642
+ * delimiter occurrences. It exists to give watch-mode users a signal
1643
+ * when a save is liquid-syntax-broken: the server accepts
1644
+ * syntax-broken liquid silently on upload, and the storefront
1645
+ * renderer then serves stale content for that section with no error
1646
+ * anywhere else in the pipeline.
1647
+ *
1648
+ * Known false positive: delimiters written literally inside a
1649
+ * `{% raw %}...{% endraw %}` block are still counted and can trip
1650
+ * this check even though the liquid is valid. Acceptable for a
1651
+ * warn-only heuristic — a real fix requires parsing liquid, which is
1652
+ * out of scope here (see the server-side validation note in the PR).
1653
+ */
1654
+ function hasUnbalancedLiquidDelimiters(content) {
1655
+ const tagOpens = content.match(/\{%/g)?.length ?? 0;
1656
+ const tagCloses = content.match(/%\}/g)?.length ?? 0;
1657
+ const outputOpens = content.match(/\{\{/g)?.length ?? 0;
1658
+ const outputCloses = content.match(/\}\}/g)?.length ?? 0;
1659
+ return tagOpens !== tagCloses || outputOpens !== outputCloses;
1660
+ }
1661
+ //#endregion
1625
1662
  //#region src/theme/dev-server/port-preflight.ts
1626
1663
  /**
1627
1664
  * The dev command does real work before it ever binds a port: it resolves
@@ -1663,6 +1700,9 @@ function checkPortAvailable(host, port) {
1663
1700
  }
1664
1701
  //#endregion
1665
1702
  //#region src/theme/dev-server/index.ts
1703
+ function timestamp() {
1704
+ return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
1705
+ }
1666
1706
  async function startDevServer(api, theme, themeRoot, opts, onReady) {
1667
1707
  const sse = new SSEStream();
1668
1708
  const syncer = new Syncer(api, theme.id, themeRoot);
@@ -1693,7 +1733,9 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
1693
1733
  }
1694
1734
  pendingUpdates.add(file.relativePath);
1695
1735
  try {
1696
- await syncer.uploadFile(file);
1736
+ const uploadedContent = await syncer.uploadFile(file);
1737
+ console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
1738
+ if (file.isLiquid && uploadedContent !== null && hasUnbalancedLiquidDelimiters(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: unbalanced liquid delimiters — the storefront may silently serve stale content for this section`);
1697
1739
  } catch (e) {
1698
1740
  console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
1699
1741
  } finally {
@@ -1702,6 +1744,7 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
1702
1744
  }
1703
1745
  for (const file of removed) try {
1704
1746
  await syncer.deleteRemoteFile(file.relativePath);
1747
+ console.log(` ✓ removed ${file.relativePath}`);
1705
1748
  } catch {}
1706
1749
  if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
1707
1750
  else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
@@ -2182,8 +2225,15 @@ var ShadowRepo = class ShadowRepo {
2182
2225
  * `base` is null when HEAD has never seen this path; we merge
2183
2226
  * against an empty base, which is what git itself does for a new
2184
2227
  * file added on both sides.
2228
+ *
2229
+ * `favor` maps to `git merge-file`'s `--ours` / `--theirs`: instead
2230
+ * of emitting `<<<<<<<` markers, conflicting hunks are resolved to
2231
+ * the local (`"local"` → `--ours`, local is file1) or remote
2232
+ * (`"remote"` → `--theirs`) side. The output then never contains
2233
+ * markers, so the result is reported conflict-free even when
2234
+ * merge-file's exit code still counts the auto-resolved hunks.
2185
2235
  */
2186
- async merge3(base, local, remote) {
2236
+ async merge3(base, local, remote, favor) {
2187
2237
  const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
2188
2238
  const localPath = join(dir, "local");
2189
2239
  const basePath = join(dir, "base");
@@ -2196,6 +2246,7 @@ var ShadowRepo = class ShadowRepo {
2196
2246
  const { stdout } = await this.git([
2197
2247
  "merge-file",
2198
2248
  "-p",
2249
+ ...favor === "local" ? ["--ours"] : favor === "remote" ? ["--theirs"] : [],
2199
2250
  "-L",
2200
2251
  "local",
2201
2252
  "-L",
@@ -2213,7 +2264,12 @@ var ShadowRepo = class ShadowRepo {
2213
2264
  } catch (err) {
2214
2265
  const e = err;
2215
2266
  const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
2216
- if (typeof e.code === "number" && e.code >= 1 && e.code <= 127 && merged.length > 0) return {
2267
+ const inConflictRange = typeof e.code === "number" && e.code >= 1 && e.code <= 127;
2268
+ if (inConflictRange && favor) return {
2269
+ merged,
2270
+ hasConflicts: false
2271
+ };
2272
+ if (inConflictRange && merged.length > 0) return {
2217
2273
  merged,
2218
2274
  hasConflicts: true
2219
2275
  };
@@ -2408,6 +2464,19 @@ function findUnresolvedConflicts(files) {
2408
2464
  }
2409
2465
  return flagged;
2410
2466
  }
2467
+ /**
2468
+ * Stable, machine-readable one-liner for non-interactive callers
2469
+ * (Mist Desktop's publish flow parses push output). Uploading marker-
2470
+ * bearing files to a live theme is never acceptable, so `--auto-
2471
+ * baseline` pushes still refuse — but they emit this line so the
2472
+ * desktop can surface WHICH files block the publish instead of a
2473
+ * dead-end wall of prose. Format:
2474
+ *
2475
+ * FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=a.liquid,b.json
2476
+ */
2477
+ function conflictMarkerBlockLine(files) {
2478
+ return `FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=${files.join(",")}`;
2479
+ }
2411
2480
  const CONFLICT_START = Buffer.from("<<<<<<<");
2412
2481
  const CONFLICT_MID = Buffer.from("=======");
2413
2482
  const CONFLICT_END = Buffer.from(">>>>>>>");
@@ -2438,9 +2507,185 @@ async function commitPushedState(themeRoot, shadow, message) {
2438
2507
  if (entries.length > 0) await shadow.commitState(entries, message);
2439
2508
  }
2440
2509
  //#endregion
2510
+ //#region src/theme/auto-baseline.ts
2511
+ /**
2512
+ * `fluid theme push --auto-baseline`: when a theme directory has no
2513
+ * shadow baseline (scaffold that never pulled, or a dir last synced
2514
+ * by the checksum-era CLI whose migration had nothing to seed),
2515
+ * record the server's CURRENT state as the baseline commit so the
2516
+ * normal push diff (local vs baseline) can run.
2517
+ *
2518
+ * Invariants:
2519
+ *
2520
+ * 1. **The working tree is never touched.** Baseline recording writes
2521
+ * blobs into the bare shadow repo only; not a single byte on disk
2522
+ * changes. Local files identical to the server simply won't diff;
2523
+ * files that differ (or exist only locally) will push.
2524
+ *
2525
+ * 2. **Server-only files are never deleted.** Paths that exist on the
2526
+ * server but not locally are deliberately excluded from the
2527
+ * baseline commit. `diffAgainstShadow` reports deletions as "in
2528
+ * HEAD but not on disk", so putting server-only paths into HEAD
2529
+ * would mark them for remote deletion — on THIS push (or worse, a
2530
+ * later one) — for files the user never had. Excluding them makes
2531
+ * the diff structurally unable to delete them; the next `pull`
2532
+ * materializes them locally and records them for real.
2533
+ *
2534
+ * Bandwidth note: a server file whose sha256 checksum matches the
2535
+ * local file's is recorded from the LOCAL bytes (identical by
2536
+ * definition), so binary assets that are already in sync are never
2537
+ * downloaded just to seed the baseline.
2538
+ *
2539
+ * No-op (`seeded: false`) when HEAD already exists.
2540
+ */
2541
+ async function seedBaselineFromServer(input) {
2542
+ const { shadow, themeRoot, remote, fetchBinary, message } = input;
2543
+ const result = {
2544
+ seeded: false,
2545
+ recorded: [],
2546
+ serverOnly: [],
2547
+ errors: []
2548
+ };
2549
+ if (await shadow.hasHead()) return result;
2550
+ const entries = [];
2551
+ for (const resource of remote) {
2552
+ const file = themeRoot.file(resource.key);
2553
+ if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
2554
+ result.errors.push(`Baseline ${resource.key}: path traversal detected`);
2555
+ continue;
2556
+ }
2557
+ if (!file.exists) {
2558
+ result.serverOnly.push(resource.key);
2559
+ continue;
2560
+ }
2561
+ let content;
2562
+ if (resource.checksum && file.checksum() === resource.checksum) content = file.isText ? Buffer.from(file.read()) : file.readBinary();
2563
+ else try {
2564
+ content = await materializeRemote(resource, fetchBinary);
2565
+ } catch (e) {
2566
+ result.errors.push(`Baseline ${resource.key}: ${e instanceof Error ? e.message : String(e)}`);
2567
+ continue;
2568
+ }
2569
+ if (content == null) continue;
2570
+ entries.push({
2571
+ path: resource.key,
2572
+ sha: await shadow.writeBlob(content)
2573
+ });
2574
+ result.recorded.push(resource.key);
2575
+ }
2576
+ if (entries.length > 0) {
2577
+ await shadow.commitState(entries, message);
2578
+ result.seeded = true;
2579
+ }
2580
+ return result;
2581
+ }
2582
+ async function materializeRemote(resource, fetchBinary) {
2583
+ if (resource.resource_type === "FileResource" && resource.url) return fetchBinary(resource.url);
2584
+ if (resource.content == null) return null;
2585
+ const text = typeof resource.content === "string" ? resource.content : JSON.stringify(resource.content);
2586
+ return Buffer.from(text);
2587
+ }
2588
+ //#endregion
2589
+ //#region src/theme/legacy-migration.ts
2590
+ /**
2591
+ * On first pull after upgrading from a checksum-era CLI, the shadow
2592
+ * repo starts with no HEAD. `mergePull` would then run every diverged
2593
+ * file through a null-base merge — even files the user never touched
2594
+ * locally — producing spurious `<<<<<<<` markers for every file the
2595
+ * server updated since the last pull.
2596
+ *
2597
+ * Recover a real merge base by trusting the legacy sha256 checksums:
2598
+ * any local file whose content still matches its stored checksum is
2599
+ * "unmodified since last pull" and can be committed as HEAD. Files
2600
+ * whose local sha256 diverges from the stored checksum stay
2601
+ * unseeded — we don't have their pre-modification content, so a
2602
+ * null-base merge (marker-first UX) is the honest fallback for them.
2603
+ *
2604
+ * Idempotent: no-op when HEAD already exists, when there is no
2605
+ * legacy config, when the config is for a different theme, or when
2606
+ * the checksums map is empty. Runs before `mergePull` so its base
2607
+ * lookups see the seeded tree.
2608
+ */
2609
+ async function migrateLegacyChecksumsIntoShadow(input) {
2610
+ const { shadow, themeRoot, absoluteRoot, themeId } = input;
2611
+ if (await shadow.hasHead()) return;
2612
+ const legacy = readLegacyThemeConfig(absoluteRoot);
2613
+ if (!legacy) return;
2614
+ if (legacy.themeId !== themeId) return;
2615
+ if (!legacy.checksums || Object.keys(legacy.checksums).length === 0) return;
2616
+ const seed = [];
2617
+ for (const file of themeRoot.files()) {
2618
+ if (!file.exists) continue;
2619
+ const stored = legacy.checksums[file.relativePath];
2620
+ if (!stored) continue;
2621
+ if (file.checksum() !== stored) continue;
2622
+ const content = file.isText ? Buffer.from(file.read()) : file.readBinary();
2623
+ seed.push({
2624
+ path: file.relativePath,
2625
+ content
2626
+ });
2627
+ }
2628
+ if (seed.length === 0) return;
2629
+ await shadow.seedFromWorkingTree(seed, `migrate from checksum-era CLI @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
2630
+ }
2631
+ //#endregion
2632
+ //#region src/theme/sync-identity.ts
2633
+ /** Legacy (actor-less) wording per verb. `Push`/`Pull` are byte-
2634
+ * compatible with what the CLI has always written. */
2635
+ const LEGACY_WORDING = {
2636
+ Push: "push",
2637
+ Pull: "pull",
2638
+ "Baseline from server": "baseline from server",
2639
+ "Snapshot before pull": "snapshot before pull"
2640
+ };
2641
+ /** Human base message per verb, used in the stamped format. */
2642
+ const BASE_MESSAGE = {
2643
+ Push: "Push from Fluid CLI",
2644
+ Pull: "Pull from Fluid CLI",
2645
+ "Baseline from server": "Baseline from server",
2646
+ "Snapshot before pull": "Snapshot before pull"
2647
+ };
2648
+ function syncCommitSubject(verb, actor, when = /* @__PURE__ */ new Date()) {
2649
+ if (!actor) return `${LEGACY_WORDING[verb]} @ ${when.toISOString()}`;
2650
+ const stamp = when.toISOString().slice(0, 19).replace("T", " ");
2651
+ const idSuffix = actor.publicId ? ` (${actor.publicId})` : "";
2652
+ return `${actor.name}: ${BASE_MESSAGE[verb]} · ${stamp}${idSuffix}`;
2653
+ }
2654
+ /** Resolve a `/api/me` body to a commit actor. Exported for tests.
2655
+ * Name fallback ordering matches the Mist CLI: full_name (or a
2656
+ * first+last composition) → email → `user-<id>`; null when none of
2657
+ * those exist — the caller then uses the legacy wording. */
2658
+ function actorFromMe(raw) {
2659
+ const user = raw.user ?? raw;
2660
+ const composed = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
2661
+ const name = user.full_name && user.full_name.trim() || (composed.length > 0 ? composed : null) || user.email && user.email.trim() || (typeof user.id === "number" && Number.isFinite(user.id) ? `user-${user.id}` : null);
2662
+ if (!name) return null;
2663
+ return {
2664
+ name,
2665
+ publicId: user.public_id && user.public_id.trim() || null
2666
+ };
2667
+ }
2668
+ /**
2669
+ * Best-effort fetch of the signed-in user for commit stamping. Races
2670
+ * `/api/me` against a short timeout and NEVER rejects — a sync must
2671
+ * not block or fail because the identity lookup did. Callers kick
2672
+ * this off early and await it only at commit time.
2673
+ */
2674
+ async function fetchSyncActor(api, timeoutMs = 5e3) {
2675
+ try {
2676
+ return actorFromMe(await Promise.race([api.get("/api/me"), new Promise((_, reject) => {
2677
+ setTimeout(() => reject(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms`)), timeoutMs).unref?.();
2678
+ })]));
2679
+ } catch (err) {
2680
+ const reason = err instanceof Error ? err.message : String(err);
2681
+ console.warn(` (skipping user-stamp on sync commit — couldn't fetch /api/me: ${reason})`);
2682
+ return null;
2683
+ }
2684
+ }
2685
+ //#endregion
2441
2686
  //#region src/commands/push.ts
2442
2687
  function createPushCommand() {
2443
- return new Command("push").description("Push local theme files to a remote theme").option("-t, --theme <name-or-id>", "Theme name or ID to push to").option("-n, --nodelete", "Do not delete remote files missing locally").option("-f, --force", "Skip schema validation and the server-side merge check").option("-p, --publish", "Publish the theme after pushing").option("-u, --unpublished", "Create a new unpublished theme and push to it").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
2688
+ return new Command("push").description("Push local theme files to a remote theme").option("-t, --theme <name-or-id>", "Theme name or ID to push to").option("-n, --nodelete", "Do not delete remote files missing locally").option("-f, --force", "Skip schema validation and the server-side merge check").option("-p, --publish", "Publish the theme after pushing").option("-u, --unpublished", "Create a new unpublished theme and push to it").option("--auto-baseline", "When no local baseline exists, record the server's current state as the baseline and push only what differs locally (never modifies local files, never deletes server-only files)").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
2444
2689
  requireToken();
2445
2690
  let rootPath = opts.root;
2446
2691
  if (rootPath === ".") {
@@ -2476,7 +2721,16 @@ function createPushCommand() {
2476
2721
  theme = (await getApplicationTheme(api, config.themeId)).application_theme;
2477
2722
  } else theme = await selectTheme(api, "Select a theme to push to");
2478
2723
  const shadow = await ShadowRepo.open(themeRoot.root, theme.id);
2479
- const unresolved = findUnresolvedConflicts(themeRoot.files().filter((f) => f.exists));
2724
+ await migrateLegacyChecksumsIntoShadow({
2725
+ shadow,
2726
+ themeRoot,
2727
+ absoluteRoot: themeRoot.root,
2728
+ themeId: theme.id
2729
+ });
2730
+ const localFiles = themeRoot.files().filter((f) => f.exists);
2731
+ let actorPromise = null;
2732
+ const getActor = () => actorPromise ??= fetchSyncActor(api);
2733
+ const unresolved = findUnresolvedConflicts(localFiles);
2480
2734
  if (unresolved.length > 0) {
2481
2735
  console.log();
2482
2736
  console.log(chalk.red(`✗ ${unresolved.length} file(s) still contain unresolved conflict markers:`));
@@ -2485,29 +2739,55 @@ function createPushCommand() {
2485
2739
  console.log(` Edit each file to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} sections,`);
2486
2740
  console.log(` then re-run ${chalk.cyan("fluid theme push")}.`);
2487
2741
  console.log();
2742
+ if (opts.autoBaseline) console.error(conflictMarkerBlockLine(unresolved));
2488
2743
  process.exit(1);
2489
2744
  }
2745
+ const syncer = new Syncer(api, theme.id, themeRoot);
2746
+ const configMatchesTheme = config?.themeId === theme.id;
2747
+ let baseSha = opts.force || !configMatchesTheme ? null : config?.baseSha ?? null;
2748
+ const hasBaseline = baseSha != null || await shadow.hasHead();
2749
+ if (!opts.force && !opts.unpublished && !hasBaseline) {
2750
+ if (!opts.autoBaseline) {
2751
+ console.error();
2752
+ console.error(chalk.red(`No local baseline for theme "${theme.name}" (#${theme.id}).`));
2753
+ console.error();
2754
+ console.error(` Run ${chalk.cyan(`fluid theme pull -t ${theme.id}`)} first to sync down the current server state,`);
2755
+ console.error(` then push — this way you see what would change before it goes live.`);
2756
+ console.error();
2757
+ console.error(` Or re-run with ${chalk.cyan("--auto-baseline")} to record the server's current state`);
2758
+ console.error(` as the baseline and push only what differs locally (local files are never modified).`);
2759
+ console.error();
2760
+ console.error(` Or, if you know what you're doing and want to overwrite the server's current`);
2761
+ console.error(` contents wholesale, re-run with ${chalk.cyan("--force")}.`);
2762
+ console.error();
2763
+ process.exit(1);
2764
+ }
2765
+ const baselineSpinner = ora(`No local baseline — recording current server state for ${theme.name} (#${theme.id})…`).start();
2766
+ try {
2767
+ const seedResult = await seedBaselineFromServer({
2768
+ shadow,
2769
+ themeRoot,
2770
+ remote: await syncer.downloadAll(),
2771
+ fetchBinary: (url) => syncer.downloadBinaryAsset(url),
2772
+ message: syncCommitSubject("Baseline from server", await getActor())
2773
+ });
2774
+ baseSha = syncer.remoteSha() ?? null;
2775
+ const parts = [`recorded ${seedResult.recorded.length} file(s)`];
2776
+ if (seedResult.serverOnly.length > 0) parts.push(`left ${seedResult.serverOnly.length} server-only file(s) untouched`);
2777
+ baselineSpinner.succeed(`Baseline recorded — ${parts.join(", ")}.`);
2778
+ for (const err of seedResult.errors) console.warn(` ${chalk.yellow("warn")} ${err}`);
2779
+ } catch (e) {
2780
+ baselineSpinner.fail(`Could not record a baseline from the server: ${formatError(e)}`);
2781
+ process.exit(1);
2782
+ }
2783
+ }
2490
2784
  const { changed, deleted } = await diffAgainstShadow(themeRoot, shadow);
2491
2785
  if (changed.length === 0 && deleted.length === 0) {
2492
2786
  console.log("Nothing to push — local matches the last synced state.");
2787
+ await persistConfig();
2493
2788
  return;
2494
2789
  }
2495
2790
  const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
2496
- const syncer = new Syncer(api, theme.id, themeRoot);
2497
- const configMatchesTheme = config?.themeId === theme.id;
2498
- let baseSha = opts.force || !configMatchesTheme ? null : config?.baseSha ?? null;
2499
- if (!opts.force && !opts.unpublished && !baseSha) {
2500
- console.error();
2501
- console.error(chalk.red(`No local baseline for theme "${theme.name}" (#${theme.id}).`));
2502
- console.error();
2503
- console.error(` Run ${chalk.cyan(`fluid theme pull -t ${theme.id}`)} first to sync down the current server state,`);
2504
- console.error(` then push — this way you see what would change before it goes live.`);
2505
- console.error();
2506
- console.error(` Or, if you know what you're doing and want to overwrite the server's current`);
2507
- console.error(` contents wholesale, re-run with ${chalk.cyan("--force")}.`);
2508
- console.error();
2509
- process.exit(1);
2510
- }
2511
2791
  try {
2512
2792
  await syncer.preflightPush(baseSha);
2513
2793
  baseSha = syncer.remoteSha() ?? baseSha;
@@ -2569,13 +2849,38 @@ function createPushCommand() {
2569
2849
  spinner.warn(`Pushed with ${errors.length} error(s).`);
2570
2850
  for (const err of errors) console.error(` ${err}`);
2571
2851
  } else spinner.succeed(`Pushed ${uploaded} file(s)` + (deletedCount > 0 ? `, deleted ${deletedCount} remote file(s).` : "."));
2572
- if (errors.length === 0) await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
2573
- if (config) writeThemeConfig(themeRoot.root, {
2574
- themeId: theme.id,
2575
- themeName: theme.name,
2576
- company: config.company,
2577
- baseSha: baseSha ?? void 0
2578
- });
2852
+ if (errors.length === 0) await commitPushedState(themeRoot, shadow, syncCommitSubject("Push", await getActor()));
2853
+ await persistConfig();
2854
+ /**
2855
+ * Persist `.fluid-theme.json`. When a config already exists,
2856
+ * this is the pre-existing baseSha refresh. When it doesn't
2857
+ * (a scaffold's first `--auto-baseline` push), bind the dir
2858
+ * to the theme now — best-effort, since the company subdomain
2859
+ * needs one more API call — so the next push/pull needs no
2860
+ * interactive picker.
2861
+ */
2862
+ async function persistConfig() {
2863
+ if (config) {
2864
+ writeThemeConfig(themeRoot.root, {
2865
+ themeId: theme.id,
2866
+ themeName: theme.name,
2867
+ company: config.company,
2868
+ baseSha: baseSha ?? void 0
2869
+ });
2870
+ return;
2871
+ }
2872
+ if (!opts.autoBaseline) return;
2873
+ try {
2874
+ const subdomain = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain;
2875
+ if (!subdomain) return;
2876
+ writeThemeConfig(themeRoot.root, {
2877
+ themeId: theme.id,
2878
+ themeName: theme.name,
2879
+ company: subdomain,
2880
+ baseSha: baseSha ?? void 0
2881
+ });
2882
+ } catch {}
2883
+ }
2579
2884
  if (opts.publish) {
2580
2885
  const pubSpinner = ora("Publishing theme…").start();
2581
2886
  try {
@@ -2617,10 +2922,19 @@ async function mergePull(input) {
2617
2922
  written: 0,
2618
2923
  merged: 0,
2619
2924
  conflicts: [],
2925
+ autoResolved: [],
2620
2926
  deleted: 0,
2621
2927
  skipped: 0,
2622
2928
  errors: []
2623
2929
  };
2930
+ const pendingWrites = [];
2931
+ const write = (file, content) => {
2932
+ if (input.resolve) pendingWrites.push({
2933
+ file,
2934
+ content
2935
+ });
2936
+ else file.write(content);
2937
+ };
2624
2938
  const remoteContent = /* @__PURE__ */ new Map();
2625
2939
  const remoteKeys = /* @__PURE__ */ new Set();
2626
2940
  let done = 0;
@@ -2641,14 +2955,14 @@ async function mergePull(input) {
2641
2955
  continue;
2642
2956
  }
2643
2957
  if (input.force) {
2644
- file.write(remoteBuf);
2958
+ write(file, remoteBuf);
2645
2959
  result.written++;
2646
2960
  continue;
2647
2961
  }
2648
2962
  const localBuf = readIfExists(file.absolutePath);
2649
2963
  const baseBuf = await shadow.blobAtHead(key);
2650
2964
  if (localBuf == null) {
2651
- file.write(remoteBuf);
2965
+ write(file, remoteBuf);
2652
2966
  result.written++;
2653
2967
  continue;
2654
2968
  }
@@ -2657,7 +2971,7 @@ async function mergePull(input) {
2657
2971
  continue;
2658
2972
  }
2659
2973
  if (baseBuf && localBuf.equals(baseBuf)) {
2660
- file.write(remoteBuf);
2974
+ write(file, remoteBuf);
2661
2975
  result.written++;
2662
2976
  continue;
2663
2977
  }
@@ -2666,15 +2980,29 @@ async function mergePull(input) {
2666
2980
  continue;
2667
2981
  }
2668
2982
  if (looksBinary(localBuf) || looksBinary(remoteBuf) || (baseBuf ? looksBinary(baseBuf) : false)) {
2669
- file.write(remoteBuf);
2670
- result.conflicts.push(`${key} (binary — kept remote)`);
2983
+ if (input.resolve === "local") {
2984
+ result.autoResolved.push(`${key} (binary — kept local)`);
2985
+ continue;
2986
+ }
2987
+ write(file, remoteBuf);
2988
+ if (input.resolve === "remote") result.autoResolved.push(`${key} (binary — kept remote)`);
2989
+ else result.conflicts.push(`${key} (binary — kept remote)`);
2671
2990
  continue;
2672
2991
  }
2673
2992
  const { merged, hasConflicts } = await shadow.merge3(baseBuf, localBuf, remoteBuf);
2674
- file.write(merged);
2993
+ if (hasConflicts && input.resolve) {
2994
+ write(file, (await shadow.merge3(baseBuf, localBuf, remoteBuf, input.resolve)).merged);
2995
+ result.autoResolved.push(key);
2996
+ continue;
2997
+ }
2998
+ write(file, merged);
2675
2999
  if (hasConflicts) result.conflicts.push(key);
2676
3000
  else result.merged++;
2677
3001
  }
3002
+ if (input.resolve) {
3003
+ if (result.autoResolved.length > 0) await commitPushedState(themeRoot, shadow, syncCommitSubject("Snapshot before pull", input.actor ?? null));
3004
+ for (const { file, content } of pendingWrites) file.write(content);
3005
+ }
2678
3006
  if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
2679
3007
  if (remoteKeys.has(file.relativePath)) continue;
2680
3008
  if (isStylesheetKey(file.relativePath)) continue;
@@ -2702,7 +3030,7 @@ async function mergePull(input) {
2702
3030
  sha: await shadow.writeBlob(prevBlob)
2703
3031
  });
2704
3032
  }
2705
- if (commitEntries.length > 0) await shadow.commitState(commitEntries, `pull @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
3033
+ if (commitEntries.length > 0) await shadow.commitState(commitEntries, syncCommitSubject("Pull", input.actor ?? null));
2706
3034
  return result;
2707
3035
  }
2708
3036
  async function materialize(resource, fetchBinary) {
@@ -2715,49 +3043,6 @@ function errMsg(e) {
2715
3043
  return e instanceof Error ? e.message : String(e);
2716
3044
  }
2717
3045
  //#endregion
2718
- //#region src/theme/legacy-migration.ts
2719
- /**
2720
- * On first pull after upgrading from a checksum-era CLI, the shadow
2721
- * repo starts with no HEAD. `mergePull` would then run every diverged
2722
- * file through a null-base merge — even files the user never touched
2723
- * locally — producing spurious `<<<<<<<` markers for every file the
2724
- * server updated since the last pull.
2725
- *
2726
- * Recover a real merge base by trusting the legacy sha256 checksums:
2727
- * any local file whose content still matches its stored checksum is
2728
- * "unmodified since last pull" and can be committed as HEAD. Files
2729
- * whose local sha256 diverges from the stored checksum stay
2730
- * unseeded — we don't have their pre-modification content, so a
2731
- * null-base merge (marker-first UX) is the honest fallback for them.
2732
- *
2733
- * Idempotent: no-op when HEAD already exists, when there is no
2734
- * legacy config, when the config is for a different theme, or when
2735
- * the checksums map is empty. Runs before `mergePull` so its base
2736
- * lookups see the seeded tree.
2737
- */
2738
- async function migrateLegacyChecksumsIntoShadow(input) {
2739
- const { shadow, themeRoot, absoluteRoot, themeId } = input;
2740
- if (await shadow.hasHead()) return;
2741
- const legacy = readLegacyThemeConfig(absoluteRoot);
2742
- if (!legacy) return;
2743
- if (legacy.themeId !== themeId) return;
2744
- if (!legacy.checksums || Object.keys(legacy.checksums).length === 0) return;
2745
- const seed = [];
2746
- for (const file of themeRoot.files()) {
2747
- if (!file.exists) continue;
2748
- const stored = legacy.checksums[file.relativePath];
2749
- if (!stored) continue;
2750
- if (file.checksum() !== stored) continue;
2751
- const content = file.isText ? Buffer.from(file.read()) : file.readBinary();
2752
- seed.push({
2753
- path: file.relativePath,
2754
- content
2755
- });
2756
- }
2757
- if (seed.length === 0) return;
2758
- await shadow.seedFromWorkingTree(seed, `migrate from checksum-era CLI @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
2759
- }
2760
- //#endregion
2761
3046
  //#region src/commands/pull.ts
2762
3047
  async function fetchCompanySubdomain(api) {
2763
3048
  const subdomain = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain;
@@ -2768,8 +3053,13 @@ async function fetchCompanySubdomain(api) {
2768
3053
  return subdomain;
2769
3054
  }
2770
3055
  function createPullCommand() {
2771
- return new Command("pull").description("Pull a remote theme to your local directory").option("-t, --theme <name-or-id>", "Theme name or ID to pull").option("-n, --nodelete", "Do not delete local files missing on remote").option("--root <path>", "Theme root directory").option("-y, --yes", "Skip confirmation prompt").option("-f, --force", "Overwrite local without merging (skip conflict markers)").action(async (opts) => {
3056
+ return new Command("pull").description("Pull a remote theme to your local directory").option("-t, --theme <name-or-id>", "Theme name or ID to pull").option("-n, --nodelete", "Do not delete local files missing on remote").option("--root <path>", "Theme root directory").option("-y, --yes", "Skip confirmation prompt").option("-f, --force", "Overwrite local without merging (skip conflict markers)").option("--resolve <side>", "Auto-resolve merge conflicts to one side instead of writing conflict markers: 'local' keeps your files' hunks, 'remote' takes the server's (for non-interactive use)").action(async (opts) => {
2772
3057
  requireToken();
3058
+ if (opts.resolve !== void 0 && opts.resolve !== "local" && opts.resolve !== "remote") {
3059
+ console.error(`Invalid --resolve value "${opts.resolve}" — use "local" or "remote".`);
3060
+ process.exit(1);
3061
+ }
3062
+ const resolveSide = opts.resolve;
2773
3063
  const api = createApiClient();
2774
3064
  const workspace = findWorkspace();
2775
3065
  const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
@@ -2806,6 +3096,7 @@ function createPullCommand() {
2806
3096
  themeId: theme.id
2807
3097
  });
2808
3098
  const syncer = new Syncer(api, theme.id, themeRoot);
3099
+ const actorPromise = fetchSyncActor(api);
2809
3100
  const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
2810
3101
  const result = await mergePull({
2811
3102
  themeRoot,
@@ -2814,6 +3105,8 @@ function createPullCommand() {
2814
3105
  fetchBinary: (url) => syncer.downloadBinaryAsset(url),
2815
3106
  delete: !opts.nodelete,
2816
3107
  force: opts.force ?? false,
3108
+ resolve: resolveSide,
3109
+ actor: await actorPromise,
2817
3110
  onProgress: (done, total) => {
2818
3111
  spinner.text = `Downloading ${done}/${total} files…`;
2819
3112
  }
@@ -2821,6 +3114,7 @@ function createPullCommand() {
2821
3114
  const parts = [];
2822
3115
  if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
2823
3116
  if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
3117
+ if (result.autoResolved.length > 0) parts.push(`auto-resolved ${result.autoResolved.length} conflict(s) (kept ${resolveSide})`);
2824
3118
  if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
2825
3119
  if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
2826
3120
  if (result.errors.length) {