@fluid-app/fluid-cli-theme-dev 0.1.34 → 0.1.36

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,15 +1,17 @@
1
1
  import { Command } from "commander";
2
2
  import { getAuthToken, readConfig, updateConfig } from "@fluid-app/fluid-cli";
3
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { cpSync, existsSync, 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 } from "node:crypto";
6
6
  import http from "node:http";
7
7
  import https from "node:https";
8
8
  import chokidar from "chokidar";
9
+ import net from "node:net";
9
10
  import chalk from "chalk";
10
11
  import prompts from "prompts";
11
12
  import ora from "ora";
12
- import { execFileSync } from "node:child_process";
13
+ import { execFileSync, spawn } from "node:child_process";
14
+ import { tmpdir } from "node:os";
13
15
  import { fileURLToPath } from "node:url";
14
16
  //#region ../../platform/api-client-core/src/fetch-client.ts
15
17
  /**
@@ -276,6 +278,21 @@ function readThemeConfig(themeRoot) {
276
278
  return null;
277
279
  }
278
280
  }
281
+ /**
282
+ * Read a legacy config (pre-shadow-repo). Used only by the migration
283
+ * path on first new-CLI pull to seed shadow HEAD from files whose
284
+ * local content still matches their stored sha256 checksum.
285
+ */
286
+ function readLegacyThemeConfig(themeRoot) {
287
+ const path = configPath(themeRoot);
288
+ if (!existsSync(path)) return null;
289
+ try {
290
+ const raw = readFileSync(path, "utf-8");
291
+ return JSON.parse(raw);
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
279
296
  /** Write `.fluid-theme.json` to a theme directory. */
280
297
  function writeThemeConfig(themeRoot, config) {
281
298
  writeFileSync(configPath(themeRoot), JSON.stringify(config, null, 2) + "\n", "utf-8");
@@ -1605,6 +1622,46 @@ var Syncer = class {
1605
1622
  }
1606
1623
  };
1607
1624
  //#endregion
1625
+ //#region src/theme/dev-server/port-preflight.ts
1626
+ /**
1627
+ * The dev command does real work before it ever binds a port: it resolves
1628
+ * (or creates) a server-side dev theme and runs a full initial sync, which
1629
+ * can take minutes on a large theme. If the requested port is already taken
1630
+ * — most commonly by another `fluid theme dev` or the Mist Desktop preview,
1631
+ * which both default to 9292 — all of that work is wasted and the process
1632
+ * used to die with a raw `EADDRINUSE` stack trace. Call this before any of
1633
+ * that work starts so we fail fast with a clear message instead.
1634
+ */
1635
+ var PortInUseError = class extends Error {
1636
+ constructor(host, port) {
1637
+ super(formatPortConflictMessage(host, port));
1638
+ this.host = host;
1639
+ this.port = port;
1640
+ this.name = "PortInUseError";
1641
+ }
1642
+ };
1643
+ function formatPortConflictMessage(host, port) {
1644
+ 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>.`;
1645
+ }
1646
+ /**
1647
+ * Attempt to bind `host:port`, then immediately release it. Resolves if the
1648
+ * port is free; rejects with `PortInUseError` on `EADDRINUSE`/`EACCES`, or
1649
+ * the raw error for anything else unexpected.
1650
+ */
1651
+ function checkPortAvailable(host, port) {
1652
+ return new Promise((resolve, reject) => {
1653
+ const server = net.createServer();
1654
+ server.once("error", (err) => {
1655
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") reject(new PortInUseError(host, port));
1656
+ else reject(err);
1657
+ });
1658
+ server.once("listening", () => {
1659
+ server.close(() => resolve());
1660
+ });
1661
+ server.listen(port, host);
1662
+ });
1663
+ }
1664
+ //#endregion
1608
1665
  //#region src/theme/dev-server/index.ts
1609
1666
  async function startDevServer(api, theme, themeRoot, opts, onReady) {
1610
1667
  const sse = new SSEStream();
@@ -1673,8 +1730,14 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
1673
1730
  }
1674
1731
  });
1675
1732
  await new Promise((resolve, reject) => {
1733
+ server.once("error", (err) => {
1734
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") {
1735
+ console.error(formatPortConflictMessage(opts.host, opts.port));
1736
+ process.exit(1);
1737
+ }
1738
+ reject(err);
1739
+ });
1676
1740
  server.listen(opts.port, opts.host, () => resolve());
1677
- server.on("error", reject);
1678
1741
  });
1679
1742
  const address = `http://${opts.host}:${opts.port}`;
1680
1743
  onReady?.(address);
@@ -1889,6 +1952,13 @@ function createDevCommand() {
1889
1952
  console.error(`Invalid port: '${opts.port}'. Must be an integer between 1 and 65535.`);
1890
1953
  process.exit(1);
1891
1954
  }
1955
+ try {
1956
+ await checkPortAvailable(opts.host, port);
1957
+ } catch (e) {
1958
+ if (e instanceof PortInUseError) console.error(e.message);
1959
+ else console.error(`Failed to check port availability: ${e}`);
1960
+ process.exit(1);
1961
+ }
1892
1962
  const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
1893
1963
  const api = createApiClient();
1894
1964
  const config = readThemeConfig(themeRoot.root);
@@ -1931,24 +2001,444 @@ function createDevCommand() {
1931
2001
  });
1932
2002
  }
1933
2003
  //#endregion
1934
- //#region src/commands/push.ts
2004
+ //#region src/theme/shadow-repo.ts
1935
2005
  /**
1936
- * Detect files where the remote has changed since the last pull,
1937
- * and we also have local changes (i.e. we'd overwrite someone else's work).
2006
+ * A bare git repo hidden under `.fluid-theme/repo` that stands in for
2007
+ * git's index+HEAD when talking to the Fluid server. Every pull commits
2008
+ * the incoming remote state onto a single branch (`refs/heads/main`);
2009
+ * every successful push commits the outgoing local state onto the same
2010
+ * branch. HEAD therefore represents "the last state the CLI and server
2011
+ * agreed on", and its tree is the natural three-way-merge base for the
2012
+ * next pull.
2013
+ *
2014
+ * We stay in git's plumbing layer — no working tree, no index file
2015
+ * next to the theme content — so the shadow repo cannot interfere
2016
+ * with the user's own git repo (if they have one) around the theme
2017
+ * dir. All state lives inside `.fluid-theme/`.
2018
+ *
2019
+ * The class only wraps the small handful of plumbing commands we
2020
+ * actually need: hash-object, cat-file, write-tree (via a temp index),
2021
+ * commit-tree, update-ref, and merge-file. Everything else stays out.
1938
2022
  */
1939
- function detectRemoteDrift(storedChecksums, remoteChecksums, themeRoot) {
1940
- const conflicts = [];
1941
- for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
1942
- const remoteChecksum = remoteChecksums[key];
1943
- if (remoteChecksum === void 0) continue;
1944
- if (remoteChecksum === storedChecksum) continue;
1945
- const file = themeRoot.file(key);
2023
+ var ShadowRepo = class ShadowRepo {
2024
+ headExists = void 0;
2025
+ constructor(themeRoot, gitDir) {
2026
+ this.themeRoot = themeRoot;
2027
+ this.gitDir = gitDir;
2028
+ }
2029
+ /**
2030
+ * Return a ShadowRepo bound to `themeId` for the given theme root.
2031
+ * The shadow is initialized on first use and re-initialized when
2032
+ * the caller passes a themeId different from the one previously
2033
+ * recorded — the merge base is only meaningful for the theme it
2034
+ * was captured against, so a cross-theme operation (pull A → push
2035
+ * B, or two pulls of different themes into one dir) starts from a
2036
+ * clean slate rather than pretending B's state matches A's HEAD.
2037
+ */
2038
+ static async open(themeRoot, themeId) {
2039
+ const shadowDir = join(themeRoot, ".fluid-theme");
2040
+ const gitDir = join(shadowDir, "repo");
2041
+ const themeIdFile = join(shadowDir, "theme-id");
2042
+ if (existsSync(gitDir)) {
2043
+ if (readStoredThemeId(themeIdFile) !== themeId) rmSync(gitDir, {
2044
+ recursive: true,
2045
+ force: true
2046
+ });
2047
+ }
2048
+ const repo = new ShadowRepo(themeRoot, gitDir);
2049
+ if (!existsSync(gitDir)) {
2050
+ mkdirSync(shadowDir, { recursive: true });
2051
+ await repo.git([
2052
+ "init",
2053
+ "--bare",
2054
+ "-b",
2055
+ "main",
2056
+ gitDir
2057
+ ], { cwd: themeRoot });
2058
+ }
2059
+ writeFileSync(themeIdFile, `${themeId}\n`, "utf-8");
2060
+ const shadowIgnore = join(shadowDir, ".gitignore");
2061
+ if (!existsSync(shadowIgnore)) writeFileSync(shadowIgnore, "# Fluid CLI shadow repo — internal state, not for version control.\n*\n");
2062
+ await ensureRootGitignoreHidesShadow(themeRoot);
2063
+ return repo;
2064
+ }
2065
+ /** True when the repo has at least one commit on `refs/heads/main`. */
2066
+ async hasHead() {
2067
+ if (this.headExists !== void 0) return this.headExists;
2068
+ try {
2069
+ await this.git([
2070
+ "rev-parse",
2071
+ "--verify",
2072
+ "HEAD"
2073
+ ]);
2074
+ this.headExists = true;
2075
+ } catch {
2076
+ this.headExists = false;
2077
+ }
2078
+ return this.headExists;
2079
+ }
2080
+ /**
2081
+ * Every path recorded under HEAD's tree, recursively. Callers use
2082
+ * this to detect local deletions (paths in HEAD, absent from the
2083
+ * working tree). Returns [] when HEAD has never been committed.
2084
+ */
2085
+ async headPaths() {
2086
+ if (!await this.hasHead()) return [];
2087
+ const { stdout } = await this.git([
2088
+ "ls-tree",
2089
+ "-r",
2090
+ "HEAD",
2091
+ "--name-only"
2092
+ ]);
2093
+ return stdout.toString("utf8").split("\n").filter((line) => line.length > 0);
2094
+ }
2095
+ /**
2096
+ * The content of `path` in HEAD's tree, or null when the path does
2097
+ * not exist there. Callers use this as the merge base for pull
2098
+ * conflict resolution.
2099
+ */
2100
+ async blobAtHead(path) {
2101
+ if (!await this.hasHead()) return null;
2102
+ try {
2103
+ const { stdout } = await this.git([
2104
+ "cat-file",
2105
+ "-p",
2106
+ `HEAD:${path}`
2107
+ ]);
2108
+ return stdout;
2109
+ } catch {
2110
+ return null;
2111
+ }
2112
+ }
2113
+ /**
2114
+ * Write `content` as a blob in the shadow repo and return its sha.
2115
+ * Used by `commitState` to stage each file's content before the
2116
+ * `write-tree` call.
2117
+ */
2118
+ async writeBlob(content) {
2119
+ const buf = typeof content === "string" ? Buffer.from(content) : content;
2120
+ const { stdout } = await this.git([
2121
+ "hash-object",
2122
+ "-w",
2123
+ "--stdin"
2124
+ ], { input: buf });
2125
+ return stdout.toString("utf8").trim();
2126
+ }
2127
+ /**
2128
+ * Commit `files` as HEAD's new tree, threaded onto the current HEAD
2129
+ * as the parent. Uses a per-call temp index so a partial run can't
2130
+ * corrupt anything reachable from HEAD; the previous commit stays
2131
+ * intact until `update-ref` at the end.
2132
+ *
2133
+ * Returns the new commit sha.
2134
+ */
2135
+ async commitState(files, message) {
2136
+ const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
2137
+ try {
2138
+ for (const { path, sha } of files) await this.git([
2139
+ "update-index",
2140
+ "--add",
2141
+ "--cacheinfo",
2142
+ `100644,${sha},${path}`
2143
+ ], { env: { GIT_INDEX_FILE: indexPath } });
2144
+ const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
2145
+ const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
2146
+ const commitArgs = [
2147
+ "commit-tree",
2148
+ treeSha,
2149
+ "-m",
2150
+ message
2151
+ ];
2152
+ if (parent) commitArgs.push("-p", parent);
2153
+ const commitSha = (await this.git(commitArgs, { env: {
2154
+ GIT_AUTHOR_NAME: "Fluid CLI",
2155
+ GIT_AUTHOR_EMAIL: "cli@fluid.app",
2156
+ GIT_COMMITTER_NAME: "Fluid CLI",
2157
+ GIT_COMMITTER_EMAIL: "cli@fluid.app"
2158
+ } })).stdout.toString("utf8").trim();
2159
+ await this.git([
2160
+ "update-ref",
2161
+ "refs/heads/main",
2162
+ commitSha
2163
+ ]);
2164
+ this.headExists = true;
2165
+ return commitSha;
2166
+ } finally {
2167
+ try {
2168
+ rmSync(indexPath, { force: true });
2169
+ rmSync(indexPath.substring(0, indexPath.length - 6), {
2170
+ recursive: true,
2171
+ force: true
2172
+ });
2173
+ } catch {}
2174
+ }
2175
+ }
2176
+ /**
2177
+ * Three-way merge of `local` against `remote` with `base` as the
2178
+ * common ancestor. Returns the merged bytes and a flag when
2179
+ * `git merge-file` reported unresolved conflicts (i.e. the output
2180
+ * contains `<<<<<<<` markers for the reader to resolve).
2181
+ *
2182
+ * `base` is null when HEAD has never seen this path; we merge
2183
+ * against an empty base, which is what git itself does for a new
2184
+ * file added on both sides.
2185
+ */
2186
+ async merge3(base, local, remote) {
2187
+ const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
2188
+ const localPath = join(dir, "local");
2189
+ const basePath = join(dir, "base");
2190
+ const remotePath = join(dir, "remote");
2191
+ try {
2192
+ writeFileSync(localPath, local);
2193
+ writeFileSync(basePath, base ?? Buffer.alloc(0));
2194
+ writeFileSync(remotePath, remote);
2195
+ try {
2196
+ const { stdout } = await this.git([
2197
+ "merge-file",
2198
+ "-p",
2199
+ "-L",
2200
+ "local",
2201
+ "-L",
2202
+ "base",
2203
+ "-L",
2204
+ "remote",
2205
+ localPath,
2206
+ basePath,
2207
+ remotePath
2208
+ ]);
2209
+ return {
2210
+ merged: stdout,
2211
+ hasConflicts: false
2212
+ };
2213
+ } catch (err) {
2214
+ const e = err;
2215
+ 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 {
2217
+ merged,
2218
+ hasConflicts: true
2219
+ };
2220
+ throw err;
2221
+ }
2222
+ } finally {
2223
+ try {
2224
+ rmSync(dir, {
2225
+ recursive: true,
2226
+ force: true
2227
+ });
2228
+ } catch {}
2229
+ }
2230
+ }
2231
+ /**
2232
+ * Snapshot the working-tree copy of `paths` into HEAD as a single
2233
+ * commit. Intended for the migration path: on the first pull with
2234
+ * the new CLI (no shadow repo yet, but a `.fluid-theme.json` with
2235
+ * checksums exists) we seed HEAD with whatever is on disk before
2236
+ * running the merge, so unmodified files fast-forward cleanly and
2237
+ * modified files show a diff.
2238
+ */
2239
+ async seedFromWorkingTree(files, message) {
2240
+ const entries = [];
2241
+ for (const { path, content } of files) entries.push({
2242
+ path,
2243
+ sha: await this.writeBlob(content)
2244
+ });
2245
+ await this.commitState(entries, message);
2246
+ }
2247
+ async git(args, opts = {}) {
2248
+ const child = spawn("git", args[0] === "init" ? args : [
2249
+ "--git-dir",
2250
+ this.gitDir,
2251
+ ...args
2252
+ ], {
2253
+ cwd: opts.cwd ?? this.themeRoot,
2254
+ env: {
2255
+ ...process.env,
2256
+ ...opts.env
2257
+ },
2258
+ stdio: [
2259
+ "pipe",
2260
+ "pipe",
2261
+ "pipe"
2262
+ ]
2263
+ });
2264
+ if (opts.input) child.stdin.write(opts.input);
2265
+ child.stdin.end();
2266
+ return new Promise((resolve, reject) => {
2267
+ const stdout = [];
2268
+ const stderr = [];
2269
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
2270
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
2271
+ child.on("error", reject);
2272
+ child.on("close", (code) => {
2273
+ const out = Buffer.concat(stdout);
2274
+ const err = Buffer.concat(stderr);
2275
+ if (code === 0) resolve({
2276
+ stdout: out,
2277
+ stderr: err
2278
+ });
2279
+ else {
2280
+ const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
2281
+ e.code = code ?? -1;
2282
+ e.stdout = out;
2283
+ e.stderr = err;
2284
+ reject(e);
2285
+ }
2286
+ });
2287
+ });
2288
+ }
2289
+ };
2290
+ /**
2291
+ * Content-type check the pull command uses to decide whether a file
2292
+ * is safe to run through `merge3` (line-based) or must fall back to
2293
+ * whole-file "either/or" resolution (binary).
2294
+ */
2295
+ function looksBinary(content) {
2296
+ return content.subarray(0, Math.min(content.length, 8e3)).includes(0);
2297
+ }
2298
+ /** Best-effort readFile that returns null when the file does not exist. */
2299
+ function readIfExists(path) {
2300
+ try {
2301
+ return readFileSync(path);
2302
+ } catch {
2303
+ return null;
2304
+ }
2305
+ }
2306
+ /**
2307
+ * Parse the numeric theme id from `.fluid-theme/theme-id`. Returns
2308
+ * null when the file is missing or the contents don't parse cleanly;
2309
+ * `open` treats that as "unknown theme" and rebuilds the shadow.
2310
+ */
2311
+ function readStoredThemeId(themeIdFile) {
2312
+ try {
2313
+ const stored = parseInt(readFileSync(themeIdFile, "utf-8").trim(), 10);
2314
+ return Number.isFinite(stored) ? stored : null;
2315
+ } catch {
2316
+ return null;
2317
+ }
2318
+ }
2319
+ /**
2320
+ * Append `.fluid-theme/` to the theme root's `.gitignore` when the
2321
+ * theme dir sits inside a git working tree and the entry isn't
2322
+ * already there. Skipped when the user isn't in a git repo — no
2323
+ * point manufacturing a `.gitignore` for someone who doesn't use
2324
+ * git. Idempotent — a second call is a no-op.
2325
+ */
2326
+ async function ensureRootGitignoreHidesShadow(themeRoot) {
2327
+ if (!await new Promise((resolve) => {
2328
+ const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
2329
+ cwd: themeRoot,
2330
+ stdio: [
2331
+ "ignore",
2332
+ "pipe",
2333
+ "pipe"
2334
+ ]
2335
+ });
2336
+ child.on("close", (code) => resolve(code === 0));
2337
+ child.on("error", () => resolve(false));
2338
+ })) return;
2339
+ const gitignorePath = join(themeRoot, ".gitignore");
2340
+ let existing = "";
2341
+ try {
2342
+ existing = readFileSync(gitignorePath, "utf-8");
2343
+ } catch {
2344
+ existing = "";
2345
+ }
2346
+ const lines = existing.split("\n").map((line) => line.trim());
2347
+ if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
2348
+ const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
2349
+ writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
2350
+ }
2351
+ //#endregion
2352
+ //#region src/theme/merge-push.ts
2353
+ /**
2354
+ * Compute what changed locally since the last time the shadow repo
2355
+ * committed a state. Replaces the sha256 `checksums` map: shadow HEAD
2356
+ * is the source of truth for "what the CLI last saw the server have".
2357
+ *
2358
+ * Files whose local bytes are byte-identical to their HEAD blob are
2359
+ * skipped; anything else — new, modified, or a locally-deleted path
2360
+ * that HEAD still has — is included.
2361
+ */
2362
+ async function diffAgainstShadow(themeRoot, shadow) {
2363
+ const changed = [];
2364
+ const deleted = [];
2365
+ const localFiles = themeRoot.files();
2366
+ const localByKey = /* @__PURE__ */ new Map();
2367
+ for (const file of localFiles) {
2368
+ if (!file.exists) continue;
2369
+ localByKey.set(file.relativePath, file);
2370
+ const headBlob = await shadow.blobAtHead(file.relativePath);
2371
+ const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
2372
+ if (headBlob && headBlob.equals(localBuf)) continue;
2373
+ changed.push(file);
2374
+ }
2375
+ if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
2376
+ if (localByKey.has(key)) continue;
2377
+ if (isStylesheetKey(key)) continue;
2378
+ deleted.push(key);
2379
+ }
2380
+ return {
2381
+ changed,
2382
+ deleted
2383
+ };
2384
+ }
2385
+ /**
2386
+ * Read every path recorded under HEAD's tree. Used to detect local
2387
+ * deletions (paths in HEAD, absent from the working tree). Implemented
2388
+ * with `git ls-tree -r HEAD --name-only` piped through the shadow
2389
+ * repo's plumbing.
2390
+ */
2391
+ async function listHeadPaths(shadow) {
2392
+ return shadow.headPaths();
2393
+ }
2394
+ /**
2395
+ * Refuse a push when any working file still contains a conflict marker
2396
+ * from a previous pull. Mirrors git's "you have unresolved conflicts;
2397
+ * fix them and re-run" behavior — the whole point of writing markers
2398
+ * on pull was to hand resolution to the user, so we can't send them
2399
+ * upstream.
2400
+ */
2401
+ function findUnresolvedConflicts(files) {
2402
+ const flagged = [];
2403
+ for (const file of files) {
2404
+ if (!file.isText) continue;
2405
+ const buf = readIfExists(file.absolutePath);
2406
+ if (!buf) continue;
2407
+ if (containsConflictMarker(buf)) flagged.push(file.relativePath);
2408
+ }
2409
+ return flagged;
2410
+ }
2411
+ const CONFLICT_START = Buffer.from("<<<<<<<");
2412
+ const CONFLICT_MID = Buffer.from("=======");
2413
+ const CONFLICT_END = Buffer.from(">>>>>>>");
2414
+ /**
2415
+ * A file counts as unresolved when it contains all three marker
2416
+ * shapes: `<<<<<<<`, `=======`, and `>>>>>>>`. Requiring all three
2417
+ * avoids false positives — a line of equals signs alone (e.g. inside
2418
+ * an ASCII table in a template comment) doesn't trip the guard.
2419
+ */
2420
+ function containsConflictMarker(buf) {
2421
+ return buf.includes(CONFLICT_START) && buf.includes(CONFLICT_MID) && buf.includes(CONFLICT_END);
2422
+ }
2423
+ /**
2424
+ * Commit the current working-tree state to shadow HEAD after a
2425
+ * successful push. Ensures the next pull's merge base is the state
2426
+ * we know the server just accepted.
2427
+ */
2428
+ async function commitPushedState(themeRoot, shadow, message) {
2429
+ const entries = [];
2430
+ for (const file of themeRoot.files()) {
1946
2431
  if (!file.exists) continue;
1947
- if (file.checksum() === remoteChecksum) continue;
1948
- conflicts.push(key);
2432
+ const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
2433
+ entries.push({
2434
+ path: file.relativePath,
2435
+ sha: await shadow.writeBlob(buf)
2436
+ });
1949
2437
  }
1950
- return conflicts;
2438
+ if (entries.length > 0) await shadow.commitState(entries, message);
1951
2439
  }
