@fluid-app/fluid-cli-theme-dev 0.1.54 → 0.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { getAuthToken, gitSyncActorFromMe, gitSyncCommitSubject, readConfig, resolveGitSyncActor, summarizeChanges, updateConfig } from "@fluid-app/fluid-cli";
3
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import http from "node:http";
@@ -285,10 +285,16 @@ function createFetchClient(config) {
285
285
  function getApiBase() {
286
286
  return process.env["FLUID_API_BASE"] ?? "https://api.fluid.app";
287
287
  }
288
+ let backgroundGate = Promise.resolve();
289
+ async function backgroundToken(tokenOverride) {
290
+ backgroundGate = backgroundGate.then(() => new Promise((resolve) => setTimeout(resolve, 1e3)));
291
+ await backgroundGate;
292
+ return tokenOverride ?? getAuthToken() ?? null;
293
+ }
288
294
  function createApiClient(tokenOverride) {
289
295
  return createFetchClient({
290
296
  baseUrl: getApiBase(),
291
- getAuthToken: () => tokenOverride ?? getAuthToken() ?? null
297
+ getAuthToken: () => process.env["FLUID_BACKGROUND_SYNC"] === "1" ? backgroundToken(tokenOverride) : tokenOverride ?? getAuthToken() ?? null
292
298
  });
293
299
  }
294
300
  function requireToken() {
@@ -1455,6 +1461,75 @@ function watchTheme(root, handler) {
1455
1461
  return () => watcher.close();
1456
1462
  }
1457
1463
  //#endregion
1464
+ //#region src/theme/background-pull-guard.ts
1465
+ const METADATA = [
1466
+ ".fluid-assets.json",
1467
+ ".fluid-theme.json",
1468
+ ".fluidignore"
1469
+ ];
1470
+ var BackgroundPullChangedError = class extends Error {
1471
+ constructor() {
1472
+ super("Local files changed during background pull; remaining updates skipped.");
1473
+ }
1474
+ };
1475
+ /** Keep the preflight snapshot across asynchronous download/merge work. Each
1476
+ * mutation checks its original bytes synchronously and records only our writes.
1477
+ * This prevents an edit made after preflight from becoming a merge input or
1478
+ * being overwritten by a deferred write, asset cleanup, or metadata update. */
1479
+ var BackgroundPullGuard = class {
1480
+ root;
1481
+ expected;
1482
+ constructor(root) {
1483
+ this.root = resolve(root);
1484
+ this.expected = this.snapshot();
1485
+ }
1486
+ assertUnchanged() {
1487
+ const current = this.snapshot();
1488
+ for (const key of new Set([...current.keys(), ...this.expected.keys()])) if ((current.get(key) ?? null) !== (this.expected.get(key) ?? null)) throw new BackgroundPullChangedError();
1489
+ }
1490
+ mutate(keys, action) {
1491
+ for (const key of keys) if (this.fingerprint(key) !== (this.expected.get(key) ?? null)) throw new BackgroundPullChangedError();
1492
+ action();
1493
+ for (const key of keys) this.expected.set(key, this.fingerprint(key));
1494
+ }
1495
+ fingerprint(key) {
1496
+ const path = resolve(this.root, key);
1497
+ if (!path.startsWith(this.root + sep)) throw new BackgroundPullChangedError();
1498
+ for (let cursor = path;; cursor = dirname(cursor)) {
1499
+ try {
1500
+ if (lstatSync(cursor).isSymbolicLink()) throw new BackgroundPullChangedError();
1501
+ } catch (error) {
1502
+ if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")) throw error;
1503
+ }
1504
+ if (cursor === this.root) break;
1505
+ }
1506
+ try {
1507
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
1508
+ } catch (error) {
1509
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return null;
1510
+ throw error;
1511
+ }
1512
+ }
1513
+ snapshot() {
1514
+ const result = /* @__PURE__ */ new Map();
1515
+ const visit = (directory) => {
1516
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
1517
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
1518
+ if (entry.isSymbolicLink()) throw new BackgroundPullChangedError();
1519
+ const path = join(directory, entry.name);
1520
+ if (entry.isDirectory()) visit(path);
1521
+ else {
1522
+ const key = relative(this.root, path).split(sep).join("/");
1523
+ result.set(key, this.fingerprint(key));
1524
+ }
1525
+ }
1526
+ };
1527
+ visit(this.root);
1528
+ for (const key of METADATA) result.set(key, this.fingerprint(key));
1529
+ return result;
1530
+ }
1531
+ };
1532
+ //#endregion
1458
1533
  //#region src/theme/case-collisions.ts
1459
1534
  var CaseCollisionError = class extends Error {
1460
1535
  constructor(collisions) {
@@ -2228,6 +2303,7 @@ var Syncer = class {
2228
2303
  try {
2229
2304
  assetMetadata = await this.fetchThemeAssetMetadata(this.themeId);
2230
2305
  } catch (error) {
2306
+ if (error instanceof BackgroundPullChangedError) throw error;
2231
2307
  errors.push(`Read remote asset metadata: ${formatError(error)}`);
2232
2308
  for (const resource of resourcesNeedingMetadata) if (resource.key) unresolvedMetadataKeys.add(resource.key);
2233
2309
  }
@@ -2238,6 +2314,7 @@ var Syncer = class {
2238
2314
  errors.push(`Could not find usable metadata for ${key} in theme #${this.themeId}`);
2239
2315
  }
2240
2316
  }
2317
+ opts.backgroundGuard?.assertUnchanged();
2241
2318
  for (const resource of resources) {
2242
2319
  const key = resource.key;
2243
2320
  if (!key) continue;
@@ -2277,6 +2354,7 @@ var Syncer = class {
2277
2354
  managedKeys.add(key);
2278
2355
  filesToRemove.set(key, file);
2279
2356
  } catch (error) {
2357
+ if (error instanceof BackgroundPullChangedError) throw error;
2280
2358
  errors.push(`Externalize ${key}: ${formatError(error)}`);
2281
2359
  }
2282
2360
  }
@@ -2286,8 +2364,10 @@ var Syncer = class {
2286
2364
  manifestChanged = true;
2287
2365
  }
2288
2366
  if (manifestChanged) try {
2289
- this.assetManifest.write();
2367
+ if (opts.backgroundGuard) opts.backgroundGuard.mutate([".fluid-assets.json"], () => this.assetManifest.write());
2368
+ else this.assetManifest.write();
2290
2369
  } catch (error) {
2370
+ if (error instanceof BackgroundPullChangedError) throw error;
2291
2371
  errors.push(`Persist remote asset manifest: ${formatError(error)}`);
2292
2372
  for (const key of changedManifestKeys) managedKeys.delete(key);
2293
2373
  return {
@@ -2298,9 +2378,11 @@ var Syncer = class {
2298
2378
  }
2299
2379
  let linked = 0;
2300
2380
  for (const [key, file] of filesToRemove) try {
2301
- if (file.exists) unlinkSync(file.absolutePath);
2381
+ if (file.exists) if (opts.backgroundGuard) opts.backgroundGuard.mutate([key], () => unlinkSync(file.absolutePath));
2382
+ else unlinkSync(file.absolutePath);
2302
2383
  linked++;
2303
2384
  } catch (error) {
2385
+ if (error instanceof BackgroundPullChangedError) throw error;
2304
2386
  errors.push(`Externalize ${key}: ${formatError(error)}`);
2305
2387
  }
2306
2388
  return {
@@ -3137,6 +3219,17 @@ var ShadowRepo = class ShadowRepo {
3137
3219
  }
3138
3220
  return this.headExists;
3139
3221
  }
3222
+ /** Desktop snapshots deliberately use a different author. They are local
3223
+ * recovery points, not proof that those bytes were synced to the server. */
3224
+ async hasSyncedHead() {
3225
+ if (!await this.hasHead()) return false;
3226
+ const { stdout } = await this.git([
3227
+ "log",
3228
+ "-1",
3229
+ "--format=%ae"
3230
+ ]);
3231
+ return stdout.toString("utf8").trim() === "cli@fluid.app";
3232
+ }
3140
3233
  /**
3141
3234
  * Every path recorded under HEAD's tree, recursively. Callers use
3142
3235
  * this to detect local deletions (paths in HEAD, absent from the
@@ -4280,6 +4373,38 @@ function renderPullFirst(spinner) {
4280
4373
  console.log();
4281
4374
  }
4282
4375
  //#endregion
4376
+ //#region src/theme/background-pull.ts
4377
+ function containsSymlink(directory) {
4378
+ return readdirSync(directory, { withFileTypes: true }).some((entry) => {
4379
+ if (entry.name.startsWith(".") || entry.name === "node_modules") return false;
4380
+ return entry.isSymbolicLink() || entry.isDirectory() && containsSymlink(join(directory, entry.name));
4381
+ });
4382
+ }
4383
+ /** Fail closed for unattended updates. Desktop snapshots can advance shadow
4384
+ * HEAD with local work, so only a CLI sync baseline is eligible. */
4385
+ async function canPullThemeInBackground(root, themeId) {
4386
+ try {
4387
+ if (containsSymlink(root)) return false;
4388
+ const config = readThemeConfig(root);
4389
+ if (!config || config.themeId !== themeId || !existsSync(join(root, ".fluid-theme", "repo", "HEAD"))) return false;
4390
+ if (readFileSync(join(root, ".fluid-theme", "theme-id"), "utf8").trim() !== String(themeId)) return false;
4391
+ const manifestPath = join(root, ".fluid-assets.json");
4392
+ if (existsSync(manifestPath)) {
4393
+ const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
4394
+ if (!raw || typeof raw !== "object" || !("version" in raw) || raw.version !== 1 || !("assets" in raw) || !raw.assets || typeof raw.assets !== "object" || Array.isArray(raw.assets)) return false;
4395
+ const manifest = new ThemeAssetManifest(root);
4396
+ if (manifest.entries().some(([, link]) => link.pending)) return false;
4397
+ if (!config.assetManifestSha || config.assetManifestSha !== manifest.fingerprint()) return false;
4398
+ } else if (config.assetManifestSha && config.assetManifestSha !== new ThemeAssetManifest(root).fingerprint()) return false;
4399
+ const shadow = await ShadowRepo.open(root, themeId);
4400
+ if (!await shadow.hasSyncedHead()) return false;
4401
+ const diff = await diffAgainstShadow(new ThemeRoot(root), shadow);
4402
+ return diff.changed.length === 0 && diff.deleted.length === 0;
4403
+ } catch {
4404
+ return false;
4405
+ }
4406
+ }
4407
+ //#endregion
4283
4408
  //#region src/theme/merge-pull.ts
4284
4409
  /** `write(..., RESOLVES_CONFLICT)`: this write is the chosen side of a conflict. */
4285
4410
  const RESOLVES_CONFLICT = true;
@@ -4321,9 +4446,11 @@ async function mergePull(input) {
4321
4446
  const unwrittenKeys = /* @__PURE__ */ new Set();
4322
4447
  const tryWrite = (key, file, content) => {
4323
4448
  try {
4324
- file.write(content);
4449
+ if (input.backgroundGuard) input.backgroundGuard.mutate([key], () => file.write(content));
4450
+ else file.write(content);
4325
4451
  return true;
4326
4452
  } catch (e) {
4453
+ if (e instanceof BackgroundPullChangedError) throw e;
4327
4454
  unwrittenKeys.add(key);
4328
4455
  result.errors.push(`Reconcile ${key}: ${errMsg(e)}. ${RENAME_REMEDY}`);
4329
4456
  return false;
@@ -4366,6 +4493,7 @@ async function mergePull(input) {
4366
4493
  }
4367
4494
  onProgress?.(++done, remote.length);
4368
4495
  }
4496
+ input.backgroundGuard?.assertUnchanged();
4369
4497
  for (const [key, remoteBuf] of remoteContent) {
4370
4498
  const file = themeRoot.file(key);
4371
4499
  if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
@@ -4422,6 +4550,7 @@ async function mergePull(input) {
4422
4550
  }
4423
4551
  if (input.resolve) {
4424
4552
  if (pendingWrites.some((w) => w.resolvesConflict)) await commitPushedState(themeRoot, shadow, syncCommitSubject("Snapshot before pull", input.actor ?? null));
4553
+ input.backgroundGuard?.assertUnchanged();
4425
4554
  for (const { key, file, content, record } of pendingWrites) if (tryWrite(key, file, content)) record();
4426
4555
  }
4427
4556
  if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
@@ -4433,9 +4562,12 @@ async function mergePull(input) {
4433
4562
  if (!localBuf) continue;
4434
4563
  if (!localBuf.equals(baseBuf)) continue;
4435
4564
  try {
4436
- unlinkSync(file.absolutePath);
4565
+ if (input.backgroundGuard) input.backgroundGuard.mutate([file.relativePath], () => unlinkSync(file.absolutePath));
4566
+ else unlinkSync(file.absolutePath);
4437
4567
  result.deleted++;
4438
- } catch {}
4568
+ } catch (error) {
4569
+ if (error instanceof BackgroundPullChangedError) throw error;
4570
+ }
4439
4571
  }
4440
4572
  const commitEntries = [];
4441
4573
  for (const [key, buf] of remoteContent) {
@@ -4465,7 +4597,10 @@ async function mergePull(input) {
4465
4597
  sha: managedAssetSentinelSha
4466
4598
  });
4467
4599
  }
4468
- if (commitEntries.length > 0 || remoteKeys.size === 0 && await shadow.hasHead()) await shadow.commitState(commitEntries, syncCommitSubject("Pull", input.actor ?? null));
4600
+ if (commitEntries.length > 0 || remoteKeys.size === 0 && await shadow.hasHead()) {
4601
+ input.backgroundGuard?.assertUnchanged();
4602
+ await shadow.commitState(commitEntries, syncCommitSubject("Pull", input.actor ?? null));
4603
+ }
4469
4604
  return result;
4470
4605
  }
4471
4606
  function hasGeneratedConflictMarkers(content) {
@@ -4492,101 +4627,127 @@ async function fetchCompanySubdomain(api) {
4492
4627
  return subdomain;
4493
4628
  }
4494
4629
  function createPullCommand() {
4495
- 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) => {
4496
- requireToken();
4497
- if (opts.resolve !== void 0 && opts.resolve !== "local" && opts.resolve !== "remote") {
4498
- console.error(`Invalid --resolve value "${opts.resolve}" — use "local" or "remote".`);
4499
- process.exit(1);
4500
- }
4501
- const resolveSide = opts.resolve;
4502
- const api = createApiClient();
4503
- const workspace = findWorkspace();
4504
- const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
4505
- const subdomain = await fetchCompanySubdomain(api);
4506
- let root;
4507
- if (opts.root) root = opts.root;
4508
- else if (workspace) root = resolveThemeRootFromCwd(workspace) ?? join(workspace.root, "local", subdomain);
4509
- else root = `.`;
4510
- const absoluteRoot = resolve(root);
4511
- const existingConfig = readThemeConfig(absoluteRoot);
4512
- console.log();
4513
- console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
4514
- console.log(` Company: ${chalk.bold(subdomain)}`);
4515
- console.log(` Target: ${chalk.bold(absoluteRoot)}`);
4516
- console.log();
4517
- if (!opts.yes) {
4518
- const { confirmed } = await prompts({
4519
- type: "confirm",
4520
- name: "confirmed",
4521
- message: "Pull theme to this directory?",
4522
- initial: true
4523
- }, { onCancel: () => process.exit(130) });
4524
- if (!confirmed) {
4525
- console.log("Aborted.");
4526
- process.exit(0);
4527
- }
4528
- }
4529
- const themeRoot = new ThemeRoot(root);
4530
- const shadow = await ShadowRepo.open(absoluteRoot, theme.id);
4531
- await migrateLegacyChecksumsIntoShadow({
4532
- shadow,
4533
- themeRoot,
4534
- absoluteRoot,
4535
- themeId: theme.id
4536
- });
4537
- const syncer = new Syncer(api, theme.id, themeRoot);
4538
- const actorPromise = fetchSyncActor(api);
4539
- const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
4540
- const resources = await syncer.downloadAll().catch((error) => {
4541
- if (!(error instanceof CaseCollisionError)) throw error;
4542
- spinner.fail(error.message);
4543
- process.exit(1);
4544
- });
4545
- const externalizedAssets = await syncer.externalizePulledAssets(resources, { delete: !opts.nodelete });
4546
- const result = await mergePull({
4547
- themeRoot,
4548
- shadow,
4549
- remote: resources,
4550
- fetchBinary: (url) => syncer.downloadBinaryAsset(url),
4551
- delete: !opts.nodelete,
4552
- force: opts.force ?? false,
4553
- skipRemoteKeys: externalizedAssets.managedKeys,
4554
- resolve: resolveSide,
4555
- actor: await actorPromise,
4556
- onProgress: (done, total) => {
4557
- spinner.text = `Downloading ${done}/${total} files…`;
4630
+ 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("--only-if-clean", "Skip with exit code 3 when local work cannot be proven synced").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) => {
4631
+ let spinner;
4632
+ try {
4633
+ requireToken();
4634
+ if (opts.resolve !== void 0 && opts.resolve !== "local" && opts.resolve !== "remote") {
4635
+ console.error(`Invalid --resolve value "${opts.resolve}" — use "local" or "remote".`);
4636
+ process.exit(1);
4558
4637
  }
4559
- });
4560
- result.errors.push(...externalizedAssets.errors);
4561
- const parts = [];
4562
- if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
4563
- if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
4564
- if (externalizedAssets.linked > 0) parts.push(`kept ${externalizedAssets.linked} binary asset(s) remote`);
4565
- if (result.autoResolved.length > 0) parts.push(`auto-resolved ${result.autoResolved.length} conflict(s) (kept ${resolveSide})`);
4566
- if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
4567
- if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
4568
- if (result.errors.length) {
4569
- spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
4570
- for (const e of result.errors) console.error(` ${e}`);
4571
- process.exitCode = 1;
4572
- } else if (result.conflicts.length > 0) {
4573
- spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
4574
- console.log();
4575
- for (const c of result.conflicts) console.log(` ${chalk.yellow("CONFLICT")} ${c}`);
4638
+ const resolveSide = opts.resolve;
4639
+ const api = createApiClient();
4640
+ const workspace = findWorkspace();
4641
+ const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
4642
+ const subdomain = await fetchCompanySubdomain(api);
4643
+ let root;
4644
+ if (opts.root) root = opts.root;
4645
+ else if (workspace) root = resolveThemeRootFromCwd(workspace) ?? join(workspace.root, "local", subdomain);
4646
+ else root = `.`;
4647
+ const absoluteRoot = resolve(root);
4648
+ const existingConfig = readThemeConfig(absoluteRoot);
4649
+ const backgroundGuard = opts.onlyIfClean ? new BackgroundPullGuard(absoluteRoot) : void 0;
4576
4650
  console.log();
4577
- console.log(` Edit each file above to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} markers,`);
4578
- console.log(` then run ${chalk.cyan("fluid theme push")} once your resolution is in place.`);
4651
+ console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
4652
+ console.log(` Company: ${chalk.bold(subdomain)}`);
4653
+ console.log(` Target: ${chalk.bold(absoluteRoot)}`);
4579
4654
  console.log();
4580
- } else spinner.succeed(parts.join(", ") || "Already up to date.");
4581
- const remoteSha = syncer.remoteSha();
4582
- writeThemeConfig(absoluteRoot, {
4583
- themeId: theme.id,
4584
- themeName: theme.name,
4585
- company: subdomain,
4586
- baseSha: remoteSha ?? existingConfig?.baseSha,
4587
- assetManifestSha: new ThemeAssetManifest(absoluteRoot).fingerprint({ excludePending: true })
4588
- });
4589
- if (result.conflicts.length > 0) process.exit(1);
4655
+ if (!opts.yes) {
4656
+ const { confirmed } = await prompts({
4657
+ type: "confirm",
4658
+ name: "confirmed",
4659
+ message: "Pull theme to this directory?",
4660
+ initial: true
4661
+ }, { onCancel: () => process.exit(130) });
4662
+ if (!confirmed) {
4663
+ console.log("Aborted.");
4664
+ process.exit(0);
4665
+ }
4666
+ }
4667
+ if (opts.onlyIfClean && !await canPullThemeInBackground(absoluteRoot, theme.id)) {
4668
+ console.log("Skipped: local changes or an unknown sync baseline.");
4669
+ process.exit(3);
4670
+ }
4671
+ backgroundGuard?.assertUnchanged();
4672
+ const themeRoot = new ThemeRoot(root);
4673
+ const shadow = await ShadowRepo.open(absoluteRoot, theme.id);
4674
+ await migrateLegacyChecksumsIntoShadow({
4675
+ shadow,
4676
+ themeRoot,
4677
+ absoluteRoot,
4678
+ themeId: theme.id
4679
+ });
4680
+ const syncer = new Syncer(api, theme.id, themeRoot);
4681
+ const actorPromise = fetchSyncActor(api);
4682
+ spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
4683
+ const resources = await syncer.downloadAll().catch((error) => {
4684
+ if (!(error instanceof CaseCollisionError)) throw error;
4685
+ if (spinner) spinner.fail(error.message);
4686
+ process.exit(1);
4687
+ });
4688
+ if (opts.onlyIfClean && !await canPullThemeInBackground(absoluteRoot, theme.id)) {
4689
+ spinner.stop();
4690
+ console.log("Skipped: local files changed while downloading.");
4691
+ process.exit(3);
4692
+ }
4693
+ const externalizedAssets = await syncer.externalizePulledAssets(resources, {
4694
+ delete: !opts.nodelete,
4695
+ backgroundGuard
4696
+ });
4697
+ const result = await mergePull({
4698
+ themeRoot,
4699
+ shadow,
4700
+ backgroundGuard,
4701
+ remote: resources,
4702
+ fetchBinary: (url) => syncer.downloadBinaryAsset(url),
4703
+ delete: !opts.nodelete,
4704
+ force: opts.force ?? false,
4705
+ skipRemoteKeys: externalizedAssets.managedKeys,
4706
+ resolve: resolveSide,
4707
+ actor: await actorPromise,
4708
+ onProgress: (done, total) => {
4709
+ if (spinner) spinner.text = `Downloading ${done}/${total} files…`;
4710
+ }
4711
+ });
4712
+ result.errors.push(...externalizedAssets.errors);
4713
+ const parts = [];
4714
+ if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
4715
+ if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
4716
+ if (externalizedAssets.linked > 0) parts.push(`kept ${externalizedAssets.linked} binary asset(s) remote`);
4717
+ if (result.autoResolved.length > 0) parts.push(`auto-resolved ${result.autoResolved.length} conflict(s) (kept ${resolveSide})`);
4718
+ if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
4719
+ if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
4720
+ if (result.errors.length) {
4721
+ spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
4722
+ for (const e of result.errors) console.error(` ${e}`);
4723
+ process.exitCode = 1;
4724
+ } else if (result.conflicts.length > 0) {
4725
+ spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
4726
+ console.log();
4727
+ for (const c of result.conflicts) console.log(` ${chalk.yellow("CONFLICT")} ${c}`);
4728
+ console.log();
4729
+ console.log(` Edit each file above to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} markers,`);
4730
+ console.log(` then run ${chalk.cyan("fluid theme push")} once your resolution is in place.`);
4731
+ console.log();
4732
+ } else spinner.succeed(parts.join(", ") || "Already up to date.");
4733
+ const remoteSha = syncer.remoteSha();
4734
+ backgroundGuard?.assertUnchanged();
4735
+ const updateConfig = () => writeThemeConfig(absoluteRoot, {
4736
+ themeId: theme.id,
4737
+ themeName: theme.name,
4738
+ company: subdomain,
4739
+ baseSha: remoteSha ?? existingConfig?.baseSha,
4740
+ assetManifestSha: new ThemeAssetManifest(absoluteRoot).fingerprint({ excludePending: true })
4741
+ });
4742
+ if (backgroundGuard) backgroundGuard.mutate([".fluid-theme.json"], updateConfig);
4743
+ else updateConfig();
4744
+ if (result.conflicts.length > 0) process.exit(1);
4745
+ } catch (error) {
4746
+ if (!(error instanceof BackgroundPullChangedError)) throw error;
4747
+ spinner?.stop();
4748
+ console.log(error.message);
4749
+ process.exitCode = 3;
4750
+ }
4590
4751
  });
4591
4752
  }
4592
4753
  //#endregion