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

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, 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";
@@ -9,7 +9,8 @@ import chokidar from "chokidar";
9
9
  import chalk from "chalk";
10
10
  import prompts from "prompts";
11
11
  import ora from "ora";
12
- import { execFileSync } from "node:child_process";
12
+ import { execFileSync, spawn } from "node:child_process";
13
+ import { tmpdir } from "node:os";
13
14
  import { fileURLToPath } from "node:url";
14
15
  //#region ../../platform/api-client-core/src/fetch-client.ts
15
16
  /**
@@ -276,6 +277,21 @@ function readThemeConfig(themeRoot) {
276
277
  return null;
277
278
  }
278
279
  }
280
+ /**
281
+ * Read a legacy config (pre-shadow-repo). Used only by the migration
282
+ * path on first new-CLI pull to seed shadow HEAD from files whose
283
+ * local content still matches their stored sha256 checksum.
284
+ */
285
+ function readLegacyThemeConfig(themeRoot) {
286
+ const path = configPath(themeRoot);
287
+ if (!existsSync(path)) return null;
288
+ try {
289
+ const raw = readFileSync(path, "utf-8");
290
+ return JSON.parse(raw);
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
279
295
  /** Write `.fluid-theme.json` to a theme directory. */
280
296
  function writeThemeConfig(themeRoot, config) {
281
297
  writeFileSync(configPath(themeRoot), JSON.stringify(config, null, 2) + "\n", "utf-8");
@@ -1931,24 +1947,444 @@ function createDevCommand() {
1931
1947
  });
1932
1948
  }
1933
1949
  //#endregion
1934
- //#region src/commands/push.ts
1950
+ //#region src/theme/shadow-repo.ts
1935
1951
  /**
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).
1952
+ * A bare git repo hidden under `.fluid-theme/repo` that stands in for
1953
+ * git's index+HEAD when talking to the Fluid server. Every pull commits
1954
+ * the incoming remote state onto a single branch (`refs/heads/main`);
1955
+ * every successful push commits the outgoing local state onto the same
1956
+ * branch. HEAD therefore represents "the last state the CLI and server
1957
+ * agreed on", and its tree is the natural three-way-merge base for the
1958
+ * next pull.
1959
+ *
1960
+ * We stay in git's plumbing layer — no working tree, no index file
1961
+ * next to the theme content — so the shadow repo cannot interfere
1962
+ * with the user's own git repo (if they have one) around the theme
1963
+ * dir. All state lives inside `.fluid-theme/`.
1964
+ *
1965
+ * The class only wraps the small handful of plumbing commands we
1966
+ * actually need: hash-object, cat-file, write-tree (via a temp index),
1967
+ * commit-tree, update-ref, and merge-file. Everything else stays out.
1938
1968
  */
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);
1969
+ var ShadowRepo = class ShadowRepo {
1970
+ headExists = void 0;
1971
+ constructor(themeRoot, gitDir) {
1972
+ this.themeRoot = themeRoot;
1973
+ this.gitDir = gitDir;
1974
+ }
1975
+ /**
1976
+ * Return a ShadowRepo bound to `themeId` for the given theme root.
1977
+ * The shadow is initialized on first use and re-initialized when
1978
+ * the caller passes a themeId different from the one previously
1979
+ * recorded — the merge base is only meaningful for the theme it
1980
+ * was captured against, so a cross-theme operation (pull A → push
1981
+ * B, or two pulls of different themes into one dir) starts from a
1982
+ * clean slate rather than pretending B's state matches A's HEAD.
1983
+ */
1984
+ static async open(themeRoot, themeId) {
1985
+ const shadowDir = join(themeRoot, ".fluid-theme");
1986
+ const gitDir = join(shadowDir, "repo");
1987
+ const themeIdFile = join(shadowDir, "theme-id");
1988
+ if (existsSync(gitDir)) {
1989
+ if (readStoredThemeId(themeIdFile) !== themeId) rmSync(gitDir, {
1990
+ recursive: true,
1991
+ force: true
1992
+ });
1993
+ }
1994
+ const repo = new ShadowRepo(themeRoot, gitDir);
1995
+ if (!existsSync(gitDir)) {
1996
+ mkdirSync(shadowDir, { recursive: true });
1997
+ await repo.git([
1998
+ "init",
1999
+ "--bare",
2000
+ "-b",
2001
+ "main",
2002
+ gitDir
2003
+ ], { cwd: themeRoot });
2004
+ }
2005
+ writeFileSync(themeIdFile, `${themeId}\n`, "utf-8");
2006
+ const shadowIgnore = join(shadowDir, ".gitignore");
2007
+ if (!existsSync(shadowIgnore)) writeFileSync(shadowIgnore, "# Fluid CLI shadow repo — internal state, not for version control.\n*\n");
2008
+ await ensureRootGitignoreHidesShadow(themeRoot);
2009
+ return repo;
2010
+ }
2011
+ /** True when the repo has at least one commit on `refs/heads/main`. */
2012
+ async hasHead() {
2013
+ if (this.headExists !== void 0) return this.headExists;
2014
+ try {
2015
+ await this.git([
2016
+ "rev-parse",
2017
+ "--verify",
2018
+ "HEAD"
2019
+ ]);
2020
+ this.headExists = true;
2021
+ } catch {
2022
+ this.headExists = false;
2023
+ }
2024
+ return this.headExists;
2025
+ }
2026
+ /**
2027
+ * Every path recorded under HEAD's tree, recursively. Callers use
2028
+ * this to detect local deletions (paths in HEAD, absent from the
2029
+ * working tree). Returns [] when HEAD has never been committed.
2030
+ */
2031
+ async headPaths() {
2032
+ if (!await this.hasHead()) return [];
2033
+ const { stdout } = await this.git([
2034
+ "ls-tree",
2035
+ "-r",
2036
+ "HEAD",
2037
+ "--name-only"
2038
+ ]);
2039
+ return stdout.toString("utf8").split("\n").filter((line) => line.length > 0);
2040
+ }
2041
+ /**
2042
+ * The content of `path` in HEAD's tree, or null when the path does
2043
+ * not exist there. Callers use this as the merge base for pull
2044
+ * conflict resolution.
2045
+ */
2046
+ async blobAtHead(path) {
2047
+ if (!await this.hasHead()) return null;
2048
+ try {
2049
+ const { stdout } = await this.git([
2050
+ "cat-file",
2051
+ "-p",
2052
+ `HEAD:${path}`
2053
+ ]);
2054
+ return stdout;
2055
+ } catch {
2056
+ return null;
2057
+ }
2058
+ }
2059
+ /**
2060
+ * Write `content` as a blob in the shadow repo and return its sha.
2061
+ * Used by `commitState` to stage each file's content before the
2062
+ * `write-tree` call.
2063
+ */
2064
+ async writeBlob(content) {
2065
+ const buf = typeof content === "string" ? Buffer.from(content) : content;
2066
+ const { stdout } = await this.git([
2067
+ "hash-object",
2068
+ "-w",
2069
+ "--stdin"
2070
+ ], { input: buf });
2071
+ return stdout.toString("utf8").trim();
2072
+ }
2073
+ /**
2074
+ * Commit `files` as HEAD's new tree, threaded onto the current HEAD
2075
+ * as the parent. Uses a per-call temp index so a partial run can't
2076
+ * corrupt anything reachable from HEAD; the previous commit stays
2077
+ * intact until `update-ref` at the end.
2078
+ *
2079
+ * Returns the new commit sha.
2080
+ */
2081
+ async commitState(files, message) {
2082
+ const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
2083
+ try {
2084
+ for (const { path, sha } of files) await this.git([
2085
+ "update-index",
2086
+ "--add",
2087
+ "--cacheinfo",
2088
+ `100644,${sha},${path}`
2089
+ ], { env: { GIT_INDEX_FILE: indexPath } });
2090
+ const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
2091
+ const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
2092
+ const commitArgs = [
2093
+ "commit-tree",
2094
+ treeSha,
2095
+ "-m",
2096
+ message
2097
+ ];
2098
+ if (parent) commitArgs.push("-p", parent);
2099
+ const commitSha = (await this.git(commitArgs, { env: {
2100
+ GIT_AUTHOR_NAME: "Fluid CLI",
2101
+ GIT_AUTHOR_EMAIL: "cli@fluid.app",
2102
+ GIT_COMMITTER_NAME: "Fluid CLI",
2103
+ GIT_COMMITTER_EMAIL: "cli@fluid.app"
2104
+ } })).stdout.toString("utf8").trim();
2105
+ await this.git([
2106
+ "update-ref",
2107
+ "refs/heads/main",
2108
+ commitSha
2109
+ ]);
2110
+ this.headExists = true;
2111
+ return commitSha;
2112
+ } finally {
2113
+ try {
2114
+ rmSync(indexPath, { force: true });
2115
+ rmSync(indexPath.substring(0, indexPath.length - 6), {
2116
+ recursive: true,
2117
+ force: true
2118
+ });
2119
+ } catch {}
2120
+ }
2121
+ }
2122
+ /**
2123
+ * Three-way merge of `local` against `remote` with `base` as the
2124
+ * common ancestor. Returns the merged bytes and a flag when
2125
+ * `git merge-file` reported unresolved conflicts (i.e. the output
2126
+ * contains `<<<<<<<` markers for the reader to resolve).
2127
+ *
2128
+ * `base` is null when HEAD has never seen this path; we merge
2129
+ * against an empty base, which is what git itself does for a new
2130
+ * file added on both sides.
2131
+ */
2132
+ async merge3(base, local, remote) {
2133
+ const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
2134
+ const localPath = join(dir, "local");
2135
+ const basePath = join(dir, "base");
2136
+ const remotePath = join(dir, "remote");
2137
+ try {
2138
+ writeFileSync(localPath, local);
2139
+ writeFileSync(basePath, base ?? Buffer.alloc(0));
2140
+ writeFileSync(remotePath, remote);
2141
+ try {
2142
+ const { stdout } = await this.git([
2143
+ "merge-file",
2144
+ "-p",
2145
+ "-L",
2146
+ "local",
2147
+ "-L",
2148
+ "base",
2149
+ "-L",
2150
+ "remote",
2151
+ localPath,
2152
+ basePath,
2153
+ remotePath
2154
+ ]);
2155
+ return {
2156
+ merged: stdout,
2157
+ hasConflicts: false
2158
+ };
2159
+ } catch (err) {
2160
+ const e = err;
2161
+ const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
2162
+ if (typeof e.code === "number" && e.code >= 1 && e.code <= 127 && merged.length > 0) return {
2163
+ merged,
2164
+ hasConflicts: true
2165
+ };
2166
+ throw err;
2167
+ }
2168
+ } finally {
2169
+ try {
2170
+ rmSync(dir, {
2171
+ recursive: true,
2172
+ force: true
2173
+ });
2174
+ } catch {}
2175
+ }
2176
+ }
2177
+ /**
2178
+ * Snapshot the working-tree copy of `paths` into HEAD as a single
2179
+ * commit. Intended for the migration path: on the first pull with
2180
+ * the new CLI (no shadow repo yet, but a `.fluid-theme.json` with
2181
+ * checksums exists) we seed HEAD with whatever is on disk before
2182
+ * running the merge, so unmodified files fast-forward cleanly and
2183
+ * modified files show a diff.
2184
+ */
2185
+ async seedFromWorkingTree(files, message) {
2186
+ const entries = [];
2187
+ for (const { path, content } of files) entries.push({
2188
+ path,
2189
+ sha: await this.writeBlob(content)
2190
+ });
2191
+ await this.commitState(entries, message);
2192
+ }
2193
+ async git(args, opts = {}) {
2194
+ const child = spawn("git", args[0] === "init" ? args : [
2195
+ "--git-dir",
2196
+ this.gitDir,
2197
+ ...args
2198
+ ], {
2199
+ cwd: opts.cwd ?? this.themeRoot,
2200
+ env: {
2201
+ ...process.env,
2202
+ ...opts.env
2203
+ },
2204
+ stdio: [
2205
+ "pipe",
2206
+ "pipe",
2207
+ "pipe"
2208
+ ]
2209
+ });
2210
+ if (opts.input) child.stdin.write(opts.input);
2211
+ child.stdin.end();
2212
+ return new Promise((resolve, reject) => {
2213
+ const stdout = [];
2214
+ const stderr = [];
2215
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
2216
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
2217
+ child.on("error", reject);
2218
+ child.on("close", (code) => {
2219
+ const out = Buffer.concat(stdout);
2220
+ const err = Buffer.concat(stderr);
2221
+ if (code === 0) resolve({
2222
+ stdout: out,
2223
+ stderr: err
2224
+ });
2225
+ else {
2226
+ const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
2227
+ e.code = code ?? -1;
2228
+ e.stdout = out;
2229
+ e.stderr = err;
2230
+ reject(e);
2231
+ }
2232
+ });
2233
+ });
2234
+ }
2235
+ };
2236
+ /**
2237
+ * Content-type check the pull command uses to decide whether a file
2238
+ * is safe to run through `merge3` (line-based) or must fall back to
2239
+ * whole-file "either/or" resolution (binary).
2240
+ */
2241
+ function looksBinary(content) {
2242
+ return content.subarray(0, Math.min(content.length, 8e3)).includes(0);
2243
+ }
2244
+ /** Best-effort readFile that returns null when the file does not exist. */
2245
+ function readIfExists(path) {
2246
+ try {
2247
+ return readFileSync(path);
2248
+ } catch {
2249
+ return null;
2250
+ }
2251
+ }
2252
+ /**
2253
+ * Parse the numeric theme id from `.fluid-theme/theme-id`. Returns
2254
+ * null when the file is missing or the contents don't parse cleanly;
2255
+ * `open` treats that as "unknown theme" and rebuilds the shadow.
2256
+ */
2257
+ function readStoredThemeId(themeIdFile) {
2258
+ try {
2259
+ const stored = parseInt(readFileSync(themeIdFile, "utf-8").trim(), 10);
2260
+ return Number.isFinite(stored) ? stored : null;
2261
+ } catch {
2262
+ return null;
2263
+ }
2264
+ }
2265
+ /**
2266
+ * Append `.fluid-theme/` to the theme root's `.gitignore` when the
2267
+ * theme dir sits inside a git working tree and the entry isn't
2268
+ * already there. Skipped when the user isn't in a git repo — no
2269
+ * point manufacturing a `.gitignore` for someone who doesn't use
2270
+ * git. Idempotent — a second call is a no-op.
2271
+ */
2272
+ async function ensureRootGitignoreHidesShadow(themeRoot) {
2273
+ if (!await new Promise((resolve) => {
2274
+ const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
2275
+ cwd: themeRoot,
2276
+ stdio: [
2277
+ "ignore",
2278
+ "pipe",
2279
+ "pipe"
2280
+ ]
2281
+ });
2282
+ child.on("close", (code) => resolve(code === 0));
2283
+ child.on("error", () => resolve(false));
2284
+ })) return;
2285
+ const gitignorePath = join(themeRoot, ".gitignore");
2286
+ let existing = "";
2287
+ try {
2288
+ existing = readFileSync(gitignorePath, "utf-8");
2289
+ } catch {
2290
+ existing = "";
2291
+ }
2292
+ const lines = existing.split("\n").map((line) => line.trim());
2293
+ if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
2294
+ const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
2295
+ writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
2296
+ }
2297
+ //#endregion
2298
+ //#region src/theme/merge-push.ts
2299
+ /**
2300
+ * Compute what changed locally since the last time the shadow repo
2301
+ * committed a state. Replaces the sha256 `checksums` map: shadow HEAD
2302
+ * is the source of truth for "what the CLI last saw the server have".
2303
+ *
2304
+ * Files whose local bytes are byte-identical to their HEAD blob are
2305
+ * skipped; anything else — new, modified, or a locally-deleted path
2306
+ * that HEAD still has — is included.
2307
+ */
2308
+ async function diffAgainstShadow(themeRoot, shadow) {
2309
+ const changed = [];
2310
+ const deleted = [];
2311
+ const localFiles = themeRoot.files();
2312
+ const localByKey = /* @__PURE__ */ new Map();
2313
+ for (const file of localFiles) {
1946
2314
  if (!file.exists) continue;
1947
- if (file.checksum() === remoteChecksum) continue;
1948
- conflicts.push(key);
2315
+ localByKey.set(file.relativePath, file);
2316
+ const headBlob = await shadow.blobAtHead(file.relativePath);
2317
+ const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
2318
+ if (headBlob && headBlob.equals(localBuf)) continue;
2319
+ changed.push(file);
1949
2320
  }
1950
- return conflicts;
2321
+ if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
2322
+ if (localByKey.has(key)) continue;
2323
+ if (isStylesheetKey(key)) continue;
2324
+ deleted.push(key);
2325
+ }
2326
+ return {
2327
+ changed,
2328
+ deleted
2329
+ };
1951
2330
  }
