@fluid-app/fluid-cli-theme-dev 0.1.55 → 0.1.57

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
@@ -3196,9 +3289,14 @@ var ShadowRepo = class ShadowRepo {
3196
3289
  async commitState(files, message) {
3197
3290
  const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
3198
3291
  try {
3199
- const indexArgs = ["update-index", "--add"];
3200
- for (const { path, sha } of files) indexArgs.push("--cacheinfo", `100644,${sha},${path}`);
3201
- if (files.length > 0) await this.git(indexArgs, { env: { GIT_INDEX_FILE: indexPath } });
3292
+ if (files.length > 0) await this.git([
3293
+ "update-index",
3294
+ "-z",
3295
+ "--index-info"
3296
+ ], {
3297
+ input: Buffer.from(files.map(({ path, sha }) => `100644 ${sha}\t${path}\0`).join("")),
3298
+ env: { GIT_INDEX_FILE: indexPath }
3299
+ });
3202
3300
  const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
3203
3301
  const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
3204
3302
  const commitArgs = [
@@ -3332,14 +3430,13 @@ var ShadowRepo = class ShadowRepo {
3332
3430
  "pipe"
3333
3431
  ]
3334
3432
  });
3335
- if (opts.input) child.stdin.write(opts.input);
3336
- child.stdin.end();
3337
3433
  return new Promise((resolve, reject) => {
3338
3434
  const stdout = [];
3339
3435
  const stderr = [];
3340
3436
  child.stdout.on("data", (chunk) => stdout.push(chunk));
3341
3437
  child.stderr.on("data", (chunk) => stderr.push(chunk));
3342
3438
  child.on("error", reject);
3439
+ child.stdin.on("error", reject);
3343
3440
  child.on("close", (code) => {
3344
3441
  const out = Buffer.concat(stdout);
3345
3442
  const err = Buffer.concat(stderr);
@@ -3355,6 +3452,7 @@ var ShadowRepo = class ShadowRepo {
3355
3452
  reject(e);
3356
3453
  }
3357
3454
  });
3455
+ child.stdin.end(opts.input);
3358
3456
  });
3359
3457
  }
3360
3458
  };
@@ -4280,6 +4378,38 @@ function renderPullFirst(spinner) {
4280
4378
  console.log();
4281
4379
  }
4282
4380
  //#endregion
4381
+ //#region src/theme/background-pull.ts
4382
+ function containsSymlink(directory) {
4383
+ return readdirSync(directory, { withFileTypes: true }).some((entry) => {
4384
+ if (entry.name.startsWith(".") || entry.name === "node_modules") return false;
4385
+ return entry.isSymbolicLink() || entry.isDirectory() && containsSymlink(join(directory, entry.name));
4386
+ });
4387
+ }
4388
+ /** Fail closed for unattended updates. Desktop snapshots can advance shadow
4389
+ * HEAD with local work, so only a CLI sync baseline is eligible. */
4390
+ async function canPullThemeInBackground(root, themeId) {
4391
+ try {
4392
+ if (containsSymlink(root)) return false;
4393
+ const config = readThemeConfig(root);
4394
+ if (!config || config.themeId !== themeId || !existsSync(join(root, ".fluid-theme", "repo", "HEAD"))) return false;
4395
+ if (readFileSync(join(root, ".fluid-theme", "theme-id"), "utf8").trim() !== String(themeId)) return false;
4396
+ const manifestPath = join(root, ".fluid-assets.json");
4397
+ if (existsSync(manifestPath)) {
4398
+ const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
4399
+ 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;
4400
+ const manifest = new ThemeAssetManifest(root);
4401
+ if (manifest.entries().some(([, link]) => link.pending)) return false;
4402
+ if (!config.assetManifestSha || config.assetManifestSha !== manifest.fingerprint()) return false;
4403
+ } else if (config.assetManifestSha && config.assetManifestSha !== new ThemeAssetManifest(root).fingerprint()) return false;
4404
+ const shadow = await ShadowRepo.open(root, themeId);
4405
+ if (!await shadow.hasSyncedHead()) return false;
4406
+ const diff = await diffAgainstShadow(new ThemeRoot(root), shadow);
4407
+ return diff.changed.length === 0 && diff.deleted.length === 0;
4408
+ } catch {
4409
+ return false;
4410
+ }
4411
+ }
4412
+ //#endregion
4283
4413
  //#region src/theme/merge-pull.ts
4284
4414
  /** `write(..., RESOLVES_CONFLICT)`: this write is the chosen side of a conflict. */
4285
4415
  const RESOLVES_CONFLICT = true;
