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