2331
+ /**
2332
+ * Read every path recorded under HEAD's tree. Used to detect local
2333
+ * deletions (paths in HEAD, absent from the working tree). Implemented
2334
+ * with `git ls-tree -r HEAD --name-only` piped through the shadow
2335
+ * repo's plumbing.
2336
+ */
2337
+ async function listHeadPaths(shadow) {
2338
+ return shadow.headPaths();
2339
+ }
2340
+ /**
2341
+ * Refuse a push when any working file still contains a conflict marker
2342
+ * from a previous pull. Mirrors git's "you have unresolved conflicts;
2343
+ * fix them and re-run" behavior — the whole point of writing markers
2344
+ * on pull was to hand resolution to the user, so we can't send them
2345
+ * upstream.
2346
+ */
2347
+ function findUnresolvedConflicts(files) {
2348
+ const flagged = [];
2349
+ for (const file of files) {
2350
+ if (!file.isText) continue;
2351
+ const buf = readIfExists(file.absolutePath);
2352
+ if (!buf) continue;
2353
+ if (containsConflictMarker(buf)) flagged.push(file.relativePath);
2354
+ }
2355
+ return flagged;
2356
+ }
2357
+ const CONFLICT_START = Buffer.from("<<<<<<<");
2358
+ const CONFLICT_MID = Buffer.from("=======");
2359
+ const CONFLICT_END = Buffer.from(">>>>>>>");
2360
+ /**
2361
+ * A file counts as unresolved when it contains all three marker
2362
+ * shapes: `<<<<<<<`, `=======`, and `>>>>>>>`. Requiring all three
2363
+ * avoids false positives — a line of equals signs alone (e.g. inside
2364
+ * an ASCII table in a template comment) doesn't trip the guard.
2365
+ */
2366
+ function containsConflictMarker(buf) {
2367
+ return buf.includes(CONFLICT_START) && buf.includes(CONFLICT_MID) && buf.includes(CONFLICT_END);
2368
+ }
2369
+ /**
2370
+ * Commit the current working-tree state to shadow HEAD after a
2371
+ * successful push. Ensures the next pull's merge base is the state
2372
+ * we know the server just accepted.
2373
+ */
2374
+ async function commitPushedState(themeRoot, shadow, message) {
2375
+ const entries = [];
2376
+ for (const file of themeRoot.files()) {
2377
+ if (!file.exists) continue;
2378
+ const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
2379
+ entries.push({
2380
+ path: file.relativePath,
2381
+ sha: await shadow.writeBlob(buf)
2382
+ });
2383
+ }
2384
+ if (entries.length > 0) await shadow.commitState(entries, message);
2385
+ }
2386
+ //#endregion
2387
+ //#region src/commands/push.ts
1952
2388
  function createPushCommand() {
1953
2389
  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
2390
  requireToken();
@@ -1985,88 +2421,107 @@ function createPushCommand() {
1985
2421
  console.log(` Using theme from .fluid-theme.json: ${chalk.bold(config.themeName)} (#${config.themeId})`);
1986
2422
  theme = (await getApplicationTheme(api, config.themeId)).application_theme;
1987
2423
  } 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
- }
2424
+ const shadow = await ShadowRepo.open(themeRoot.root, theme.id);
2425
+ const unresolved = findUnresolvedConflicts(themeRoot.files().filter((f) => f.exists));
2426
+ if (unresolved.length > 0) {
2427
+ console.log();
2428
+ console.log(chalk.red(`✗ ${unresolved.length} file(s) still contain unresolved conflict markers:`));
2429
+ for (const key of unresolved) console.log(` ${key}`);
2430
+ console.log();
2431
+ console.log(` Edit each file to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} sections,`);
2432
+ console.log(` then re-run ${chalk.cyan("fluid theme push")}.`);
2433
+ console.log();
2434
+ process.exit(1);
2435
+ }
2436
+ const { changed, deleted } = await diffAgainstShadow(themeRoot, shadow);
2437
+ if (changed.length === 0 && deleted.length === 0) {
2438
+ console.log("Nothing to push — local matches the last synced state.");
2439
+ return;
2029
2440
  }