2440
+ //#endregion
2441
+ //#region src/commands/push.ts
1952
2442
  function createPushCommand() {
1953
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) => {
1954
2444
  requireToken();
@@ -1985,88 +2475,107 @@ function createPushCommand() {
1985
2475
  console.log(` Using theme from .fluid-theme.json: ${chalk.bold(config.themeName)} (#${config.themeId})`);
1986
2476
  theme = (await getApplicationTheme(api, config.themeId)).application_theme;
1987
2477
  } else theme = await selectTheme(api, "Select a theme to push to");
1988
- let overrideMergeCheck = false;
1989
- if (config?.checksums && !opts.force) {
1990
- const driftSpinner = ora("Checking for remote changes…").start();
1991
- const driftSyncer = new Syncer(api, theme.id, themeRoot);
1992
- await driftSyncer.fetchChecksums();
1993
- const remoteChecksums = driftSyncer.remoteChecksums();
1994
- const conflicts = detectRemoteDrift(config.checksums, remoteChecksums, themeRoot);
1995
- driftSpinner.stop();
1996
- if (conflicts.length > 0) {
1997
- console.log(chalk.yellow(`\n⚠ ${conflicts.length} file(s) changed on remote since last pull:\n`));
1998
- for (const key of conflicts) console.log(` ${key}`);
1999
- console.log();
2000
- const { resolution } = await prompts({
2001
- type: "select",
2002
- name: "resolution",
2003
- message: "How do you want to handle this?",
2004
- choices: [
2005
- {
2006
- title: "Push anyway (overwrite remote changes)",
2007
- value: "push"
2008
- },
2009
- {
2010
- title: "Pull first, then push",
2011
- value: "pull-first"
2012
- },
2013
- {
2014
- title: "Abort",
2015
- value: "abort"
2016
- }
2017
- ]
2018
- }, { onCancel: () => process.exit(130) });
2019
- if (resolution === "abort") {
2020
- console.log("Aborted.");
2021
- process.exit(0);
2022
- }
2023
- if (resolution === "pull-first") {
2024
- console.log(`Run ${chalk.cyan("fluid theme pull")} first, then push again.`);
2025
- process.exit(0);
2026
- }
2027
- if (resolution === "push") overrideMergeCheck = true;
2028
- }
2478
+ const shadow = await ShadowRepo.open(themeRoot.root, theme.id);
2479
+ const unresolved = findUnresolvedConflicts(themeRoot.files().filter((f) => f.exists));
2480
+ if (unresolved.length > 0) {
2481
+ console.log();
2482
+ console.log(chalk.red(`✗ ${unresolved.length} file(s) still contain unresolved conflict markers:`));
2483
+ for (const key of unresolved) console.log(` ${key}`);
2484
+ console.log();
2485
+ console.log(` Edit each file to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} sections,`);
2486
+ console.log(` then re-run ${chalk.cyan("fluid theme push")}.`);
2487
+ console.log();
2488
+ process.exit(1);
2489
+ }
2490
+ const { changed, deleted } = await diffAgainstShadow(themeRoot, shadow);
2491
+ if (changed.length === 0 && deleted.length === 0) {
2492
+ console.log("Nothing to push — local matches the last synced state.");
2493
+ return;
2029
2494
  }
2030
- const syncer = new Syncer(api, theme.id, themeRoot);
2031
2495
  const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
2032
- const baseSha = opts.force || overrideMergeCheck ? null : config?.baseSha ?? null;
2033
- let result;
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
+ }
2034
2511
  try {
2035
- result = await syncer.uploadTheme({
2036
- delete: !opts.nodelete,
2037
- validate: !opts.force,
2038
- baseSha,
2039
- onProgress: (d, total) => {
2040
- spinner.text = `Pushing ${d}/${total} files…`;
2041
- }
2042
- });
2512
+ await syncer.preflightPush(baseSha);
2513
+ baseSha = syncer.remoteSha() ?? baseSha;
2043
2514
  } catch (e) {
2044
2515
  if (e instanceof PushConflictError) {
2045
- spinner.fail("Server has changed since your last pull. Push aborted.");
2046
- console.log();
2047
- console.log(` ${chalk.cyan("Run `fluid theme pull` first")} to sync down the remote changes,`);
2048
- console.log(` then push again. Use ${chalk.cyan("fluid theme push --force")} to overwrite anyway.`);
2049
- console.log();
2516
+ renderPullFirst(spinner);
2050
2517
  process.exit(1);
2051
2518
  }
2052
2519
  throw e;
2053
2520
  }
2054
- if (result.validationFailed) {
2055
- spinner.fail(`Schema validation failed (${result.errors.length} error(s)). Use --force to skip.`);
2056
- for (const e of result.errors) console.error(` ${e}`);
2057
- process.exit(1);
2058
- } else if (result.errors.length) {
2059
- spinner.warn(`Pushed with ${result.errors.length} error(s).`);
2060
- for (const e of result.errors) console.error(` ${e}`);
2061
- } else spinner.succeed(`Pushed ${result.uploaded} file(s), deleted ${result.deleted} remote file(s).`);
2062
- if (config) {
2063
- const remoteSha = syncer.remoteSha();
2064
- writeThemeConfig(themeRoot.root, {
2065
- ...config,
2066
- checksums: syncer.remoteChecksums(),
2067
- baseSha: remoteSha ?? config.baseSha
2068
- });
2521
+ if (!opts.force) {
2522
+ const errors = [];
2523
+ for (const file of changed) {
2524
+ if (!file.isLiquid) continue;
2525
+ for (const d of file.validateSchema()) if (d.severity === "error") errors.push(`${file.relativePath}: ${d.message}`);
2526
+ }
2527
+ if (errors.length > 0) {
2528
+ spinner.fail(`Schema validation failed (${errors.length} error(s)). Use --force to skip.`);
2529
+ for (const err of errors) console.error(` ${err}`);
2530
+ process.exit(1);
2531
+ }
2532
+ }
2533
+ let uploaded = 0;
2534
+ let deletedCount = 0;
2535
+ const errors = [];
2536
+ let progress = 0;
2537
+ const total = changed.length + (opts.nodelete ? 0 : deleted.length);
2538
+ for (const file of changed) {
2539
+ try {
2540
+ await syncer.uploadFile(file, baseSha);
2541
+ baseSha = syncer.remoteSha() ?? baseSha;
2542
+ uploaded++;
2543
+ } catch (e) {
2544
+ if (e instanceof PushConflictError) {
2545
+ spinner.stop();
2546
+ renderPullFirst(ora());
2547
+ process.exit(1);
2548
+ }
2549
+ errors.push(`Upload ${file.relativePath}: ${formatError(e)}`);
2550
+ }
2551
+ spinner.text = `Pushing ${++progress}/${total} files…`;
2552
+ }
2553
+ if (!opts.nodelete) for (const key of deleted) {
2554
+ try {
2555
+ await syncer.deleteRemoteFile(key, baseSha);
2556
+ baseSha = syncer.remoteSha() ?? baseSha;
2557
+ deletedCount++;
2558
+ } catch (e) {
2559
+ if (e instanceof PushConflictError) {
2560
+ spinner.stop();
2561
+ renderPullFirst(ora());
2562
+ process.exit(1);
2563
+ }
2564
+ errors.push(`Delete ${key}: ${formatError(e)}`);
2565
+ }
2566
+ spinner.text = `Pushing ${++progress}/${total} files…`;
2069
2567
  }
2568
+ if (errors.length) {
2569
+ spinner.warn(`Pushed with ${errors.length} error(s).`);
2570
+ for (const err of errors) console.error(` ${err}`);
2571
+ } 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
+ });
2070
2579
  if (opts.publish) {
2071
2580
  const pubSpinner = ora("Publishing theme…").start();
2072
2581
  try {
@@ -2078,6 +2587,176 @@ function createPushCommand() {
2078
2587
  }
2079
2588
  });