@@ -4321,9 +4451,11 @@ async function mergePull(input) {
4321
4451
  const unwrittenKeys = /* @__PURE__ */ new Set();
4322
4452
  const tryWrite = (key, file, content) => {
4323
4453
  try {
4324
- file.write(content);
4454
+ if (input.backgroundGuard) input.backgroundGuard.mutate([key], () => file.write(content));
4455
+ else file.write(content);
4325
4456
  return true;
4326
4457
  } catch (e) {
4458
+ if (e instanceof BackgroundPullChangedError) throw e;
4327
4459
  unwrittenKeys.add(key);
4328
4460
  result.errors.push(`Reconcile ${key}: ${errMsg(e)}. ${RENAME_REMEDY}`);
4329
4461
  return false;
@@ -4366,6 +4498,7 @@ async function mergePull(input) {
4366
4498
  }
4367
4499
  onProgress?.(++done, remote.length);
4368
4500
  }
4501
+ input.backgroundGuard?.assertUnchanged();
4369
4502
  for (const [key, remoteBuf] of remoteContent) {
4370
4503
  const file = themeRoot.file(key);
4371
4504
  if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
@@ -4422,6 +4555,7 @@ async function mergePull(input) {
4422
4555
  }
4423
4556
  if (input.resolve) {
4424
4557
  if (pendingWrites.some((w) => w.resolvesConflict)) await commitPushedState(themeRoot, shadow, syncCommitSubject("Snapshot before pull", input.actor ?? null));
4558
+ input.backgroundGuard?.assertUnchanged();
4425
4559
  for (const { key, file, content, record } of pendingWrites) if (tryWrite(key, file, content)) record();
4426
4560
  }
4427
4561
  if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
@@ -4433,9 +4567,12 @@ async function mergePull(input) {
4433
4567
  if (!localBuf) continue;
4434
4568
  if (!localBuf.equals(baseBuf)) continue;
4435
4569
  try {
4436
- unlinkSync(file.absolutePath);
4570
+ if (input.backgroundGuard) input.backgroundGuard.mutate([file.relativePath], () => unlinkSync(file.absolutePath));
4571
+ else unlinkSync(file.absolutePath);
4437
4572
  result.deleted++;
4438
- } catch {}
4573
+ } catch (error) {
4574
+ if (error instanceof BackgroundPullChangedError) throw error;
4575
+ }
4439
4576
  }
4440
4577
  const commitEntries = [];
4441
4578
  for (const [key, buf] of remoteContent) {
@@ -4465,7 +4602,10 @@ async function mergePull(input) {
4465
4602
  sha: managedAssetSentinelSha
4466
4603
  });
4467
4604
  }
4468
- if (commitEntries.length > 0 || remoteKeys.size === 0 && await shadow.hasHead()) await shadow.commitState(commitEntries, syncCommitSubject("Pull", input.actor ?? null));
4605
+ if (commitEntries.length > 0 || remoteKeys.size === 0 && await shadow.hasHead()) {
4606
+ input.backgroundGuard?.assertUnchanged();
4607
+ await shadow.commitState(commitEntries, syncCommitSubject("Pull", input.actor ?? null));
4608
+ }
4469
4609
  return result;
4470
4610
  }