2030
- const syncer = new Syncer(api, theme.id, themeRoot);
2031
2441
  const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
2032
- const baseSha = opts.force || overrideMergeCheck ? null : config?.baseSha ?? null;
2033
- let result;
2442
+ const syncer = new Syncer(api, theme.id, themeRoot);
2443
+ const configMatchesTheme = config?.themeId === theme.id;
2444
+ let baseSha = opts.force || !configMatchesTheme ? null : config?.baseSha ?? null;
2445
+ if (!opts.force && !opts.unpublished && !baseSha) {
2446
+ console.error();
2447
+ console.error(chalk.red(`No local baseline for theme "${theme.name}" (#${theme.id}).`));
2448
+ console.error();
2449
+ console.error(` Run ${chalk.cyan(`fluid theme pull -t ${theme.id}`)} first to sync down the current server state,`);
2450
+ console.error(` then push — this way you see what would change before it goes live.`);
2451
+ console.error();
2452
+ console.error(` Or, if you know what you're doing and want to overwrite the server's current`);
2453
+ console.error(` contents wholesale, re-run with ${chalk.cyan("--force")}.`);
2454
+ console.error();
2455
+ process.exit(1);
2456
+ }
2034
2457
  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
- });
2458
+ await syncer.preflightPush(baseSha);
2459
+ baseSha = syncer.remoteSha() ?? baseSha;
2043
2460
  } catch (e) {
2044
2461
  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();
2462
+ renderPullFirst(spinner);
2050
2463
  process.exit(1);
2051
2464
  }
