@fluid-app/fluid-cli-theme-dev 0.1.38 → 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
  }
@@ -1200,6 +1290,17 @@ async function getApplicationThemeAvailableThemeables(client, id, params) {
1200
1290
  return client.get(`/api/application_themes/${id}/available_themeables`, params);
1201
1291
  }
1202
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
+ /**
1203
1304
  * Publishes the theme
1204
1305
  *
1205
1306
  *
@@ -1210,6 +1311,16 @@ async function publishApplicationTheme(client, id) {
1210
1311
  return client.post(`/api/application_themes/${id}/publish`);
1211
1312
  }
1212
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
+ /**
1213
1324
  * Lists all theme resources
1214
1325
  *
1215
1326
  *
@@ -1241,6 +1352,26 @@ async function updateThemeResource(client, application_theme_id, body) {
1241
1352
  async function deleteThemeResource(client, application_theme_id, body) {
1242
1353
  return client.delete(`/api/application_themes/${application_theme_id}/resources`, { body });
1243
1354
  }
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}`);
1374
+ }
1244
1375
  //#endregion
1245
1376
  //#region src/theme/format-error.ts
1246
1377
  const STYLESHEET_MIGRATION_SKILL = "template-stylesheet-to-asset-migration";
@@ -1276,13 +1407,16 @@ function formatError(e) {
1276
1407
  }
1277
1408
  //#endregion
1278
1409
  //#region src/theme/dev-server/watcher.ts
1410
+ function relativeThemePath(root, filePath) {
1411
+ return relative(root.root, filePath).split(sep).join("/");
1412
+ }
1279
1413
  function watchTheme(root, handler) {
1280
1414
  const watcher = chokidar.watch(root.root, {
1281
1415
  ignoreInitial: true,
1282
1416
  ignored: (filePath) => {
1283
1417
  if (filePath.includes("node_modules")) return true;
1284
1418
  try {
1285
- const rel = relative(root.root, filePath);
1419
+ const rel = relativeThemePath(root, filePath);
1286
1420
  return (rel.split(/[\\/]/).pop() ?? "").startsWith(".") || root.ignore.ignore(rel);
1287
1421
  } catch {
1288
1422
  return false;
@@ -1301,21 +1435,151 @@ function watchTheme(root, handler) {
1301
1435
  });
1302
1436
  };
1303
1437
  watcher.on("change", (filePath) => {
1304
- const rel = relative(root.root, filePath);
1305
- if (root.ignore.ignore(rel)) return;
1306
- enqueue(() => handler([root.file(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));
1307
1442
  });
1308
1443
  watcher.on("add", (filePath) => {
1309
- const rel = relative(root.root, filePath);
1310
- if (root.ignore.ignore(rel)) return;
1311
- enqueue(() => handler([], [root.file(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));
1312
1448
  });
1313
1449
  watcher.on("unlink", (filePath) => {
1314
- enqueue(() => handler([], [], [root.file(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));
1315
1454
  });
1316
1455
  return () => watcher.close();
1317
1456
  }
1318
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
1319
1583
  //#region src/theme/stylesheet-keys.ts
1320
1584
  const STYLESHEET_KEY_PATTERN = /^(styles\.css|global_styles\.css|[^/]+\/[^/]+\/styles\.css)$/;
1321
1585
  function isStylesheetKey(key) {
@@ -1323,6 +1587,7 @@ function isStylesheetKey(key) {
1323
1587
  }
1324
1588
  //#endregion
1325
1589
  //#region src/theme/syncer.ts
1590
+ const ASSET_REFERENCE_CONCURRENCY = 6;
1326
1591
  /**
1327
1592
  * Server rejected the push because the CLI's `base_sha` no longer
1328
1593
  * matches the theme's current `content_version_sha` (someone else
@@ -1340,12 +1605,22 @@ var PushConflictError = class extends Error {
1340
1605
  }
1341
1606
  };
1342
1607
  var Syncer = class {
1343
- 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;
1344
1613
  lastKnownRemoteSha = null;
1345
- constructor(api, themeId, themeRoot) {
1614
+ assetManifestInstance;
1615
+ constructor(api, themeId, themeRoot, assetManifest) {
1346
1616
  this.api = api;
1347
1617
  this.themeId = themeId;
1348
1618
  this.themeRoot = themeRoot;
1619
+ this.assetManifestInstance = assetManifest;
1620
+ }
1621
+ get assetManifest() {
1622
+ this.assetManifestInstance ??= new ThemeAssetManifest(this.themeRoot.root);
1623
+ return this.assetManifestInstance;
1349
1624
  }
1350
1625
  async fetchChecksums() {
1351
1626
  const body = await listThemeResources(this.api, this.themeId);
@@ -1360,35 +1635,294 @@ var Syncer = class {
1360
1635
  return this.lastKnownRemoteSha;
1361
1636
  }
1362
1637
  updateChecksums(resources) {
1363
- for (const r of resources) if (r.key && r.checksum) this.checksums.set(r.key, r.checksum);
1364
- 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
+ }
1365
1677
  }
1366
1678
  hasChanged(file) {
1367
1679
  return file.checksum() !== this.checksums.get(file.relativePath);
1368
1680
  }
1369
1681
  remoteKeys() {
1370
- return [...this.checksums.keys()];
1682
+ return [...this.remoteResources.keys()];
1371
1683
  }
1372
1684
  /** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
1373
1685
  remoteChecksums() {
1374
1686
  return Object.fromEntries(this.checksums);
1375
1687
  }
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
+ }
1376
1818
  /**
1377
- * Uploads one file. Resolves with the exact text content that was
1378
- * sent to the server (null for binary files) so callers can run
1379
- * diagnostics against the same bytes instead of re-reading a file
1380
- * that may have changed on disk while the request was in flight.
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.
1381
1821
  */