4471
4611
  function hasGeneratedConflictMarkers(content) {
@@ -4492,101 +4632,127 @@ async function fetchCompanySubdomain(api) {
4492
4632
  return subdomain;
4493
4633
  }
4494
4634
  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…`;
4635
+ 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) => {
4636
+ let spinner;
4637
+ try {
4638
+ requireToken();
4639
+ if (opts.resolve !== void 0 && opts.resolve !== "local" && opts.resolve !== "remote") {
4640
+ console.error(`Invalid --resolve value "${opts.resolve}" — use "local" or "remote".`);
4641
+ process.exit(1);
4558
4642
  }
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}`);
4643
+ const resolveSide = opts.resolve;
4644
+ const api = createApiClient();
4645
+ const workspace = findWorkspace();
4646
+ const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
4647
+ const subdomain = await fetchCompanySubdomain(api);
4648
+ let root;
4649
+ if (opts.root) root = opts.root;
4650
+ else if (workspace) root = resolveThemeRootFromCwd(workspace) ?? join(workspace.root, "local", subdomain);
4651
+ else root = `.`;
4652
+ const absoluteRoot = resolve(root);
4653
+ const existingConfig = readThemeConfig(absoluteRoot);
4654
+ const backgroundGuard = opts.onlyIfClean ? new BackgroundPullGuard(absoluteRoot) : void 0;
4576
4655
  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.`);
4656
+ console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
4657
+ console.log(` Company: ${chalk.bold(subdomain)}`);
4658
+ console.log(` Target: ${chalk.bold(absoluteRoot)}`);
4579
4659
  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);
4660
+ if (!opts.yes) {
4661
+ const { confirmed } = await prompts({
4662
+ type: "confirm",
4663
+ name: "confirmed",
4664
+ message: "Pull theme to this directory?",
4665
+ initial: true
4666
+ }, { onCancel: () => process.exit(130) });
4667
+ if (!confirmed) {
4668
+ console.log("Aborted.");
4669
+ process.exit(0);
4670
+ }
4671
+ }
4672
+ if (opts.onlyIfClean && !await canPullThemeInBackground(absoluteRoot, theme.id)) {
4673
+ console.log("Skipped: local changes or an unknown sync baseline.");
4674
+ process.exit(3);
4675
+ }
4676
+ backgroundGuard?.assertUnchanged();
4677
+ const themeRoot = new ThemeRoot(root);
4678
+ const shadow = await ShadowRepo.open(absoluteRoot, theme.id);
4679
+ await migrateLegacyChecksumsIntoShadow({
4680
+ shadow,
4681
+ themeRoot,
4682
+ absoluteRoot,
4683
+ themeId: theme.id
4684
+ });
4685
+ const syncer = new Syncer(api, theme.id, themeRoot);
4686
+ const actorPromise = fetchSyncActor(api);
4687
+ spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
4688
+ const resources = await syncer.downloadAll().catch((error) => {
4689
+ if (!(error instanceof CaseCollisionError)) throw error;
4690
+ if (spinner) spinner.fail(error.message);
4691
+ process.exit(1);
4692
+ });
4693
+ if (opts.onlyIfClean && !await canPullThemeInBackground(absoluteRoot, theme.id)) {
4694
+ spinner.stop();
4695
+ console.log("Skipped: local files changed while downloading.");
4696
+ process.exit(3);
4697
+ }
4698
+ const externalizedAssets = await syncer.externalizePulledAssets(resources, {
4699
+ delete: !opts.nodelete,
4700
+ backgroundGuard
4701
+ });
4702
+ const result = await mergePull({
4703
+ themeRoot,
4704
+ shadow,
4705
+ backgroundGuard,
4706
+ remote: resources,
4707
+ fetchBinary: (url) => syncer.downloadBinaryAsset(url),
4708
+ delete: !opts.nodelete,
4709
+ force: opts.force ?? false,
4710
+ skipRemoteKeys: externalizedAssets.managedKeys,
4711
+ resolve: resolveSide,
4712
+ actor: await actorPromise,
4713
+ onProgress: (done, total) => {
4714
+ if (spinner) spinner.text = `Downloading ${done}/${total} files…`;
4715
+ }
4716
+ });
4717
+ result.errors.push(...externalizedAssets.errors);
4718
+ const parts = [];
4719
+ if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
4720
+ if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
4721
+ if (externalizedAssets.linked > 0) parts.push(`kept ${externalizedAssets.linked} binary asset(s) remote`);
4722
+ if (result.autoResolved.length > 0) parts.push(`auto-resolved ${result.autoResolved.length} conflict(s) (kept ${resolveSide})`);
4723
+ if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
4724
+ if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
4725
+ if (result.errors.length) {
4726
+ spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
4727
+ for (const e of result.errors) console.error(` ${e}`);
4728
+ process.exitCode = 1;
4729
+ } else if (result.conflicts.length > 0) {
4730
+ spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
4731
+ console.log();
4732
+ for (const c of result.conflicts) console.log(` ${chalk.yellow("CONFLICT")} ${c}`);
4733
+ console.log();
4734
+ console.log(` Edit each file above to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} markers,`);
4735
+ console.log(` then run ${chalk.cyan("fluid theme push")} once your resolution is in place.`);
4736
+ console.log();
4737
+ } else spinner.succeed(parts.join(", ") || "Already up to date.");
4738
+ const remoteSha = syncer.remoteSha();
4739
+ backgroundGuard?.assertUnchanged();
4740
+ const updateConfig = () => writeThemeConfig(absoluteRoot, {
4741
+ themeId: theme.id,
4742
+ themeName: theme.name,
4743
+ company: subdomain,
4744
+ baseSha: remoteSha ?? existingConfig?.baseSha,
4745
+ assetManifestSha: new ThemeAssetManifest(absoluteRoot).fingerprint({ excludePending: true })
4746
+ });
4747
+ if (backgroundGuard) backgroundGuard.mutate([".fluid-theme.json"], updateConfig);
4748
+ else updateConfig();
4749
+ if (result.conflicts.length > 0) process.exit(1);
4750
+ } catch (error) {
4751
+ if (!(error instanceof BackgroundPullChangedError)) throw error;
4752
+ spinner?.stop();
4753
+ console.log(error.message);
4754
+ process.exitCode = 3;
4755
+ }
4590
4756
  });
4591
4757
  }
4592
4758
  //#endregion