@fluid-app/fluid-cli-theme-dev 0.1.37 → 0.1.39

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
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  import { getAuthToken, readConfig, updateConfig } from "@fluid-app/fluid-cli";
3
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
- import { createHash } from "node:crypto";
5
+ import { createHash, randomBytes } from "node:crypto";
6
6
  import http from "node:http";
7
7
  import https from "node:https";
8
8
  import chokidar from "chokidar";
@@ -13,19 +13,50 @@ import ora from "ora";
13
13
  import { execFileSync, spawn } from "node:child_process";
14
14
  import { tmpdir } from "node:os";
15
15
  import { fileURLToPath } from "node:url";
16
+ //#region ../../platform/api-client-core/src/api-error-shape.ts
17
+ /**
18
+ * Narrows a parsed JSON error payload to the envelope contract. Arrays and
19
+ * primitives are not envelopes, so they resolve to `null` — their content is
20
+ * still reachable through `ApiError.data`.
21
+ */
22
+ function toApiErrorBody(value) {
23
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
24
+ return value;
25
+ }
26
+ /**
27
+ * The single unwrap rule, shared by every producer of `ApiError`.
28
+ *
29
+ * `??` rather than `||` so an explicitly empty `errors` (`""`, `0`, `false`)
30
+ * is preserved instead of silently falling back to the whole envelope.
31
+ */
32
+ function toApiErrorData(body) {
33
+ if (!body) return null;
34
+ return body.errors ?? body;
35
+ }
36
+ //#endregion
16
37
  //#region ../../platform/api-client-core/src/fetch-client.ts
17
38
  /**
18
39
  * API Error class compatible with fluid-admin's ApiError
19
40
  */