1382
- async uploadFile(file, baseSha) {
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 = {}) {
1383
1913
  if (file.isText) {
1384
1914
  const content = file.read();
1385
- await this.putResource({
1915
+ const resource = await this.putResource({
1386
1916
  key: file.relativePath,
1387
1917
  content
1388
1918
  }, baseSha);
1919
+ this.setRemoteResource(resource);
1389
1920
  return content;
1390
1921
  }
1391
- await this.uploadBinaryFile(file, baseSha);
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);
1392
1926
  return null;
1393
1927
  }
1394
1928
  /**
@@ -1419,6 +1953,10 @@ var Syncer = class {
1419
1953
  try {
1420
1954
  const response = await updateThemeResource(this.api, this.themeId, body);
1421
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
+ };
1422
1960
  } catch (e) {
1423
1961
  throw this.rethrowIfConflict(e);
1424
1962
  }
@@ -1434,6 +1972,41 @@ var Syncer = class {
1434
1972
  * (no stored `baseSha` in `.fluid-theme.json`) and `--force` pushes
1435
1973
  * short-circuit past the check.
1436
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
+ }
1437
2010
  async preflightPush(baseSha) {
1438
2011
  if (!baseSha) return;
1439
2012
  try {
@@ -1483,7 +2056,7 @@ var Syncer = class {
1483
2056
  if (ikBody.height) backfillPayload["asset"]["height"] = ikBody.height;
1484
2057
  if (ikBody.width) backfillPayload["asset"]["width"] = ikBody.width;
1485
2058
  const backfillBody = await this.api.post("/api/dam/assets/backfill_imagekit", backfillPayload);
1486
- await this.putResource({
2059
+ const update = await this.putResource({
1487
2060
  key: file.relativePath,
1488
2061
  dam_asset: {
1489
2062
  dam_asset_code: backfillBody.asset.code,
@@ -1495,6 +2068,32 @@ var Syncer = class {
1495
2068
  preview_image_url: ikBody.thumbnailUrl
1496
2069
  }
1497
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);
1498
2097
  }
1499
2098
  canonicalPathToImageKitFolder(canonicalPath) {
1500
2099
  const parts = canonicalPath.split(".");
@@ -1510,6 +2109,8 @@ var Syncer = class {
1510
2109
  }[category] ?? "files"}/${assetCode}`;
1511
2110
  }
1512
2111
  async deleteRemoteFile(relativePath, baseSha) {
2112
+ this.assetManifest.reload();
2113
+ if (this.assetManifest.has(relativePath)) return;
1513
2114
  const body = { application_theme_resource: { key: relativePath } };
1514
2115
  if (baseSha) body["base_sha"] = baseSha;
1515
2116
  try {
@@ -1518,7 +2119,7 @@ var Syncer = class {
1518
2119
  } catch (e) {
1519
2120
  throw this.rethrowIfConflict(e);
1520
2121
  }
1521
- this.checksums.delete(relativePath);
2122
+ this.removeRemoteResource(relativePath);
1522
2123
  }
1523
2124
  async downloadAll() {
1524
2125
  const body = await listThemeResources(this.api, this.themeId);
@@ -1532,15 +2133,138 @@ var Syncer = class {
1532
2133
  if (!resp.ok) throw new Error(`Failed to download asset: ${resp.status}`);
1533
2134
  return Buffer.from(await resp.arrayBuffer());
1534
2135
  }
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
+ }
1535
2261
  async uploadTheme(opts = {}) {
1536
- await this.fetchChecksums();
1537
- await this.preflightPush(opts.baseSha);
1538
- let baseSha = opts.baseSha ?? null;
1539
2262
  const localFiles = this.themeRoot.files();
1540
2263
  const result = {
1541
2264
  uploaded: 0,
1542
2265
  deleted: 0,
1543
2266
  downloaded: 0,
2267
+ linked: 0,
1544
2268
  errors: [],
1545
2269
  validationFailed: false
1546
2270
  };
@@ -1555,11 +2279,21 @@ var Syncer = class {
1555
2279
  return result;
1556
2280
  }
1557
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
+ }
1558
2292
  const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
1559
2293
  let done = 0;
1560
2294
  for (const file of toUpload) {
1561
2295
  try {
1562
- await this.uploadFile(file, baseSha);
2296
+ await this.uploadFile(file, baseSha, { pendingAsset: opts.pendingBinaryAssets });
1563
2297
  baseSha = this.lastKnownRemoteSha;
1564
2298
  result.uploaded++;
1565
2299
  } catch (e) {
@@ -1570,7 +2304,8 @@ var Syncer = class {
1570
2304
  }
1571
2305
  if (opts.delete) {
1572
2306
  const localPaths = new Set(localFiles.map((f) => f.relativePath));
1573
- 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));
1574
2309
  for (const key of toDelete) try {
1575
2310
  await this.deleteRemoteFile(key, baseSha);
1576
2311
  baseSha = this.lastKnownRemoteSha;
@@ -1584,23 +2319,29 @@ var Syncer = class {
1584
2319
  }
1585
2320
  async downloadTheme(opts = {}) {
1586
2321
  const resources = await this.downloadAll();
2322
+ const externalizedAssets = await this.externalizePulledAssets(resources, { delete: opts.delete ?? false });
1587
2323
  const result = {
1588
2324
  uploaded: 0,
1589
2325
  deleted: 0,
1590
2326
  downloaded: 0,
2327
+ linked: externalizedAssets.linked,
1591
2328
  skipped: 0,
1592
- errors: [],
2329
+ errors: [...externalizedAssets.errors],
1593
2330
  validationFailed: false
1594
2331
  };
1595
2332
  let done = 0;
1596
2333
  for (const resource of resources) {
2334
+ if (externalizedAssets.managedKeys.has(resource.key)) {
2335
+ opts.onProgress?.(++done, resources.length);
2336
+ continue;
2337
+ }
1597
2338
  if (opts.skip?.has(resource.key)) {
1598
2339
  result.skipped++;
1599
2340
  opts.onProgress?.(++done, resources.length);
1600
2341
  continue;
1601
2342
  }
1602
2343
  const file = this.themeRoot.file(resource.key);
1603
- if (!file.absolutePath.startsWith(this.themeRoot.root + sep)) {
2344
+ if (!this.isSafeThemeFile(resource.key, file)) {
1604
2345
  result.errors.push(`Download ${resource.key}: path traversal detected`);
1605
2346
  opts.onProgress?.(++done, resources.length);
1606
2347
  continue;
@@ -1625,7 +2366,6 @@ var Syncer = class {
1625
2366
  if (remoteKeys.has(file.relativePath)) continue;
1626
2367
  if (isStylesheetKey(file.relativePath)) continue;
1627
2368
  try {
1628
- const { unlinkSync } = await import("node:fs");
1629
2369
  unlinkSync(file.absolutePath);
1630
2370
  result.deleted++;
1631
2371
  } catch {}
@@ -1634,17 +2374,106 @@ var Syncer = class {
1634
2374
  return result;
1635
2375
  }
1636
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
+ }
1637
2461
  //#endregion
1638
2462
  //#region src/theme/liquid-delimiters.ts
1639
2463
  /**
1640
2464
  * Heuristic-only check for obviously unbalanced liquid delimiters
1641
- * (`{% %}` and `{{ }}`). This is NOT a liquid parser — it just counts
1642
- * delimiter occurrences. It exists to give watch-mode users a signal
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
1643
2467
  * when a save is liquid-syntax-broken: the server accepts
1644
2468
  * syntax-broken liquid silently on upload, and the storefront
1645
2469
  * renderer then serves stale content for that section with no error
1646
2470
  * anywhere else in the pipeline.
1647
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
+ *
1648
2477
  * Known false positive: delimiters written literally inside a
1649
2478
  * `{% raw %}...{% endraw %}` block are still counted and can trip
1650
2479
  * this check even though the liquid is valid. Acceptable for a
@@ -1652,11 +2481,118 @@ var Syncer = class {
1652
2481
  * out of scope here (see the server-side validation note in the PR).
1653
2482
  */
1654
2483
  function hasUnbalancedLiquidDelimiters(content) {
1655
- const tagOpens = content.match(/\{%/g)?.length ?? 0;
1656
- const tagCloses = content.match(/%\}/g)?.length ?? 0;
1657
- const outputOpens = content.match(/\{\{/g)?.length ?? 0;
1658
- const outputCloses = content.match(/\}\}/g)?.length ?? 0;
1659
- return tagOpens !== tagCloses || outputOpens !== outputCloses;
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;
1660
2596
  }
1661
2597
  //#endregion
1662
2598
  //#region src/theme/dev-server/port-preflight.ts
@@ -1711,17 +2647,57 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
1711
2647
  const syncResult = await syncer.uploadTheme({
1712
2648
  delete: true,
1713
2649
  validate: opts.validate,
2650
+ linkManagedAssets: { replace: true },
2651
+ pendingBinaryAssets: true,
1714
2652
  onProgress: (done, total) => {
1715
2653
  process.stdout.write(`\r Uploading ${done}/${total} files…`);
1716
2654
  }
1717
2655
  });
1718
2656
  process.stdout.write("\n");
2657
+ if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
1719
2658
  if (syncResult.validationFailed) {
1720
2659
  console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
1721
2660
  for (const e of syncResult.errors) console.error(` ${e}`);
1722
2661
  process.exit(1);
1723
- } else if (syncResult.errors.length > 0) for (const e of syncResult.errors) console.error(` ${e}`);
1724
- 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
+ }
1725
2701
  const changed = [...modified, ...added];