2052
2465
  throw e;
2053
2466
  }
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
- });
2467
+ if (!opts.force) {
2468
+ const errors = [];
2469
+ for (const file of changed) {
2470
+ if (!file.isLiquid) continue;
2471
+ for (const d of file.validateSchema()) if (d.severity === "error") errors.push(`${file.relativePath}: ${d.message}`);
2472
+ }
2473
+ if (errors.length > 0) {
2474
+ spinner.fail(`Schema validation failed (${errors.length} error(s)). Use --force to skip.`);
2475
+ for (const err of errors) console.error(` ${err}`);
2476
+ process.exit(1);
2477
+ }
2478
+ }
2479
+ let uploaded = 0;
2480
+ let deletedCount = 0;
2481
+ const errors = [];
2482
+ let progress = 0;
2483
+ const total = changed.length + (opts.nodelete ? 0 : deleted.length);
2484
+ for (const file of changed) {
2485
+ try {
2486
+ await syncer.uploadFile(file, baseSha);
2487
+ baseSha = syncer.remoteSha() ?? baseSha;
2488
+ uploaded++;
2489
+ } catch (e) {
2490
+ if (e instanceof PushConflictError) {
2491
+ spinner.stop();
2492
+ renderPullFirst(ora());
2493
+ process.exit(1);
2494
+ }
2495
+ errors.push(`Upload ${file.relativePath}: ${formatError(e)}`);
2496
+ }
2497
+ spinner.text = `Pushing ${++progress}/${total} files…`;
2069
2498
  }