2080
2589
  }
2590
+ function renderPullFirst(spinner) {
2591
+ spinner.fail("Server has changed since your last pull. Push aborted.");
2592
+ console.log();
2593
+ console.log(` ${chalk.cyan("Run `fluid theme pull` first")} to merge the remote changes,`);
2594
+ console.log(` then re-run push. Use ${chalk.cyan("fluid theme push --force")} to overwrite anyway.`);
2595
+ console.log();
2596
+ }
2597
+ //#endregion
2598
+ //#region src/theme/merge-pull.ts
2599
+ /**
2600
+ * Reconcile the just-downloaded remote tree against the working tree,
2601
+ * using the shadow repo's HEAD as the merge base. Text files that
2602
+ * diverge on both sides get run through `git merge-file`; the result
2603
+ * — clean or with `<<<<<<<` markers — is written to disk. Binary
2604
+ * files fall back to "take remote" because there is no principled
2605
+ * three-way merge for them.
2606
+ *
2607
+ * HEAD advances to reflect the remote state we just materialized,
2608
+ * even when unresolved conflict markers remain in the working tree.
2609
+ * The user resolves markers in their editor and re-runs push; push
2610
+ * refuses to send files whose content still starts with a marker,
2611
+ * so a half-resolved push cannot silently ship broken content.
2612
+ */
2613
+ async function mergePull(input) {
2614
+ const { themeRoot, shadow, remote, fetchBinary, onProgress } = input;
2615
+ const doDelete = input.delete;
2616
+ const result = {
2617
+ written: 0,
2618
+ merged: 0,
2619
+ conflicts: [],
2620
+ deleted: 0,
2621
+ skipped: 0,
2622
+ errors: []
2623
+ };
2624
+ const remoteContent = /* @__PURE__ */ new Map();
2625
+ const remoteKeys = /* @__PURE__ */ new Set();
2626
+ let done = 0;
2627
+ for (const resource of remote) {
2628
+ remoteKeys.add(resource.key);
2629
+ try {
2630
+ const buf = await materialize(resource, fetchBinary);
2631
+ if (buf) remoteContent.set(resource.key, buf);
2632
+ } catch (e) {
2633
+ result.errors.push(`Download ${resource.key}: ${errMsg(e)}`);
2634
+ }
2635
+ onProgress?.(++done, remote.length);
2636
+ }
2637
+ for (const [key, remoteBuf] of remoteContent) {
2638
+ const file = themeRoot.file(key);
2639
+ if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
2640
+ result.errors.push(`Reconcile ${key}: path traversal detected`);
2641
+ continue;
2642
+ }
2643
+ if (input.force) {
2644
+ file.write(remoteBuf);
2645
+ result.written++;
2646
+ continue;
2647
+ }
2648
+ const localBuf = readIfExists(file.absolutePath);
2649
+ const baseBuf = await shadow.blobAtHead(key);
2650
+ if (localBuf == null) {
2651
+ file.write(remoteBuf);
2652
+ result.written++;
2653
+ continue;
2654
+ }
2655
+ if (localBuf.equals(remoteBuf)) {
2656
+ result.skipped++;
2657
+ continue;
2658
+ }
2659
+ if (baseBuf && localBuf.equals(baseBuf)) {
2660
+ file.write(remoteBuf);
2661
+ result.written++;
2662
+ continue;
2663
+ }
2664
+ if (baseBuf && remoteBuf.equals(baseBuf)) {
2665
+ result.skipped++;
2666
+ continue;
2667
+ }
2668
+ if (looksBinary(localBuf) || looksBinary(remoteBuf) || (baseBuf ? looksBinary(baseBuf) : false)) {
2669
+ file.write(remoteBuf);
2670
+ result.conflicts.push(`${key} (binary — kept remote)`);
2671
+ continue;
2672
+ }
2673
+ const { merged, hasConflicts } = await shadow.merge3(baseBuf, localBuf, remoteBuf);
2674
+ file.write(merged);
2675
+ if (hasConflicts) result.conflicts.push(key);
2676
+ else result.merged++;
2677
+ }
2678
+ if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
2679
+ if (remoteKeys.has(file.relativePath)) continue;
2680
+ if (isStylesheetKey(file.relativePath)) continue;
2681
+ const baseBuf = await shadow.blobAtHead(file.relativePath);
2682
+ if (!baseBuf) continue;
2683
+ const localBuf = readIfExists(file.absolutePath);
2684
+ if (!localBuf) continue;
2685
+ if (!localBuf.equals(baseBuf)) continue;
2686
+ try {
2687
+ unlinkSync(file.absolutePath);
2688
+ result.deleted++;
2689
+ } catch {}
2690
+ }
2691
+ const commitEntries = [];
2692
+ for (const [key, buf] of remoteContent) commitEntries.push({
2693
+ path: key,
2694
+ sha: await shadow.writeBlob(buf)
2695
+ });
2696
+ for (const key of remoteKeys) {
2697
+ if (remoteContent.has(key)) continue;
2698
+ const prevBlob = await shadow.blobAtHead(key);
2699
+ if (prevBlob == null) continue;
2700
+ commitEntries.push({
2701
+ path: key,
2702
+ sha: await shadow.writeBlob(prevBlob)
2703
+ });
2704
+ }
2705
+ if (commitEntries.length > 0) await shadow.commitState(commitEntries, `pull @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
2706
+ return result;
2707
+ }
2708
+ async function materialize(resource, fetchBinary) {
2709
+ if (resource.resource_type === "FileResource" && resource.url) return fetchBinary(resource.url);
2710
+ if (resource.content == null) return null;
2711
+ const text = typeof resource.content === "string" ? resource.content : JSON.stringify(resource.content);
2712
+ return Buffer.from(text);
2713
+ }
2714
+ function errMsg(e) {
2715
+ return e instanceof Error ? e.message : String(e);
2716
+ }
2717
+ //#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
+ }
2081
2760
  //#endregion
2082
2761
  //#region src/commands/pull.ts
2083
2762
  async function fetchCompanySubdomain(api) {
@@ -2088,42 +2767,8 @@ async function fetchCompanySubdomain(api) {
2088
2767
  }
2089
2768
  return subdomain;
2090
2769
  }
2091
- function formatRelativeTime(iso) {
2092
- const diff = Date.now() - new Date(iso).getTime();
2093
- const minutes = Math.floor(diff / 6e4);
2094
- if (minutes < 1) return "just now";
2095
- if (minutes < 60) return `${minutes}m ago`;
2096
- const hours = Math.floor(minutes / 60);
2097
- if (hours < 24) return `${hours}h ago`;
2098
- const days = Math.floor(hours / 24);
2099
- if (days === 1) return "yesterday";
2100
- return `${days}d ago (${new Date(iso).toLocaleDateString("en-US", {
2101
- month: "short",
2102
- day: "numeric",
2103
- year: "numeric"
2104
- })})`;
2105
- }
2106
- /**
2107
- * Detect files where both local and remote have changed since the last pull.
2108
- * Returns the set of conflicting resource keys.
2109
- */
2110
- function detectConflicts(storedChecksums, remoteChecksums, themeRoot) {
2111
- const conflicts = [];
2112
- for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
2113
- const remoteChecksum = remoteChecksums[key];
2114
- if (remoteChecksum === void 0) continue;
2115
- if (remoteChecksum === storedChecksum) continue;
2116
- const file = themeRoot.file(key);
2117
- if (!file.exists) continue;
2118
- const localChecksum = file.checksum();
2119
- if (localChecksum === storedChecksum) continue;
2120
- if (localChecksum === remoteChecksum) continue;
2121
- conflicts.push(key);
2122
- }
2123
- return conflicts;
2124
- }
2125
2770
  function createPullCommand() {
2126
- 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 changes without prompting on conflicts").action(async (opts) => {
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) => {
2127
2772
  requireToken();
2128
2773
  const api = createApiClient();
2129
2774
  const workspace = findWorkspace();
@@ -2139,48 +2784,8 @@ function createPullCommand() {
2139
2784
  console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
2140
2785
  console.log(` Company: ${chalk.bold(subdomain)}`);
2141
2786
  console.log(` Target: ${chalk.bold(absoluteRoot)}`);
2142
- if (existingConfig?.lastPulledAt) console.log(` Last pulled: ${formatRelativeTime(existingConfig.lastPulledAt)}`);
2143
2787
  console.log();
2144
- const themeRoot = new ThemeRoot(root);
2145
- let skipKeys;
2146
- if (existingConfig?.checksums && !opts.force) {
2147
- const fetchSpinner = ora("Checking for conflicts…").start();
2148
- const syncer = new Syncer(api, theme.id, themeRoot);
2149
- await syncer.fetchChecksums();
2150
- const remoteChecksums = syncer.remoteChecksums();
2151
- const conflicts = detectConflicts(existingConfig.checksums, remoteChecksums, themeRoot);
2152
- fetchSpinner.stop();
2153
- if (conflicts.length > 0) {
2154
- console.log(chalk.yellow(`⚠ ${conflicts.length} conflict(s) detected:\n`));
2155
- for (const key of conflicts) console.log(` ${key}`);
2156
- console.log();
2157
- const { resolution } = await prompts({
2158
- type: "select",
2159
- name: "resolution",
2160
- message: "How do you want to handle conflicts?",
2161
- choices: [
2162
- {
2163
- title: "Keep local (skip conflicting files)",
2164
- value: "keep-local"
2165
- },
2166
- {
2167
- title: "Use remote (overwrite local changes)",
2168
- value: "use-remote"
2169
- },
2170
- {
2171
- title: "Abort",
2172
- value: "abort"
2173
- }
2174
- ]
2175
- }, { onCancel: () => process.exit(130) });
2176
- if (resolution === "abort") {
2177
- console.log("Aborted.");
2178
- process.exit(0);
2179
- }
2180
- if (resolution === "keep-local") skipKeys = new Set(conflicts);
2181
- }
2182
- }
2183
- if (!opts.yes && !skipKeys) {
2788
+ if (!opts.yes) {
2184
2789
  const { confirmed } = await prompts({
2185
2790
  type: "confirm",
2186
2791
  name: "confirmed",
@@ -2192,36 +2797,52 @@ function createPullCommand() {
2192
2797
  process.exit(0);
2193
2798
  }
2194
2799
  }
2800
+ const themeRoot = new ThemeRoot(root);
2801
+ const shadow = await ShadowRepo.open(absoluteRoot, theme.id);
2802
+ await migrateLegacyChecksumsIntoShadow({
2803
+ shadow,
2804
+ themeRoot,
2805
+ absoluteRoot,
2806
+ themeId: theme.id
2807
+ });
2195
2808
  const syncer = new Syncer(api, theme.id, themeRoot);
2196
2809
  const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
2197
- const result = await syncer.downloadTheme({
2810
+ const result = await mergePull({
2811
+ themeRoot,
2812
+ shadow,
2813
+ remote: await syncer.downloadAll(),
2814
+ fetchBinary: (url) => syncer.downloadBinaryAsset(url),
2198
2815
  delete: !opts.nodelete,
2199
- skip: skipKeys,
2200
- onProgress: (d, total) => {
2201
- spinner.text = `Downloading ${d}/${total} files…`;
2816
+ force: opts.force ?? false,
2817
+ onProgress: (done, total) => {
2818
+ spinner.text = `Downloading ${done}/${total} files…`;
2202
2819
  }
2203
2820
  });
2204
- const newChecksums = syncer.remoteChecksums();
2205
- if (skipKeys && existingConfig?.checksums) for (const key of skipKeys) {
2206
- const oldChecksum = existingConfig.checksums[key];
2207
- if (oldChecksum) newChecksums[key] = oldChecksum;
2208
- }
2821
+ const parts = [];
2822
+ if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
2823
+ if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
2824
+ if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
2825
+ if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
2826
+ if (result.errors.length) {
2827
+ spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
2828
+ for (const e of result.errors) console.error(` ${e}`);
2829
+ } else if (result.conflicts.length > 0) {
2830
+ spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
2831
+ console.log();
2832
+ for (const c of result.conflicts) console.log(` ${chalk.yellow("CONFLICT")} ${c}`);
2833
+ console.log();
2834
+ console.log(` Edit each file above to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} markers,`);
2835
+ console.log(` then run ${chalk.cyan("fluid theme push")} once your resolution is in place.`);
2836
+ console.log();
2837
+ } else spinner.succeed(parts.join(", ") || "Already up to date.");
2209
2838
  const remoteSha = syncer.remoteSha();
2210
2839
  writeThemeConfig(absoluteRoot, {
2211
2840
  themeId: theme.id,
2212
2841
  themeName: theme.name,
2213
2842
  company: subdomain,
2214
- lastPulledAt: (/* @__PURE__ */ new Date()).toISOString(),
2215
- checksums: newChecksums,
2216
2843
  baseSha: remoteSha ?? existingConfig?.baseSha
2217
2844
  });
2218
- const parts = [`Downloaded ${result.downloaded} file(s)`];
2219
- if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
2220
- if (result.skipped > 0) parts.push(`skipped ${result.skipped} conflict(s)`);
2221
- if (result.errors.length) {
2222
- spinner.warn(`Pulled with ${result.errors.length} error(s).`);
2223
- for (const e of result.errors) console.error(` ${e}`);
2224
- } else spinner.succeed(`${parts.join(", ")}.`);
2845
+ if (result.conflicts.length > 0) process.exit(1);
2225
2846
  });
2226
2847
  }
2227
2848
  //#endregion