1726
2702
  for (const file of changed) {
1727
2703
  if (opts.validate && file.isLiquid) {
@@ -1733,21 +2709,26 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
1733
2709
  }
1734
2710
  pendingUpdates.add(file.relativePath);
1735
2711
  try {
1736
- const uploadedContent = await syncer.uploadFile(file);
2712
+ const uploadedContent = await syncer.uploadFile(file, void 0, { pendingAsset: true });
1737
2713
  console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
1738
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}`);
1739
2716
  } catch (e) {
1740
2717
  console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
1741
2718
  } finally {
1742
2719
  pendingUpdates.delete(file.relativePath);
1743
2720
  }
1744
2721
  }
1745
- for (const file of removed) try {
1746
- await syncer.deleteRemoteFile(file.relativePath);
1747
- console.log(` ✓ removed ${file.relativePath}`);
1748
- } 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
+ }
1749
2729
  if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
1750
2730
  else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
2731
+ scheduleSync();
1751
2732
  });
1752
2733
  const server = http.createServer(async (req, res) => {
1753
2734
  if (req.url === "/hot-reload") {
@@ -1767,8 +2748,9 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
1767
2748
  } catch (e) {
1768
2749
  console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
1769
2750
  if (!res.headersSent) {
1770
- res.writeHead(502);
1771
- 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.`);
1772
2754
  }