2499
+ if (!opts.nodelete) for (const key of deleted) {
2500
+ try {
2501
+ await syncer.deleteRemoteFile(key, baseSha);
2502
+ baseSha = syncer.remoteSha() ?? baseSha;
2503
+ deletedCount++;
2504
+ } catch (e) {
2505
+ if (e instanceof PushConflictError) {
2506
+ spinner.stop();
2507
+ renderPullFirst(ora());
2508
+ process.exit(1);
2509
+ }
2510
+ errors.push(`Delete ${key}: ${formatError(e)}`);
2511
+ }
2512
+ spinner.text = `Pushing ${++progress}/${total} files…`;
2513
+ }
2514
+ if (errors.length) {
2515
+ spinner.warn(`Pushed with ${errors.length} error(s).`);
2516
+ for (const err of errors) console.error(` ${err}`);
2517
+ } else spinner.succeed(`Pushed ${uploaded} file(s)` + (deletedCount > 0 ? `, deleted ${deletedCount} remote file(s).` : "."));
2518
+ if (errors.length === 0) await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
2519
+ if (config) writeThemeConfig(themeRoot.root, {
2520
+ themeId: theme.id,
2521
+ themeName: theme.name,
2522
+ company: config.company,
2523
+ baseSha: baseSha ?? void 0
2524
+ });
2070
2525
  if (opts.publish) {
2071
2526
  const pubSpinner = ora("Publishing theme…").start();
2072
2527
  try {
@@ -2078,6 +2533,176 @@ function createPushCommand() {
2078
2533
  }
2079
2534
  });
