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