1773
2755
  }
1774
2756
  });
@@ -1944,21 +2926,42 @@ function resolveThemeRootFromCwd(workspace) {
1944
2926
  }
1945
2927
  //#endregion
1946
2928
  //#region src/commands/dev.ts
1947
- 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) {
1948
2950
  if (identifier) {
1949
2951
  const theme = await findTheme(api, identifier);
1950
2952
  setLastDevThemeId(theme.id);
1951
2953
  return theme;
1952
2954
  }
1953
2955
  const stored = getDevTheme(projectKey);
1954
- if (stored) {
2956
+ if (stored && stored.sourceThemeId === sourceThemeId) {
1955
2957
  try {
1956
2958
  const existing = (await getApplicationTheme(api, stored.id)).application_theme;
1957
2959
  if (existing && existing.status === "development") {
1958
2960
  console.log(`Using existing dev theme #${existing.id}`);
1959
2961
  setDevTheme(projectKey, {
1960
2962
  id: existing.id,
1961
- name: existing.name
2963
+ name: existing.name,
2964
+ ...sourceThemeId === void 0 ? {} : { sourceThemeId }
1962
2965
  });
1963
2966
  return existing;
1964
2967
  }
@@ -1966,13 +2969,11 @@ async function ensureDevTheme(api, projectKey, identifier) {
1966
2969
  clearDevTheme(projectKey);
1967
2970
  }
1968
2971
  const { hostname } = await import("node:os");
1969
- const theme = (await createApplicationTheme(api, { application_theme: {
1970
- name: `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50),
1971
- status: "development"
1972
- } })).application_theme;
2972
+ const theme = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
1973
2973
  setDevTheme(projectKey, {
1974
2974
  id: theme.id,
1975
- name: theme.name
2975
+ name: theme.name,
2976
+ ...sourceThemeId === void 0 ? {} : { sourceThemeId }
1976
2977
  });
1977
2978
  console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
1978
2979
  return theme;
@@ -2015,7 +3016,7 @@ function createDevCommand() {
2015
3016
  }
2016
3017
  }
2017
3018
  const projectKey = devThemeKey(company, themeRoot.root);
2018
- 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);
2019
3020
  const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
2020
3021
  let stop;
2021
3022
  const cleanup = () => {
@@ -2130,10 +3131,11 @@ var ShadowRepo = class ShadowRepo {
2130
3131
  const { stdout } = await this.git([
2131
3132
  "ls-tree",
2132
3133
  "-r",
3134
+ "-z",
2133
3135
  "HEAD",
2134
3136
  "--name-only"
2135
3137
  ]);
2136
- return stdout.toString("utf8").split("\n").filter((line) => line.length > 0);
3138
+ return stdout.toString("utf8").split("\0").filter((line) => line.length > 0);
2137
3139
  }
2138
3140
  /**
2139
3141
  * The content of `path` in HEAD's tree, or null when the path does
@@ -2178,12 +3180,9 @@ var ShadowRepo = class ShadowRepo {
2178
3180
  async commitState(files, message) {
2179
3181
  const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
2180
3182
  try {
2181
- for (const { path, sha } of files) await this.git([
2182
- "update-index",
2183
- "--add",
2184
- "--cacheinfo",
2185
- `100644,${sha},${path}`
2186
- ], { 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 } });
2187
3186
  const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
2188
3187
  const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
2189
3188
  const commitArgs = [
@@ -2420,6 +3419,7 @@ async function diffAgainstShadow(themeRoot, shadow) {
2420
3419
  const deleted = [];
2421
3420
  const localFiles = themeRoot.files();
2422
3421
  const localByKey = /* @__PURE__ */ new Map();
3422
+ const assetManifest = new ThemeAssetManifest(themeRoot.root);
2423
3423
  for (const file of localFiles) {
2424
3424
  if (!file.exists) continue;
2425
3425
  localByKey.set(file.relativePath, file);
@@ -2430,6 +3430,8 @@ async function diffAgainstShadow(themeRoot, shadow) {
2430
3430
  }
2431
3431
  if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
2432
3432
  if (localByKey.has(key)) continue;
3433
+ if (themeRoot.ignore.ignore(key)) continue;
3434
+ if (assetManifest.has(key)) continue;
2433
3435
  if (isStylesheetKey(key)) continue;
2434
3436
  deleted.push(key);
2435
3437
  }
@@ -2496,15 +3498,37 @@ function containsConflictMarker(buf) {
2496
3498
  */
2497
3499
  async function commitPushedState(themeRoot, shadow, message) {
2498
3500
  const entries = [];
3501
+ const assetManifest = new ThemeAssetManifest(themeRoot.root);
3502
+ const localKeys = /* @__PURE__ */ new Set();
2499
3503
  for (const file of themeRoot.files()) {
2500
3504
  if (!file.exists) continue;
3505
+ localKeys.add(file.relativePath);
2501
3506
  const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
2502
3507
  entries.push({
2503
3508
  path: file.relativePath,
2504
3509
  sha: await shadow.writeBlob(buf)
2505
3510
  });
2506
3511
  }
2507
- 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()}`);
2508
3532
  }
2509
3533
  //#endregion
2510
3534
  //#region src/theme/auto-baseline.ts
@@ -2685,7 +3709,7 @@ async function fetchSyncActor(api, timeoutMs = 5e3) {
2685
3709
  //#endregion
2686
3710
  //#region src/commands/push.ts
2687
3711
  function createPushCommand() {
2688
- return new Command("push").description("Push local theme files to a remote theme").option("-t, --theme <name-or-id>", "Theme name or ID to push to").option("-n, --nodelete", "Do not delete remote files missing locally").option("-f, --force", "Skip schema validation and the server-side merge check").option("-p, --publish", "Publish the theme after pushing").option("-u, --unpublished", "Create a new unpublished theme and push to it").option("--auto-baseline", "When no local baseline exists, record the server's current state as the baseline and push only what differs locally (never modifies local files, never deletes server-only files)").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
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) => {
2689
3713
  requireToken();
2690
3714
  let rootPath = opts.root;
2691
3715
  if (rootPath === ".") {
@@ -2782,12 +3806,26 @@ function createPushCommand() {
2782
3806
  }
2783
3807
  }
2784
3808
  const { changed, deleted } = await diffAgainstShadow(themeRoot, shadow);
2785
- 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) {
2786
3811
  console.log("Nothing to push — local matches the last synced state.");
2787
3812
  await persistConfig();
2788
3813
  return;
2789
3814
  }
2790
3815
  const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
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
+ }
3828
+ }
2791
3829
  try {
2792
3830
  await syncer.preflightPush(baseSha);
2793
3831
  baseSha = syncer.remoteSha() ?? baseSha;
@@ -2798,17 +3836,24 @@ function createPushCommand() {
2798
3836
  }
2799
3837
  throw e;
2800
3838
  }
2801
- if (!opts.force) {
2802
- const errors = [];
2803
- for (const file of changed) {
2804
- if (!file.isLiquid) continue;
2805
- for (const d of file.validateSchema()) if (d.severity === "error") errors.push(`${file.relativePath}: ${d.message}`);
2806
- }
2807
- if (errors.length > 0) {
2808
- spinner.fail(`Schema validation failed (${errors.length} error(s)). Use --force to skip.`);
2809
- 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)}`);
2810
3852
  process.exit(1);
2811
3853
  }
3854
+ spinner.succeed("Nothing to push — local matches the remote theme.");
3855
+ await persistConfig();
3856
+ return;
2812
3857
  }
2813
3858
  let uploaded = 0;
2814
3859
  let deletedCount = 0;
@@ -2845,10 +3890,17 @@ function createPushCommand() {
2845
3890
  }
2846
3891
  spinner.text = `Pushing ${++progress}/${total} files…`;
2847
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();
2848
3899
  if (errors.length) {
2849
3900
  spinner.warn(`Pushed with ${errors.length} error(s).`);
2850
3901
  for (const err of errors) console.error(` ${err}`);
2851
- } else spinner.succeed(`Pushed ${uploaded} file(s)` + (deletedCount > 0 ? `, deleted ${deletedCount} remote file(s).` : "."));
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).` : "."));
2852
3904
  if (errors.length === 0) await commitPushedState(themeRoot, shadow, syncCommitSubject("Push", await getActor()));
2853
3905
  await persistConfig();
2854
3906
  /**
@@ -2888,6 +3940,7 @@ function createPushCommand() {
2888
3940
  pubSpinner.succeed("Theme published.");
2889
3941
  } catch (e) {
2890
3942
  pubSpinner.fail(`Publish failed: ${e}`);
3943
+ process.exitCode = 1;
2891
3944
  }
2892
3945
  }
2893
3946
  });
@@ -2901,6 +3954,16 @@ function renderPullFirst(spinner) {
2901
3954
  }
2902
3955
  //#endregion
2903
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.";
2904
3967
  /**
2905
3968
  * Reconcile the just-downloaded remote tree against the working tree,
2906
3969
  * using the shadow repo's HEAD as the merge base. Text files that
@@ -2928,18 +3991,46 @@ async function mergePull(input) {
2928
3991
  errors: []
2929
3992
  };
2930
3993
  const pendingWrites = [];
2931
- const write = (file, content) => {
2932
- if (input.resolve) pendingWrites.push({
2933
- file,
2934
- content
2935
- });
2936
- else file.write(content);
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();
2937
4024
  };
2938
4025
  const remoteContent = /* @__PURE__ */ new Map();
2939
4026
  const remoteKeys = /* @__PURE__ */ new Set();
2940
4027
  let done = 0;
2941
4028
  for (const resource of remote) {
2942
4029
  remoteKeys.add(resource.key);
4030
+ if (input.skipRemoteKeys?.has(resource.key)) {
4031
+ onProgress?.(++done, remote.length);
4032
+ continue;
4033
+ }
2943
4034
  try {
2944
4035
  const buf = await materialize(resource, fetchBinary);
2945
4036
  if (buf) remoteContent.set(resource.key, buf);
@@ -2952,18 +4043,17 @@ async function mergePull(input) {
2952
4043
  const file = themeRoot.file(key);
2953
4044
  if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
2954
4045
  result.errors.push(`Reconcile ${key}: path traversal detected`);
4046
+ unwrittenKeys.add(key);
2955
4047
  continue;
2956
4048
  }
2957
4049
  if (input.force) {
2958
- write(file, remoteBuf);
2959
- result.written++;
4050
+ write(key, file, remoteBuf, () => result.written++);
2960
4051
  continue;
2961
4052
  }
2962
4053
  const localBuf = readIfExists(file.absolutePath);
2963
4054
  const baseBuf = await shadow.blobAtHead(key);
2964
4055
  if (localBuf == null) {
2965
- write(file, remoteBuf);
2966
- result.written++;
4056
+ write(key, file, remoteBuf, () => result.written++);
2967
4057
  continue;
2968
4058
  }
2969
4059
  if (localBuf.equals(remoteBuf)) {
@@ -2971,11 +4061,14 @@ async function mergePull(input) {
2971
4061
  continue;
2972
4062
  }
2973
4063
  if (baseBuf && localBuf.equals(baseBuf)) {
2974
- write(file, remoteBuf);
2975
- result.written++;
4064
+ write(key, file, remoteBuf, () => result.written++);
2976
4065
  continue;
2977
4066
  }
2978
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
+ }
2979
4072
  result.skipped++;
2980
4073
  continue;
2981
4074
  }
@@ -2984,24 +4077,25 @@ async function mergePull(input) {
2984
4077
  result.autoResolved.push(`${key} (binary — kept local)`);
2985
4078
  continue;
2986
4079
  }
2987
- write(file, remoteBuf);
2988
- if (input.resolve === "remote") result.autoResolved.push(`${key} (binary — kept remote)`);
2989
- else result.conflicts.push(`${key} (binary — kept remote)`);
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");
2990
4084
  continue;
2991
4085
  }
2992
4086
  const { merged, hasConflicts } = await shadow.merge3(baseBuf, localBuf, remoteBuf);
2993
4087
  if (hasConflicts && input.resolve) {
2994
- write(file, (await shadow.merge3(baseBuf, localBuf, remoteBuf, input.resolve)).merged);
2995
- result.autoResolved.push(key);
4088
+ write(key, file, (await shadow.merge3(baseBuf, localBuf, remoteBuf, input.resolve)).merged, () => result.autoResolved.push(key), RESOLVES_CONFLICT);
2996
4089
  continue;
2997
4090
  }
2998
- write(file, merged);
2999
- if (hasConflicts) result.conflicts.push(key);
3000
- else result.merged++;
4091
+ write(key, file, merged, () => {
4092
+ if (hasConflicts) result.conflicts.push(key);
4093
+ else result.merged++;
4094
+ });
3001
4095
  }
3002
4096
  if (input.resolve) {
3003
- if (result.autoResolved.length > 0) await commitPushedState(themeRoot, shadow, syncCommitSubject("Snapshot before pull", input.actor ?? null));
3004
- for (const { file, content } of pendingWrites) file.write(content);
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();
3005
4099
  }
3006
4100
  if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
3007
4101
  if (remoteKeys.has(file.relativePath)) continue;
@@ -3017,12 +4111,16 @@ async function mergePull(input) {
3017
4111
  } catch {}
3018
4112
  }
3019
4113
  const commitEntries = [];
3020
- for (const [key, buf] of remoteContent) commitEntries.push({
3021
- path: key,
3022
- sha: await shadow.writeBlob(buf)
3023
- });
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
+ }
3024
4121
  for (const key of remoteKeys) {
3025
- if (remoteContent.has(key)) continue;
4122
+ if (input.skipRemoteKeys?.has(key)) continue;
4123
+ if (remoteContent.has(key) && !unwrittenKeys.has(key)) continue;
3026
4124
  const prevBlob = await shadow.blobAtHead(key);
3027
4125
  if (prevBlob == null) continue;
3028
4126
  commitEntries.push({
@@ -3030,9 +4128,23 @@ async function mergePull(input) {
3030
4128
  sha: await shadow.writeBlob(prevBlob)
3031
4129
  });
3032
4130
  }
3033
- if (commitEntries.length > 0) await shadow.commitState(commitEntries, syncCommitSubject("Pull", input.actor ?? null));
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));
3034
4142
  return result;