20
41
  var ApiError = class ApiError extends Error {
21
42
  status;
43
+ /**
44
+ * The unwrapped field-error bag. See `ApiErrorData`; the contract is
45
+ * documented in `docs/api-error-data-contract.md`.
46
+ */
22
47
  data;
48
+ /**
49
+ * The full error response envelope. Read `error_message`, `error`, `status`
50
+ * and nested `errors` from here — `data` has already unwrapped one level.
51
+ */
52
+ body;
23
53
  requestId;
24
- constructor(message, status, data, requestId) {
54
+ constructor(message, status, data, requestId, body) {
25
55
  super(message);
26
56
  this.name = "ApiError";
27
57
  this.status = status;
28
- this.data = data;
58
+ this.data = data ?? null;
59
+ this.body = body ?? null;
29
60
  this.requestId = requestId;
30
61
  if ("captureStackTrace" in Error) Error.captureStackTrace(this, ApiError);
31
62
  }
@@ -35,6 +66,7 @@ var ApiError = class ApiError extends Error {
35
66
  message: this.message,
36
67
  status: this.status,
37
68
  data: this.data,
69
+ body: this.body,
38
70
  requestId: this.requestId
39
71
  };
40
72
  }
@@ -121,14 +153,20 @@ function createFetchClient(config) {
121
153
  if (!response.ok) {
122
154
  const errorText = await response.text().catch(() => "");
123
155
  if (response.headers.get("content-type")?.includes("application/json")) {
124
- let data;
156
+ let parsed;
125
157
  try {
126
- data = JSON.parse(errorText);
158
+ parsed = JSON.parse(errorText);
127
159
  } catch {
128
160
  throw new ApiError(errorText.slice(0, 200) || `${method} request failed with status ${response.status}`, response.status, null, headerRequestId);
129
161
  }
130
- const nestedError = typeof data.error === "object" && data.error !== null ? data.error.message : void 0;
131
- throw new ApiError(data.message || data.error_message || (typeof nestedError === "string" ? nestedError : void 0) || `${method} request failed`, response.status, data.errors || data, headerRequestId ?? getRequestIdFromJsonBody(data));
162
+ const body = toApiErrorBody(parsed);
163
+ throw new ApiError((body ? (() => {
164
+ const nestedError = typeof body.error === "object" && body.error !== null ? body.error.message : void 0;
165
+ const directError = typeof body.error === "string" ? body.error : void 0;
166
+ const message = typeof body.message === "string" ? body.message : void 0;
167
+ const errorMessage = typeof body.error_message === "string" ? body.error_message : void 0;
168
+ return message || errorMessage || directError || (typeof nestedError === "string" ? nestedError : void 0);
169
+ })() : void 0) || `${method} request failed with status ${response.status}`, response.status, body ? toApiErrorData(body) : parsed, headerRequestId ?? getRequestIdFromJsonBody(parsed), body);
132
170
  } else throw new ApiError(`${method} request failed with status ${response.status}`, response.status, null, headerRequestId);
133
171
  }
134
172
  if (response.status === 204 || response.headers.get("content-length") === "0") return null;
@@ -264,6 +302,16 @@ function requireToken() {
264
302
  //#endregion
265
303
  //#region src/theme-config.ts
266
304
  const CONFIG_FILE = ".fluid-theme.json";
305
+ /**
306
+ * `company` must be the bare subdomain slug — the dev server builds
307
+ * `<company>.fluid.app` from it, so a full domain here produces an
308
+ * unreachable `<slug>.fluid.app.fluid.app` host. Some external tooling
309
+ * writes the full domain (or a URL); accept those and reduce them to
310
+ * the subdomain.
311
+ */
312
+ function normalizeCompany(company) {
313
+ return company.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/[/?#].*$/, "").replace(/\.fluid\.app$/i, "");
314
+ }
267
315
  function configPath(themeRoot) {
268
316
  return join(themeRoot, CONFIG_FILE);
269
317
  }
@@ -273,7 +321,9 @@ function readThemeConfig(themeRoot) {
273
321
  if (!existsSync(path)) return null;
274
322
  try {
275
323
  const raw = readFileSync(path, "utf-8");
276
- return JSON.parse(raw);
324
+ const config = JSON.parse(raw);
325
+ if (typeof config.company === "string") config.company = normalizeCompany(config.company);
326
+ return config;
277
327
  } catch {
278
328
  return null;
279
329
  }
@@ -473,6 +523,40 @@ function mimeTypeFor(ext) {
473
523
  };
474
524
  }
475
525
  //#endregion
526
+ //#region src/theme/resource-key.ts
527
+ const THEME_LEVEL_RESOURCE_KEYS = new Set([
528
+ "global_styles.css",
529
+ "styles.css",
530
+ "variables.json"
531
+ ]);
532
+ const COMPOSITE_RESOURCE_FILE_NAMES = new Set([
533
+ "index.liquid",
534
+ "styles.css",
535
+ "variables.json"
536
+ ]);
537
+ function hasSafeSegments(key) {
538
+ return key.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
539
+ }
540
+ function normalizeThemeResourceKey(value) {
541
+ return value.replaceAll("\\", "/");
542
+ }
543
+ /**
544
+ * Whether a relative file path is a resource key accepted by the Fluid themes
545
+ * resource API. Local project files (package manifests, QA evidence, scripts,
546
+ * source baselines, and similar agent artifacts) must never be uploaded.
547
+ */
548
+ function isThemeResourceKey(relativePath) {
549
+ const key = normalizeThemeResourceKey(relativePath);
550
+ if (!hasSafeSegments(key)) return false;
551
+ if (THEME_LEVEL_RESOURCE_KEYS.has(key)) return true;
552
+ const segments = key.split("/");
553
+ const prefix = segments[0];
554
+ const fileName = segments.at(-1);
555
+ if (!prefix || !fileName || segments.length < 2) return false;
556
+ if (prefix === "assets" || prefix === "config" || prefix === "locales" || prefix === "layouts") return true;
557
+ return segments.length >= 3 && COMPOSITE_RESOURCE_FILE_NAMES.has(fileName);
558
+ }
559
+ //#endregion
476
560
  //#region ../../platform/theme-schema/src/types.ts
477
561
  const VALID_SETTING_TYPES = Object.values({
478
562
  "input": [
@@ -853,7 +937,7 @@ var ThemeFile = class {
853
937
  mime;
854
938
  constructor(absolutePath, root) {
855
939
  this.absolutePath = absolutePath;
856
- this.relativePath = relative(root, absolutePath);
940
+ this.relativePath = normalizeThemeResourceKey(relative(root, absolutePath));
857
941
  this.mime = mimeTypeFor(extname(absolutePath).toLowerCase());
858
942
  }
859
943
  get name() {
@@ -941,6 +1025,7 @@ const THEME_MARKERS = [
941
1025
  "assets",
942
1026
  "config"
943
1027
  ];
1028
+ const THEME_ASSET_MANIFEST = ".fluid-assets.json";
944
1029
  var ThemeRoot = class {
945
1030
  root;
946
1031
  ignore;
@@ -949,7 +1034,7 @@ var ThemeRoot = class {
949
1034
  this.ignore = new FluidIgnore(this.root);
950
1035
  }
951
1036
  isValid() {
952
- return THEME_MARKERS.some((m) => {
1037
+ return existsSync(join(this.root, THEME_ASSET_MANIFEST)) || THEME_MARKERS.some((m) => {
953
1038
  try {
954
1039
  return statSync(join(this.root, m)).isDirectory();
955
1040
  } catch {
@@ -958,7 +1043,10 @@ var ThemeRoot = class {
958
1043
  });
959
1044
  }
960
1045
  files() {
961
- return this.glob(this.root).filter((f) => !this.ignore.ignore(f.relativePath));
1046
+ return this.glob(this.root).filter((f) => isThemeResourceKey(f.relativePath) && !this.ignore.ignore(f.relativePath));
1047
+ }
1048
+ isResourcePath(pathOrFile) {
1049
+ return isThemeResourceKey(this.file(pathOrFile).relativePath);
962
1050
  }
963
1051
  file(pathOrFile) {
964
1052
  if (pathOrFile instanceof ThemeFile) return pathOrFile;
@@ -969,8 +1057,10 @@ var ThemeRoot = class {
969
1057
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
970
1058
  if (entry.name.startsWith(".")) continue;
971
1059
  const full = join(dir, entry.name);
972
- if (entry.isDirectory()) results.push(...this.glob(full));
973
- else if (entry.isFile()) results.push(new ThemeFile(full, this.root));
1060
+ if (entry.isDirectory()) {
1061
+ if (entry.name === "node_modules") continue;
1062
+ results.push(...this.glob(full));
1063
+ } else if (entry.isFile()) results.push(new ThemeFile(full, this.root));
974
1064
  }
975
1065
  return results;
976
1066
  }
@@ -1156,45 +1246,6 @@ function readBody(req) {
1156
1246
  });
1157
1247
  }
1158
1248
  //#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
1249
  //#region ../../api-clients/themes/src/namespaces/v0.ts
1199
1250
  /**
1200
1251
  * List application themes
@@ -1239,6 +1290,17 @@ async function getApplicationThemeAvailableThemeables(client, id, params) {
1239
1290
  return client.get(`/api/application_themes/${id}/available_themeables`, params);
1240
1291
  }
1241
1292
  /**
1293
+ * Create a development reference clone of an application theme
1294
+ * Creates an isolated development theme while preserving existing DAM and ImageKit references without transferring asset bytes.
1295
+ *
1296
+ * @param client - Fetch client instance
1297
+ * @param id - id
1298
+ * @param body - body
1299
+ */
1300
+ async function cloneApplicationThemeForDevelopment(client, id, body) {
1301
+ return client.post(`/api/application_themes/${id}/clone_for_development`, body);
1302
+ }
1303
+ /**
1242
1304
  * Publishes the theme
1243
1305
  *
1244
1306
  *
@@ -1249,6 +1311,16 @@ async function publishApplicationTheme(client, id) {
1249
1311
  return client.post(`/api/application_themes/${id}/publish`);
1250
1312
  }
1251
1313
  /**
1314
+ * Get theme assets
1315
+ *
1316
+ *
1317
+ * @param client - Fetch client instance
1318
+ * @param id - id
1319
+ */
1320
+ async function getThemeAssets(client, id) {
1321
+ return client.get(`/api/application_themes/${id}/theme_assets`);
1322
+ }
1323
+ /**
1252
1324
  * Lists all theme resources
1253
1325
  *
1254
1326
  *
@@ -1280,11 +1352,25 @@ async function updateThemeResource(client, application_theme_id, body) {
1280
1352
  async function deleteThemeResource(client, application_theme_id, body) {
1281
1353
  return client.delete(`/api/application_themes/${application_theme_id}/resources`, { body });
1282
1354
  }
1283
- //#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);
1355
+ /**
1356
+ * Creates a file resource
1357
+ *
1358
+ *
1359
+ * @param client - Fetch client instance
1360
+ * @param body - body
1361
+ */
1362
+ async function createFileResource(client, body) {
1363
+ return client.post(`/api/file_resources`, body);
1364
+ }
1365
+ /**
1366
+ * Deletes a file resource
1367
+ *
1368
+ *
1369
+ * @param client - Fetch client instance
1370
+ * @param id - id
1371
+ */
1372
+ async function destroyFileResource(client, id) {
1373
+ return client.delete(`/api/file_resources/${id}`);
1288
1374
  }
1289
1375
  //#endregion
1290
1376
  //#region src/theme/format-error.ts
@@ -1320,7 +1406,188 @@ function formatError(e) {
1320
1406
  return String(e);
1321
1407
  }
1322
1408
  //#endregion
1409
+ //#region src/theme/dev-server/watcher.ts
1410
+ function relativeThemePath(root, filePath) {
1411
+ return relative(root.root, filePath).split(sep).join("/");
1412
+ }
1413
+ function watchTheme(root, handler) {
1414
+ const watcher = chokidar.watch(root.root, {
1415
+ ignoreInitial: true,
1416
+ ignored: (filePath) => {
1417
+ if (filePath.includes("node_modules")) return true;
1418
+ try {
1419
+ const rel = relativeThemePath(root, filePath);
1420
+ return (rel.split(/[\\/]/).pop() ?? "").startsWith(".") || root.ignore.ignore(rel);
1421
+ } catch {
1422
+ return false;
1423
+ }
1424
+ },
1425
+ persistent: true,
1426
+ awaitWriteFinish: {
1427
+ stabilityThreshold: 50,
1428
+ pollInterval: 10
1429
+ }
1430
+ });
1431
+ let pending = Promise.resolve();
1432
+ const enqueue = (fn) => {
1433
+ pending = pending.then(fn).catch((e) => {
1434
+ console.error(` [Watcher] change handling failed: ${formatError(e)}`);
1435
+ });
1436
+ };
1437
+ watcher.on("change", (filePath) => {
1438
+ const rel = relativeThemePath(root, filePath);
1439
+ if (!root.isResourcePath(filePath) || root.ignore.ignore(rel)) return;
1440
+ const arrivedAt = Date.now();
1441
+ enqueue(() => handler([root.file(filePath)], [], [], arrivedAt));
1442
+ });
1443
+ watcher.on("add", (filePath) => {
1444
+ const rel = relativeThemePath(root, filePath);
1445
+ if (!root.isResourcePath(filePath) || root.ignore.ignore(rel)) return;
1446
+ const arrivedAt = Date.now();
1447
+ enqueue(() => handler([], [root.file(filePath)], [], arrivedAt));
1448
+ });
1449
+ watcher.on("unlink", (filePath) => {
1450
+ const rel = relativeThemePath(root, filePath);
1451
+ if (!root.isResourcePath(filePath) || root.ignore.ignore(rel)) return;
1452
+ const arrivedAt = Date.now();
1453
+ enqueue(() => handler([], [], [root.file(filePath)], arrivedAt));
1454
+ });
1455
+ return () => watcher.close();
1456
+ }
1457
+ //#endregion
1458
+ //#region src/theme/asset-manifest.ts
1459
+ const MANIFEST_FILE = ".fluid-assets.json";
1460
+ const MANIFEST_VERSION = 1;
1461
+ /**
1462
+ * A tiny shadow-repo placeholder for a manifest-backed asset. It preserves
1463
+ * the path as a deletion baseline without retaining the asset bytes locally.
1464
+ */
1465
+ const MANAGED_ASSET_SHADOW_SENTINEL = "fluid-managed-asset\n";
1466
+ /**
1467
+ * Tracks binary theme assets that deliberately live only on the server.
1468
+ *
1469
+ * The manifest is a dotfile so it is excluded from theme uploads and file
1470
+ * watching. It is written before the corresponding local file is removed,
1471
+ * which prevents `delete: true` from mistaking the removed byte source for a
1472
+ * request to delete its remote FileResource.
1473
+ */
1474
+ var ThemeAssetManifest = class {
1475
+ assets;
1476
+ path;
1477
+ constructor(themeRoot) {
1478
+ this.path = join(themeRoot, MANIFEST_FILE);
1479
+ this.assets = readDocument(this.path).assets;
1480
+ }
1481
+ reload() {
1482
+ this.assets = readDocument(this.path).assets;
1483
+ }
1484
+ keys() {
1485
+ return Object.keys(this.assets);
1486
+ }
1487
+ entries() {
1488
+ return Object.entries(this.assets).map(([key, link]) => [key, copyLink(link)]);
1489
+ }
1490
+ has(key) {
1491
+ return this.assets[key] !== void 0;
1492
+ }
1493
+ get(key) {
1494
+ const link = this.assets[key];
1495
+ return link ? copyLink(link) : void 0;
1496
+ }
1497
+ set(key, link) {
1498
+ if (!isThemeAssetKey(key) || !isThemeAssetLink(link)) throw new Error(`invalid asset entry for ${key}`);
1499
+ this.assets[key] = copyLink(link);
1500
+ }
1501
+ delete(key) {
1502
+ delete this.assets[key];
1503
+ }
1504
+ write() {
1505
+ const document = {
1506
+ version: MANIFEST_VERSION,
1507
+ assets: copyAssets(this.assets)
1508
+ };
1509
+ const tempPath = `${this.path}.${randomBytes(6).toString("hex")}.tmp`;
1510
+ try {
1511
+ mkdirSync(dirname(this.path), { recursive: true });
1512
+ writeFileSync(tempPath, JSON.stringify(document, null, 2) + "\n", {
1513
+ encoding: "utf-8",
1514
+ mode: 384
1515
+ });
1516
+ renameSync(tempPath, this.path);
1517
+ } catch (error) {
1518
+ try {
1519
+ unlinkSync(tempPath);
1520
+ } catch {}
1521
+ throw error;
1522
+ }
1523
+ }
1524
+ };
1525
+ function readDocument(path) {
1526
+ if (!existsSync(path)) return emptyDocument();
1527
+ try {
1528
+ return parseDocument(JSON.parse(readFileSync(path, "utf-8")));
1529
+ } catch (error) {
1530
+ const message = error instanceof Error ? error.message : String(error);
1531
+ throw new Error(`Could not read ${MANIFEST_FILE}: ${message}`);
1532
+ }
1533
+ }
1534
+ function parseDocument(value) {
1535
+ if (!isRecord$1(value) || value["version"] !== MANIFEST_VERSION) throw new Error(`expected version ${MANIFEST_VERSION}`);
1536
+ const rawAssets = value["assets"];
1537
+ if (!isRecord$1(rawAssets)) throw new Error("expected an assets object");
1538
+ const assets = {};
1539
+ for (const [key, rawLink] of Object.entries(rawAssets)) {
1540
+ if (!isThemeAssetKey(key) || !isThemeAssetLink(rawLink)) throw new Error(`invalid asset entry for ${key}`);
1541
+ assets[key] = copyLink(rawLink);
1542
+ }
1543
+ return {
1544
+ version: MANIFEST_VERSION,
1545
+ assets
1546
+ };
1547
+ }
1548
+ function emptyDocument() {
1549
+ return {
1550
+ version: MANIFEST_VERSION,
1551
+ assets: {}
1552
+ };
1553
+ }
1554
+ function copyAssets(assets) {
1555
+ return Object.fromEntries(Object.entries(assets).map(([key, link]) => [key, copyLink(link)]));
1556
+ }
1557
+ function copyLink(link) {
1558
+ return {
1559
+ sourceThemeId: link.sourceThemeId,
1560
+ ...typeof link.checksum === "string" ? { checksum: link.checksum } : {},
1561
+ ...typeof link.url === "string" ? { url: link.url } : {},
1562
+ ...typeof link.contentType === "string" ? { contentType: link.contentType } : {},
1563
+ ...typeof link.contentSize === "number" ? { contentSize: link.contentSize } : {},
1564
+ ...typeof link.previewImageUrl === "string" ? { previewImageUrl: link.previewImageUrl } : {},
1565
+ ...typeof link.altText === "string" ? { altText: link.altText } : {},
1566
+ ...typeof link.handle === "string" ? { handle: link.handle } : {},
1567
+ ...link.pending === true ? { pending: true } : {},
1568
+ ...typeof link.damAssetCode === "string" ? { damAssetCode: link.damAssetCode } : {}
1569
+ };
1570
+ }
1571
+ function isThemeAssetLink(value) {
1572
+ return isRecord$1(value) && typeof value["sourceThemeId"] === "number" && Number.isInteger(value["sourceThemeId"]) && value["sourceThemeId"] > 0 && (value["checksum"] === void 0 || typeof value["checksum"] === "string" && value["checksum"].length > 0) && (value["url"] === void 0 || typeof value["url"] === "string" && value["url"].length > 0) && (value["contentType"] === void 0 || typeof value["contentType"] === "string" && value["contentType"].length > 0) && (value["contentSize"] === void 0 || typeof value["contentSize"] === "number" && Number.isInteger(value["contentSize"]) && value["contentSize"] > 0) && (value["previewImageUrl"] === void 0 || typeof value["previewImageUrl"] === "string" && value["previewImageUrl"].length > 0) && (value["altText"] === void 0 || typeof value["altText"] === "string") && (value["handle"] === void 0 || typeof value["handle"] === "string" && value["handle"].length > 0) && (value["pending"] === void 0 || typeof value["pending"] === "boolean") && (value["damAssetCode"] === void 0 || typeof value["damAssetCode"] === "string" && value["damAssetCode"].length > 0);
1573
+ }
1574
+ function isThemeAssetKey(key) {
1575
+ if (key.includes("\\") || key.includes("\0")) return false;
1576
+ const segments = key.split("/");
1577
+ return segments[0] === "assets" && segments.length === 2 && segments[1] !== void 0 && segments[1].length > 0 && segments[1] !== "." && segments[1] !== "..";
1578
+ }
1579
+ function isRecord$1(value) {
1580
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1581
+ }
1582
+ //#endregion
1583
+ //#region src/theme/stylesheet-keys.ts
1584
+ const STYLESHEET_KEY_PATTERN = /^(styles\.css|global_styles\.css|[^/]+\/[^/]+\/styles\.css)$/;
1585
+ function isStylesheetKey(key) {
1586
+ return STYLESHEET_KEY_PATTERN.test(key);
1587
+ }
1588
+ //#endregion
1323
1589
  //#region src/theme/syncer.ts
1590
+ const ASSET_REFERENCE_CONCURRENCY = 6;
1324
1591
  /**
1325
1592
  * Server rejected the push because the CLI's `base_sha` no longer
1326
1593
  * matches the theme's current `content_version_sha` (someone else
@@ -1338,12 +1605,22 @@ var PushConflictError = class extends Error {
1338
1605
  }
1339
1606
  };
1340
1607
  var Syncer = class {
1341
- checksums = /* @__PURE__ */ new Map();
1608
+ checksumIndex = /* @__PURE__ */ new Map();
1609
+ rawRemoteResources = /* @__PURE__ */ new Map();
1610
+ remoteResourceGroups = /* @__PURE__ */ new Map();
1611
+ remoteResourceIndex = /* @__PURE__ */ new Map();
1612
+ remoteIndexesDirty = false;
1342
1613
  lastKnownRemoteSha = null;
1343
- constructor(api, themeId, themeRoot) {
1614
+ assetManifestInstance;
1615
+ constructor(api, themeId, themeRoot, assetManifest) {
1344
1616
  this.api = api;
1345
1617
  this.themeId = themeId;
1346
1618
  this.themeRoot = themeRoot;
1619
+ this.assetManifestInstance = assetManifest;
1620
+ }
1621
+ get assetManifest() {
1622
+ this.assetManifestInstance ??= new ThemeAssetManifest(this.themeRoot.root);
1623
+ return this.assetManifestInstance;
1347
1624
  }
1348
1625
  async fetchChecksums() {
1349
1626
  const body = await listThemeResources(this.api, this.themeId);
@@ -1358,25 +1635,295 @@ var Syncer = class {
1358
1635
  return this.lastKnownRemoteSha;
1359
1636
  }
1360
1637
  updateChecksums(resources) {
1361
- for (const r of resources) if (r.key && r.checksum) this.checksums.set(r.key, r.checksum);
1362
- for (const key of this.checksums.keys()) if (this.checksums.has(`${key}.liquid`)) this.checksums.delete(key);
1638
+ this.rawRemoteResources.clear();
1639
+ this.remoteResourceGroups.clear();
1640
+ for (const resource of resources) {
1641
+ if (!resource.key) continue;
1642
+ this.rawRemoteResources.set(resource.key, resource);
1643
+ const group = this.remoteResourceGroups.get(resource.key) ?? [];
1644
+ group.push(resource);
1645
+ this.remoteResourceGroups.set(resource.key, group);
1646
+ }
1647
+ this.remoteIndexesDirty = true;
1648
+ }
1649
+ setRemoteResource(resource) {
1650
+ if (!resource.key) return;
1651
+ this.rawRemoteResources.set(resource.key, resource);
1652
+ this.remoteResourceGroups.set(resource.key, [resource]);
1653
+ this.remoteIndexesDirty = true;
1654
+ }
1655
+ removeRemoteResource(relativePath) {
1656
+ this.rawRemoteResources.delete(relativePath);
1657
+ this.remoteResourceGroups.delete(relativePath);
1658
+ this.remoteIndexesDirty = true;
1659
+ }
1660
+ get checksums() {
1661
+ if (this.remoteIndexesDirty) this.rebuildRemoteIndexes();
1662
+ return this.checksumIndex;
1663
+ }
1664
+ get remoteResources() {
1665
+ if (this.remoteIndexesDirty) this.rebuildRemoteIndexes();
1666
+ return this.remoteResourceIndex;
1667
+ }
1668
+ rebuildRemoteIndexes() {
1669
+ this.remoteIndexesDirty = false;
1670
+ this.checksumIndex.clear();
1671
+ this.remoteResourceIndex.clear();
1672
+ for (const [key, resource] of this.rawRemoteResources) {
1673
+ if (this.rawRemoteResources.has(`${key}.liquid`)) continue;
1674
+ this.remoteResourceIndex.set(key, resource);
1675
+ if (resource.checksum) this.checksumIndex.set(key, resource.checksum);
1676
+ }
1363
1677
  }
1364
1678
  hasChanged(file) {
1365
1679
  return file.checksum() !== this.checksums.get(file.relativePath);
1366
1680
  }
1367
1681
  remoteKeys() {
1368
- return [...this.checksums.keys()];
1682
+ return [...this.remoteResources.keys()];
1369
1683
  }
1370
1684
  /** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
1371
1685
  remoteChecksums() {
1372
1686
  return Object.fromEntries(this.checksums);
1373
1687
  }
1374
- async uploadFile(file, baseSha) {
1375
- if (file.isText) await this.putResource({
1376
- key: file.relativePath,
1377
- content: file.read()
1378
- }, baseSha);
1379
- else await this.uploadBinaryFile(file, baseSha);
1688
+ /** URL-backed assets keyed by their exact theme resource path. */
1689
+ remoteAssetUrls() {
1690
+ const urls = {};
1691
+ for (const [key, resource] of this.remoteResources) {
1692
+ if (!isManagedAssetResource(resource)) continue;
1693
+ const url = resource.url;
1694
+ if (typeof url === "string" && url.length > 0) urls[key] = url;
1695
+ }
1696
+ return urls;
1697
+ }
1698
+ /**
1699
+ * Adds URL-backed FileResources for manifest assets without transferring
1700
+ * their bytes. The target stores the source asset's ImageKit URL.
1701
+ */
1702
+ async linkManagedAssets(opts = {}) {
1703
+ this.assetManifest.reload();
1704
+ await this.fetchChecksums();
1705
+ const plans = [];
1706
+ for (const [key, link] of this.assetManifest.entries()) {
1707
+ if (this.themeRoot.ignore.ignore(key)) continue;
1708
+ if (this.themeRoot.file(key).exists) continue;
1709
+ const targetResource = this.managedAssetResourceForLink(key, link) ?? this.remoteResources.get(key);
1710
+ if (targetResource && !this.managedAssetNeedsRefresh(targetResource, link)) continue;
1711
+ if (targetResource && !opts.replace) continue;
1712
+ const metadata = assetMetadataFromLink(link);
1713
+ plans.push({
1714
+ key,
1715
+ link,
1716
+ ...targetResource ? { targetResource } : {},
1717
+ ...metadata ? { metadata } : {}
1718
+ });
1719
+ }
1720
+ if (plans.length > 0) {
1721
+ await this.resolveAssetMetadata(plans);
1722
+ await this.createAssetReferences(plans);
1723
+ await this.fetchChecksums();
1724
+ }
1725
+ if (opts.replace && await this.pruneDuplicateManagedAssetReferences()) await this.fetchChecksums();
1726
+ this.ensureManagedAssetsAreResolved();
1727
+ return plans.length;
1728
+ }
1729
+ async resolveAssetMetadata(plans) {
1730
+ const bySourceTheme = /* @__PURE__ */ new Map();
1731
+ for (const plan of plans) {
1732
+ if (plan.metadata) continue;
1733
+ const sourcePlans = bySourceTheme.get(plan.link.sourceThemeId) ?? [];
1734
+ sourcePlans.push(plan);
1735
+ bySourceTheme.set(plan.link.sourceThemeId, sourcePlans);
1736
+ }
1737
+ let manifestChanged = false;
1738
+ for (const [sourceThemeId, sourcePlans] of bySourceTheme) {
1739
+ let sourceAssets;
1740
+ try {
1741
+ sourceAssets = await this.fetchThemeAssetMetadata(sourceThemeId);
1742
+ } catch (error) {
1743
+ throw new Error(`Could not read asset metadata from theme #${sourceThemeId}: ${formatError(error)}`);
1744
+ }
1745
+ for (const plan of sourcePlans) {
1746
+ const sourceMetadata = sourceAssets.get(assetFilename(plan.key));
1747
+ if (!sourceMetadata) throw new Error(`Could not find usable metadata for ${plan.key} in theme #${sourceThemeId}`);
1748
+ const metadata = {
1749
+ ...sourceMetadata,
1750
+ ...typeof plan.link.url === "string" ? { url: plan.link.url } : {}
1751
+ };
1752
+ plan.metadata = metadata;
1753
+ this.assetManifest.set(plan.key, {
1754
+ ...plan.link,
1755
+ ...metadata
1756
+ });
1757
+ manifestChanged = true;
1758
+ }
1759
+ }
1760
+ if (manifestChanged) this.assetManifest.write();
1761
+ }
1762
+ async fetchThemeAssetMetadata(sourceThemeId) {
1763
+ const body = await getThemeAssets(this.api, sourceThemeId);
1764
+ if (!isRecord(body) || !Array.isArray(body["file_resources"])) throw new Error("Theme assets response did not include file_resources");
1765
+ const assets = /* @__PURE__ */ new Map();
1766
+ for (const value of body["file_resources"]) {
1767
+ const asset = parseThemeAssetMetadata(value);
1768
+ if (asset) assets.set(asset.filename, asset.metadata);
1769
+ }
1770
+ return assets;
1771
+ }
1772
+ async createAssetReferences(plans) {
1773
+ const errors = [];
1774
+ let nextPlan = 0;
1775
+ const worker = async () => {
1776
+ while (nextPlan < plans.length) {
1777
+ const plan = plans[nextPlan];
1778
+ nextPlan += 1;
1779
+ if (!plan || !plan.metadata) continue;
1780
+ try {
1781
+ await this.createAssetReference(plan);
1782
+ } catch (error) {
1783
+ errors.push(`${plan.key}: ${formatError(error)}`);
1784
+ }
1785
+ }
1786
+ };
1787
+ await Promise.all(Array.from({ length: Math.min(ASSET_REFERENCE_CONCURRENCY, plans.length) }, worker));
1788
+ if (errors.length > 0) throw new Error(`Could not save ${errors.length} ImageKit URL reference(s) (this requires File Resources update access): ${errors.join("; ")}`);
1789
+ }
1790
+ async createAssetReference(plan) {
1791
+ const metadata = plan.metadata;
1792
+ if (!metadata) throw new Error("asset metadata was not resolved");
1793
+ const targetResourceId = plan.targetResource?.resource_id;
1794
+ if (plan.targetResource && typeof targetResourceId !== "number") throw new Error("existing target asset has no resource ID");
1795
+ const createdResourceId = createdFileResourceId(await createFileResource(this.api, { file_resource: {
1796
+ url: metadata.url,
1797
+ filename: assetFilename(plan.key),
1798
+ content_type: metadata.contentType,
1799
+ content_size: metadata.contentSize,
1800
+ ...metadata.previewImageUrl ? { preview_image_url: metadata.previewImageUrl } : {},
1801
+ ...metadata.altText !== void 0 ? { alt_text: metadata.altText } : {},
1802
+ ...metadata.handle ? { handle: metadata.handle } : {},
1803
+ relateable_id: this.themeId,
1804
+ relateable_type: "ApplicationTheme"
1805
+ } }));
1806
+ if (!createdResourceId) throw new Error("create response did not include a FileResource ID");
1807
+ if (typeof targetResourceId !== "number") return;
1808
+ try {
1809
+ await destroyFileResource(this.api, targetResourceId);
1810
+ } catch (error) {
1811
+ if (isNotFoundError(error)) return;
1812
+ try {
1813
+ await destroyFileResource(this.api, createdResourceId);
1814
+ } catch {}
1815
+ throw error;
1816
+ }
1817
+ }
1818
+ /**
1819
+ * Returns every resource for an exact logical key. A legacy `foo` resource
1820
+ * is hidden when `foo.liquid` exists, matching the normal remote index.
1821
+ */
1822
+ resourcesForLogicalKey(key) {
1823
+ if (this.rawRemoteResources.has(`${key}.liquid`)) return [];
1824
+ return this.remoteResourceGroups.get(key) ?? [];
1825
+ }
1826
+ managedAssetResourceForLink(key, link) {
1827
+ return this.resourcesForLogicalKey(key).find((resource) => isManagedAssetResource(resource) && !this.managedAssetNeedsRefresh(resource, link));
1828
+ }
1829
+ /** Make interrupted reference replacement converge to one FileResource. */
1830
+ async pruneDuplicateManagedAssetReferences() {
1831
+ const resourcesToDelete = [];
1832
+ for (const [key, link] of this.assetManifest.entries()) {
1833
+ if (this.themeRoot.ignore.ignore(key) || !link.url) continue;
1834
+ const resources = this.resourcesForLogicalKey(key).filter(isManagedAssetResource);
1835
+ if (resources.length < 2) continue;
1836
+ const matchingResources = resources.filter((resource) => resource.url === link.url);
1837
+ if (matchingResources.length === 0) continue;
1838
+ const keeper = this.lowestResourceId(matchingResources, key);
1839
+ const keeperId = this.resourceIdForAssetReference(keeper, key);
1840
+ for (const resource of resources) {
1841
+ const resourceId = this.resourceIdForAssetReference(resource, key);
1842
+ if (resourceId !== keeperId) resourcesToDelete.push({
1843
+ key,
1844
+ resourceId
1845
+ });
1846
+ }
1847
+ }
1848
+ if (resourcesToDelete.length === 0) return false;
1849
+ const errors = [];
1850
+ let nextResource = 0;
1851
+ const worker = async () => {
1852
+ while (nextResource < resourcesToDelete.length) {
1853
+ const resource = resourcesToDelete[nextResource];
1854
+ nextResource += 1;
1855
+ if (!resource) continue;
1856
+ try {
1857
+ await destroyFileResource(this.api, resource.resourceId);
1858
+ } catch (error) {
1859
+ if (!isNotFoundError(error)) errors.push(`${resource.key}: ${formatError(error)}`);
1860
+ }
1861
+ }
1862
+ };
1863
+ await Promise.all(Array.from({ length: Math.min(ASSET_REFERENCE_CONCURRENCY, resourcesToDelete.length) }, worker));
1864
+ if (errors.length > 0) throw new Error(`Could not remove ${errors.length} duplicate ImageKit URL reference(s): ${errors.join("; ")}`);
1865
+ return true;
1866
+ }
1867
+ lowestResourceId(resources, key) {
1868
+ let lowest = resources[0];
1869
+ if (!lowest) throw new Error(`No asset resources found for ${key}`);
1870
+ let lowestId = this.resourceIdForAssetReference(lowest, key);
1871
+ for (const resource of resources.slice(1)) {
1872
+ const resourceId = this.resourceIdForAssetReference(resource, key);
1873
+ if (resourceId < lowestId) {
1874
+ lowest = resource;
1875
+ lowestId = resourceId;
1876
+ }
1877
+ }
1878
+ return lowest;
1879
+ }
1880
+ resourceIdForAssetReference(resource, key) {
1881
+ const resourceId = positiveInteger(resource.resource_id);
1882
+ if (!resourceId) throw new Error(`Existing target asset has no resource ID: ${key}`);
1883
+ return resourceId;
1884
+ }
1885
+ /** Makes this target the provenance source for assets it now resolves. */
1886
+ repointManagedAssetsToCurrentTheme() {
1887
+ this.assetManifest.reload();
1888
+ let changed = false;
1889
+ for (const [key, link] of this.assetManifest.entries()) {
1890
+ if (this.themeRoot.ignore.ignore(key)) continue;
1891
+ if (this.themeRoot.file(key).exists) continue;
1892
+ const resource = this.remoteResources.get(key);
1893
+ if (!isManagedAssetResource(resource)) throw new Error(`Managed asset is missing from target theme: ${key}`);
1894
+ const nextLink = {
1895
+ ...link,
1896
+ sourceThemeId: this.themeId
1897
+ };
1898
+ delete nextLink.pending;
1899
+ delete nextLink.checksum;
1900
+ Object.assign(nextLink, resourceLink(resource));
1901
+ this.assetManifest.set(key, nextLink);
1902
+ changed = true;
1903
+ }
1904
+ if (changed) this.assetManifest.write();
1905
+ }
1906
+ /**
1907
+ * Uploads one file. Resolves with the exact text content that was sent to
1908
+ * the server (null for binary files) so callers can run diagnostics against
1909
+ * the same bytes instead of re-reading a file that may have changed on disk
1910
+ * while the request was in flight.
1911
+ */
1912
+ async uploadFile(file, baseSha, opts = {}) {
1913
+ if (file.isText) {
1914
+ const content = file.read();
1915
+ const resource = await this.putResource({
1916
+ key: file.relativePath,
1917
+ content
1918
+ }, baseSha);
1919
+ this.setRemoteResource(resource);
1920
+ return content;
1921
+ }
1922
+ if (isNestedBinaryThemeAsset(file)) throw new Error(`Binary assets must be directly inside assets/: ${file.relativePath}`);
1923
+ const resource = await this.uploadBinaryFile(file, baseSha);
1924
+ this.setRemoteResource(resource);
1925
+ if (isThemeAssetKey(file.relativePath)) this.externalizeBinaryFile(file, resource, opts.pendingAsset);
1926
+ return null;
1380
1927
  }
1381
1928
  /**
1382
1929
  * Wraps the generated `updateThemeResource` client with the two
@@ -1406,6 +1953,10 @@ var Syncer = class {
1406
1953
  try {
1407
1954
  const response = await updateThemeResource(this.api, this.themeId, body);
1408
1955
  if (response.content_version_sha) this.lastKnownRemoteSha = response.content_version_sha;
1956
+ return response.application_theme_resource ?? {
1957
+ key: typeof resource["key"] === "string" ? resource["key"] : "",
1958
+ checksum: null
1959
+ };
1409
1960
  } catch (e) {
1410
1961
  throw this.rethrowIfConflict(e);
1411
1962
  }
@@ -1421,6 +1972,41 @@ var Syncer = class {
1421
1972
  * (no stored `baseSha` in `.fluid-theme.json`) and `--force` pushes
1422
1973
  * short-circuit past the check.
1423
1974
  */
1975
+ /**
1976
+ * Tell Fluid the push is finished, so it commits the theme's current state
1977
+ * as one version.
1978
+ *
1979
+ * The other half of `preflightPush`. That one runs once before the file
1980
+ * loop to reject a stale base; this runs once after it, and is the only
1981
+ * thing that turns a push into a commit — the per-file writes just mark the
1982
+ * theme changed.
1983
+ *
1984
+ * It exists because the server cannot see where an operation ends. A push
1985
+ * arrives as a hundred-odd independent requests, and every way of inferring
1986
+ * "these belong together" either merges two publishes that happened to land
1987
+ * close together or splits one push across several commits. The client
1988
+ * knows; this says so.
1989
+ *
1990
+ * Best-effort in that it never throws: the files are already on the server
1991
+ * by the time this runs, and Fluid sweeps anything left unsynced, so a
1992
+ * failure delays the commit rather than losing it and must not fail the
1993
+ * push.
1994
+ *
1995
+ * Returns whether Fluid took the request, because a caller tracking edit
1996
+ * boundaries needs to know. A failed ask leaves the previous edit
1997
+ * uncommitted, and uploading the next one over it merges the two into
1998
+ * whichever commit eventually lands.
1999
+ */
2000
+ async requestSync() {
2001
+ try {
2002
+ const response = await this.api.post(`/api/application_themes/${this.themeId}/resources/sync`, {});
2003
+ if (response.content_version_sha) this.lastKnownRemoteSha = response.content_version_sha;
2004
+ return true;
2005
+ } catch (e) {
2006
+ console.warn(` ⚠ couldn't ask Fluid to commit this push (${formatError(e)}). It will be picked up automatically.`);
2007
+ return false;
2008
+ }
2009
+ }
1424
2010
  async preflightPush(baseSha) {
1425
2011
  if (!baseSha) return;
1426
2012
  try {
@@ -1470,7 +2056,7 @@ var Syncer = class {
1470
2056
  if (ikBody.height) backfillPayload["asset"]["height"] = ikBody.height;
1471
2057
  if (ikBody.width) backfillPayload["asset"]["width"] = ikBody.width;
1472
2058
  const backfillBody = await this.api.post("/api/dam/assets/backfill_imagekit", backfillPayload);
1473
- await this.putResource({
2059
+ const update = await this.putResource({
1474
2060
  key: file.relativePath,
1475
2061
  dam_asset: {
1476
2062
  dam_asset_code: backfillBody.asset.code,
@@ -1482,6 +2068,32 @@ var Syncer = class {
1482
2068
  preview_image_url: ikBody.thumbnailUrl
1483
2069
  }
1484
2070
  }, baseSha);
2071
+ return {
2072
+ ...update,
2073
+ key: update.key || file.relativePath,
2074
+ url: update.url ?? backfillBody.asset.default_variant_url,
2075
+ damAssetCode: backfillBody.asset.code,
2076
+ assetMetadata: {
2077
+ url: backfillBody.asset.default_variant_url,
2078
+ contentType: file.mime.name,
2079
+ contentSize: ikBody.size,
2080
+ previewImageUrl: ikBody.thumbnailUrl,
2081
+ altText: file.name,
2082
+ handle: backfillBody.asset.code
2083
+ }
2084
+ };
2085
+ }
2086
+ externalizeBinaryFile(file, resource, pending) {
2087
+ this.assetManifest.reload();
2088
+ this.assetManifest.set(file.relativePath, {
2089
+ sourceThemeId: this.themeId,
2090
+ ...resourceLink(resource),
2091
+ ...uploadedAssetMetadata(file, resource),
2092
+ ...pending ? { pending: true } : {},
2093
+ ...typeof resource.damAssetCode === "string" ? { damAssetCode: resource.damAssetCode } : {}
2094
+ });
2095
+ this.assetManifest.write();
2096
+ unlinkSync(file.absolutePath);
1485
2097
  }
1486
2098
  canonicalPathToImageKitFolder(canonicalPath) {
1487
2099
  const parts = canonicalPath.split(".");
@@ -1497,6 +2109,8 @@ var Syncer = class {
1497
2109
  }[category] ?? "files"}/${assetCode}`;
1498
2110
  }
1499
2111
  async deleteRemoteFile(relativePath, baseSha) {
2112
+ this.assetManifest.reload();
2113
+ if (this.assetManifest.has(relativePath)) return;
1500
2114
  const body = { application_theme_resource: { key: relativePath } };
1501
2115
  if (baseSha) body["base_sha"] = baseSha;
1502
2116
  try {
@@ -1505,7 +2119,7 @@ var Syncer = class {
1505
2119
  } catch (e) {
1506
2120
  throw this.rethrowIfConflict(e);
1507
2121
  }
1508
- this.checksums.delete(relativePath);
2122
+ this.removeRemoteResource(relativePath);
1509
2123
  }
1510
2124
  async downloadAll() {
1511
2125
  const body = await listThemeResources(this.api, this.themeId);
@@ -1519,15 +2133,138 @@ var Syncer = class {
1519
2133
  if (!resp.ok) throw new Error(`Failed to download asset: ${resp.status}`);
1520
2134
  return Buffer.from(await resp.arrayBuffer());
1521
2135
  }
1522
- async uploadTheme(opts = {}) {
1523
- await this.fetchChecksums();
1524
- await this.preflightPush(opts.baseSha);
1525
- let baseSha = opts.baseSha ?? null;
1526
- const localFiles = this.themeRoot.files();
1527
- const result = {
1528
- uploaded: 0,
1529
- deleted: 0,
2136
+ /**
2137
+ * Move directly-addressable binary `assets/*` resources into the local
2138
+ * manifest before merge-pull sees them. This prevents a byte download and
2139
+ * keeps those paths out of the shadow repository; their canonical state is
2140
+ * the ImageKit URL, not a local file.
2141
+ */
2142
+ async externalizePulledAssets(resources, opts) {
2143
+ this.assetManifest.reload();
2144
+ const managedKeys = /* @__PURE__ */ new Set();
2145
+ const changedManifestKeys = /* @__PURE__ */ new Set();
2146
+ const remoteManagedKeys = /* @__PURE__ */ new Set();
2147
+ const preservedManifestKeys = /* @__PURE__ */ new Set();
2148
+ const filesToRemove = /* @__PURE__ */ new Map();
2149
+ const errors = [];
2150
+ let manifestChanged = false;
2151
+ const resourcesNeedingMetadata = resources.filter((resource) => {
2152
+ const key = resource.key;
2153
+ if (!key) return false;
2154
+ const file = this.themeRoot.file(key);
2155
+ if (!this.isSafeThemeFile(key, file)) return false;
2156
+ if (!isLinkableBinaryResource(resource, key, file)) return false;
2157
+ const existing = this.assetManifest.get(key);
2158
+ if (this.themeRoot.ignore.ignore(key) || existing?.pending) return false;
2159
+ return !existing || existing.url !== resource.url || !assetMetadataFromLink(existing);
2160
+ });
2161
+ let assetMetadata = /* @__PURE__ */ new Map();
2162
+ const unresolvedMetadataKeys = /* @__PURE__ */ new Set();
2163
+ if (resourcesNeedingMetadata.length > 0) {
2164
+ try {
2165
+ assetMetadata = await this.fetchThemeAssetMetadata(this.themeId);
2166
+ } catch (error) {
2167
+ errors.push(`Read remote asset metadata: ${formatError(error)}`);
2168
+ for (const resource of resourcesNeedingMetadata) if (resource.key) unresolvedMetadataKeys.add(resource.key);
2169
+ }
2170
+ if (unresolvedMetadataKeys.size === 0) for (const resource of resourcesNeedingMetadata) {
2171
+ const key = resource.key;
2172
+ if (!key || assetMetadata.has(assetFilename(key))) continue;
2173
+ unresolvedMetadataKeys.add(key);
2174
+ errors.push(`Could not find usable metadata for ${key} in theme #${this.themeId}`);
2175
+ }
2176
+ }
2177
+ for (const resource of resources) {
2178
+ const key = resource.key;
2179
+ if (!key) continue;
2180
+ const file = this.themeRoot.file(key);
2181
+ if (!this.isSafeThemeFile(key, file)) continue;
2182
+ if (!isLinkableBinaryResource(resource, key, file)) continue;
2183
+ remoteManagedKeys.add(key);
2184
+ if (this.themeRoot.ignore.ignore(key)) {
2185
+ if (this.assetManifest.has(key)) preservedManifestKeys.add(key);
2186
+ managedKeys.add(key);
2187
+ continue;
2188
+ }
2189
+ if (this.assetManifest.get(key)?.pending) {
2190
+ preservedManifestKeys.add(key);
2191
+ managedKeys.add(key);
2192
+ continue;
2193
+ }
2194
+ if (unresolvedMetadataKeys.has(key)) {
2195
+ const stale = this.assetManifest.get(key);
2196
+ if (stale && stale.url !== resource.url) {
2197
+ this.assetManifest.delete(key);
2198
+ manifestChanged = true;
2199
+ }
2200
+ continue;
2201
+ }
2202
+ try {
2203
+ const existing = this.assetManifest.get(key);
2204
+ const metadata = assetMetadata.get(assetFilename(key));
2205
+ this.assetManifest.set(key, {
2206
+ ...existing,
2207
+ sourceThemeId: this.themeId,
2208
+ ...metadata,
2209
+ ...resourceLink(resource)
2210
+ });
2211
+ manifestChanged = true;
2212
+ changedManifestKeys.add(key);
2213
+ managedKeys.add(key);
2214
+ filesToRemove.set(key, file);
2215
+ } catch (error) {
2216
+ errors.push(`Externalize ${key}: ${formatError(error)}`);
2217
+ }
2218
+ }
2219
+ if (opts.delete) for (const [key, link] of this.assetManifest.entries()) {
2220
+ if (remoteManagedKeys.has(key) || preservedManifestKeys.has(key) || this.themeRoot.ignore.ignore(key) || link.pending) continue;
2221
+ this.assetManifest.delete(key);
2222
+ manifestChanged = true;
2223
+ }
2224
+ if (manifestChanged) try {
2225
+ this.assetManifest.write();
2226
+ } catch (error) {
2227
+ errors.push(`Persist remote asset manifest: ${formatError(error)}`);
2228
+ for (const key of changedManifestKeys) managedKeys.delete(key);
2229
+ return {
2230
+ managedKeys,
2231
+ linked: 0,
2232
+ errors
2233
+ };
2234
+ }
2235
+ let linked = 0;
2236
+ for (const [key, file] of filesToRemove) try {
2237
+ if (file.exists) unlinkSync(file.absolutePath);
2238
+ linked++;
2239
+ } catch (error) {
2240
+ errors.push(`Externalize ${key}: ${formatError(error)}`);
2241
+ }
2242
+ return {
2243
+ managedKeys,
2244
+ linked,
2245
+ errors
2246
+ };
2247
+ }
2248
+ isSafeThemeFile(key, file) {
2249
+ return !key.includes("\0") && !key.split(/[\\/]/).includes("..") && (file.absolutePath === this.themeRoot.root || file.absolutePath.startsWith(this.themeRoot.root + sep));
2250
+ }
2251
+ managedAssetNeedsRefresh(resource, link) {
2252
+ if (!isManagedAssetResource(resource)) return true;
2253
+ if (link.url !== void 0) return resource.url !== link.url;
2254
+ if (link.checksum !== void 0) return resource.checksum !== link.checksum;
2255
+ return true;
2256
+ }
2257
+ ensureManagedAssetsAreResolved() {
2258
+ const unresolved = this.assetManifest.entries().filter(([key]) => !this.themeRoot.ignore.ignore(key) && !this.themeRoot.file(key).exists).map(([key]) => key).filter((key) => !isManagedAssetResource(this.remoteResources.get(key)));
2259
+ if (unresolved.length > 0) throw new Error(`Managed asset(s) could not be linked: ${unresolved.join(", ")}`);
2260
+ }
2261
+ async uploadTheme(opts = {}) {
2262
+ const localFiles = this.themeRoot.files();
2263
+ const result = {
2264
+ uploaded: 0,
2265
+ deleted: 0,
1530
2266
  downloaded: 0,
2267
+ linked: 0,
1531
2268
  errors: [],
1532
2269
  validationFailed: false
1533
2270
  };
@@ -1542,11 +2279,21 @@ var Syncer = class {
1542
2279
  return result;
1543
2280
  }
1544
2281
  }
2282
+ await this.fetchChecksums();
2283
+ await this.preflightPush(opts.baseSha);
2284
+ let baseSha = opts.baseSha ?? null;
2285
+ if (opts.linkManagedAssets) {
2286
+ result.linked = await this.linkManagedAssets(opts.linkManagedAssets);
2287
+ baseSha = this.lastKnownRemoteSha ?? baseSha;
2288
+ } else if (this.assetManifestInstance) {
2289
+ this.assetManifest.reload();
2290
+ this.ensureManagedAssetsAreResolved();
2291
+ }
1545
2292
  const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
1546
2293
  let done = 0;
1547
2294
  for (const file of toUpload) {
1548
2295
  try {
1549
- await this.uploadFile(file, baseSha);
2296
+ await this.uploadFile(file, baseSha, { pendingAsset: opts.pendingBinaryAssets });
1550
2297
  baseSha = this.lastKnownRemoteSha;
1551
2298
  result.uploaded++;
1552
2299
  } catch (e) {
@@ -1557,7 +2304,8 @@ var Syncer = class {
1557
2304
  }
1558
2305
  if (opts.delete) {
1559
2306
  const localPaths = new Set(localFiles.map((f) => f.relativePath));
1560
- const toDelete = this.remoteKeys().filter((k) => !localPaths.has(k));
2307
+ for (const key of this.assetManifest.keys()) localPaths.add(key);
2308
+ const toDelete = this.remoteKeys().filter((key) => !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
1561
2309
  for (const key of toDelete) try {
1562
2310
  await this.deleteRemoteFile(key, baseSha);
1563
2311
  baseSha = this.lastKnownRemoteSha;
@@ -1571,23 +2319,29 @@ var Syncer = class {
1571
2319
  }
1572
2320
  async downloadTheme(opts = {}) {
1573
2321
  const resources = await this.downloadAll();
2322
+ const externalizedAssets = await this.externalizePulledAssets(resources, { delete: opts.delete ?? false });
1574
2323
  const result = {
1575
2324
  uploaded: 0,
1576
2325
  deleted: 0,
1577
2326
  downloaded: 0,
2327
+ linked: externalizedAssets.linked,
1578
2328
  skipped: 0,
1579
- errors: [],
2329
+ errors: [...externalizedAssets.errors],
1580
2330
  validationFailed: false
1581
2331
  };
1582
2332
  let done = 0;
1583
2333
  for (const resource of resources) {
2334
+ if (externalizedAssets.managedKeys.has(resource.key)) {
2335
+ opts.onProgress?.(++done, resources.length);
2336
+ continue;
2337
+ }
1584
2338
  if (opts.skip?.has(resource.key)) {
1585
2339
  result.skipped++;
1586
2340
  opts.onProgress?.(++done, resources.length);
1587
2341
  continue;
1588
2342
  }
1589
2343
  const file = this.themeRoot.file(resource.key);
1590
- if (!file.absolutePath.startsWith(this.themeRoot.root + sep)) {
2344
+ if (!this.isSafeThemeFile(resource.key, file)) {
1591
2345
  result.errors.push(`Download ${resource.key}: path traversal detected`);
1592
2346
  opts.onProgress?.(++done, resources.length);
1593
2347
  continue;
@@ -1612,7 +2366,6 @@ var Syncer = class {
1612
2366
  if (remoteKeys.has(file.relativePath)) continue;
1613
2367
  if (isStylesheetKey(file.relativePath)) continue;
1614
2368
  try {
1615
- const { unlinkSync } = await import("node:fs");
1616
2369
  unlinkSync(file.absolutePath);
1617
2370
  result.deleted++;
1618
2371
  } catch {}
@@ -1621,6 +2374,226 @@ var Syncer = class {
1621
2374
  return result;
1622
2375
  }
1623
2376
  };
2377
+ function isLinkableBinaryResource(resource, key, file) {
2378
+ return isThemeAssetKey(key) && isManagedAssetResource(resource) && !file.isText;
2379
+ }
2380
+ function isNestedBinaryThemeAsset(file) {
2381
+ return !file.isText && file.relativePath.startsWith("assets/") && !isThemeAssetKey(file.relativePath);
2382
+ }
2383
+ function isManagedAssetResource(resource) {
2384
+ return resource?.resource_type === "FileResource" && typeof resource.url === "string" && resource.url.length > 0;
2385
+ }
2386
+ function isNotFoundError(error) {
2387
+ return isApiError(error) && error.status === 404;
2388
+ }
2389
+ function resourceLink(resource) {
2390
+ return {
2391
+ ...typeof resource.checksum === "string" && resource.checksum.length > 0 ? { checksum: resource.checksum } : {},
2392
+ ...typeof resource.url === "string" && resource.url.length > 0 ? { url: resource.url } : {}
2393
+ };
2394
+ }
2395
+ function assetFilename(key) {
2396
+ return key.slice(7);
2397
+ }
2398
+ function assetMetadataFromLink(link) {
2399
+ if (typeof link.url !== "string" || link.url.length === 0 || typeof link.contentType !== "string" || link.contentType.length === 0 || typeof link.contentSize !== "number" || !Number.isInteger(link.contentSize) || link.contentSize <= 0) return;
2400
+ return {
2401
+ url: link.url,
2402
+ contentType: link.contentType,
2403
+ contentSize: link.contentSize,
2404
+ ...typeof link.previewImageUrl === "string" ? { previewImageUrl: link.previewImageUrl } : {},
2405
+ ...typeof link.altText === "string" ? { altText: link.altText } : {},
2406
+ ...typeof link.handle === "string" ? { handle: link.handle } : {}
2407
+ };
2408
+ }
2409
+ function uploadedAssetMetadata(file, resource) {
2410
+ if (resource.assetMetadata) return resource.assetMetadata;
2411
+ if (!resource.url) throw new Error(`Uploaded asset has no URL: ${file.relativePath}`);
2412
+ return {
2413
+ url: resource.url,
2414
+ contentType: file.mime.name,
2415
+ contentSize: file.size(),
2416
+ altText: file.name,
2417
+ ...typeof resource.damAssetCode === "string" ? { handle: resource.damAssetCode } : {}
2418
+ };
2419
+ }
2420
+ function parseThemeAssetMetadata(value) {
2421
+ if (!isRecord(value)) return void 0;
2422
+ const filename = nonEmptyString(value["filename"]);
2423
+ const url = nonEmptyString(value["url"]);
2424
+ const contentType = nonEmptyString(value["content_type"]);
2425
+ const contentSize = positiveInteger(value["content_size"]);
2426
+ if (!filename || !url || !contentType || !contentSize) return void 0;
2427
+ const previewImageUrl = nonEmptyString(value["preview_image_url"]);
2428
+ const altText = optionalString(value["alt_text"]);
2429
+ const handle = nonEmptyString(value["handle"]);
2430
+ return {
2431
+ filename,
2432
+ metadata: {
2433
+ url,
2434
+ contentType,
2435
+ contentSize,
2436
+ ...previewImageUrl ? { previewImageUrl } : {},
2437
+ ...altText !== void 0 ? { altText } : {},
2438
+ ...handle ? { handle } : {}
2439
+ }
2440
+ };
2441
+ }
2442
+ function createdFileResourceId(value) {
2443
+ if (!isRecord(value) || !isRecord(value["file_resource"])) return;
2444
+ return positiveInteger(value["file_resource"]["id"]);
2445
+ }
2446
+ function positiveInteger(value) {
2447
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
2448
+ if (typeof value !== "string" || !/^\d+$/.test(value)) return void 0;
2449
+ const parsed = Number(value);
2450
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
2451
+ }
2452
+ function nonEmptyString(value) {
2453
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2454
+ }
2455
+ function optionalString(value) {
2456
+ return typeof value === "string" ? value : void 0;
2457
+ }
2458
+ function isRecord(value) {
2459
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2460
+ }
2461
+ //#endregion
2462
+ //#region src/theme/liquid-delimiters.ts
2463
+ /**
2464
+ * Heuristic-only check for obviously unbalanced liquid delimiters
2465
+ * (`{% %}` and `{{ }}`). This is NOT a liquid parser — it only tracks
2466
+ * opening delimiters until they are closed. It exists to give watch-mode users a signal
2467
+ * when a save is liquid-syntax-broken: the server accepts
2468
+ * syntax-broken liquid silently on upload, and the storefront
2469
+ * renderer then serves stale content for that section with no error
2470
+ * anywhere else in the pipeline.
2471
+ *
2472
+ * Closing-looking tokens without a preceding Liquid opener are intentionally
2473
+ * ignored. Liquid files commonly contain CSS such as `width:100%}` or adjacent
2474
+ * block braces (`}}`), so treating every close token as Liquid creates noisy
2475
+ * false warnings on valid theme files.
2476
+ *
2477
+ * Known false positive: delimiters written literally inside a
2478
+ * `{% raw %}...{% endraw %}` block are still counted and can trip
2479
+ * this check even though the liquid is valid. Acceptable for a
2480
+ * warn-only heuristic — a real fix requires parsing liquid, which is
2481
+ * out of scope here (see the server-side validation note in the PR).
2482
+ */
2483
+ function hasUnbalancedLiquidDelimiters(content) {
2484
+ let unclosedTags = 0;
2485
+ let unclosedOutputs = 0;
2486
+ for (const token of content.matchAll(/\{%|%\}|\{\{|\}\}/g)) switch (token[0]) {
2487
+ case "{%":
2488
+ unclosedTags += 1;
2489
+ break;
2490
+ case "%}":
2491
+ if (unclosedTags > 0) unclosedTags -= 1;
2492
+ break;
2493
+ case "{{":
2494
+ unclosedOutputs += 1;
2495
+ break;
2496
+ case "}}":
2497
+ if (unclosedOutputs > 0) unclosedOutputs -= 1;
2498
+ break;
2499
+ }
2500
+ return unclosedTags > 0 || unclosedOutputs > 0;
2501
+ }
2502
+ const BLOCK_TAGS = new Map([
2503
+ ["capture", "endcapture"],
2504
+ ["case", "endcase"],
2505
+ ["comment", "endcomment"],
2506
+ ["for", "endfor"],
2507
+ ["form", "endform"],
2508
+ ["if", "endif"],
2509
+ ["ifchanged", "endifchanged"],
2510
+ ["javascript", "endjavascript"],
2511
+ ["paginate", "endpaginate"],
2512
+ ["raw", "endraw"],
2513
+ ["schema", "endschema"],
2514
+ ["style", "endstyle"],
2515
+ ["stylesheet", "endstylesheet"],
2516
+ ["tablerow", "endtablerow"],
2517
+ ["unless", "endunless"]
2518
+ ]);
2519
+ const CLOSING_TAGS = new Set(BLOCK_TAGS.values());
2520
+ const OPAQUE_BLOCK_TAGS = new Set([
2521
+ "comment",
2522
+ "javascript",
2523
+ "raw",
2524
+ "schema",
2525
+ "style",
2526
+ "stylesheet"
2527
+ ]);
2528
+ /**
2529
+ * Find structurally unbalanced Liquid block tags such as an `{% if %}` with
2530
+ * no `{% endif %}`. This intentionally recognizes only established paired
2531
+ * tags; custom and inline tags are ignored rather than guessed at.
2532
+ *
2533
+ * Content inside raw/comment/schema/style/javascript blocks is opaque to
2534
+ * Liquid and therefore skipped until that block's matching close tag. This
2535
+ * prevents CSS, JSON, and examples containing Liquid-looking text from
2536
+ * producing false errors.
2537
+ */
2538
+ function findLiquidBlockTagDiagnostics(content) {
2539
+ const stack = [];
2540
+ const diagnostics = [];
2541
+ let line = 1;
2542
+ let previousTagIndex = 0;
2543
+ const processTag = (name, tagLine) => {
2544
+ const open = stack.at(-1);
2545
+ if (open && OPAQUE_BLOCK_TAGS.has(open.name)) {
2546
+ if (name === open.expectedClose) stack.pop();
2547
+ return;
2548
+ }
2549
+ const expectedClose = BLOCK_TAGS.get(name);
2550
+ if (expectedClose) {
2551
+ stack.push({
2552
+ name,
2553
+ expectedClose,
2554
+ line: tagLine
2555
+ });
2556
+ return;
2557
+ }
2558
+ if (!CLOSING_TAGS.has(name)) return;
2559
+ if (!open) {
2560
+ diagnostics.push({
2561
+ severity: "error",
2562
+ message: `Unexpected Liquid tag '{% ${name} %}' on line ${tagLine}; there is no open block to close.`
2563
+ });
2564
+ return;
2565
+ }
2566
+ if (name !== open.expectedClose) {
2567
+ diagnostics.push({
2568
+ severity: "error",
2569
+ message: `Mismatched Liquid tag '{% ${name} %}' on line ${tagLine}; '{% ${open.name} %}' from line ${open.line} must close with '{% ${open.expectedClose} %}'.`
2570
+ });
2571
+ return;
2572
+ }
2573
+ stack.pop();
2574
+ };
2575
+ for (const match of content.matchAll(/\{%-?\s*([a-zA-Z_][\w-]*)\b(?:(?!\{%)[\s\S])*?-?%\}/g)) {
2576
+ const name = match[1]?.toLowerCase();
2577
+ if (!name) continue;
2578
+ const index = match.index ?? 0;
2579
+ for (let cursor = previousTagIndex; cursor < index; cursor++) if (content.charCodeAt(cursor) === 10) line += 1;
2580
+ previousTagIndex = index;
2581
+ const open = stack.at(-1);
2582
+ if (name === "liquid" && !(open && OPAQUE_BLOCK_TAGS.has(open.name))) {
2583
+ const statements = match[0].replace(/^\{%-?\s*liquid\b/i, "").replace(/-?%\}$/, "").split("\n");
2584
+ for (const [offset, statement] of statements.entries()) {
2585
+ const statementName = /^\s*([a-zA-Z_][\w-]*)\b/.exec(statement)?.[1];
2586
+ if (!statementName) continue;
2587
+ processTag(statementName.toLowerCase(), line + offset);
2588
+ }
2589
+ } else processTag(name, line);
2590
+ }
2591
+ for (const open of stack.reverse()) diagnostics.push({
2592
+ severity: "error",
2593
+ message: `Unclosed Liquid tag '{% ${open.name} %}' on line ${open.line}; expected '{% ${open.expectedClose} %}'.`
2594
+ });
2595
+ return diagnostics;
2596
+ }
1624
2597
  //#endregion
1625
2598
  //#region src/theme/dev-server/port-preflight.ts
1626
2599
  /**
@@ -1663,6 +2636,9 @@ function checkPortAvailable(host, port) {
1663
2636
  }
1664
2637
  //#endregion
1665
2638
  //#region src/theme/dev-server/index.ts
2639
+ function timestamp() {
2640
+ return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
2641
+ }
1666
2642
  async function startDevServer(api, theme, themeRoot, opts, onReady) {
1667
2643
  const sse = new SSEStream();
1668
2644
  const syncer = new Syncer(api, theme.id, themeRoot);
@@ -1671,17 +2647,57 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
1671
2647
  const syncResult = await syncer.uploadTheme({
1672
2648
  delete: true,
1673
2649
  validate: opts.validate,
2650
+ linkManagedAssets: { replace: true },
2651
+ pendingBinaryAssets: true,
1674
2652
  onProgress: (done, total) => {
1675
2653
  process.stdout.write(`\r Uploading ${done}/${total} files…`);
1676
2654
  }
1677
2655
  });
1678
2656
  process.stdout.write("\n");
2657
+ if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
1679
2658
  if (syncResult.validationFailed) {
1680
2659
  console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
1681
2660
  for (const e of syncResult.errors) console.error(` ${e}`);
1682
2661
  process.exit(1);
1683
- } else if (syncResult.errors.length > 0) for (const e of syncResult.errors) console.error(` ${e}`);
1684
- const stopWatcher = watchTheme(themeRoot, async (modified, added, removed) => {
2662
+ } else if (syncResult.errors.length > 0) {
2663
+ for (const e of syncResult.errors) console.error(` ${e}`);
2664
+ if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
2665
+ }
2666
+ const SYNC_IDLE_MS = 2e3;
2667
+ let lastArrivedAt = 0;
2668
+ let pendingSync = null;
2669
+ let syncInFlight = Promise.resolve();
2670
+ let askOwed = false;
2671
+ const sendSync = () => {
2672
+ syncInFlight = syncInFlight.then(async () => {
2673
+ askOwed = !await syncer.requestSync();
2674
+ });
2675
+ };
2676
+ const flushSyncNow = () => {
2677
+ if (!pendingSync) return;
2678
+ clearTimeout(pendingSync);
2679
+ pendingSync = null;
2680
+ sendSync();
2681
+ };
2682
+ const scheduleSync = () => {
2683
+ if (pendingSync) clearTimeout(pendingSync);
2684
+ pendingSync = setTimeout(() => {
2685
+ pendingSync = null;
2686
+ sendSync();
2687
+ }, SYNC_IDLE_MS);
2688
+ };
2689
+ const stopWatcher = watchTheme(themeRoot, async (modified, added, removed, arrivedAt) => {
2690
+ if (arrivedAt - lastArrivedAt > SYNC_IDLE_MS) flushSyncNow();
2691
+ else if (pendingSync) {
2692
+ clearTimeout(pendingSync);
2693
+ pendingSync = null;
2694
+ }
2695
+ lastArrivedAt = arrivedAt;
2696
+ await syncInFlight;
2697
+ if (askOwed) {
2698
+ sendSync();
2699
+ await syncInFlight;
2700
+ }
1685
2701
  const changed = [...modified, ...added];
1686
2702
  for (const file of changed) {
1687
2703
  if (opts.validate && file.isLiquid) {
@@ -1693,18 +2709,26 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
1693
2709
  }
1694
2710
  pendingUpdates.add(file.relativePath);
1695
2711
  try {
1696
- await syncer.uploadFile(file);
2712
+ const uploadedContent = await syncer.uploadFile(file, void 0, { pendingAsset: true });
2713
+ console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
2714
+ if (file.isLiquid && uploadedContent !== null && hasUnbalancedLiquidDelimiters(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: unbalanced liquid delimiters — the storefront may silently serve stale content for this section`);
2715
+ if (file.isLiquid && uploadedContent !== null) for (const diagnostic of findLiquidBlockTagDiagnostics(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: ${diagnostic.message}`);
1697
2716
  } catch (e) {
1698
2717
  console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
1699
2718
  } finally {
1700
2719
  pendingUpdates.delete(file.relativePath);
1701
2720
  }
1702
2721
  }
1703
- for (const file of removed) try {
1704
- await syncer.deleteRemoteFile(file.relativePath);
1705
- } catch {}
2722
+ for (const file of removed) {
2723
+ if (themeRoot.ignore.ignore(file.relativePath)) continue;
2724
+ try {
2725
+ await syncer.deleteRemoteFile(file.relativePath);
2726
+ console.log(` ✓ removed ${file.relativePath}`);
2727
+ } catch {}
2728
+ }
1706
2729
  if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
1707
2730
  else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
2731
+ scheduleSync();
1708
2732
  });
1709
2733
  const server = http.createServer(async (req, res) => {
1710
2734
  if (req.url === "/hot-reload") {
@@ -1724,8 +2748,9 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
1724
2748
  } catch (e) {
1725
2749
  console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
1726
2750
  if (!res.headersSent) {
1727
- res.writeHead(502);
1728
- res.end("Bad Gateway");
2751
+ const message = e instanceof Error ? e.message : String(e);
2752
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
2753
+ res.end(`Bad Gateway — the local preview could not reach ${theme.company}.fluid.app: ${message}\nThis is the dev machine's network path to Fluid, not the theme. Common causes: TLS-inspecting security software (its root CA is in the OS keychain, which Node does not read — set NODE_EXTRA_CA_CERTS to its certificate), DNS, or a proxy. The same error is logged by the theme dev server process.`);
1729
2754
  }
1730
2755
  }
1731
2756
  });
@@ -1901,21 +2926,42 @@ function resolveThemeRootFromCwd(workspace) {
1901
2926
  }
1902
2927
  //#endregion
1903
2928
  //#region src/commands/dev.ts
1904
- async function ensureDevTheme(api, projectKey, identifier) {
2929
+ /**
2930
+ * Create the isolated theme used by `theme dev`.
2931
+ *
2932
+ * A checkout from `theme pull` has a source theme id. New servers clone that
2933
+ * source by reference, preserving its DAM/ImageKit assets without moving
2934
+ * bytes. A 404/405 keeps older deployments compatible with the established
2935
+ * empty-theme flow; other failures must remain visible to the developer.
2936
+ */
2937
+ async function createDevelopmentTheme(api, sourceThemeId, name) {
2938
+ if (sourceThemeId !== void 0) try {
2939
+ return (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme;
2940
+ } catch (error) {
2941
+ if (!isApiError(error) || error.status !== 404 && error.status !== 405) throw error;
2942
+ console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
2943
+ }
2944
+ return (await createApplicationTheme(api, { application_theme: {
2945
+ name,
2946
+ status: "development"
2947
+ } })).application_theme;
2948
+ }
2949
+ async function ensureDevTheme(api, projectKey, identifier, sourceThemeId) {
1905
2950
  if (identifier) {
1906
2951
  const theme = await findTheme(api, identifier);
1907
2952
  setLastDevThemeId(theme.id);
1908
2953
  return theme;
1909
2954
  }
1910
2955
  const stored = getDevTheme(projectKey);
1911
- if (stored) {
2956
+ if (stored && stored.sourceThemeId === sourceThemeId) {
1912
2957
  try {
1913
2958
  const existing = (await getApplicationTheme(api, stored.id)).application_theme;
1914
2959
  if (existing && existing.status === "development") {
1915
2960
  console.log(`Using existing dev theme #${existing.id}`);
1916
2961
  setDevTheme(projectKey, {
1917
2962
  id: existing.id,
1918
- name: existing.name
2963
+ name: existing.name,
2964
+ ...sourceThemeId === void 0 ? {} : { sourceThemeId }
1919
2965
  });
1920
2966
  return existing;
1921
2967
  }
@@ -1923,13 +2969,11 @@ async function ensureDevTheme(api, projectKey, identifier) {
1923
2969
  clearDevTheme(projectKey);
1924
2970
  }
1925
2971
  const { hostname } = await import("node:os");
1926
- const theme = (await createApplicationTheme(api, { application_theme: {
1927
- name: `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50),
1928
- status: "development"
1929
- } })).application_theme;
2972
+ const theme = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
1930
2973
  setDevTheme(projectKey, {
1931
2974
  id: theme.id,
1932
- name: theme.name
2975
+ name: theme.name,
2976
+ ...sourceThemeId === void 0 ? {} : { sourceThemeId }
1933
2977
  });
1934
2978
  console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
1935
2979
  return theme;
@@ -1972,7 +3016,7 @@ function createDevCommand() {
1972
3016
  }
1973
3017
  }
1974
3018
  const projectKey = devThemeKey(company, themeRoot.root);
1975
- const theme = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey);
3019
+ const theme = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId);
1976
3020
  const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
1977
3021
  let stop;
1978
3022
  const cleanup = () => {
@@ -2087,10 +3131,11 @@ var ShadowRepo = class ShadowRepo {
2087
3131
  const { stdout } = await this.git([
2088
3132
  "ls-tree",
2089
3133
  "-r",
3134
+ "-z",
2090
3135
  "HEAD",
2091
3136
  "--name-only"
2092
3137
  ]);
2093
- return stdout.toString("utf8").split("\n").filter((line) => line.length > 0);
3138
+ return stdout.toString("utf8").split("\0").filter((line) => line.length > 0);
2094
3139
  }
2095
3140
  /**
2096
3141
  * The content of `path` in HEAD's tree, or null when the path does
@@ -2135,12 +3180,9 @@ var ShadowRepo = class ShadowRepo {
2135
3180
  async commitState(files, message) {
2136
3181
  const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
2137
3182
  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 } });
3183
+ const indexArgs = ["update-index", "--add"];
3184
+ for (const { path, sha } of files) indexArgs.push("--cacheinfo", `100644,${sha},${path}`);
3185
+ if (files.length > 0) await this.git(indexArgs, { env: { GIT_INDEX_FILE: indexPath } });
2144
3186
  const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
2145
3187
  const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
2146
3188
  const commitArgs = [
@@ -2182,8 +3224,15 @@ var ShadowRepo = class ShadowRepo {
2182
3224
  * `base` is null when HEAD has never seen this path; we merge
2183
3225
  * against an empty base, which is what git itself does for a new
2184
3226
  * file added on both sides.
3227
+ *
3228
+ * `favor` maps to `git merge-file`'s `--ours` / `--theirs`: instead
3229
+ * of emitting `<<<<<<<` markers, conflicting hunks are resolved to
3230
+ * the local (`"local"` → `--ours`, local is file1) or remote
3231
+ * (`"remote"` → `--theirs`) side. The output then never contains
3232
+ * markers, so the result is reported conflict-free even when
3233
+ * merge-file's exit code still counts the auto-resolved hunks.
2185
3234
  */
2186
- async merge3(base, local, remote) {
3235
+ async merge3(base, local, remote, favor) {
2187
3236
  const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
2188
3237
  const localPath = join(dir, "local");
2189
3238
  const basePath = join(dir, "base");
@@ -2196,6 +3245,7 @@ var ShadowRepo = class ShadowRepo {
2196
3245
  const { stdout } = await this.git([
2197
3246
  "merge-file",
2198
3247
  "-p",
3248
+ ...favor === "local" ? ["--ours"] : favor === "remote" ? ["--theirs"] : [],
2199
3249
  "-L",
2200
3250
  "local",
2201
3251
  "-L",
@@ -2213,7 +3263,12 @@ var ShadowRepo = class ShadowRepo {
2213
3263
  } catch (err) {
2214
3264
  const e = err;
2215
3265
  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 {
3266
+ const inConflictRange = typeof e.code === "number" && e.code >= 1 && e.code <= 127;
3267
+ if (inConflictRange && favor) return {
3268
+ merged,
3269
+ hasConflicts: false
3270
+ };
3271
+ if (inConflictRange && merged.length > 0) return {
2217
3272
  merged,
2218
3273
  hasConflicts: true
2219
3274
  };
@@ -2364,6 +3419,7 @@ async function diffAgainstShadow(themeRoot, shadow) {
2364
3419
  const deleted = [];
2365
3420
  const localFiles = themeRoot.files();
2366
3421
  const localByKey = /* @__PURE__ */ new Map();
3422
+ const assetManifest = new ThemeAssetManifest(themeRoot.root);
2367
3423
  for (const file of localFiles) {
2368
3424
  if (!file.exists) continue;
2369
3425
  localByKey.set(file.relativePath, file);
@@ -2374,6 +3430,8 @@ async function diffAgainstShadow(themeRoot, shadow) {
2374
3430
  }
2375
3431
  if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
2376
3432
  if (localByKey.has(key)) continue;
3433
+ if (themeRoot.ignore.ignore(key)) continue;
3434
+ if (assetManifest.has(key)) continue;
2377
3435
  if (isStylesheetKey(key)) continue;
2378
3436
  deleted.push(key);
2379
3437
  }
@@ -2408,6 +3466,19 @@ function findUnresolvedConflicts(files) {
2408
3466
  }
2409
3467
  return flagged;
2410
3468
  }
3469
+ /**
3470
+ * Stable, machine-readable one-liner for non-interactive callers
3471
+ * (Mist Desktop's publish flow parses push output). Uploading marker-
3472
+ * bearing files to a live theme is never acceptable, so `--auto-
3473
+ * baseline` pushes still refuse — but they emit this line so the
3474
+ * desktop can surface WHICH files block the publish instead of a
3475
+ * dead-end wall of prose. Format:
3476
+ *
3477
+ * FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=a.liquid,b.json
3478
+ */
3479
+ function conflictMarkerBlockLine(files) {
3480
+ return `FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=${files.join(",")}`;
3481
+ }
2411
3482
  const CONFLICT_START = Buffer.from("<<<<<<<");
2412
3483
  const CONFLICT_MID = Buffer.from("=======");
2413
3484
  const CONFLICT_END = Buffer.from(">>>>>>>");
@@ -2427,20 +3498,218 @@ function containsConflictMarker(buf) {
2427
3498
  */
2428
3499
  async function commitPushedState(themeRoot, shadow, message) {
2429
3500
  const entries = [];
3501
+ const assetManifest = new ThemeAssetManifest(themeRoot.root);
3502
+ const localKeys = /* @__PURE__ */ new Set();
2430
3503
  for (const file of themeRoot.files()) {
2431
3504
  if (!file.exists) continue;
3505
+ localKeys.add(file.relativePath);
2432
3506
  const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
2433
3507
  entries.push({
2434
3508
  path: file.relativePath,
2435
3509
  sha: await shadow.writeBlob(buf)
2436
3510
  });
2437
3511
  }
2438
- if (entries.length > 0) await shadow.commitState(entries, message);
3512
+ let managedAssetSentinelSha;
3513
+ for (const key of assetManifest.keys()) {
3514
+ if (localKeys.has(key)) continue;
3515
+ managedAssetSentinelSha ??= await shadow.writeBlob(MANAGED_ASSET_SHADOW_SENTINEL);
3516
+ entries.push({
3517
+ path: key,
3518
+ sha: managedAssetSentinelSha
3519
+ });
3520
+ }
3521
+ if (entries.length > 0 || await shadow.hasHead()) await shadow.commitState(entries, message);
3522
+ }
3523
+ /**
3524
+ * A target can already have every URL-backed FileResource (for example from a
3525
+ * reference clone), while this checkout's manifest still names its old source
3526
+ * theme. Adopt the target and clear any legacy binary from shadow even though
3527
+ * no remote write was necessary.
3528
+ */
3529
+ async function finalizeManifestOnlyPush(syncer, themeRoot, shadow) {
3530
+ syncer.repointManagedAssetsToCurrentTheme();
3531
+ await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
3532
+ }
3533
+ //#endregion
3534
+ //#region src/theme/auto-baseline.ts
3535
+ /**
3536
+ * `fluid theme push --auto-baseline`: when a theme directory has no
3537
+ * shadow baseline (scaffold that never pulled, or a dir last synced
3538
+ * by the checksum-era CLI whose migration had nothing to seed),
3539
+ * record the server's CURRENT state as the baseline commit so the
3540
+ * normal push diff (local vs baseline) can run.
3541
+ *
3542
+ * Invariants:
3543
+ *
3544
+ * 1. **The working tree is never touched.** Baseline recording writes
3545
+ * blobs into the bare shadow repo only; not a single byte on disk
3546
+ * changes. Local files identical to the server simply won't diff;
3547
+ * files that differ (or exist only locally) will push.
3548
+ *
3549
+ * 2. **Server-only files are never deleted.** Paths that exist on the
3550
+ * server but not locally are deliberately excluded from the
3551
+ * baseline commit. `diffAgainstShadow` reports deletions as "in
3552
+ * HEAD but not on disk", so putting server-only paths into HEAD
3553
+ * would mark them for remote deletion — on THIS push (or worse, a
3554
+ * later one) — for files the user never had. Excluding them makes
3555
+ * the diff structurally unable to delete them; the next `pull`
3556
+ * materializes them locally and records them for real.
3557
+ *
3558
+ * Bandwidth note: a server file whose sha256 checksum matches the
3559
+ * local file's is recorded from the LOCAL bytes (identical by
3560
+ * definition), so binary assets that are already in sync are never
3561
+ * downloaded just to seed the baseline.
3562
+ *
3563
+ * No-op (`seeded: false`) when HEAD already exists.
3564
+ */
3565
+ async function seedBaselineFromServer(input) {
3566
+ const { shadow, themeRoot, remote, fetchBinary, message } = input;
3567
+ const result = {
3568
+ seeded: false,
3569
+ recorded: [],
3570
+ serverOnly: [],
3571
+ errors: []
3572
+ };
3573
+ if (await shadow.hasHead()) return result;
3574
+ const entries = [];
3575
+ for (const resource of remote) {
3576
+ const file = themeRoot.file(resource.key);
3577
+ if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
3578
+ result.errors.push(`Baseline ${resource.key}: path traversal detected`);
3579
+ continue;
3580
+ }
3581
+ if (!file.exists) {
3582
+ result.serverOnly.push(resource.key);
3583
+ continue;
3584
+ }
3585
+ let content;
3586
+ if (resource.checksum && file.checksum() === resource.checksum) content = file.isText ? Buffer.from(file.read()) : file.readBinary();
3587
+ else try {
3588
+ content = await materializeRemote(resource, fetchBinary);
3589
+ } catch (e) {
3590
+ result.errors.push(`Baseline ${resource.key}: ${e instanceof Error ? e.message : String(e)}`);
3591
+ continue;
3592
+ }
3593
+ if (content == null) continue;
3594
+ entries.push({
3595
+ path: resource.key,
3596
+ sha: await shadow.writeBlob(content)
3597
+ });
3598
+ result.recorded.push(resource.key);
3599
+ }
3600
+ if (entries.length > 0) {
3601
+ await shadow.commitState(entries, message);
3602
+ result.seeded = true;
3603
+ }
3604
+ return result;
3605
+ }
3606
+ async function materializeRemote(resource, fetchBinary) {
3607
+ if (resource.resource_type === "FileResource" && resource.url) return fetchBinary(resource.url);
3608
+ if (resource.content == null) return null;
3609
+ const text = typeof resource.content === "string" ? resource.content : JSON.stringify(resource.content);
3610
+ return Buffer.from(text);
3611
+ }
3612
+ //#endregion
3613
+ //#region src/theme/legacy-migration.ts
3614
+ /**
3615
+ * On first pull after upgrading from a checksum-era CLI, the shadow
3616
+ * repo starts with no HEAD. `mergePull` would then run every diverged
3617
+ * file through a null-base merge — even files the user never touched
3618
+ * locally — producing spurious `<<<<<<<` markers for every file the
3619
+ * server updated since the last pull.
3620
+ *
3621
+ * Recover a real merge base by trusting the legacy sha256 checksums:
3622
+ * any local file whose content still matches its stored checksum is
3623
+ * "unmodified since last pull" and can be committed as HEAD. Files
3624
+ * whose local sha256 diverges from the stored checksum stay
3625
+ * unseeded — we don't have their pre-modification content, so a
3626
+ * null-base merge (marker-first UX) is the honest fallback for them.
3627
+ *
3628
+ * Idempotent: no-op when HEAD already exists, when there is no
3629
+ * legacy config, when the config is for a different theme, or when
3630
+ * the checksums map is empty. Runs before `mergePull` so its base
3631
+ * lookups see the seeded tree.
3632
+ */
3633
+ async function migrateLegacyChecksumsIntoShadow(input) {
3634
+ const { shadow, themeRoot, absoluteRoot, themeId } = input;
3635
+ if (await shadow.hasHead()) return;
3636
+ const legacy = readLegacyThemeConfig(absoluteRoot);
3637
+ if (!legacy) return;
3638
+ if (legacy.themeId !== themeId) return;
3639
+ if (!legacy.checksums || Object.keys(legacy.checksums).length === 0) return;
3640
+ const seed = [];
3641
+ for (const file of themeRoot.files()) {
3642
+ if (!file.exists) continue;
3643
+ const stored = legacy.checksums[file.relativePath];
3644
+ if (!stored) continue;
3645
+ if (file.checksum() !== stored) continue;
3646
+ const content = file.isText ? Buffer.from(file.read()) : file.readBinary();
3647
+ seed.push({
3648
+ path: file.relativePath,
3649
+ content
3650
+ });
3651
+ }
3652
+ if (seed.length === 0) return;
3653
+ await shadow.seedFromWorkingTree(seed, `migrate from checksum-era CLI @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
3654
+ }
3655
+ //#endregion
3656
+ //#region src/theme/sync-identity.ts
3657
+ /** Legacy (actor-less) wording per verb. `Push`/`Pull` are byte-
3658
+ * compatible with what the CLI has always written. */
3659
+ const LEGACY_WORDING = {
3660
+ Push: "push",
3661
+ Pull: "pull",
3662
+ "Baseline from server": "baseline from server",
3663
+ "Snapshot before pull": "snapshot before pull"
3664
+ };
3665
+ /** Human base message per verb, used in the stamped format. */
3666
+ const BASE_MESSAGE = {
3667
+ Push: "Push from Fluid CLI",
3668
+ Pull: "Pull from Fluid CLI",
3669
+ "Baseline from server": "Baseline from server",
3670
+ "Snapshot before pull": "Snapshot before pull"
3671
+ };
3672
+ function syncCommitSubject(verb, actor, when = /* @__PURE__ */ new Date()) {
3673
+ if (!actor) return `${LEGACY_WORDING[verb]} @ ${when.toISOString()}`;
3674
+ const stamp = when.toISOString().slice(0, 19).replace("T", " ");
3675
+ const idSuffix = actor.publicId ? ` (${actor.publicId})` : "";
3676
+ return `${actor.name}: ${BASE_MESSAGE[verb]} · ${stamp}${idSuffix}`;
3677
+ }
3678
+ /** Resolve a `/api/me` body to a commit actor. Exported for tests.
3679
+ * Name fallback ordering matches the Mist CLI: full_name (or a
3680
+ * first+last composition) → email → `user-<id>`; null when none of
3681
+ * those exist — the caller then uses the legacy wording. */
3682
+ function actorFromMe(raw) {
3683
+ const user = raw.user ?? raw;
3684
+ const composed = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
3685
+ 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);
3686
+ if (!name) return null;
3687
+ return {
3688
+ name,
3689
+ publicId: user.public_id && user.public_id.trim() || null
3690
+ };
3691
+ }
3692
+ /**
3693
+ * Best-effort fetch of the signed-in user for commit stamping. Races
3694
+ * `/api/me` against a short timeout and NEVER rejects — a sync must
3695
+ * not block or fail because the identity lookup did. Callers kick
3696
+ * this off early and await it only at commit time.
3697
+ */
3698
+ async function fetchSyncActor(api, timeoutMs = 5e3) {
3699
+ try {
3700
+ return actorFromMe(await Promise.race([api.get("/api/me"), new Promise((_, reject) => {
3701
+ setTimeout(() => reject(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms`)), timeoutMs).unref?.();
3702
+ })]));
3703
+ } catch (err) {
3704
+ const reason = err instanceof Error ? err.message : String(err);
3705
+ console.warn(` (skipping user-stamp on sync commit — couldn't fetch /api/me: ${reason})`);
3706
+ return null;
3707
+ }
2439
3708
  }
2440
3709
  //#endregion
2441
3710
  //#region src/commands/push.ts
2442
3711
  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) => {
3712
+ 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 local Liquid 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
3713
  requireToken();
2445
3714
  let rootPath = opts.root;
2446
3715
  if (rootPath === ".") {
@@ -2476,7 +3745,16 @@ function createPushCommand() {
2476
3745
  theme = (await getApplicationTheme(api, config.themeId)).application_theme;
2477
3746
  } else theme = await selectTheme(api, "Select a theme to push to");
2478
3747
  const shadow = await ShadowRepo.open(themeRoot.root, theme.id);
2479
- const unresolved = findUnresolvedConflicts(themeRoot.files().filter((f) => f.exists));
3748
+ await migrateLegacyChecksumsIntoShadow({
3749
+ shadow,
3750
+ themeRoot,
3751
+ absoluteRoot: themeRoot.root,
3752
+ themeId: theme.id
3753
+ });
3754
+ const localFiles = themeRoot.files().filter((f) => f.exists);
3755
+ let actorPromise = null;
3756
+ const getActor = () => actorPromise ??= fetchSyncActor(api);
3757
+ const unresolved = findUnresolvedConflicts(localFiles);
2480
3758
  if (unresolved.length > 0) {
2481
3759
  console.log();
2482
3760
  console.log(chalk.red(`✗ ${unresolved.length} file(s) still contain unresolved conflict markers:`));
@@ -2485,28 +3763,68 @@ function createPushCommand() {
2485
3763
  console.log(` Edit each file to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} sections,`);
2486
3764
  console.log(` then re-run ${chalk.cyan("fluid theme push")}.`);
2487
3765
  console.log();
3766
+ if (opts.autoBaseline) console.error(conflictMarkerBlockLine(unresolved));
2488
3767
  process.exit(1);
2489
3768
  }
3769
+ const syncer = new Syncer(api, theme.id, themeRoot);
3770
+ const configMatchesTheme = config?.themeId === theme.id;
3771
+ let baseSha = opts.force || !configMatchesTheme ? null : config?.baseSha ?? null;
3772
+ const hasBaseline = baseSha != null || await shadow.hasHead();
3773
+ if (!opts.force && !opts.unpublished && !hasBaseline) {
3774
+ if (!opts.autoBaseline) {
3775
+ console.error();
3776
+ console.error(chalk.red(`No local baseline for theme "${theme.name}" (#${theme.id}).`));
3777
+ console.error();
3778
+ console.error(` Run ${chalk.cyan(`fluid theme pull -t ${theme.id}`)} first to sync down the current server state,`);
3779
+ console.error(` then push — this way you see what would change before it goes live.`);
3780
+ console.error();
3781
+ console.error(` Or re-run with ${chalk.cyan("--auto-baseline")} to record the server's current state`);
3782
+ console.error(` as the baseline and push only what differs locally (local files are never modified).`);
3783
+ console.error();
3784
+ console.error(` Or, if you know what you're doing and want to overwrite the server's current`);
3785
+ console.error(` contents wholesale, re-run with ${chalk.cyan("--force")}.`);
3786
+ console.error();
3787
+ process.exit(1);
3788
+ }
3789
+ const baselineSpinner = ora(`No local baseline — recording current server state for ${theme.name} (#${theme.id})…`).start();
3790
+ try {
3791
+ const seedResult = await seedBaselineFromServer({
3792
+ shadow,
3793
+ themeRoot,
3794
+ remote: await syncer.downloadAll(),
3795
+ fetchBinary: (url) => syncer.downloadBinaryAsset(url),
3796
+ message: syncCommitSubject("Baseline from server", await getActor())
3797
+ });
3798
+ baseSha = syncer.remoteSha() ?? null;
3799
+ const parts = [`recorded ${seedResult.recorded.length} file(s)`];
3800
+ if (seedResult.serverOnly.length > 0) parts.push(`left ${seedResult.serverOnly.length} server-only file(s) untouched`);
3801
+ baselineSpinner.succeed(`Baseline recorded — ${parts.join(", ")}.`);
3802
+ for (const err of seedResult.errors) console.warn(` ${chalk.yellow("warn")} ${err}`);
3803
+ } catch (e) {
3804
+ baselineSpinner.fail(`Could not record a baseline from the server: ${formatError(e)}`);
3805
+ process.exit(1);
3806
+ }
3807
+ }
2490
3808
  const { changed, deleted } = await diffAgainstShadow(themeRoot, shadow);
2491
- if (changed.length === 0 && deleted.length === 0) {
3809
+ const managedAssetCount = new ThemeAssetManifest(themeRoot.root).keys().length;
3810
+ if (changed.length === 0 && deleted.length === 0 && managedAssetCount === 0) {
2492
3811
  console.log("Nothing to push — local matches the last synced state.");
3812
+ await persistConfig();
2493
3813
  return;
2494
3814
  }
2495
3815
  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);
3816
+ if (!opts.force) {
3817
+ const validationErrors = [];
3818
+ for (const file of changed) {
3819
+ if (!file.isLiquid) continue;
3820
+ for (const diagnostic of file.validateSchema()) if (diagnostic.severity === "error") validationErrors.push(`${file.relativePath}: ${diagnostic.message}`);
3821
+ for (const diagnostic of findLiquidBlockTagDiagnostics(file.read())) validationErrors.push(`${file.relativePath}: ${diagnostic.message}`);
3822
+ }
3823
+ if (validationErrors.length > 0) {
3824
+ spinner.fail(`Liquid validation failed (${validationErrors.length} error(s)). Use --force to skip.`);
3825
+ for (const error of validationErrors) console.error(` ${error}`);
3826
+ process.exit(1);
3827
+ }
2510
3828
  }
2511
3829
  try {
2512
3830
  await syncer.preflightPush(baseSha);
@@ -2518,17 +3836,24 @@ function createPushCommand() {
2518
3836
  }
2519
3837
  throw e;
2520
3838
  }
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}`);
3839
+ let linked = 0;
3840
+ try {
3841
+ linked = await syncer.linkManagedAssets({ replace: true });
3842
+ baseSha = syncer.remoteSha() ?? baseSha;
3843
+ } catch (error) {
3844
+ spinner.fail(`Could not save remote asset references: ${formatError(error)}`);
3845
+ process.exit(1);
3846
+ }
3847
+ if (changed.length === 0 && deleted.length === 0 && linked === 0) {
3848
+ try {
3849
+ await finalizeManifestOnlyPush(syncer, themeRoot, shadow);
3850
+ } catch (error) {
3851
+ spinner.fail(`Could not finalize remote asset references: ${formatError(error)}`);
2530
3852
  process.exit(1);
2531
3853
  }
3854
+ spinner.succeed("Nothing to push — local matches the remote theme.");
3855
+ await persistConfig();
3856
+ return;
2532
3857
  }
2533
3858
  let uploaded = 0;
2534
3859
  let deletedCount = 0;
@@ -2565,17 +3890,49 @@ function createPushCommand() {
2565
3890
  }
2566
3891
  spinner.text = `Pushing ${++progress}/${total} files…`;
2567
3892
  }
3893
+ if (errors.length === 0) try {
3894
+ syncer.repointManagedAssetsToCurrentTheme();
3895
+ } catch (error) {
3896
+ errors.push(`Save asset provenance: ${formatError(error)}`);
3897
+ }
3898
+ if (uploaded > 0 || deletedCount > 0 || linked > 0) await syncer.requestSync();
2568
3899
  if (errors.length) {
2569
3900
  spinner.warn(`Pushed with ${errors.length} error(s).`);
2570
3901
  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
- });
3902
+ process.exitCode = 1;
3903
+ } else spinner.succeed(`Pushed ${uploaded} file(s), saved ${linked} remote asset reference(s)` + (deletedCount > 0 ? `, deleted ${deletedCount} remote file(s).` : "."));
3904
+ if (errors.length === 0) await commitPushedState(themeRoot, shadow, syncCommitSubject("Push", await getActor()));
3905
+ await persistConfig();
3906
+ /**
3907
+ * Persist `.fluid-theme.json`. When a config already exists,
3908
+ * this is the pre-existing baseSha refresh. When it doesn't
3909
+ * (a scaffold's first `--auto-baseline` push), bind the dir
3910
+ * to the theme now — best-effort, since the company subdomain
3911
+ * needs one more API call — so the next push/pull needs no
3912
+ * interactive picker.
3913
+ */
3914
+ async function persistConfig() {
3915
+ if (config) {
3916
+ writeThemeConfig(themeRoot.root, {
3917
+ themeId: theme.id,
3918
+ themeName: theme.name,
3919
+ company: config.company,
3920
+ baseSha: baseSha ?? void 0
3921
+ });
3922
+ return;
3923
+ }
3924
+ if (!opts.autoBaseline) return;
3925
+ try {
3926
+ const subdomain = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain;
3927
+ if (!subdomain) return;
3928
+ writeThemeConfig(themeRoot.root, {
3929
+ themeId: theme.id,
3930
+ themeName: theme.name,
3931
+ company: subdomain,
3932
+ baseSha: baseSha ?? void 0
3933
+ });
3934
+ } catch {}
3935
+ }
2579
3936
  if (opts.publish) {
2580
3937
  const pubSpinner = ora("Publishing theme…").start();
2581
3938
  try {
@@ -2583,6 +3940,7 @@ function createPushCommand() {
2583
3940
  pubSpinner.succeed("Theme published.");
2584
3941
  } catch (e) {
2585
3942
  pubSpinner.fail(`Publish failed: ${e}`);
3943
+ process.exitCode = 1;
2586
3944
  }
2587
3945
  }
2588
3946
  });
@@ -2596,6 +3954,16 @@ function renderPullFirst(spinner) {
2596
3954
  }
2597
3955
  //#endregion
2598
3956
  //#region src/theme/merge-pull.ts
3957
+ /** `write(..., RESOLVES_CONFLICT)`: this write is the chosen side of a conflict. */
3958
+ const RESOLVES_CONFLICT = true;
3959
+ /**
3960
+ * Appended to every per-file write failure. Resource keys are verbatim
3961
+ * relative paths, so a template named `Sale 11/14/2025, 12:30 PM` asks
3962
+ * the filesystem for directories that Windows (and any path already
3963
+ * occupied by a file) refuses. Renaming the template in the admin is
3964
+ * the only fix the user owns.
3965
+ */
3966
+ const RENAME_REMEDY = "Rename the template in the admin visual builder to remove / \\ : * ? \" < > | from its name, then pull again.";
2599
3967
  /**
2600
3968
  * Reconcile the just-downloaded remote tree against the working tree,
2601
3969
  * using the shadow repo's HEAD as the merge base. Text files that
@@ -2617,15 +3985,52 @@ async function mergePull(input) {
2617
3985
  written: 0,
2618
3986
  merged: 0,
2619
3987
  conflicts: [],
3988
+ autoResolved: [],
2620
3989
  deleted: 0,
2621
3990
  skipped: 0,
2622
3991
  errors: []
2623
3992
  };
3993
+ const pendingWrites = [];
3994
+ const unwrittenKeys = /* @__PURE__ */ new Set();
3995
+ const tryWrite = (key, file, content) => {
3996
+ try {
3997
+ file.write(content);
3998
+ return true;
3999
+ } catch (e) {
4000
+ unwrittenKeys.add(key);
4001
+ result.errors.push(`Reconcile ${key}: ${errMsg(e)}. ${RENAME_REMEDY}`);
4002
+ return false;
4003
+ }
4004
+ };
4005
+ /**
4006
+ * Write `content`, then apply `record` — the bookkeeping that says
4007
+ * which bucket of the summary this file counted as. The two travel
4008
+ * together, including into the deferred flush: bookkeeping applied
4009
+ * when the write was merely QUEUED would report a file the flush
4010
+ * never managed to write as both written and errored.
4011
+ */
4012
+ const write = (key, file, content, record, resolvesConflict = false) => {
4013
+ if (input.resolve) {
4014
+ pendingWrites.push({
4015
+ key,
4016
+ file,
4017
+ content,
4018
+ record,
4019
+ resolvesConflict
4020
+ });
4021
+ return;
4022
+ }
4023
+ if (tryWrite(key, file, content)) record();
4024
+ };
2624
4025
  const remoteContent = /* @__PURE__ */ new Map();
2625
4026
  const remoteKeys = /* @__PURE__ */ new Set();
2626
4027
  let done = 0;
2627
4028
  for (const resource of remote) {
2628
4029
  remoteKeys.add(resource.key);
4030
+ if (input.skipRemoteKeys?.has(resource.key)) {
4031
+ onProgress?.(++done, remote.length);
4032
+ continue;
4033
+ }
2629
4034
  try {
2630
4035
  const buf = await materialize(resource, fetchBinary);
2631
4036
  if (buf) remoteContent.set(resource.key, buf);
@@ -2638,18 +4043,17 @@ async function mergePull(input) {
2638
4043
  const file = themeRoot.file(key);
2639
4044
  if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
2640
4045
  result.errors.push(`Reconcile ${key}: path traversal detected`);
4046
+ unwrittenKeys.add(key);
2641
4047
  continue;
2642
4048
  }
2643
4049
  if (input.force) {
2644
- file.write(remoteBuf);
2645
- result.written++;
4050
+ write(key, file, remoteBuf, () => result.written++);
2646
4051
  continue;
2647
4052
  }
2648
4053
  const localBuf = readIfExists(file.absolutePath);
2649
4054
  const baseBuf = await shadow.blobAtHead(key);
2650
4055
  if (localBuf == null) {
2651
- file.write(remoteBuf);
2652
- result.written++;
4056
+ write(key, file, remoteBuf, () => result.written++);
2653
4057
  continue;
2654
4058
  }
2655
4059
  if (localBuf.equals(remoteBuf)) {
@@ -2657,23 +4061,41 @@ async function mergePull(input) {
2657
4061
  continue;
2658
4062
  }
2659
4063
  if (baseBuf && localBuf.equals(baseBuf)) {
2660
- file.write(remoteBuf);
2661
- result.written++;
4064
+ write(key, file, remoteBuf, () => result.written++);
2662
4065
  continue;
2663
4066
  }
2664
4067
  if (baseBuf && remoteBuf.equals(baseBuf)) {
4068
+ if (input.resolve === "remote" && !looksBinary(localBuf) && hasGeneratedConflictMarkers(localBuf)) {
4069
+ write(key, file, remoteBuf, () => result.autoResolved.push(`${key} (cleared conflict markers)`), RESOLVES_CONFLICT);
4070
+ continue;
4071
+ }
2665
4072
  result.skipped++;
2666
4073
  continue;
2667
4074
  }
2668
4075
  if (looksBinary(localBuf) || looksBinary(remoteBuf) || (baseBuf ? looksBinary(baseBuf) : false)) {
2669
- file.write(remoteBuf);
2670
- result.conflicts.push(`${key} (binary — kept remote)`);
4076
+ if (input.resolve === "local") {
4077
+ result.autoResolved.push(`${key} (binary — kept local)`);
4078
+ continue;
4079
+ }
4080
+ write(key, file, remoteBuf, () => {
4081
+ if (input.resolve === "remote") result.autoResolved.push(`${key} (binary — kept remote)`);
4082
+ else result.conflicts.push(`${key} (binary — kept remote)`);
4083
+ }, input.resolve === "remote");
2671
4084
  continue;
2672
4085
  }
2673
4086
  const { merged, hasConflicts } = await shadow.merge3(baseBuf, localBuf, remoteBuf);
2674
- file.write(merged);
2675
- if (hasConflicts) result.conflicts.push(key);
2676
- else result.merged++;
4087
+ if (hasConflicts && input.resolve) {
4088
+ write(key, file, (await shadow.merge3(baseBuf, localBuf, remoteBuf, input.resolve)).merged, () => result.autoResolved.push(key), RESOLVES_CONFLICT);
4089
+ continue;
4090
+ }
4091
+ write(key, file, merged, () => {
4092
+ if (hasConflicts) result.conflicts.push(key);
4093
+ else result.merged++;
4094
+ });
4095
+ }
4096
+ if (input.resolve) {
4097
+ if (pendingWrites.some((w) => w.resolvesConflict)) await commitPushedState(themeRoot, shadow, syncCommitSubject("Snapshot before pull", input.actor ?? null));
4098
+ for (const { key, file, content, record } of pendingWrites) if (tryWrite(key, file, content)) record();
2677
4099
  }
2678
4100
  if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
2679
4101
  if (remoteKeys.has(file.relativePath)) continue;
@@ -2689,12 +4111,16 @@ async function mergePull(input) {
2689
4111
  } catch {}
2690
4112
  }
2691
4113
  const commitEntries = [];
2692
- for (const [key, buf] of remoteContent) commitEntries.push({
2693
- path: key,
2694
- sha: await shadow.writeBlob(buf)
2695
- });
4114
+ for (const [key, buf] of remoteContent) {
4115
+ if (unwrittenKeys.has(key)) continue;
4116
+ commitEntries.push({
4117
+ path: key,
4118
+ sha: await shadow.writeBlob(buf)
4119
+ });
4120
+ }
2696
4121
  for (const key of remoteKeys) {
2697
- if (remoteContent.has(key)) continue;
4122
+ if (input.skipRemoteKeys?.has(key)) continue;
4123
+ if (remoteContent.has(key) && !unwrittenKeys.has(key)) continue;
2698
4124
  const prevBlob = await shadow.blobAtHead(key);
2699
4125
  if (prevBlob == null) continue;
2700
4126
  commitEntries.push({
@@ -2702,9 +4128,23 @@ async function mergePull(input) {
2702
4128
  sha: await shadow.writeBlob(prevBlob)
2703
4129
  });
2704
4130
  }
2705
- if (commitEntries.length > 0) await shadow.commitState(commitEntries, `pull @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
4131
+ const assetManifest = new ThemeAssetManifest(themeRoot.root);
4132
+ let managedAssetSentinelSha;
4133
+ for (const key of input.skipRemoteKeys ?? []) {
4134
+ if (!remoteKeys.has(key) || !assetManifest.has(key)) continue;
4135
+ managedAssetSentinelSha ??= await shadow.writeBlob(MANAGED_ASSET_SHADOW_SENTINEL);
4136
+ commitEntries.push({
4137
+ path: key,
4138
+ sha: managedAssetSentinelSha
4139
+ });
4140
+ }
4141
+ if (commitEntries.length > 0 || remoteKeys.size === 0 && await shadow.hasHead()) await shadow.commitState(commitEntries, syncCommitSubject("Pull", input.actor ?? null));
2706
4142
  return result;
2707
4143
  }
4144
+ function hasGeneratedConflictMarkers(content) {
4145
+ const text = content.toString("utf8");
4146
+ return /^<<<<<<< local\r?$/m.test(text) && /^=======\r?$/m.test(text) && /^>>>>>>> remote\r?$/m.test(text);
4147
+ }
2708
4148
  async function materialize(resource, fetchBinary) {
2709
4149
  if (resource.resource_type === "FileResource" && resource.url) return fetchBinary(resource.url);
2710
4150
  if (resource.content == null) return null;
@@ -2715,49 +4155,6 @@ function errMsg(e) {
2715
4155
  return e instanceof Error ? e.message : String(e);
2716
4156
  }
2717
4157
  //#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
4158
  //#region src/commands/pull.ts
2762
4159
  async function fetchCompanySubdomain(api) {
2763
4160
  const subdomain = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain;
@@ -2768,8 +4165,13 @@ async function fetchCompanySubdomain(api) {
2768
4165
  return subdomain;
2769
4166
  }
2770
4167
  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) => {
4168
+ 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
4169
  requireToken();
4170
+ if (opts.resolve !== void 0 && opts.resolve !== "local" && opts.resolve !== "remote") {
4171
+ console.error(`Invalid --resolve value "${opts.resolve}" — use "local" or "remote".`);
4172
+ process.exit(1);
4173
+ }
4174
+ const resolveSide = opts.resolve;
2773
4175
  const api = createApiClient();
2774
4176
  const workspace = findWorkspace();
2775
4177
  const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
@@ -2806,26 +4208,36 @@ function createPullCommand() {
2806
4208
  themeId: theme.id
2807
4209
  });
2808
4210
  const syncer = new Syncer(api, theme.id, themeRoot);
4211
+ const actorPromise = fetchSyncActor(api);
2809
4212
  const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
4213
+ const resources = await syncer.downloadAll();
4214
+ const externalizedAssets = await syncer.externalizePulledAssets(resources, { delete: !opts.nodelete });
2810
4215
  const result = await mergePull({
2811
4216
  themeRoot,
2812
4217
  shadow,
2813
- remote: await syncer.downloadAll(),
4218
+ remote: resources,
2814
4219
  fetchBinary: (url) => syncer.downloadBinaryAsset(url),
2815
4220
  delete: !opts.nodelete,
2816
4221
  force: opts.force ?? false,
4222
+ skipRemoteKeys: externalizedAssets.managedKeys,
4223
+ resolve: resolveSide,
4224
+ actor: await actorPromise,
2817
4225
  onProgress: (done, total) => {
2818
4226
  spinner.text = `Downloading ${done}/${total} files…`;
2819
4227
  }
2820
4228
  });
4229
+ result.errors.push(...externalizedAssets.errors);
2821
4230
  const parts = [];
2822
4231
  if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
2823
4232
  if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
4233
+ if (externalizedAssets.linked > 0) parts.push(`kept ${externalizedAssets.linked} binary asset(s) remote`);
4234
+ if (result.autoResolved.length > 0) parts.push(`auto-resolved ${result.autoResolved.length} conflict(s) (kept ${resolveSide})`);
2824
4235
  if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
2825
4236
  if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
2826
4237
  if (result.errors.length) {
2827
4238
  spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
2828
4239
  for (const e of result.errors) console.error(` ${e}`);
4240
+ process.exitCode = 1;
2829
4241
  } else if (result.conflicts.length > 0) {
2830
4242
  spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
2831
4243
  console.log();
@@ -2882,6 +4294,7 @@ function createLintCommand() {
2882
4294
  for (const { file, content } of liquidFiles) {
2883
4295
  const blocksSchemaType = file.isTemplate ? "object" : "array";
2884
4296
  for (const diagnostic of validateSchemaText(content, { blocksSchemaType })) record(file.relativePath, diagnostic);
4297
+ for (const diagnostic of findLiquidBlockTagDiagnostics(content)) record(file.relativePath, diagnostic);
2885
4298
  }
2886
4299
  const existingSectionNames = /* @__PURE__ */ new Set();
2887
4300
  for (const { file } of liquidFiles) {