2080
2535
  }
2536
+ function renderPullFirst(spinner) {
2537
+ spinner.fail("Server has changed since your last pull. Push aborted.");
2538
+ console.log();
2539
+ console.log(` ${chalk.cyan("Run `fluid theme pull` first")} to merge the remote changes,`);
2540
+ console.log(` then re-run push. Use ${chalk.cyan("fluid theme push --force")} to overwrite anyway.`);
2541
+ console.log();
2542
+ }
2543
+ //#endregion
2544
+ //#region src/theme/merge-pull.ts
2545
+ /**
2546
+ * Reconcile the just-downloaded remote tree against the working tree,
2547
+ * using the shadow repo's HEAD as the merge base. Text files that
2548
+ * diverge on both sides get run through `git merge-file`; the result
2549
+ * — clean or with `<<<<<<<` markers — is written to disk. Binary
2550
+ * files fall back to "take remote" because there is no principled
2551
+ * three-way merge for them.
2552
+ *
2553
+ * HEAD advances to reflect the remote state we just materialized,
2554
+ * even when unresolved conflict markers remain in the working tree.
2555
+ * The user resolves markers in their editor and re-runs push; push
2556
+ * refuses to send files whose content still starts with a marker,
2557
+ * so a half-resolved push cannot silently ship broken content.
2558
+ */
2559
+ async function mergePull(input) {
2560
+ const { themeRoot, shadow, remote, fetchBinary, onProgress } = input;
2561
+ const doDelete = input.delete;
2562
+ const result = {
2563
+ written: 0,
2564
+ merged: 0,
2565
+ conflicts: [],
2566
+ deleted: 0,
2567
+ skipped: 0,
2568
+ errors: []
2569
+ };
2570
+ const remoteContent = /* @__PURE__ */ new Map();
2571
+ const remoteKeys = /* @__PURE__ */ new Set();
2572
+ let done = 0;
2573
+ for (const resource of remote) {
2574
+ remoteKeys.add(resource.key);
2575
+ try {
2576
+ const buf = await materialize(resource, fetchBinary);
2577
+ if (buf) remoteContent.set(resource.key, buf);
2578
+ } catch (e) {
2579
+ result.errors.push(`Download ${resource.key}: ${errMsg(e)}`);
2580
+ }
2581
+ onProgress?.(++done, remote.length);
2582
+ }
2583
+ for (const [key, remoteBuf] of remoteContent) {
2584
+ const file = themeRoot.file(key);
2585
+ if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
2586
+ result.errors.push(`Reconcile ${key}: path traversal detected`);
2587
+ continue;
2588
+ }
2589
+ if (input.force) {
2590
+ file.write(remoteBuf);
2591
+ result.written++;
2592
+ continue;
2593
+ }
2594
+ const localBuf = readIfExists(file.absolutePath);
2595
+ const baseBuf = await shadow.blobAtHead(key);
2596
+ if (localBuf == null) {
2597
+ file.write(remoteBuf);
2598
+ result.written++;
2599
+ continue;
2600
+ }
2601
+ if (localBuf.equals(remoteBuf)) {
2602
+ result.skipped++;
2603
+ continue;
2604
+ }
2605
+ if (baseBuf && localBuf.equals(baseBuf)) {
2606
+ file.write(remoteBuf);
2607
+ result.written++;
2608
+ continue;
2609
+ }
2610
+ if (baseBuf && remoteBuf.equals(baseBuf)) {
2611
+ result.skipped++;
2612
+ continue;
2613
+ }
2614
+ if (looksBinary(localBuf) || looksBinary(remoteBuf) || (baseBuf ? looksBinary(baseBuf) : false)) {
2615
+ file.write(remoteBuf);
2616
+ result.conflicts.push(`${key} (binary — kept remote)`);
2617
+ continue;
2618
+ }
2619
+ const { merged, hasConflicts } = await shadow.merge3(baseBuf, localBuf, remoteBuf);
2620
+ file.write(merged);
2621
+ if (hasConflicts) result.conflicts.push(key);
2622
+ else result.merged++;
2623
+ }
2624
+ if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
2625
+ if (remoteKeys.has(file.relativePath)) continue;
2626
+ if (isStylesheetKey(file.relativePath)) continue;
2627
+ const baseBuf = await shadow.blobAtHead(file.relativePath);
2628
+ if (!baseBuf) continue;
2629
+ const localBuf = readIfExists(file.absolutePath);
2630
+ if (!localBuf) continue;
2631
+ if (!localBuf.equals(baseBuf)) continue;
2632
+ try {
2633
+ unlinkSync(file.absolutePath);
2634
+ result.deleted++;
2635
+ } catch {}
2636
+ }
2637
+ const commitEntries = [];
2638
+ for (const [key, buf] of remoteContent) commitEntries.push({
2639
+ path: key,
2640
+ sha: await shadow.writeBlob(buf)
2641
+ });
2642
+ for (const key of remoteKeys) {
2643
+ if (remoteContent.has(key)) continue;
2644
+ const prevBlob = await shadow.blobAtHead(key);
2645
+ if (prevBlob == null) continue;
2646
+ commitEntries.push({
2647
+ path: key,
2648
+ sha: await shadow.writeBlob(prevBlob)
2649
+ });
2650
+ }
2651
+ if (commitEntries.length > 0) await shadow.commitState(commitEntries, `pull @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
2652
+ return result;
2653
+ }
2654
+ async function materialize(resource, fetchBinary) {
2655
+ if (resource.resource_type === "FileResource" && resource.url) return fetchBinary(resource.url);
2656
+ if (resource.content == null) return null;
2657
+ const text = typeof resource.content === "string" ? resource.content : JSON.stringify(resource.content);
2658
+ return Buffer.from(text);
2659
+ }
2660
+ function errMsg(e) {
2661
+ return e instanceof Error ? e.message : String(e);
2662
+ }
2663
+ //#endregion
2664
+ //#region src/theme/legacy-migration.ts
2665
+ /**
2666
+ * On first pull after upgrading from a checksum-era CLI, the shadow
2667
+ * repo starts with no HEAD. `mergePull` would then run every diverged
2668
+ * file through a null-base merge — even files the user never touched
2669
+ * locally — producing spurious `<<<<<<<` markers for every file the
2670
+ * server updated since the last pull.
2671
+ *
2672
+ * Recover a real merge base by trusting the legacy sha256 checksums:
2673
+ * any local file whose content still matches its stored checksum is
2674
+ * "unmodified since last pull" and can be committed as HEAD. Files
2675
+ * whose local sha256 diverges from the stored checksum stay
2676
+ * unseeded — we don't have their pre-modification content, so a
2677
+ * null-base merge (marker-first UX) is the honest fallback for them.
2678
+ *
2679
+ * Idempotent: no-op when HEAD already exists, when there is no
2680
+ * legacy config, when the config is for a different theme, or when
2681
+ * the checksums map is empty. Runs before `mergePull` so its base
2682
+ * lookups see the seeded tree.
2683
+ */
2684
+ async function migrateLegacyChecksumsIntoShadow(input) {
2685
+ const { shadow, themeRoot, absoluteRoot, themeId } = input;
2686
+ if (await shadow.hasHead()) return;
2687
+ const legacy = readLegacyThemeConfig(absoluteRoot);
2688
+ if (!legacy) return;
2689
+ if (legacy.themeId !== themeId) return;
2690
+ if (!legacy.checksums || Object.keys(legacy.checksums).length === 0) return;
2691
+ const seed = [];
2692
+ for (const file of themeRoot.files()) {
2693
+ if (!file.exists) continue;
2694
+ const stored = legacy.checksums[file.relativePath];
2695
+ if (!stored) continue;
2696
+ if (file.checksum() !== stored) continue;
2697
+ const content = file.isText ? Buffer.from(file.read()) : file.readBinary();
2698
+ seed.push({
2699
+ path: file.relativePath,
2700
+ content
2701
+ });
2702
+ }
2703
+ if (seed.length === 0) return;
2704
+ await shadow.seedFromWorkingTree(seed, `migrate from checksum-era CLI @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
2705
+ }
2081
2706
  //#endregion