3035
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
+ }
3036
4148
  async function materialize(resource, fetchBinary) {
3037
4149
  if (resource.resource_type === "FileResource" && resource.url) return fetchBinary(resource.url);
3038
4150
  if (resource.content == null) return null;
@@ -3098,28 +4210,34 @@ function createPullCommand() {
3098
4210
  const syncer = new Syncer(api, theme.id, themeRoot);
3099
4211
  const actorPromise = fetchSyncActor(api);
3100
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 });
3101
4215
  const result = await mergePull({
3102
4216
  themeRoot,
3103
4217
  shadow,
3104
- remote: await syncer.downloadAll(),
4218
+ remote: resources,
3105
4219
  fetchBinary: (url) => syncer.downloadBinaryAsset(url),
3106
4220
  delete: !opts.nodelete,
3107
4221
  force: opts.force ?? false,
4222
+ skipRemoteKeys: externalizedAssets.managedKeys,
3108
4223
  resolve: resolveSide,
3109
4224
  actor: await actorPromise,
3110
4225
  onProgress: (done, total) => {
3111
4226
  spinner.text = `Downloading ${done}/${total} files…`;
3112
4227
  }
3113
4228
  });
4229
+ result.errors.push(...externalizedAssets.errors);
3114
4230
  const parts = [];
3115
4231
  if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
3116
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`);
3117
4234
  if (result.autoResolved.length > 0) parts.push(`auto-resolved ${result.autoResolved.length} conflict(s) (kept ${resolveSide})`);
3118
4235
  if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
3119
4236
  if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
3120
4237
  if (result.errors.length) {
3121
4238
  spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
3122
4239
  for (const e of result.errors) console.error(` ${e}`);
4240
+ process.exitCode = 1;
3123
4241
  } else if (result.conflicts.length > 0) {
3124
4242
  spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
3125
4243
  console.log();
@@ -3176,6 +4294,7 @@ function createLintCommand() {
3176
4294
  for (const { file, content } of liquidFiles) {
3177
4295
  const blocksSchemaType = file.isTemplate ? "object" : "array";
3178
4296
  for (const diagnostic of validateSchemaText(content, { blocksSchemaType })) record(file.relativePath, diagnostic);
4297
+ for (const diagnostic of findLiquidBlockTagDiagnostics(content)) record(file.relativePath, diagnostic);
3179
4298
  }
3180
4299
  const existingSectionNames = /* @__PURE__ */ new Set();
3181
4300
  for (const { file } of liquidFiles) {