@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
|
|
156
|
+
let parsed;
|
|
125
157
|
try {
|
|
126
|
-
|
|
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
|
|
131
|
-
throw new ApiError(
|
|
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
|
-
|
|
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())
|
|
973
|
-
|
|
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 =
|
|
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 =
|
|
1305
|
-
if (root.ignore.ignore(rel)) return;
|
|
1306
|
-
|
|
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 =
|
|
1310
|
-
if (root.ignore.ignore(rel)) return;
|
|
1311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1364
|
-
|
|
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.
|
|
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
|
-
*
|
|
1378
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 (!
|
|
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
|
|
1642
|
-
*
|
|
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
|
-
|
|
1656
|
-
|
|
1657
|
-
const
|
|
1658
|
-
|
|
1659
|
-
|
|
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)
|
|
1724
|
-
|
|
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)
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
-
|
|
1771
|
-
res.
|
|
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
|
-
|
|
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 =
|
|
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("\
|
|
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
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
}
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
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
|
-
|
|
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
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
content
|
|
2935
|
-
|
|
2936
|
-
|
|
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
|
-
|
|
2989
|
-
|
|
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
|
-
|
|
3000
|
-
|
|
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 (
|
|
3004
|
-
for (const { file, content } of pendingWrites) file
|
|
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)
|
|
3021
|
-
|
|
3022
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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) {
|