2082
2707
  //#region src/commands/pull.ts
2083
2708
  async function fetchCompanySubdomain(api) {
@@ -2088,42 +2713,8 @@ async function fetchCompanySubdomain(api) {
2088
2713
  }
2089
2714
  return subdomain;
2090
2715
  }
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
2716
  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) => {
2717
+ 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
2718
  requireToken();
2128
2719
  const api = createApiClient();
2129
2720
  const workspace = findWorkspace();
@@ -2139,48 +2730,8 @@ function createPullCommand() {
2139
2730
  console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
2140
2731
  console.log(` Company: ${chalk.bold(subdomain)}`);
2141
2732
  console.log(` Target: ${chalk.bold(absoluteRoot)}`);
2142
- if (existingConfig?.lastPulledAt) console.log(` Last pulled: ${formatRelativeTime(existingConfig.lastPulledAt)}`);
2143
2733
  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) {
2734
+ if (!opts.yes) {
2184
2735
  const { confirmed } = await prompts({
2185
2736
  type: "confirm",
2186
2737
  name: "confirmed",
@@ -2192,36 +2743,52 @@ function createPullCommand() {
2192
2743
  process.exit(0);
2193
2744
  }
2194
2745
  }
2746
+ const themeRoot = new ThemeRoot(root);
2747
+ const shadow = await ShadowRepo.open(absoluteRoot, theme.id);
2748
+ await migrateLegacyChecksumsIntoShadow({
2749
+ shadow,
2750
+ themeRoot,
2751
+ absoluteRoot,
2752
+ themeId: theme.id
2753
+ });
2195
2754
  const syncer = new Syncer(api, theme.id, themeRoot);
2196
2755
  const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
2197
- const result = await syncer.downloadTheme({
2756
+ const result = await mergePull({
2757
+ themeRoot,
2758
+ shadow,
2759
+ remote: await syncer.downloadAll(),
2760
+ fetchBinary: (url) => syncer.downloadBinaryAsset(url),
2198
2761
  delete: !opts.nodelete,
2199
- skip: skipKeys,
2200
- onProgress: (d, total) => {
2201
- spinner.text = `Downloading ${d}/${total} files…`;
2762
+ force: opts.force ?? false,
2763
+ onProgress: (done, total) => {
2764
+ spinner.text = `Downloading ${done}/${total} files…`;
2202
2765
  }
2203
2766
  });
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
- }
2767
+ const parts = [];
2768
+ if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
2769
+ if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
2770
+ if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
2771
+ if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
2772
+ if (result.errors.length) {
2773
+ spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
2774
+ for (const e of result.errors) console.error(` ${e}`);
2775
+ } else if (result.conflicts.length > 0) {
2776
+ spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
2777
+ console.log();
2778
+ for (const c of result.conflicts) console.log(` ${chalk.yellow("CONFLICT")} ${c}`);
2779
+ console.log();
2780
+ console.log(` Edit each file above to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} markers,`);
2781
+ console.log(` then run ${chalk.cyan("fluid theme push")} once your resolution is in place.`);
2782
+ console.log();
2783
+ } else spinner.succeed(parts.join(", ") || "Already up to date.");
2209
2784
  const remoteSha = syncer.remoteSha();
2210
2785
  writeThemeConfig(absoluteRoot, {
2211
2786
  themeId: theme.id,
2212
2787
  themeName: theme.name,
2213
2788
  company: subdomain,
2214
- lastPulledAt: (/* @__PURE__ */ new Date()).toISOString(),
2215
- checksums: newChecksums,
2216
2789
  baseSha: remoteSha ?? existingConfig?.baseSha
2217
2790
  });
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(", ")}.`);
2791
+ if (result.conflicts.length > 0) process.exit(1);
2225
2792
  });
2226
2793
  }
2227
2794
  //#endregion