@fluid-app/fluid-cli-theme-dev 0.1.48 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/dist/index.mjs +1092 -861
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -6,9 +6,9 @@ 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";
|
|
9
|
+
import net from "node:net";
|
|
9
10
|
import { execFileSync, spawn } from "node:child_process";
|
|
10
11
|
import { tmpdir } from "node:os";
|
|
11
|
-
import net from "node:net";
|
|
12
12
|
import chalk from "chalk";
|
|
13
13
|
import prompts from "prompts";
|
|
14
14
|
import ora from "ora";
|
|
@@ -1455,6 +1455,28 @@ function watchTheme(root, handler) {
|
|
|
1455
1455
|
return () => watcher.close();
|
|
1456
1456
|
}
|
|
1457
1457
|
//#endregion
|
|
1458
|
+
//#region src/theme/case-collisions.ts
|
|
1459
|
+
var CaseCollisionError = class extends Error {
|
|
1460
|
+
constructor(collisions) {
|
|
1461
|
+
super(`Theme contains paths that differ only by letter case and cannot be synchronized safely:\n${collisions.map((paths) => ` ${paths.join(", ")}`).join("\n")}`);
|
|
1462
|
+
this.collisions = collisions;
|
|
1463
|
+
this.name = "CaseCollisionError";
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
/** Refuse paths a case-insensitive checkout cannot represent independently. */
|
|
1467
|
+
function assertNoCaseCollisions(paths) {
|
|
1468
|
+
const pathsByFoldedKey = /* @__PURE__ */ new Map();
|
|
1469
|
+
for (const rawPath of paths) {
|
|
1470
|
+
const path = normalizeThemeResourceKey(rawPath);
|
|
1471
|
+
const foldedKey = path.toLowerCase();
|
|
1472
|
+
const matchingPaths = pathsByFoldedKey.get(foldedKey) ?? /* @__PURE__ */ new Set();
|
|
1473
|
+
matchingPaths.add(path);
|
|
1474
|
+
pathsByFoldedKey.set(foldedKey, matchingPaths);
|
|
1475
|
+
}
|
|
1476
|
+
const collisions = [...pathsByFoldedKey.values()].filter((matchingPaths) => matchingPaths.size > 1).map((matchingPaths) => [...matchingPaths].sort()).sort(([left = ""], [right = ""]) => left.localeCompare(right));
|
|
1477
|
+
if (collisions.length > 0) throw new CaseCollisionError(collisions);
|
|
1478
|
+
}
|
|
1479
|
+
//#endregion
|
|
1458
1480
|
//#region src/theme/asset-manifest.ts
|
|
1459
1481
|
const MANIFEST_FILE = ".fluid-assets.json";
|
|
1460
1482
|
const MANIFEST_VERSION = 1;
|
|
@@ -1534,16 +1556,16 @@ var ThemeAssetManifest = class {
|
|
|
1534
1556
|
function readDocument(path) {
|
|
1535
1557
|
if (!existsSync(path)) return emptyDocument();
|
|
1536
1558
|
try {
|
|
1537
|
-
return parseDocument(JSON.parse(readFileSync(path, "utf-8")));
|
|
1559
|
+
return parseDocument$1(JSON.parse(readFileSync(path, "utf-8")));
|
|
1538
1560
|
} catch (error) {
|
|
1539
1561
|
const message = error instanceof Error ? error.message : String(error);
|
|
1540
1562
|
throw new Error(`Could not read ${MANIFEST_FILE}: ${message}`);
|
|
1541
1563
|
}
|
|
1542
1564
|
}
|
|
1543
|
-
function parseDocument(value) {
|
|
1544
|
-
if (!isRecord$
|
|
1565
|
+
function parseDocument$1(value) {
|
|
1566
|
+
if (!isRecord$2(value) || value["version"] !== MANIFEST_VERSION) throw new Error(`expected version ${MANIFEST_VERSION}`);
|
|
1545
1567
|
const rawAssets = value["assets"];
|
|
1546
|
-
if (!isRecord$
|
|
1568
|
+
if (!isRecord$2(rawAssets)) throw new Error("expected an assets object");
|
|
1547
1569
|
const assets = {};
|
|
1548
1570
|
for (const [key, rawLink] of Object.entries(rawAssets)) {
|
|
1549
1571
|
if (!isThemeAssetKey(key) || !isThemeAssetLink(rawLink)) throw new Error(`invalid asset entry for ${key}`);
|
|
@@ -1578,14 +1600,14 @@ function copyLink(link) {
|
|
|
1578
1600
|
};
|
|
1579
1601
|
}
|
|
1580
1602
|
function isThemeAssetLink(value) {
|
|
1581
|
-
return isRecord$
|
|
1603
|
+
return isRecord$2(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);
|
|
1582
1604
|
}
|
|
1583
1605
|
function isThemeAssetKey(key) {
|
|
1584
1606
|
if (key.includes("\\") || key.includes("\0")) return false;
|
|
1585
1607
|
const segments = key.split("/");
|
|
1586
1608
|
return segments[0] === "assets" && segments.length === 2 && segments[1] !== void 0 && segments[1].length > 0 && segments[1] !== "." && segments[1] !== "..";
|
|
1587
1609
|
}
|
|
1588
|
-
function isRecord$
|
|
1610
|
+
function isRecord$2(value) {
|
|
1589
1611
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1590
1612
|
}
|
|
1591
1613
|
//#endregion
|
|
@@ -1619,6 +1641,7 @@ var Syncer = class {
|
|
|
1619
1641
|
remoteResourceGroups = /* @__PURE__ */ new Map();
|
|
1620
1642
|
remoteResourceIndex = /* @__PURE__ */ new Map();
|
|
1621
1643
|
remoteIndexesDirty = false;
|
|
1644
|
+
remoteResourcesLoaded = false;
|
|
1622
1645
|
lastKnownRemoteSha = null;
|
|
1623
1646
|
assetManifestInstance;
|
|
1624
1647
|
constructor(api, themeId, themeRoot, assetManifest) {
|
|
@@ -1635,6 +1658,7 @@ var Syncer = class {
|
|
|
1635
1658
|
const body = await listThemeResources(this.api, this.themeId);
|
|
1636
1659
|
this.updateChecksums(body.application_theme_resources ?? []);
|
|
1637
1660
|
this.lastKnownRemoteSha = body.content_version_sha ?? null;
|
|
1661
|
+
this.remoteResourcesLoaded = true;
|
|
1638
1662
|
}
|
|
1639
1663
|
/**
|
|
1640
1664
|
* Server's `content_version_sha` captured on the last `fetchChecksums()`
|
|
@@ -1644,6 +1668,7 @@ var Syncer = class {
|
|
|
1644
1668
|
return this.lastKnownRemoteSha;
|
|
1645
1669
|
}
|
|
1646
1670
|
updateChecksums(resources) {
|
|
1671
|
+
assertNoCaseCollisions(resources.flatMap((resource) => resource.key ? [resource.key] : []));
|
|
1647
1672
|
this.rawRemoteResources.clear();
|
|
1648
1673
|
this.remoteResourceGroups.clear();
|
|
1649
1674
|
for (const resource of resources) {
|
|
@@ -1690,6 +1715,11 @@ var Syncer = class {
|
|
|
1690
1715
|
remoteKeys() {
|
|
1691
1716
|
return [...this.remoteResources.keys()];
|
|
1692
1717
|
}
|
|
1718
|
+
/** A null-content resource has no possible working-tree counterpart. */
|
|
1719
|
+
canDeleteRemoteResource(key) {
|
|
1720
|
+
const resource = this.remoteResources.get(key);
|
|
1721
|
+
return resource?.content != null || isManagedAssetResource(resource);
|
|
1722
|
+
}
|
|
1693
1723
|
/** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
|
|
1694
1724
|
remoteChecksums() {
|
|
1695
1725
|
return Object.fromEntries(this.checksums);
|
|
@@ -1704,13 +1734,32 @@ var Syncer = class {
|
|
|
1704
1734
|
}
|
|
1705
1735
|
return urls;
|
|
1706
1736
|
}
|
|
1737
|
+
/** Compact, complete resource state paired with its acknowledged dev SHA. */
|
|
1738
|
+
devRemoteState(assetManifestSha) {
|
|
1739
|
+
if (!this.remoteResourcesLoaded || !this.lastKnownRemoteSha) return null;
|
|
1740
|
+
return {
|
|
1741
|
+
themeId: this.themeId,
|
|
1742
|
+
remoteSha: this.lastKnownRemoteSha,
|
|
1743
|
+
assetManifestSha,
|
|
1744
|
+
resources: [...this.remoteResourceGroups.values()].flat().map(remoteResourceState)
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
useDevRemoteState(state) {
|
|
1748
|
+
if (state.themeId !== this.themeId) throw new Error(`Dev remote state belongs to theme #${state.themeId}, not #${this.themeId}`);
|
|
1749
|
+
this.updateChecksums(state.resources.map(remoteResourceFromState));
|
|
1750
|
+
this.lastKnownRemoteSha = state.remoteSha;
|
|
1751
|
+
this.remoteResourcesLoaded = true;
|
|
1752
|
+
}
|
|
1753
|
+
async ensureRemoteResourcesLoaded() {
|
|
1754
|
+
if (!this.remoteResourcesLoaded) await this.fetchChecksums();
|
|
1755
|
+
}
|
|
1707
1756
|
/**
|
|
1708
1757
|
* Adds URL-backed FileResources for manifest assets without transferring
|
|
1709
1758
|
* their bytes. The target stores the source asset's ImageKit URL.
|
|
1710
1759
|
*/
|
|
1711
1760
|
async linkManagedAssets(opts = {}) {
|
|
1712
1761
|
this.assetManifest.reload();
|
|
1713
|
-
await this.
|
|
1762
|
+
await this.ensureRemoteResourcesLoaded();
|
|
1714
1763
|
const plans = [];
|
|
1715
1764
|
for (const [key, link] of this.assetManifest.entries()) {
|
|
1716
1765
|
if (this.themeRoot.ignore.ignore(key)) continue;
|
|
@@ -1770,7 +1819,7 @@ var Syncer = class {
|
|
|
1770
1819
|
}
|
|
1771
1820
|
async fetchThemeAssetMetadata(sourceThemeId) {
|
|
1772
1821
|
const body = await getThemeAssets(this.api, sourceThemeId);
|
|
1773
|
-
if (!isRecord(body) || !Array.isArray(body["file_resources"])) throw new Error("Theme assets response did not include file_resources");
|
|
1822
|
+
if (!isRecord$1(body) || !Array.isArray(body["file_resources"])) throw new Error("Theme assets response did not include file_resources");
|
|
1774
1823
|
const assets = /* @__PURE__ */ new Map();
|
|
1775
1824
|
for (const value of body["file_resources"]) {
|
|
1776
1825
|
const asset = parseThemeAssetMetadata(value);
|
|
@@ -1925,7 +1974,12 @@ var Syncer = class {
|
|
|
1925
1974
|
key: file.relativePath,
|
|
1926
1975
|
content
|
|
1927
1976
|
}, baseSha);
|
|
1928
|
-
this.setRemoteResource(
|
|
1977
|
+
this.setRemoteResource({
|
|
1978
|
+
...resource,
|
|
1979
|
+
key: file.relativePath,
|
|
1980
|
+
content,
|
|
1981
|
+
checksum: resource.checksum ?? file.checksum()
|
|
1982
|
+
});
|
|
1929
1983
|
return content;
|
|
1930
1984
|
}
|
|
1931
1985
|
if (isNestedBinaryThemeAsset(file)) throw new Error(`Binary assets must be directly inside assets/: ${file.relativePath}`);
|
|
@@ -2135,6 +2189,7 @@ var Syncer = class {
|
|
|
2135
2189
|
const resources = body.application_theme_resources ?? [];
|
|
2136
2190
|
this.updateChecksums(resources);
|
|
2137
2191
|
this.lastKnownRemoteSha = body.content_version_sha ?? null;
|
|
2192
|
+
this.remoteResourcesLoaded = true;
|
|
2138
2193
|
return resources;
|
|
2139
2194
|
}
|
|
2140
2195
|
async downloadBinaryAsset(url) {
|
|
@@ -2269,6 +2324,7 @@ var Syncer = class {
|
|
|
2269
2324
|
}
|
|
2270
2325
|
async uploadTheme(opts = {}) {
|
|
2271
2326
|
const localFiles = this.themeRoot.files();
|
|
2327
|
+
assertNoCaseCollisions(localFiles.map((file) => file.relativePath));
|
|
2272
2328
|
const result = {
|
|
2273
2329
|
uploaded: 0,
|
|
2274
2330
|
deleted: 0,
|
|
@@ -2288,7 +2344,8 @@ var Syncer = class {
|
|
|
2288
2344
|
return result;
|
|
2289
2345
|
}
|
|
2290
2346
|
}
|
|
2291
|
-
if (
|
|
2347
|
+
if (opts.remoteState) this.useDevRemoteState(opts.remoteState);
|
|
2348
|
+
else await this.fetchChecksums();
|
|
2292
2349
|
if (!opts.skipPreflight) await this.preflightPush(opts.baseSha);
|
|
2293
2350
|
let baseSha = opts.baseSha ?? null;
|
|
2294
2351
|
if (opts.linkManagedAssets) {
|
|
@@ -2298,7 +2355,7 @@ var Syncer = class {
|
|
|
2298
2355
|
this.assetManifest.reload();
|
|
2299
2356
|
this.ensureManagedAssetsAreResolved();
|
|
2300
2357
|
}
|
|
2301
|
-
const toUpload =
|
|
2358
|
+
const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
|
|
2302
2359
|
let done = 0;
|
|
2303
2360
|
for (const file of toUpload) {
|
|
2304
2361
|
try {
|
|
@@ -2312,11 +2369,9 @@ var Syncer = class {
|
|
|
2312
2369
|
opts.onProgress?.(++done, toUpload.length);
|
|
2313
2370
|
}
|
|
2314
2371
|
if (opts.delete) {
|
|
2315
|
-
const
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
return this.remoteKeys().filter((key) => !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
|
|
2319
|
-
})();
|
|
2372
|
+
const localPaths = new Set(localFiles.map((f) => f.relativePath));
|
|
2373
|
+
for (const key of this.assetManifest.keys()) localPaths.add(key);
|
|
2374
|
+
const toDelete = this.remoteKeys().filter((key) => this.canDeleteRemoteResource(key) && !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
|
|
2320
2375
|
for (const key of toDelete) try {
|
|
2321
2376
|
await this.deleteRemoteFile(key, baseSha);
|
|
2322
2377
|
baseSha = this.lastKnownRemoteSha;
|
|
@@ -2394,6 +2449,26 @@ function isNestedBinaryThemeAsset(file) {
|
|
|
2394
2449
|
function isManagedAssetResource(resource) {
|
|
2395
2450
|
return resource?.resource_type === "FileResource" && typeof resource.url === "string" && resource.url.length > 0;
|
|
2396
2451
|
}
|
|
2452
|
+
function remoteResourceFromState(state) {
|
|
2453
|
+
return {
|
|
2454
|
+
key: state.key,
|
|
2455
|
+
checksum: state.checksum,
|
|
2456
|
+
content: state.contentPresent ? "" : null,
|
|
2457
|
+
resource_type: state.resourceType,
|
|
2458
|
+
resource_id: state.resourceId,
|
|
2459
|
+
url: state.url
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
function remoteResourceState(resource) {
|
|
2463
|
+
return {
|
|
2464
|
+
key: resource.key,
|
|
2465
|
+
checksum: resource.checksum,
|
|
2466
|
+
contentPresent: resource.content != null,
|
|
2467
|
+
resourceType: resource.resource_type,
|
|
2468
|
+
resourceId: resource.resource_id,
|
|
2469
|
+
url: resource.url
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2397
2472
|
function isNotFoundError(error) {
|
|
2398
2473
|
return isApiError(error) && error.status === 404;
|
|
2399
2474
|
}
|
|
@@ -2429,7 +2504,7 @@ function uploadedAssetMetadata(file, resource) {
|
|
|
2429
2504
|
};
|
|
2430
2505
|
}
|
|
2431
2506
|
function parseThemeAssetMetadata(value) {
|
|
2432
|
-
if (!isRecord(value)) return void 0;
|
|
2507
|
+
if (!isRecord$1(value)) return void 0;
|
|
2433
2508
|
const filename = nonEmptyString(value["filename"]);
|
|
2434
2509
|
const url = nonEmptyString(value["url"]);
|
|
2435
2510
|
const contentType = nonEmptyString(value["content_type"]);
|
|
@@ -2451,7 +2526,7 @@ function parseThemeAssetMetadata(value) {
|
|
|
2451
2526
|
};
|
|
2452
2527
|
}
|
|
2453
2528
|
function createdFileResourceId(value) {
|
|
2454
|
-
if (!isRecord(value) || !isRecord(value["file_resource"])) return;
|
|
2529
|
+
if (!isRecord$1(value) || !isRecord$1(value["file_resource"])) return;
|
|
2455
2530
|
return positiveInteger(value["file_resource"]["id"]);
|
|
2456
2531
|
}
|
|
2457
2532
|
function positiveInteger(value) {
|
|
@@ -2466,840 +2541,882 @@ function nonEmptyString(value) {
|
|
|
2466
2541
|
function optionalString(value) {
|
|
2467
2542
|
return typeof value === "string" ? value : void 0;
|
|
2468
2543
|
}
|
|
2469
|
-
function isRecord(value) {
|
|
2544
|
+
function isRecord$1(value) {
|
|
2470
2545
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2471
2546
|
}
|
|
2472
2547
|
//#endregion
|
|
2473
|
-
//#region src/theme/
|
|
2548
|
+
//#region src/theme/liquid-delimiters.ts
|
|
2474
2549
|
/**
|
|
2475
|
-
*
|
|
2476
|
-
*
|
|
2477
|
-
*
|
|
2478
|
-
*
|
|
2479
|
-
*
|
|
2480
|
-
*
|
|
2481
|
-
*
|
|
2550
|
+
* Heuristic-only check for obviously unbalanced liquid delimiters
|
|
2551
|
+
* (`{% %}` and `{{ }}`). This is NOT a liquid parser — it only tracks
|
|
2552
|
+
* opening delimiters until they are closed. It exists to give watch-mode users a signal
|
|
2553
|
+
* when a save is liquid-syntax-broken: the server accepts
|
|
2554
|
+
* syntax-broken liquid silently on upload, and the storefront
|
|
2555
|
+
* renderer then serves stale content for that section with no error
|
|
2556
|
+
* anywhere else in the pipeline.
|
|
2482
2557
|
*
|
|
2483
|
-
*
|
|
2484
|
-
*
|
|
2485
|
-
*
|
|
2486
|
-
*
|
|
2558
|
+
* Closing-looking tokens without a preceding Liquid opener are intentionally
|
|
2559
|
+
* ignored. Liquid files commonly contain CSS such as `width:100%}` or adjacent
|
|
2560
|
+
* block braces (`}}`), so treating every close token as Liquid creates noisy
|
|
2561
|
+
* false warnings on valid theme files.
|
|
2487
2562
|
*
|
|
2488
|
-
*
|
|
2489
|
-
*
|
|
2490
|
-
*
|
|
2563
|
+
* Known false positive: delimiters written literally inside a
|
|
2564
|
+
* `{% raw %}...{% endraw %}` block are still counted and can trip
|
|
2565
|
+
* this check even though the liquid is valid. Acceptable for a
|
|
2566
|
+
* warn-only heuristic — a real fix requires parsing liquid, which is
|
|
2567
|
+
* out of scope here (see the server-side validation note in the PR).
|
|
2491
2568
|
*/
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2569
|
+
function hasUnbalancedLiquidDelimiters(content) {
|
|
2570
|
+
let unclosedTags = 0;
|
|
2571
|
+
let unclosedOutputs = 0;
|
|
2572
|
+
for (const token of content.matchAll(/\{%|%\}|\{\{|\}\}/g)) switch (token[0]) {
|
|
2573
|
+
case "{%":
|
|
2574
|
+
unclosedTags += 1;
|
|
2575
|
+
break;
|
|
2576
|
+
case "%}":
|
|
2577
|
+
if (unclosedTags > 0) unclosedTags -= 1;
|
|
2578
|
+
break;
|
|
2579
|
+
case "{{":
|
|
2580
|
+
unclosedOutputs += 1;
|
|
2581
|
+
break;
|
|
2582
|
+
case "}}":
|
|
2583
|
+
if (unclosedOutputs > 0) unclosedOutputs -= 1;
|
|
2584
|
+
break;
|
|
2497
2585
|
}
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2586
|
+
return unclosedTags > 0 || unclosedOutputs > 0;
|
|
2587
|
+
}
|
|
2588
|
+
const BLOCK_TAGS = new Map([
|
|
2589
|
+
["capture", "endcapture"],
|
|
2590
|
+
["case", "endcase"],
|
|
2591
|
+
["comment", "endcomment"],
|
|
2592
|
+
["for", "endfor"],
|
|
2593
|
+
["form", "endform"],
|
|
2594
|
+
["if", "endif"],
|
|
2595
|
+
["ifchanged", "endifchanged"],
|
|
2596
|
+
["javascript", "endjavascript"],
|
|
2597
|
+
["paginate", "endpaginate"],
|
|
2598
|
+
["raw", "endraw"],
|
|
2599
|
+
["schema", "endschema"],
|
|
2600
|
+
["style", "endstyle"],
|
|
2601
|
+
["stylesheet", "endstylesheet"],
|
|
2602
|
+
["tablerow", "endtablerow"],
|
|
2603
|
+
["unless", "endunless"]
|
|
2604
|
+
]);
|
|
2605
|
+
const CLOSING_TAGS = new Set(BLOCK_TAGS.values());
|
|
2606
|
+
const OPAQUE_BLOCK_TAGS = new Set([
|
|
2607
|
+
"comment",
|
|
2608
|
+
"javascript",
|
|
2609
|
+
"raw",
|
|
2610
|
+
"schema",
|
|
2611
|
+
"style",
|
|
2612
|
+
"stylesheet"
|
|
2613
|
+
]);
|
|
2614
|
+
/**
|
|
2615
|
+
* Find structurally unbalanced Liquid block tags such as an `{% if %}` with
|
|
2616
|
+
* no `{% endif %}`. This intentionally recognizes only established paired
|
|
2617
|
+
* tags; custom and inline tags are ignored rather than guessed at.
|
|
2618
|
+
*
|
|
2619
|
+
* Content inside raw/comment/schema/style/javascript blocks is opaque to
|
|
2620
|
+
* Liquid and therefore skipped until that block's matching close tag. This
|
|
2621
|
+
* prevents CSS, JSON, and examples containing Liquid-looking text from
|
|
2622
|
+
* producing false errors.
|
|
2623
|
+
*/
|
|
2624
|
+
function findLiquidBlockTagDiagnostics(content) {
|
|
2625
|
+
const stack = [];
|
|
2626
|
+
const diagnostics = [];
|
|
2627
|
+
let line = 1;
|
|
2628
|
+
let previousTagIndex = 0;
|
|
2629
|
+
const processTag = (name, tagLine) => {
|
|
2630
|
+
const open = stack.at(-1);
|
|
2631
|
+
if (open && OPAQUE_BLOCK_TAGS.has(open.name)) {
|
|
2632
|
+
if (name === open.expectedClose) stack.pop();
|
|
2633
|
+
return;
|
|
2634
|
+
}
|
|
2635
|
+
const expectedClose = BLOCK_TAGS.get(name);
|
|
2636
|
+
if (expectedClose) {
|
|
2637
|
+
stack.push({
|
|
2638
|
+
name,
|
|
2639
|
+
expectedClose,
|
|
2640
|
+
line: tagLine
|
|
2515
2641
|
});
|
|
2642
|
+
return;
|
|
2516
2643
|
}
|
|
2517
|
-
|
|
2518
|
-
if (!
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
"main",
|
|
2525
|
-
gitDir
|
|
2526
|
-
], { cwd: themeRoot });
|
|
2644
|
+
if (!CLOSING_TAGS.has(name)) return;
|
|
2645
|
+
if (!open) {
|
|
2646
|
+
diagnostics.push({
|
|
2647
|
+
severity: "error",
|
|
2648
|
+
message: `Unexpected Liquid tag '{% ${name} %}' on line ${tagLine}; there is no open block to close.`
|
|
2649
|
+
});
|
|
2650
|
+
return;
|
|
2527
2651
|
}
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
/** True when the repo has at least one commit on `refs/heads/main`. */
|
|
2535
|
-
async hasHead() {
|
|
2536
|
-
if (this.headExists !== void 0) return this.headExists;
|
|
2537
|
-
try {
|
|
2538
|
-
await this.git([
|
|
2539
|
-
"rev-parse",
|
|
2540
|
-
"--verify",
|
|
2541
|
-
"HEAD"
|
|
2542
|
-
]);
|
|
2543
|
-
this.headExists = true;
|
|
2544
|
-
} catch {
|
|
2545
|
-
this.headExists = false;
|
|
2652
|
+
if (name !== open.expectedClose) {
|
|
2653
|
+
diagnostics.push({
|
|
2654
|
+
severity: "error",
|
|
2655
|
+
message: `Mismatched Liquid tag '{% ${name} %}' on line ${tagLine}; '{% ${open.name} %}' from line ${open.line} must close with '{% ${open.expectedClose} %}'.`
|
|
2656
|
+
});
|
|
2657
|
+
return;
|
|
2546
2658
|
}
|
|
2547
|
-
|
|
2659
|
+
stack.pop();
|
|
2660
|
+
};
|
|
2661
|
+
for (const match of content.matchAll(/\{%-?\s*([a-zA-Z_][\w-]*)\b(?:(?!\{%)[\s\S])*?-?%\}/g)) {
|
|
2662
|
+
const name = match[1]?.toLowerCase();
|
|
2663
|
+
if (!name) continue;
|
|
2664
|
+
const index = match.index ?? 0;
|
|
2665
|
+
for (let cursor = previousTagIndex; cursor < index; cursor++) if (content.charCodeAt(cursor) === 10) line += 1;
|
|
2666
|
+
previousTagIndex = index;
|
|
2667
|
+
const open = stack.at(-1);
|
|
2668
|
+
if (name === "liquid" && !(open && OPAQUE_BLOCK_TAGS.has(open.name))) {
|
|
2669
|
+
const statements = match[0].replace(/^\{%-?\s*liquid\b/i, "").replace(/-?%\}$/, "").split("\n");
|
|
2670
|
+
for (const [offset, statement] of statements.entries()) {
|
|
2671
|
+
const statementName = /^\s*([a-zA-Z_][\w-]*)\b/.exec(statement)?.[1];
|
|
2672
|
+
if (!statementName) continue;
|
|
2673
|
+
processTag(statementName.toLowerCase(), line + offset);
|
|
2674
|
+
}
|
|
2675
|
+
} else processTag(name, line);
|
|
2548
2676
|
}
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2677
|
+
for (const open of stack.reverse()) diagnostics.push({
|
|
2678
|
+
severity: "error",
|
|
2679
|
+
message: `Unclosed Liquid tag '{% ${open.name} %}' on line ${open.line}; expected '{% ${open.expectedClose} %}'.`
|
|
2680
|
+
});
|
|
2681
|
+
return diagnostics;
|
|
2682
|
+
}
|
|
2683
|
+
//#endregion
|
|
2684
|
+
//#region src/theme/dev-server/port-preflight.ts
|
|
2685
|
+
/**
|
|
2686
|
+
* The dev command does real work before it ever binds a port: it resolves
|
|
2687
|
+
* (or creates) a server-side dev theme and runs a full initial sync, which
|
|
2688
|
+
* can take minutes on a large theme. If the requested port is already taken
|
|
2689
|
+
* — most commonly by another `fluid theme dev` or the Mist Desktop preview,
|
|
2690
|
+
* which both default to 9292 — all of that work is wasted and the process
|
|
2691
|
+
* used to die with a raw `EADDRINUSE` stack trace. Call this before any of
|
|
2692
|
+
* that work starts so we fail fast with a clear message instead.
|
|
2693
|
+
*/
|
|
2694
|
+
var PortInUseError = class extends Error {
|
|
2695
|
+
constructor(host, port) {
|
|
2696
|
+
super(formatPortConflictMessage(host, port));
|
|
2697
|
+
this.host = host;
|
|
2698
|
+
this.port = port;
|
|
2699
|
+
this.name = "PortInUseError";
|
|
2564
2700
|
}
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
* conflict resolution.
|
|
2569
|
-
*/
|
|
2570
|
-
async blobAtHead(path) {
|
|
2571
|
-
if (!await this.hasHead()) return null;
|
|
2572
|
-
try {
|
|
2573
|
-
const { stdout } = await this.git([
|
|
2574
|
-
"cat-file",
|
|
2575
|
-
"-p",
|
|
2576
|
-
`HEAD:${path}`
|
|
2577
|
-
]);
|
|
2578
|
-
return stdout;
|
|
2579
|
-
} catch {
|
|
2580
|
-
return null;
|
|
2581
|
-
}
|
|
2582
|
-
}
|
|
2583
|
-
/**
|
|
2584
|
-
* Write `content` as a blob in the shadow repo and return its sha.
|
|
2585
|
-
* Used by `commitState` to stage each file's content before the
|
|
2586
|
-
* `write-tree` call.
|
|
2587
|
-
*/
|
|
2588
|
-
async writeBlob(content) {
|
|
2589
|
-
const buf = typeof content === "string" ? Buffer.from(content) : content;
|
|
2590
|
-
const { stdout } = await this.git([
|
|
2591
|
-
"hash-object",
|
|
2592
|
-
"-w",
|
|
2593
|
-
"--stdin"
|
|
2594
|
-
], { input: buf });
|
|
2595
|
-
return stdout.toString("utf8").trim();
|
|
2596
|
-
}
|
|
2597
|
-
/**
|
|
2598
|
-
* Commit `files` as HEAD's new tree, threaded onto the current HEAD
|
|
2599
|
-
* as the parent. Uses a per-call temp index so a partial run can't
|
|
2600
|
-
* corrupt anything reachable from HEAD; the previous commit stays
|
|
2601
|
-
* intact until `update-ref` at the end.
|
|
2602
|
-
*
|
|
2603
|
-
* Returns the new commit sha.
|
|
2604
|
-
*/
|
|
2605
|
-
async commitState(files, message) {
|
|
2606
|
-
const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
|
|
2607
|
-
try {
|
|
2608
|
-
const indexArgs = ["update-index", "--add"];
|
|
2609
|
-
for (const { path, sha } of files) indexArgs.push("--cacheinfo", `100644,${sha},${path}`);
|
|
2610
|
-
if (files.length > 0) await this.git(indexArgs, { env: { GIT_INDEX_FILE: indexPath } });
|
|
2611
|
-
const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
|
|
2612
|
-
const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
|
|
2613
|
-
const commitArgs = [
|
|
2614
|
-
"commit-tree",
|
|
2615
|
-
treeSha,
|
|
2616
|
-
"-m",
|
|
2617
|
-
message
|
|
2618
|
-
];
|
|
2619
|
-
if (parent) commitArgs.push("-p", parent);
|
|
2620
|
-
const commitSha = (await this.git(commitArgs, { env: {
|
|
2621
|
-
GIT_AUTHOR_NAME: "Fluid CLI",
|
|
2622
|
-
GIT_AUTHOR_EMAIL: "cli@fluid.app",
|
|
2623
|
-
GIT_COMMITTER_NAME: "Fluid CLI",
|
|
2624
|
-
GIT_COMMITTER_EMAIL: "cli@fluid.app"
|
|
2625
|
-
} })).stdout.toString("utf8").trim();
|
|
2626
|
-
await this.git([
|
|
2627
|
-
"update-ref",
|
|
2628
|
-
"refs/heads/main",
|
|
2629
|
-
commitSha
|
|
2630
|
-
]);
|
|
2631
|
-
this.headExists = true;
|
|
2632
|
-
return commitSha;
|
|
2633
|
-
} finally {
|
|
2634
|
-
try {
|
|
2635
|
-
rmSync(indexPath, { force: true });
|
|
2636
|
-
rmSync(indexPath.substring(0, indexPath.length - 6), {
|
|
2637
|
-
recursive: true,
|
|
2638
|
-
force: true
|
|
2639
|
-
});
|
|
2640
|
-
} catch {}
|
|
2641
|
-
}
|
|
2642
|
-
}
|
|
2643
|
-
/**
|
|
2644
|
-
* Three-way merge of `local` against `remote` with `base` as the
|
|
2645
|
-
* common ancestor. Returns the merged bytes and a flag when
|
|
2646
|
-
* `git merge-file` reported unresolved conflicts (i.e. the output
|
|
2647
|
-
* contains `<<<<<<<` markers for the reader to resolve).
|
|
2648
|
-
*
|
|
2649
|
-
* `base` is null when HEAD has never seen this path; we merge
|
|
2650
|
-
* against an empty base, which is what git itself does for a new
|
|
2651
|
-
* file added on both sides.
|
|
2652
|
-
*
|
|
2653
|
-
* `favor` maps to `git merge-file`'s `--ours` / `--theirs`: instead
|
|
2654
|
-
* of emitting `<<<<<<<` markers, conflicting hunks are resolved to
|
|
2655
|
-
* the local (`"local"` → `--ours`, local is file1) or remote
|
|
2656
|
-
* (`"remote"` → `--theirs`) side. The output then never contains
|
|
2657
|
-
* markers, so the result is reported conflict-free even when
|
|
2658
|
-
* merge-file's exit code still counts the auto-resolved hunks.
|
|
2659
|
-
*/
|
|
2660
|
-
async merge3(base, local, remote, favor) {
|
|
2661
|
-
const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
|
|
2662
|
-
const localPath = join(dir, "local");
|
|
2663
|
-
const basePath = join(dir, "base");
|
|
2664
|
-
const remotePath = join(dir, "remote");
|
|
2665
|
-
try {
|
|
2666
|
-
writeFileSync(localPath, local);
|
|
2667
|
-
writeFileSync(basePath, base ?? Buffer.alloc(0));
|
|
2668
|
-
writeFileSync(remotePath, remote);
|
|
2669
|
-
try {
|
|
2670
|
-
const { stdout } = await this.git([
|
|
2671
|
-
"merge-file",
|
|
2672
|
-
"-p",
|
|
2673
|
-
...favor === "local" ? ["--ours"] : favor === "remote" ? ["--theirs"] : [],
|
|
2674
|
-
"-L",
|
|
2675
|
-
"local",
|
|
2676
|
-
"-L",
|
|
2677
|
-
"base",
|
|
2678
|
-
"-L",
|
|
2679
|
-
"remote",
|
|
2680
|
-
localPath,
|
|
2681
|
-
basePath,
|
|
2682
|
-
remotePath
|
|
2683
|
-
]);
|
|
2684
|
-
return {
|
|
2685
|
-
merged: stdout,
|
|
2686
|
-
hasConflicts: false
|
|
2687
|
-
};
|
|
2688
|
-
} catch (err) {
|
|
2689
|
-
const e = err;
|
|
2690
|
-
const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
|
|
2691
|
-
const inConflictRange = typeof e.code === "number" && e.code >= 1 && e.code <= 127;
|
|
2692
|
-
if (inConflictRange && favor) return {
|
|
2693
|
-
merged,
|
|
2694
|
-
hasConflicts: false
|
|
2695
|
-
};
|
|
2696
|
-
if (inConflictRange && merged.length > 0) return {
|
|
2697
|
-
merged,
|
|
2698
|
-
hasConflicts: true
|
|
2699
|
-
};
|
|
2700
|
-
throw err;
|
|
2701
|
-
}
|
|
2702
|
-
} finally {
|
|
2703
|
-
try {
|
|
2704
|
-
rmSync(dir, {
|
|
2705
|
-
recursive: true,
|
|
2706
|
-
force: true
|
|
2707
|
-
});
|
|
2708
|
-
} catch {}
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
/**
|
|
2712
|
-
* Snapshot the working-tree copy of `paths` into HEAD as a single
|
|
2713
|
-
* commit. Intended for the migration path: on the first pull with
|
|
2714
|
-
* the new CLI (no shadow repo yet, but a `.fluid-theme.json` with
|
|
2715
|
-
* checksums exists) we seed HEAD with whatever is on disk before
|
|
2716
|
-
* running the merge, so unmodified files fast-forward cleanly and
|
|
2717
|
-
* modified files show a diff.
|
|
2718
|
-
*/
|
|
2719
|
-
async seedFromWorkingTree(files, message) {
|
|
2720
|
-
const entries = [];
|
|
2721
|
-
for (const { path, content } of files) entries.push({
|
|
2722
|
-
path,
|
|
2723
|
-
sha: await this.writeBlob(content)
|
|
2724
|
-
});
|
|
2725
|
-
await this.commitState(entries, message);
|
|
2726
|
-
}
|
|
2727
|
-
async git(args, opts = {}) {
|
|
2728
|
-
const child = spawn("git", args[0] === "init" ? args : [
|
|
2729
|
-
"--git-dir",
|
|
2730
|
-
this.gitDir,
|
|
2731
|
-
...args
|
|
2732
|
-
], {
|
|
2733
|
-
cwd: opts.cwd ?? this.themeRoot,
|
|
2734
|
-
env: {
|
|
2735
|
-
...process.env,
|
|
2736
|
-
...opts.env
|
|
2737
|
-
},
|
|
2738
|
-
stdio: [
|
|
2739
|
-
"pipe",
|
|
2740
|
-
"pipe",
|
|
2741
|
-
"pipe"
|
|
2742
|
-
]
|
|
2743
|
-
});
|
|
2744
|
-
if (opts.input) child.stdin.write(opts.input);
|
|
2745
|
-
child.stdin.end();
|
|
2746
|
-
return new Promise((resolve, reject) => {
|
|
2747
|
-
const stdout = [];
|
|
2748
|
-
const stderr = [];
|
|
2749
|
-
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
2750
|
-
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
2751
|
-
child.on("error", reject);
|
|
2752
|
-
child.on("close", (code) => {
|
|
2753
|
-
const out = Buffer.concat(stdout);
|
|
2754
|
-
const err = Buffer.concat(stderr);
|
|
2755
|
-
if (code === 0) resolve({
|
|
2756
|
-
stdout: out,
|
|
2757
|
-
stderr: err
|
|
2758
|
-
});
|
|
2759
|
-
else {
|
|
2760
|
-
const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
|
|
2761
|
-
e.code = code ?? -1;
|
|
2762
|
-
e.stdout = out;
|
|
2763
|
-
e.stderr = err;
|
|
2764
|
-
reject(e);
|
|
2765
|
-
}
|
|
2766
|
-
});
|
|
2767
|
-
});
|
|
2768
|
-
}
|
|
2769
|
-
};
|
|
2770
|
-
/**
|
|
2771
|
-
* Content-type check the pull command uses to decide whether a file
|
|
2772
|
-
* is safe to run through `merge3` (line-based) or must fall back to
|
|
2773
|
-
* whole-file "either/or" resolution (binary).
|
|
2774
|
-
*/
|
|
2775
|
-
function looksBinary(content) {
|
|
2776
|
-
return content.subarray(0, Math.min(content.length, 8e3)).includes(0);
|
|
2777
|
-
}
|
|
2778
|
-
/** Best-effort readFile that returns null when the file does not exist. */
|
|
2779
|
-
function readIfExists(path) {
|
|
2780
|
-
try {
|
|
2781
|
-
return readFileSync(path);
|
|
2782
|
-
} catch {
|
|
2783
|
-
return null;
|
|
2784
|
-
}
|
|
2785
|
-
}
|
|
2786
|
-
/**
|
|
2787
|
-
* Parse the numeric theme id from `.fluid-theme/theme-id`. Returns
|
|
2788
|
-
* null when the file is missing or the contents don't parse cleanly;
|
|
2789
|
-
* `open` treats that as "unknown theme" and rebuilds the shadow.
|
|
2790
|
-
*/
|
|
2791
|
-
function readStoredThemeId(themeIdFile) {
|
|
2792
|
-
try {
|
|
2793
|
-
const stored = parseInt(readFileSync(themeIdFile, "utf-8").trim(), 10);
|
|
2794
|
-
return Number.isFinite(stored) ? stored : null;
|
|
2795
|
-
} catch {
|
|
2796
|
-
return null;
|
|
2797
|
-
}
|
|
2798
|
-
}
|
|
2799
|
-
/**
|
|
2800
|
-
* Append `.fluid-theme/` to the theme root's `.gitignore` when the
|
|
2801
|
-
* theme dir sits inside a git working tree and the entry isn't
|
|
2802
|
-
* already there. Skipped when the user isn't in a git repo — no
|
|
2803
|
-
* point manufacturing a `.gitignore` for someone who doesn't use
|
|
2804
|
-
* git. Idempotent — a second call is a no-op.
|
|
2805
|
-
*/
|
|
2806
|
-
async function ensureRootGitignoreHidesShadow(themeRoot) {
|
|
2807
|
-
if (!await new Promise((resolve) => {
|
|
2808
|
-
const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
2809
|
-
cwd: themeRoot,
|
|
2810
|
-
stdio: [
|
|
2811
|
-
"ignore",
|
|
2812
|
-
"pipe",
|
|
2813
|
-
"pipe"
|
|
2814
|
-
]
|
|
2815
|
-
});
|
|
2816
|
-
child.on("close", (code) => resolve(code === 0));
|
|
2817
|
-
child.on("error", () => resolve(false));
|
|
2818
|
-
})) return;
|
|
2819
|
-
const gitignorePath = join(themeRoot, ".gitignore");
|
|
2820
|
-
let existing = "";
|
|
2821
|
-
try {
|
|
2822
|
-
existing = readFileSync(gitignorePath, "utf-8");
|
|
2823
|
-
} catch {
|
|
2824
|
-
existing = "";
|
|
2825
|
-
}
|
|
2826
|
-
const lines = existing.split("\n").map((line) => line.trim());
|
|
2827
|
-
if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
|
|
2828
|
-
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
2829
|
-
writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
|
|
2830
|
-
}
|
|
2831
|
-
//#endregion
|
|
2832
|
-
//#region src/theme/merge-push.ts
|
|
2833
|
-
/**
|
|
2834
|
-
* Compute what changed locally since the last time the shadow repo
|
|
2835
|
-
* committed a state. Replaces the sha256 `checksums` map: shadow HEAD
|
|
2836
|
-
* is the source of truth for "what the CLI last saw the server have".
|
|
2837
|
-
*
|
|
2838
|
-
* Files whose local bytes are byte-identical to their HEAD blob are
|
|
2839
|
-
* skipped; anything else — new, modified, or a locally-deleted path
|
|
2840
|
-
* that HEAD still has — is included.
|
|
2841
|
-
*/
|
|
2842
|
-
async function diffAgainstShadow(themeRoot, shadow) {
|
|
2843
|
-
const changed = [];
|
|
2844
|
-
const deleted = [];
|
|
2845
|
-
const localFiles = themeRoot.files();
|
|
2846
|
-
const localByKey = /* @__PURE__ */ new Map();
|
|
2847
|
-
const assetManifest = new ThemeAssetManifest(themeRoot.root);
|
|
2848
|
-
for (const file of localFiles) {
|
|
2849
|
-
if (!file.exists) continue;
|
|
2850
|
-
localByKey.set(file.relativePath, file);
|
|
2851
|
-
const headBlob = await shadow.blobAtHead(file.relativePath);
|
|
2852
|
-
const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
2853
|
-
if (headBlob && headBlob.equals(localBuf)) continue;
|
|
2854
|
-
changed.push(file);
|
|
2855
|
-
}
|
|
2856
|
-
if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
|
|
2857
|
-
if (localByKey.has(key)) continue;
|
|
2858
|
-
if (themeRoot.ignore.ignore(key)) continue;
|
|
2859
|
-
if (assetManifest.has(key)) continue;
|
|
2860
|
-
if (isStylesheetKey(key)) continue;
|
|
2861
|
-
deleted.push(key);
|
|
2862
|
-
}
|
|
2863
|
-
return {
|
|
2864
|
-
changed,
|
|
2865
|
-
deleted
|
|
2866
|
-
};
|
|
2867
|
-
}
|
|
2868
|
-
/**
|
|
2869
|
-
* Read every path recorded under HEAD's tree. Used to detect local
|
|
2870
|
-
* deletions (paths in HEAD, absent from the working tree). Implemented
|
|
2871
|
-
* with `git ls-tree -r HEAD --name-only` piped through the shadow
|
|
2872
|
-
* repo's plumbing.
|
|
2873
|
-
*/
|
|
2874
|
-
async function listHeadPaths(shadow) {
|
|
2875
|
-
return shadow.headPaths();
|
|
2876
|
-
}
|
|
2877
|
-
/**
|
|
2878
|
-
* Refuse a push when any working file still contains a conflict marker
|
|
2879
|
-
* from a previous pull. Mirrors git's "you have unresolved conflicts;
|
|
2880
|
-
* fix them and re-run" behavior — the whole point of writing markers
|
|
2881
|
-
* on pull was to hand resolution to the user, so we can't send them
|
|
2882
|
-
* upstream.
|
|
2883
|
-
*/
|
|
2884
|
-
function findUnresolvedConflicts(files) {
|
|
2885
|
-
const flagged = [];
|
|
2886
|
-
for (const file of files) {
|
|
2887
|
-
if (!file.isText) continue;
|
|
2888
|
-
const buf = readIfExists(file.absolutePath);
|
|
2889
|
-
if (!buf) continue;
|
|
2890
|
-
if (containsConflictMarker(buf)) flagged.push(file.relativePath);
|
|
2891
|
-
}
|
|
2892
|
-
return flagged;
|
|
2893
|
-
}
|
|
2894
|
-
/**
|
|
2895
|
-
* Stable, machine-readable one-liner for non-interactive callers
|
|
2896
|
-
* (Mist Desktop's publish flow parses push output). Uploading marker-
|
|
2897
|
-
* bearing files to a live theme is never acceptable, so `--auto-
|
|
2898
|
-
* baseline` pushes still refuse — but they emit this line so the
|
|
2899
|
-
* desktop can surface WHICH files block the publish instead of a
|
|
2900
|
-
* dead-end wall of prose. Format:
|
|
2901
|
-
*
|
|
2902
|
-
* FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=a.liquid,b.json
|
|
2903
|
-
*/
|
|
2904
|
-
function conflictMarkerBlockLine(files) {
|
|
2905
|
-
return `FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=${files.join(",")}`;
|
|
2906
|
-
}
|
|
2907
|
-
const CONFLICT_START = Buffer.from("<<<<<<<");
|
|
2908
|
-
const CONFLICT_MID = Buffer.from("=======");
|
|
2909
|
-
const CONFLICT_END = Buffer.from(">>>>>>>");
|
|
2910
|
-
/**
|
|
2911
|
-
* A file counts as unresolved when it contains all three marker
|
|
2912
|
-
* shapes: `<<<<<<<`, `=======`, and `>>>>>>>`. Requiring all three
|
|
2913
|
-
* avoids false positives — a line of equals signs alone (e.g. inside
|
|
2914
|
-
* an ASCII table in a template comment) doesn't trip the guard.
|
|
2915
|
-
*/
|
|
2916
|
-
function containsConflictMarker(buf) {
|
|
2917
|
-
return buf.includes(CONFLICT_START) && buf.includes(CONFLICT_MID) && buf.includes(CONFLICT_END);
|
|
2701
|
+
};
|
|
2702
|
+
function formatPortConflictMessage(host, port) {
|
|
2703
|
+
return `Port ${port} on ${host} is already in use — likely another \`fluid theme dev\` or the Mist Desktop preview. Stop the other server or pass --port <number>.`;
|
|
2918
2704
|
}
|
|
2919
2705
|
/**
|
|
2920
|
-
*
|
|
2921
|
-
*
|
|
2922
|
-
*
|
|
2706
|
+
* Attempt to bind `host:port`, then immediately release it. Resolves if the
|
|
2707
|
+
* port is free; rejects with `PortInUseError` on `EADDRINUSE`/`EACCES`, or
|
|
2708
|
+
* the raw error for anything else unexpected.
|
|
2923
2709
|
*/
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
localKeys.add(file.relativePath);
|
|
2931
|
-
const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
2932
|
-
entries.push({
|
|
2933
|
-
path: file.relativePath,
|
|
2934
|
-
sha: await shadow.writeBlob(buf)
|
|
2710
|
+
function checkPortAvailable(host, port) {
|
|
2711
|
+
return new Promise((resolve, reject) => {
|
|
2712
|
+
const server = net.createServer();
|
|
2713
|
+
server.once("error", (err) => {
|
|
2714
|
+
if (err.code === "EADDRINUSE" || err.code === "EACCES") reject(new PortInUseError(host, port));
|
|
2715
|
+
else reject(err);
|
|
2935
2716
|
});
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
for (const key of assetManifest.keys()) {
|
|
2939
|
-
if (localKeys.has(key)) continue;
|
|
2940
|
-
managedAssetSentinelSha ??= await shadow.writeBlob(MANAGED_ASSET_SHADOW_SENTINEL);
|
|
2941
|
-
entries.push({
|
|
2942
|
-
path: key,
|
|
2943
|
-
sha: managedAssetSentinelSha
|
|
2717
|
+
server.once("listening", () => {
|
|
2718
|
+
server.close(() => resolve());
|
|
2944
2719
|
});
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
}
|
|
2948
|
-
/**
|
|
2949
|
-
* A target can already have every URL-backed FileResource (for example from a
|
|
2950
|
-
* reference clone), while this checkout's manifest still names its old source
|
|
2951
|
-
* theme. Adopt the target and clear any legacy binary from shadow even though
|
|
2952
|
-
* no remote write was necessary.
|
|
2953
|
-
*/
|
|
2954
|
-
async function finalizeManifestOnlyPush(syncer, themeRoot, shadow) {
|
|
2955
|
-
syncer.repointManagedAssetsToCurrentTheme();
|
|
2956
|
-
await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
2720
|
+
server.listen(port, host);
|
|
2721
|
+
});
|
|
2957
2722
|
}
|
|
2958
2723
|
//#endregion
|
|
2959
|
-
//#region src/theme/
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
* (`{% %}` and `{{ }}`). This is NOT a liquid parser — it only tracks
|
|
2963
|
-
* opening delimiters until they are closed. It exists to give watch-mode users a signal
|
|
2964
|
-
* when a save is liquid-syntax-broken: the server accepts
|
|
2965
|
-
* syntax-broken liquid silently on upload, and the storefront
|
|
2966
|
-
* renderer then serves stale content for that section with no error
|
|
2967
|
-
* anywhere else in the pipeline.
|
|
2968
|
-
*
|
|
2969
|
-
* Closing-looking tokens without a preceding Liquid opener are intentionally
|
|
2970
|
-
* ignored. Liquid files commonly contain CSS such as `width:100%}` or adjacent
|
|
2971
|
-
* block braces (`}}`), so treating every close token as Liquid creates noisy
|
|
2972
|
-
* false warnings on valid theme files.
|
|
2973
|
-
*
|
|
2974
|
-
* Known false positive: delimiters written literally inside a
|
|
2975
|
-
* `{% raw %}...{% endraw %}` block are still counted and can trip
|
|
2976
|
-
* this check even though the liquid is valid. Acceptable for a
|
|
2977
|
-
* warn-only heuristic — a real fix requires parsing liquid, which is
|
|
2978
|
-
* out of scope here (see the server-side validation note in the PR).
|
|
2979
|
-
*/
|
|
2980
|
-
function hasUnbalancedLiquidDelimiters(content) {
|
|
2981
|
-
let unclosedTags = 0;
|
|
2982
|
-
let unclosedOutputs = 0;
|
|
2983
|
-
for (const token of content.matchAll(/\{%|%\}|\{\{|\}\}/g)) switch (token[0]) {
|
|
2984
|
-
case "{%":
|
|
2985
|
-
unclosedTags += 1;
|
|
2986
|
-
break;
|
|
2987
|
-
case "%}":
|
|
2988
|
-
if (unclosedTags > 0) unclosedTags -= 1;
|
|
2989
|
-
break;
|
|
2990
|
-
case "{{":
|
|
2991
|
-
unclosedOutputs += 1;
|
|
2992
|
-
break;
|
|
2993
|
-
case "}}":
|
|
2994
|
-
if (unclosedOutputs > 0) unclosedOutputs -= 1;
|
|
2995
|
-
break;
|
|
2996
|
-
}
|
|
2997
|
-
return unclosedTags > 0 || unclosedOutputs > 0;
|
|
2724
|
+
//#region src/theme/dev-server/index.ts
|
|
2725
|
+
function timestamp() {
|
|
2726
|
+
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
2998
2727
|
}
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
"javascript",
|
|
3020
|
-
"raw",
|
|
3021
|
-
"schema",
|
|
3022
|
-
"style",
|
|
3023
|
-
"stylesheet"
|
|
3024
|
-
]);
|
|
3025
|
-
/**
|
|
3026
|
-
* Find structurally unbalanced Liquid block tags such as an `{% if %}` with
|
|
3027
|
-
* no `{% endif %}`. This intentionally recognizes only established paired
|
|
3028
|
-
* tags; custom and inline tags are ignored rather than guessed at.
|
|
3029
|
-
*
|
|
3030
|
-
* Content inside raw/comment/schema/style/javascript blocks is opaque to
|
|
3031
|
-
* Liquid and therefore skipped until that block's matching close tag. This
|
|
3032
|
-
* prevents CSS, JSON, and examples containing Liquid-looking text from
|
|
3033
|
-
* producing false errors.
|
|
3034
|
-
*/
|
|
3035
|
-
function findLiquidBlockTagDiagnostics(content) {
|
|
3036
|
-
const stack = [];
|
|
3037
|
-
const diagnostics = [];
|
|
3038
|
-
let line = 1;
|
|
3039
|
-
let previousTagIndex = 0;
|
|
3040
|
-
const processTag = (name, tagLine) => {
|
|
3041
|
-
const open = stack.at(-1);
|
|
3042
|
-
if (open && OPAQUE_BLOCK_TAGS.has(open.name)) {
|
|
3043
|
-
if (name === open.expectedClose) stack.pop();
|
|
3044
|
-
return;
|
|
2728
|
+
async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
2729
|
+
const sse = new SSEStream();
|
|
2730
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
2731
|
+
let remoteStateSafe = true;
|
|
2732
|
+
const invalidateRemoteState = () => {
|
|
2733
|
+
if (!remoteStateSafe) return;
|
|
2734
|
+
remoteStateSafe = false;
|
|
2735
|
+
try {
|
|
2736
|
+
opts.onRemoteStateInvalidated?.();
|
|
2737
|
+
} catch {}
|
|
2738
|
+
};
|
|
2739
|
+
const recordRemoteState = () => {
|
|
2740
|
+
if (!remoteStateSafe) return;
|
|
2741
|
+
if (opts.onRemoteState) {
|
|
2742
|
+
const state = syncer.devRemoteState(new ThemeAssetManifest(themeRoot.root).fingerprint());
|
|
2743
|
+
if (state) try {
|
|
2744
|
+
opts.onRemoteState(state);
|
|
2745
|
+
} catch {
|
|
2746
|
+
invalidateRemoteState();
|
|
2747
|
+
}
|
|
3045
2748
|
}
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
2749
|
+
};
|
|
2750
|
+
const pendingUpdates = /* @__PURE__ */ new Set();
|
|
2751
|
+
console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
|
|
2752
|
+
const progress = (done, total) => {
|
|
2753
|
+
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
2754
|
+
};
|
|
2755
|
+
const uploadFromRemoteIndex = () => syncer.uploadTheme({
|
|
2756
|
+
delete: true,
|
|
2757
|
+
validate: opts.validate,
|
|
2758
|
+
linkManagedAssets: { replace: true },
|
|
2759
|
+
pendingBinaryAssets: true,
|
|
2760
|
+
onProgress: progress
|
|
2761
|
+
});
|
|
2762
|
+
let syncResult;
|
|
2763
|
+
if (opts.initialSync) {
|
|
2764
|
+
let remoteStateIsTrusted = false;
|
|
2765
|
+
try {
|
|
2766
|
+
syncer.useDevRemoteState(opts.initialSync);
|
|
2767
|
+
await syncer.preflightPush(opts.initialSync.remoteSha);
|
|
2768
|
+
remoteStateIsTrusted = true;
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
if (!(error instanceof PushConflictError)) throw error;
|
|
2771
|
+
}
|
|
2772
|
+
syncResult = remoteStateIsTrusted ? await syncer.uploadTheme({
|
|
2773
|
+
delete: true,
|
|
2774
|
+
validate: opts.validate,
|
|
2775
|
+
linkManagedAssets: { replace: true },
|
|
2776
|
+
pendingBinaryAssets: true,
|
|
2777
|
+
baseSha: syncer.remoteSha(),
|
|
2778
|
+
remoteState: opts.initialSync,
|
|
2779
|
+
skipPreflight: true,
|
|
2780
|
+
onProgress: progress
|
|
2781
|
+
}) : await uploadFromRemoteIndex();
|
|
2782
|
+
} else syncResult = await uploadFromRemoteIndex();
|
|
2783
|
+
process.stdout.write("\n");
|
|
2784
|
+
if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
|
|
2785
|
+
if (syncResult.validationFailed) {
|
|
2786
|
+
console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
|
|
2787
|
+
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
2788
|
+
process.exit(1);
|
|
2789
|
+
} else if (syncResult.errors.length > 0) {
|
|
2790
|
+
invalidateRemoteState();
|
|
2791
|
+
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
2792
|
+
if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
|
|
2793
|
+
}
|
|
2794
|
+
if (syncResult.errors.length === 0) recordRemoteState();
|
|
2795
|
+
const SYNC_IDLE_MS = 2e3;
|
|
2796
|
+
let lastArrivedAt = 0;
|
|
2797
|
+
let pendingSync = null;
|
|
2798
|
+
let syncInFlight = Promise.resolve();
|
|
2799
|
+
let askOwed = false;
|
|
2800
|
+
let remoteWritesBlocked = false;
|
|
2801
|
+
const blockRemoteWrites = (error) => {
|
|
2802
|
+
if (remoteWritesBlocked) return;
|
|
2803
|
+
remoteWritesBlocked = true;
|
|
2804
|
+
invalidateRemoteState();
|
|
2805
|
+
console.error(`\n[Watcher] Remote theme changed outside this dev session (${error.message}). Restart theme dev to compare the current remote state before writing again.`);
|
|
2806
|
+
};
|
|
2807
|
+
const sendSync = () => {
|
|
2808
|
+
syncInFlight = syncInFlight.then(async () => {
|
|
2809
|
+
const accepted = await syncer.requestSync();
|
|
2810
|
+
askOwed = !accepted;
|
|
2811
|
+
if (accepted) recordRemoteState();
|
|
2812
|
+
});
|
|
2813
|
+
};
|
|
2814
|
+
const flushSyncNow = () => {
|
|
2815
|
+
if (!pendingSync) return;
|
|
2816
|
+
clearTimeout(pendingSync);
|
|
2817
|
+
pendingSync = null;
|
|
2818
|
+
sendSync();
|
|
2819
|
+
};
|
|
2820
|
+
const scheduleSync = () => {
|
|
2821
|
+
if (pendingSync) clearTimeout(pendingSync);
|
|
2822
|
+
pendingSync = setTimeout(() => {
|
|
2823
|
+
pendingSync = null;
|
|
2824
|
+
sendSync();
|
|
2825
|
+
}, SYNC_IDLE_MS);
|
|
2826
|
+
};
|
|
2827
|
+
const stopWatcher = watchTheme(themeRoot, async (modified, added, removed, arrivedAt) => {
|
|
2828
|
+
if (arrivedAt - lastArrivedAt > SYNC_IDLE_MS) flushSyncNow();
|
|
2829
|
+
else if (pendingSync) {
|
|
2830
|
+
clearTimeout(pendingSync);
|
|
2831
|
+
pendingSync = null;
|
|
2832
|
+
}
|
|
2833
|
+
lastArrivedAt = arrivedAt;
|
|
2834
|
+
await syncInFlight;
|
|
2835
|
+
if (askOwed) {
|
|
2836
|
+
sendSync();
|
|
2837
|
+
await syncInFlight;
|
|
2838
|
+
}
|
|
2839
|
+
if (remoteWritesBlocked) return;
|
|
2840
|
+
try {
|
|
2841
|
+
assertNoCaseCollisions(themeRoot.files().map((file) => file.relativePath));
|
|
2842
|
+
} catch (error) {
|
|
2843
|
+
console.error(`\n[Watcher] Sync blocked: ${String(error)}`);
|
|
3053
2844
|
return;
|
|
3054
2845
|
}
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
2846
|
+
const changed = [...modified, ...added];
|
|
2847
|
+
let wroteRemote = false;
|
|
2848
|
+
for (const file of changed) {
|
|
2849
|
+
if (opts.validate && file.isLiquid) {
|
|
2850
|
+
const diagnostics = file.validateSchema();
|
|
2851
|
+
for (const d of diagnostics) {
|
|
2852
|
+
const prefix = d.severity === "error" ? "Schema error" : "Schema warning";
|
|
2853
|
+
console.warn(`\n[${prefix}] ${file.relativePath}: ${d.message}`);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
pendingUpdates.add(file.relativePath);
|
|
2857
|
+
try {
|
|
2858
|
+
const uploadedContent = await syncer.uploadFile(file, syncer.remoteSha(), { pendingAsset: true });
|
|
2859
|
+
wroteRemote = true;
|
|
2860
|
+
console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
|
|
2861
|
+
if (file.isLiquid && uploadedContent !== null && hasUnbalancedLiquidDelimiters(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: unbalanced liquid delimiters — the storefront may silently serve stale content for this section`);
|
|
2862
|
+
if (file.isLiquid && uploadedContent !== null) for (const diagnostic of findLiquidBlockTagDiagnostics(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: ${diagnostic.message}`);
|
|
2863
|
+
} catch (e) {
|
|
2864
|
+
if (e instanceof PushConflictError) {
|
|
2865
|
+
blockRemoteWrites(e);
|
|
2866
|
+
break;
|
|
2867
|
+
}
|
|
2868
|
+
invalidateRemoteState();
|
|
2869
|
+
console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
|
|
2870
|
+
} finally {
|
|
2871
|
+
pendingUpdates.delete(file.relativePath);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
if (remoteWritesBlocked) return;
|
|
2875
|
+
for (const file of removed) {
|
|
2876
|
+
if (themeRoot.ignore.ignore(file.relativePath)) continue;
|
|
2877
|
+
try {
|
|
2878
|
+
await syncer.deleteRemoteFile(file.relativePath, syncer.remoteSha());
|
|
2879
|
+
wroteRemote = true;
|
|
2880
|
+
console.log(` ✓ removed ${file.relativePath}`);
|
|
2881
|
+
} catch (error) {
|
|
2882
|
+
if (error instanceof PushConflictError) {
|
|
2883
|
+
blockRemoteWrites(error);
|
|
2884
|
+
break;
|
|
2885
|
+
}
|
|
2886
|
+
invalidateRemoteState();
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
if (remoteWritesBlocked) return;
|
|
2890
|
+
if (wroteRemote) recordRemoteState();
|
|
2891
|
+
if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
|
|
2892
|
+
else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
|
|
2893
|
+
scheduleSync();
|
|
2894
|
+
});
|
|
2895
|
+
const server = http.createServer(async (req, res) => {
|
|
2896
|
+
if (req.url === "/hot-reload") {
|
|
2897
|
+
sse.add(res);
|
|
3061
2898
|
return;
|
|
3062
2899
|
}
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
2900
|
+
try {
|
|
2901
|
+
await proxyRequest(req, res, {
|
|
2902
|
+
company: theme.company,
|
|
2903
|
+
themeId: theme.id,
|
|
2904
|
+
reloadMode: opts.reloadMode,
|
|
2905
|
+
pendingFiles: () => [...pendingUpdates].map((p) => themeRoot.file(p)).filter((f) => f.isText).map((f) => ({
|
|
2906
|
+
relativePath: f.relativePath,
|
|
2907
|
+
read: () => f.read()
|
|
2908
|
+
}))
|
|
3067
2909
|
});
|
|
3068
|
-
|
|
2910
|
+
} catch (e) {
|
|
2911
|
+
console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
|
|
2912
|
+
if (!res.headersSent) {
|
|
2913
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
2914
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
2915
|
+
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.`);
|
|
2916
|
+
}
|
|
3069
2917
|
}
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
for (let cursor = previousTagIndex; cursor < index; cursor++) if (content.charCodeAt(cursor) === 10) line += 1;
|
|
3077
|
-
previousTagIndex = index;
|
|
3078
|
-
const open = stack.at(-1);
|
|
3079
|
-
if (name === "liquid" && !(open && OPAQUE_BLOCK_TAGS.has(open.name))) {
|
|
3080
|
-
const statements = match[0].replace(/^\{%-?\s*liquid\b/i, "").replace(/-?%\}$/, "").split("\n");
|
|
3081
|
-
for (const [offset, statement] of statements.entries()) {
|
|
3082
|
-
const statementName = /^\s*([a-zA-Z_][\w-]*)\b/.exec(statement)?.[1];
|
|
3083
|
-
if (!statementName) continue;
|
|
3084
|
-
processTag(statementName.toLowerCase(), line + offset);
|
|
2918
|
+
});
|
|
2919
|
+
await new Promise((resolve, reject) => {
|
|
2920
|
+
server.once("error", (err) => {
|
|
2921
|
+
if (err.code === "EADDRINUSE" || err.code === "EACCES") {
|
|
2922
|
+
console.error(formatPortConflictMessage(opts.host, opts.port));
|
|
2923
|
+
process.exit(1);
|
|
3085
2924
|
}
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
severity: "error",
|
|
3090
|
-
message: `Unclosed Liquid tag '{% ${open.name} %}' on line ${open.line}; expected '{% ${open.expectedClose} %}'.`
|
|
2925
|
+
reject(err);
|
|
2926
|
+
});
|
|
2927
|
+
server.listen(opts.port, opts.host, () => resolve());
|
|
3091
2928
|
});
|
|
3092
|
-
|
|
2929
|
+
const address = `http://${opts.host}:${opts.port}`;
|
|
2930
|
+
onReady?.(address);
|
|
2931
|
+
return function stop() {
|
|
2932
|
+
sse.close();
|
|
2933
|
+
stopWatcher();
|
|
2934
|
+
server.close();
|
|
2935
|
+
};
|
|
3093
2936
|
}
|
|
3094
2937
|
//#endregion
|
|
3095
|
-
//#region src/theme/dev-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
constructor(host, port) {
|
|
3107
|
-
super(formatPortConflictMessage(host, port));
|
|
3108
|
-
this.host = host;
|
|
3109
|
-
this.port = port;
|
|
3110
|
-
this.name = "PortInUseError";
|
|
2938
|
+
//#region src/theme/dev-remote-baseline.ts
|
|
2939
|
+
const BASELINE_VERSION = 1;
|
|
2940
|
+
const BASELINE_FILE = join(".fluid-theme", "dev-baseline.json");
|
|
2941
|
+
function readDevRemoteBaseline(themeRoot, themeId, assetManifestSha) {
|
|
2942
|
+
try {
|
|
2943
|
+
const state = parseDocument(JSON.parse(readFileSync(join(themeRoot, BASELINE_FILE), "utf-8")));
|
|
2944
|
+
if (state.themeId !== themeId) return null;
|
|
2945
|
+
if (state.assetManifestSha !== assetManifestSha) return null;
|
|
2946
|
+
return state;
|
|
2947
|
+
} catch {
|
|
2948
|
+
return null;
|
|
3111
2949
|
}
|
|
3112
|
-
};
|
|
3113
|
-
function formatPortConflictMessage(host, port) {
|
|
3114
|
-
return `Port ${port} on ${host} is already in use — likely another \`fluid theme dev\` or the Mist Desktop preview. Stop the other server or pass --port <number>.`;
|
|
3115
2950
|
}
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
server.close(() => resolve());
|
|
2951
|
+
function writeDevRemoteBaseline(themeRoot, state) {
|
|
2952
|
+
const document = {
|
|
2953
|
+
version: BASELINE_VERSION,
|
|
2954
|
+
...state
|
|
2955
|
+
};
|
|
2956
|
+
parseDocument(document);
|
|
2957
|
+
const path = join(themeRoot, BASELINE_FILE);
|
|
2958
|
+
const tempPath = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
2959
|
+
try {
|
|
2960
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2961
|
+
writeFileSync(tempPath, `${JSON.stringify(document)}\n`, {
|
|
2962
|
+
encoding: "utf-8",
|
|
2963
|
+
mode: 384
|
|
3130
2964
|
});
|
|
3131
|
-
|
|
3132
|
-
})
|
|
2965
|
+
renameSync(tempPath, path);
|
|
2966
|
+
} catch (error) {
|
|
2967
|
+
rmSync(tempPath, { force: true });
|
|
2968
|
+
throw error;
|
|
2969
|
+
}
|
|
3133
2970
|
}
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
function timestamp() {
|
|
3137
|
-
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
2971
|
+
function removeDevRemoteBaseline(themeRoot) {
|
|
2972
|
+
rmSync(join(themeRoot, BASELINE_FILE), { force: true });
|
|
3138
2973
|
}
|
|
3139
|
-
async function
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
const
|
|
3144
|
-
|
|
2974
|
+
async function devRemoteStateFromSourceShadow(themeRoot, shadow, themeId, remoteSha) {
|
|
2975
|
+
try {
|
|
2976
|
+
if (!await shadow.hasHead()) return null;
|
|
2977
|
+
const assetManifest = new ThemeAssetManifest(themeRoot.root);
|
|
2978
|
+
const resources = [];
|
|
2979
|
+
for (const key of await shadow.headPaths()) {
|
|
2980
|
+
const content = await shadow.blobAtHead(key);
|
|
2981
|
+
if (!content) return null;
|
|
2982
|
+
const asset = assetManifest.get(key);
|
|
2983
|
+
if (asset) {
|
|
2984
|
+
if (asset.pending || !asset.url || !content.equals(Buffer.from("fluid-managed-asset\n"))) return null;
|
|
2985
|
+
resources.push({
|
|
2986
|
+
key,
|
|
2987
|
+
checksum: asset.checksum ?? null,
|
|
2988
|
+
contentPresent: false,
|
|
2989
|
+
resourceType: "FileResource",
|
|
2990
|
+
url: asset.url
|
|
2991
|
+
});
|
|
2992
|
+
continue;
|
|
2993
|
+
}
|
|
2994
|
+
if (content.equals(Buffer.from("fluid-managed-asset\n"))) return null;
|
|
2995
|
+
resources.push({
|
|
2996
|
+
key,
|
|
2997
|
+
checksum: createHash("sha256").update(content).digest("hex"),
|
|
2998
|
+
contentPresent: true
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
return {
|
|
3002
|
+
themeId,
|
|
3003
|
+
remoteSha,
|
|
3004
|
+
assetManifestSha: assetManifest.fingerprint(),
|
|
3005
|
+
resources
|
|
3006
|
+
};
|
|
3007
|
+
} catch {
|
|
3008
|
+
return null;
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
function parseDocument(document) {
|
|
3012
|
+
if (!isRecord(document) || document["version"] !== BASELINE_VERSION) throw new Error(`expected version ${BASELINE_VERSION}`);
|
|
3013
|
+
const themeId = document["themeId"];
|
|
3014
|
+
const remoteSha = document["remoteSha"];
|
|
3015
|
+
const assetManifestSha = document["assetManifestSha"];
|
|
3016
|
+
const rawResources = document["resources"];
|
|
3017
|
+
if (!isPositiveInteger(themeId)) throw new Error("invalid theme id");
|
|
3018
|
+
if (!isNonEmptyString(remoteSha)) throw new Error("invalid remote sha");
|
|
3019
|
+
if (!isNonEmptyString(assetManifestSha)) throw new Error("invalid asset manifest sha");
|
|
3020
|
+
if (!Array.isArray(rawResources)) throw new Error("invalid resources");
|
|
3021
|
+
const resources = rawResources.map(parseResource);
|
|
3022
|
+
assertNoCaseCollisions(resources.map((resource) => resource.key));
|
|
3023
|
+
return {
|
|
3024
|
+
themeId,
|
|
3025
|
+
remoteSha,
|
|
3026
|
+
assetManifestSha,
|
|
3027
|
+
resources
|
|
3145
3028
|
};
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3029
|
+
}
|
|
3030
|
+
function parseResource(resource) {
|
|
3031
|
+
if (!isRecord(resource)) throw new Error("invalid resource");
|
|
3032
|
+
const key = resource["key"];
|
|
3033
|
+
const checksum = resource["checksum"];
|
|
3034
|
+
const contentPresent = resource["contentPresent"];
|
|
3035
|
+
const resourceType = resource["resourceType"];
|
|
3036
|
+
const resourceId = resource["resourceId"];
|
|
3037
|
+
const url = resource["url"];
|
|
3038
|
+
if (!isNonEmptyString(key) || key.includes("\0")) throw new Error("invalid resource key");
|
|
3039
|
+
if (checksum !== null && !isNonEmptyString(checksum)) throw new Error("invalid resource checksum");
|
|
3040
|
+
if (typeof contentPresent !== "boolean") throw new Error("invalid resource content marker");
|
|
3041
|
+
if (resourceType !== void 0 && resourceType !== null && !isNonEmptyString(resourceType)) throw new Error("invalid resource type");
|
|
3042
|
+
if (resourceId !== void 0 && resourceId !== null && !isPositiveInteger(resourceId)) throw new Error("invalid resource id");
|
|
3043
|
+
if (url !== void 0 && url !== null && !isNonEmptyString(url)) throw new Error("invalid resource url");
|
|
3044
|
+
return {
|
|
3045
|
+
key,
|
|
3046
|
+
checksum,
|
|
3047
|
+
contentPresent,
|
|
3048
|
+
resourceType,
|
|
3049
|
+
resourceId,
|
|
3050
|
+
url
|
|
3150
3051
|
};
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3052
|
+
}
|
|
3053
|
+
function isRecord(candidate) {
|
|
3054
|
+
return typeof candidate === "object" && candidate !== null;
|
|
3055
|
+
}
|
|
3056
|
+
function isNonEmptyString(candidate) {
|
|
3057
|
+
return typeof candidate === "string" && candidate.length > 0;
|
|
3058
|
+
}
|
|
3059
|
+
function isPositiveInteger(candidate) {
|
|
3060
|
+
return typeof candidate === "number" && Number.isInteger(candidate) && candidate > 0;
|
|
3061
|
+
}
|
|
3062
|
+
//#endregion
|
|
3063
|
+
//#region src/theme/shadow-repo.ts
|
|
3064
|
+
/**
|
|
3065
|
+
* A bare git repo hidden under `.fluid-theme/repo` that stands in for
|
|
3066
|
+
* git's index+HEAD when talking to the Fluid server. Every pull commits
|
|
3067
|
+
* the incoming remote state onto a single branch (`refs/heads/main`);
|
|
3068
|
+
* every successful push commits the outgoing local state onto the same
|
|
3069
|
+
* branch. HEAD therefore represents "the last state the CLI and server
|
|
3070
|
+
* agreed on", and its tree is the natural three-way-merge base for the
|
|
3071
|
+
* next pull.
|
|
3072
|
+
*
|
|
3073
|
+
* We stay in git's plumbing layer — no working tree, no index file
|
|
3074
|
+
* next to the theme content — so the shadow repo cannot interfere
|
|
3075
|
+
* with the user's own git repo (if they have one) around the theme
|
|
3076
|
+
* dir. All state lives inside `.fluid-theme/`.
|
|
3077
|
+
*
|
|
3078
|
+
* The class only wraps the small handful of plumbing commands we
|
|
3079
|
+
* actually need: hash-object, cat-file, write-tree (via a temp index),
|
|
3080
|
+
* commit-tree, update-ref, and merge-file. Everything else stays out.
|
|
3081
|
+
*/
|
|
3082
|
+
var ShadowRepo = class ShadowRepo {
|
|
3083
|
+
headExists = void 0;
|
|
3084
|
+
constructor(themeRoot, gitDir) {
|
|
3085
|
+
this.themeRoot = themeRoot;
|
|
3086
|
+
this.gitDir = gitDir;
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Return a ShadowRepo bound to `themeId` for the given theme root.
|
|
3090
|
+
* The shadow is initialized on first use and re-initialized when
|
|
3091
|
+
* the caller passes a themeId different from the one previously
|
|
3092
|
+
* recorded — the merge base is only meaningful for the theme it
|
|
3093
|
+
* was captured against, so a cross-theme operation (pull A → push
|
|
3094
|
+
* B, or two pulls of different themes into one dir) starts from a
|
|
3095
|
+
* clean slate rather than pretending B's state matches A's HEAD.
|
|
3096
|
+
*/
|
|
3097
|
+
static async open(themeRoot, themeId) {
|
|
3098
|
+
const shadowDir = join(themeRoot, ".fluid-theme");
|
|
3099
|
+
const gitDir = join(shadowDir, "repo");
|
|
3100
|
+
const themeIdFile = join(shadowDir, "theme-id");
|
|
3101
|
+
if (existsSync(gitDir)) {
|
|
3102
|
+
if (readStoredThemeId(themeIdFile) !== themeId) rmSync(gitDir, {
|
|
3103
|
+
recursive: true,
|
|
3104
|
+
force: true
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
3107
|
+
const repo = new ShadowRepo(themeRoot, gitDir);
|
|
3108
|
+
if (!existsSync(gitDir)) {
|
|
3109
|
+
mkdirSync(shadowDir, { recursive: true });
|
|
3110
|
+
await repo.git([
|
|
3111
|
+
"init",
|
|
3112
|
+
"--bare",
|
|
3113
|
+
"-b",
|
|
3114
|
+
"main",
|
|
3115
|
+
gitDir
|
|
3116
|
+
], { cwd: themeRoot });
|
|
3117
|
+
}
|
|
3118
|
+
writeFileSync(themeIdFile, `${themeId}\n`, "utf-8");
|
|
3119
|
+
const shadowIgnore = join(shadowDir, ".gitignore");
|
|
3120
|
+
if (!existsSync(shadowIgnore)) writeFileSync(shadowIgnore, "# Fluid CLI shadow repo — internal state, not for version control.\n*\n");
|
|
3121
|
+
await ensureRootGitignoreHidesShadow(themeRoot);
|
|
3122
|
+
return repo;
|
|
3123
|
+
}
|
|
3124
|
+
/** True when the repo has at least one commit on `refs/heads/main`. */
|
|
3125
|
+
async hasHead() {
|
|
3126
|
+
if (this.headExists !== void 0) return this.headExists;
|
|
3127
|
+
try {
|
|
3128
|
+
await this.git([
|
|
3129
|
+
"rev-parse",
|
|
3130
|
+
"--verify",
|
|
3131
|
+
"HEAD"
|
|
3132
|
+
]);
|
|
3133
|
+
this.headExists = true;
|
|
3134
|
+
} catch {
|
|
3135
|
+
this.headExists = false;
|
|
3136
|
+
}
|
|
3137
|
+
return this.headExists;
|
|
3138
|
+
}
|
|
3139
|
+
/**
|
|
3140
|
+
* Every path recorded under HEAD's tree, recursively. Callers use
|
|
3141
|
+
* this to detect local deletions (paths in HEAD, absent from the
|
|
3142
|
+
* working tree). Returns [] when HEAD has never been committed.
|
|
3143
|
+
*/
|
|
3144
|
+
async headPaths() {
|
|
3145
|
+
if (!await this.hasHead()) return [];
|
|
3146
|
+
const { stdout } = await this.git([
|
|
3147
|
+
"ls-tree",
|
|
3148
|
+
"-r",
|
|
3149
|
+
"-z",
|
|
3150
|
+
"HEAD",
|
|
3151
|
+
"--name-only"
|
|
3152
|
+
]);
|
|
3153
|
+
return stdout.toString("utf8").split("\0").filter((line) => line.length > 0);
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* The content of `path` in HEAD's tree, or null when the path does
|
|
3157
|
+
* not exist there. Callers use this as the merge base for pull
|
|
3158
|
+
* conflict resolution.
|
|
3159
|
+
*/
|
|
3160
|
+
async blobAtHead(path) {
|
|
3161
|
+
if (!await this.hasHead()) return null;
|
|
3161
3162
|
try {
|
|
3162
|
-
await
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3163
|
+
const { stdout } = await this.git([
|
|
3164
|
+
"cat-file",
|
|
3165
|
+
"-p",
|
|
3166
|
+
`HEAD:${path}`
|
|
3167
|
+
]);
|
|
3168
|
+
return stdout;
|
|
3169
|
+
} catch {
|
|
3170
|
+
return null;
|
|
3166
3171
|
}
|
|
3167
|
-
if (shadowIsTrusted) {
|
|
3168
|
-
const diff = await diffAgainstShadow(themeRoot, opts.initialSync.shadow);
|
|
3169
|
-
syncResult = await syncer.uploadTheme({
|
|
3170
|
-
delete: true,
|
|
3171
|
-
validate: opts.validate,
|
|
3172
|
-
pendingBinaryAssets: true,
|
|
3173
|
-
baseSha: syncer.remoteSha(),
|
|
3174
|
-
diff,
|
|
3175
|
-
skipPreflight: true,
|
|
3176
|
-
onProgress: progress
|
|
3177
|
-
});
|
|
3178
|
-
} else syncResult = await uploadFromRemoteIndex();
|
|
3179
|
-
} else syncResult = await uploadFromRemoteIndex();
|
|
3180
|
-
process.stdout.write("\n");
|
|
3181
|
-
if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
|
|
3182
|
-
if (syncResult.validationFailed) {
|
|
3183
|
-
console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
|
|
3184
|
-
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
3185
|
-
process.exit(1);
|
|
3186
|
-
} else if (syncResult.errors.length > 0) {
|
|
3187
|
-
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
3188
|
-
if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
|
|
3189
3172
|
}
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3173
|
+
/**
|
|
3174
|
+
* Write `content` as a blob in the shadow repo and return its sha.
|
|
3175
|
+
* Used by `commitState` to stage each file's content before the
|
|
3176
|
+
* `write-tree` call.
|
|
3177
|
+
*/
|
|
3178
|
+
async writeBlob(content) {
|
|
3179
|
+
const buf = typeof content === "string" ? Buffer.from(content) : content;
|
|
3180
|
+
const { stdout } = await this.git([
|
|
3181
|
+
"hash-object",
|
|
3182
|
+
"-w",
|
|
3183
|
+
"--stdin"
|
|
3184
|
+
], { input: buf });
|
|
3185
|
+
return stdout.toString("utf8").trim();
|
|
3186
|
+
}
|
|
3187
|
+
/**
|
|
3188
|
+
* Commit `files` as HEAD's new tree, threaded onto the current HEAD
|
|
3189
|
+
* as the parent. Uses a per-call temp index so a partial run can't
|
|
3190
|
+
* corrupt anything reachable from HEAD; the previous commit stays
|
|
3191
|
+
* intact until `update-ref` at the end.
|
|
3192
|
+
*
|
|
3193
|
+
* Returns the new commit sha.
|
|
3194
|
+
*/
|
|
3195
|
+
async commitState(files, message) {
|
|
3196
|
+
const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
|
|
3197
|
+
try {
|
|
3198
|
+
const indexArgs = ["update-index", "--add"];
|
|
3199
|
+
for (const { path, sha } of files) indexArgs.push("--cacheinfo", `100644,${sha},${path}`);
|
|
3200
|
+
if (files.length > 0) await this.git(indexArgs, { env: { GIT_INDEX_FILE: indexPath } });
|
|
3201
|
+
const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
|
|
3202
|
+
const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
|
|
3203
|
+
const commitArgs = [
|
|
3204
|
+
"commit-tree",
|
|
3205
|
+
treeSha,
|
|
3206
|
+
"-m",
|
|
3207
|
+
message
|
|
3208
|
+
];
|
|
3209
|
+
if (parent) commitArgs.push("-p", parent);
|
|
3210
|
+
const commitSha = (await this.git(commitArgs, { env: {
|
|
3211
|
+
GIT_AUTHOR_NAME: "Fluid CLI",
|
|
3212
|
+
GIT_AUTHOR_EMAIL: "cli@fluid.app",
|
|
3213
|
+
GIT_COMMITTER_NAME: "Fluid CLI",
|
|
3214
|
+
GIT_COMMITTER_EMAIL: "cli@fluid.app"
|
|
3215
|
+
} })).stdout.toString("utf8").trim();
|
|
3216
|
+
await this.git([
|
|
3217
|
+
"update-ref",
|
|
3218
|
+
"refs/heads/main",
|
|
3219
|
+
commitSha
|
|
3220
|
+
]);
|
|
3221
|
+
this.headExists = true;
|
|
3222
|
+
return commitSha;
|
|
3223
|
+
} finally {
|
|
3224
|
+
try {
|
|
3225
|
+
rmSync(indexPath, { force: true });
|
|
3226
|
+
rmSync(indexPath.substring(0, indexPath.length - 6), {
|
|
3227
|
+
recursive: true,
|
|
3228
|
+
force: true
|
|
3229
|
+
});
|
|
3230
|
+
} catch {}
|
|
3225
3231
|
}
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* Three-way merge of `local` against `remote` with `base` as the
|
|
3235
|
+
* common ancestor. Returns the merged bytes and a flag when
|
|
3236
|
+
* `git merge-file` reported unresolved conflicts (i.e. the output
|
|
3237
|
+
* contains `<<<<<<<` markers for the reader to resolve).
|
|
3238
|
+
*
|
|
3239
|
+
* `base` is null when HEAD has never seen this path; we merge
|
|
3240
|
+
* against an empty base, which is what git itself does for a new
|
|
3241
|
+
* file added on both sides.
|
|
3242
|
+
*
|
|
3243
|
+
* `favor` maps to `git merge-file`'s `--ours` / `--theirs`: instead
|
|
3244
|
+
* of emitting `<<<<<<<` markers, conflicting hunks are resolved to
|
|
3245
|
+
* the local (`"local"` → `--ours`, local is file1) or remote
|
|
3246
|
+
* (`"remote"` → `--theirs`) side. The output then never contains
|
|
3247
|
+
* markers, so the result is reported conflict-free even when
|
|
3248
|
+
* merge-file's exit code still counts the auto-resolved hunks.
|
|
3249
|
+
*/
|
|
3250
|
+
async merge3(base, local, remote, favor) {
|
|
3251
|
+
const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
|
|
3252
|
+
const localPath = join(dir, "local");
|
|
3253
|
+
const basePath = join(dir, "base");
|
|
3254
|
+
const remotePath = join(dir, "remote");
|
|
3255
|
+
try {
|
|
3256
|
+
writeFileSync(localPath, local);
|
|
3257
|
+
writeFileSync(basePath, base ?? Buffer.alloc(0));
|
|
3258
|
+
writeFileSync(remotePath, remote);
|
|
3237
3259
|
try {
|
|
3238
|
-
const
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3260
|
+
const { stdout } = await this.git([
|
|
3261
|
+
"merge-file",
|
|
3262
|
+
"-p",
|
|
3263
|
+
...favor === "local" ? ["--ours"] : favor === "remote" ? ["--theirs"] : [],
|
|
3264
|
+
"-L",
|
|
3265
|
+
"local",
|
|
3266
|
+
"-L",
|
|
3267
|
+
"base",
|
|
3268
|
+
"-L",
|
|
3269
|
+
"remote",
|
|
3270
|
+
localPath,
|
|
3271
|
+
basePath,
|
|
3272
|
+
remotePath
|
|
3273
|
+
]);
|
|
3274
|
+
return {
|
|
3275
|
+
merged: stdout,
|
|
3276
|
+
hasConflicts: false
|
|
3277
|
+
};
|
|
3278
|
+
} catch (err) {
|
|
3279
|
+
const e = err;
|
|
3280
|
+
const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
|
|
3281
|
+
const inConflictRange = typeof e.code === "number" && e.code >= 1 && e.code <= 127;
|
|
3282
|
+
if (inConflictRange && favor) return {
|
|
3283
|
+
merged,
|
|
3284
|
+
hasConflicts: false
|
|
3285
|
+
};
|
|
3286
|
+
if (inConflictRange && merged.length > 0) return {
|
|
3287
|
+
merged,
|
|
3288
|
+
hasConflicts: true
|
|
3289
|
+
};
|
|
3290
|
+
throw err;
|
|
3247
3291
|
}
|
|
3248
|
-
}
|
|
3249
|
-
for (const file of removed) {
|
|
3250
|
-
if (themeRoot.ignore.ignore(file.relativePath)) continue;
|
|
3292
|
+
} finally {
|
|
3251
3293
|
try {
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3294
|
+
rmSync(dir, {
|
|
3295
|
+
recursive: true,
|
|
3296
|
+
force: true
|
|
3297
|
+
});
|
|
3255
3298
|
} catch {}
|
|
3256
3299
|
}
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* Snapshot the working-tree copy of `paths` into HEAD as a single
|
|
3303
|
+
* commit. Intended for the migration path: on the first pull with
|
|
3304
|
+
* the new CLI (no shadow repo yet, but a `.fluid-theme.json` with
|
|
3305
|
+
* checksums exists) we seed HEAD with whatever is on disk before
|
|
3306
|
+
* running the merge, so unmodified files fast-forward cleanly and
|
|
3307
|
+
* modified files show a diff.
|
|
3308
|
+
*/
|
|
3309
|
+
async seedFromWorkingTree(files, message) {
|
|
3310
|
+
const entries = [];
|
|
3311
|
+
for (const { path, content } of files) entries.push({
|
|
3312
|
+
path,
|
|
3313
|
+
sha: await this.writeBlob(content)
|
|
3314
|
+
});
|
|
3315
|
+
await this.commitState(entries, message);
|
|
3316
|
+
}
|
|
3317
|
+
async git(args, opts = {}) {
|
|
3318
|
+
const child = spawn("git", args[0] === "init" ? args : [
|
|
3319
|
+
"--git-dir",
|
|
3320
|
+
this.gitDir,
|
|
3321
|
+
...args
|
|
3322
|
+
], {
|
|
3323
|
+
cwd: opts.cwd ?? this.themeRoot,
|
|
3324
|
+
env: {
|
|
3325
|
+
...process.env,
|
|
3326
|
+
...opts.env
|
|
3327
|
+
},
|
|
3328
|
+
stdio: [
|
|
3329
|
+
"pipe",
|
|
3330
|
+
"pipe",
|
|
3331
|
+
"pipe"
|
|
3332
|
+
]
|
|
3333
|
+
});
|
|
3334
|
+
if (opts.input) child.stdin.write(opts.input);
|
|
3335
|
+
child.stdin.end();
|
|
3336
|
+
return new Promise((resolve, reject) => {
|
|
3337
|
+
const stdout = [];
|
|
3338
|
+
const stderr = [];
|
|
3339
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
3340
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
3341
|
+
child.on("error", reject);
|
|
3342
|
+
child.on("close", (code) => {
|
|
3343
|
+
const out = Buffer.concat(stdout);
|
|
3344
|
+
const err = Buffer.concat(stderr);
|
|
3345
|
+
if (code === 0) resolve({
|
|
3346
|
+
stdout: out,
|
|
3347
|
+
stderr: err
|
|
3348
|
+
});
|
|
3349
|
+
else {
|
|
3350
|
+
const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
|
|
3351
|
+
e.code = code ?? -1;
|
|
3352
|
+
e.stdout = out;
|
|
3353
|
+
e.stderr = err;
|
|
3354
|
+
reject(e);
|
|
3355
|
+
}
|
|
3276
3356
|
});
|
|
3277
|
-
} catch (e) {
|
|
3278
|
-
console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
|
|
3279
|
-
if (!res.headersSent) {
|
|
3280
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
3281
|
-
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
3282
|
-
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.`);
|
|
3283
|
-
}
|
|
3284
|
-
}
|
|
3285
|
-
});
|
|
3286
|
-
await new Promise((resolve, reject) => {
|
|
3287
|
-
server.once("error", (err) => {
|
|
3288
|
-
if (err.code === "EADDRINUSE" || err.code === "EACCES") {
|
|
3289
|
-
console.error(formatPortConflictMessage(opts.host, opts.port));
|
|
3290
|
-
process.exit(1);
|
|
3291
|
-
}
|
|
3292
|
-
reject(err);
|
|
3293
3357
|
});
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3358
|
+
}
|
|
3359
|
+
};
|
|
3360
|
+
/**
|
|
3361
|
+
* Content-type check the pull command uses to decide whether a file
|
|
3362
|
+
* is safe to run through `merge3` (line-based) or must fall back to
|
|
3363
|
+
* whole-file "either/or" resolution (binary).
|
|
3364
|
+
*/
|
|
3365
|
+
function looksBinary(content) {
|
|
3366
|
+
return content.subarray(0, Math.min(content.length, 8e3)).includes(0);
|
|
3367
|
+
}
|
|
3368
|
+
/** Best-effort readFile that returns null when the file does not exist. */
|
|
3369
|
+
function readIfExists(path) {
|
|
3370
|
+
try {
|
|
3371
|
+
return readFileSync(path);
|
|
3372
|
+
} catch {
|
|
3373
|
+
return null;
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
/**
|
|
3377
|
+
* Parse the numeric theme id from `.fluid-theme/theme-id`. Returns
|
|
3378
|
+
* null when the file is missing or the contents don't parse cleanly;
|
|
3379
|
+
* `open` treats that as "unknown theme" and rebuilds the shadow.
|
|
3380
|
+
*/
|
|
3381
|
+
function readStoredThemeId(themeIdFile) {
|
|
3382
|
+
try {
|
|
3383
|
+
const stored = parseInt(readFileSync(themeIdFile, "utf-8").trim(), 10);
|
|
3384
|
+
return Number.isFinite(stored) ? stored : null;
|
|
3385
|
+
} catch {
|
|
3386
|
+
return null;
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Append `.fluid-theme/` to the theme root's `.gitignore` when the
|
|
3391
|
+
* theme dir sits inside a git working tree and the entry isn't
|
|
3392
|
+
* already there. Skipped when the user isn't in a git repo — no
|
|
3393
|
+
* point manufacturing a `.gitignore` for someone who doesn't use
|
|
3394
|
+
* git. Idempotent — a second call is a no-op.
|
|
3395
|
+
*/
|
|
3396
|
+
async function ensureRootGitignoreHidesShadow(themeRoot) {
|
|
3397
|
+
if (!await new Promise((resolve) => {
|
|
3398
|
+
const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
3399
|
+
cwd: themeRoot,
|
|
3400
|
+
stdio: [
|
|
3401
|
+
"ignore",
|
|
3402
|
+
"pipe",
|
|
3403
|
+
"pipe"
|
|
3404
|
+
]
|
|
3405
|
+
});
|
|
3406
|
+
child.on("close", (code) => resolve(code === 0));
|
|
3407
|
+
child.on("error", () => resolve(false));
|
|
3408
|
+
})) return;
|
|
3409
|
+
const gitignorePath = join(themeRoot, ".gitignore");
|
|
3410
|
+
let existing = "";
|
|
3411
|
+
try {
|
|
3412
|
+
existing = readFileSync(gitignorePath, "utf-8");
|
|
3413
|
+
} catch {
|
|
3414
|
+
existing = "";
|
|
3415
|
+
}
|
|
3416
|
+
const lines = existing.split("\n").map((line) => line.trim());
|
|
3417
|
+
if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
|
|
3418
|
+
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
3419
|
+
writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
|
|
3303
3420
|
}
|
|
3304
3421
|
//#endregion
|
|
3305
3422
|
//#region src/theme-picker.ts
|
|
@@ -3455,22 +3572,9 @@ function resolveThemeRootFromCwd(workspace) {
|
|
|
3455
3572
|
}
|
|
3456
3573
|
//#endregion
|
|
3457
3574
|
//#region src/commands/dev.ts
|
|
3458
|
-
/**
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
* All three values must still describe the same world: the checkout has not
|
|
3462
|
-
* pulled a newer source, the dev theme has not changed outside this CLI, and
|
|
3463
|
-
* managed ImageKit references have not changed outside the shadow's file tree.
|
|
3464
|
-
*/
|
|
3465
|
-
function devShadowTrust(config, devTheme, assetManifestSha) {
|
|
3466
|
-
if (!config?.baseSha || !devTheme?.remoteSha || !devTheme.sourceBaseSha || !devTheme.assetManifestSha) return null;
|
|
3467
|
-
if (devTheme.sourceThemeId !== config.themeId) return null;
|
|
3468
|
-
if (devTheme.sourceBaseSha !== config.baseSha) return null;
|
|
3469
|
-
if (devTheme.assetManifestSha !== assetManifestSha) return null;
|
|
3470
|
-
return {
|
|
3471
|
-
sourceThemeId: config.themeId,
|
|
3472
|
-
remoteSha: devTheme.remoteSha
|
|
3473
|
-
};
|
|
3575
|
+
/** Whether this invocation may read and persist the dev remote baseline. */
|
|
3576
|
+
function devRemoteBaselineEnabled(explicitTheme) {
|
|
3577
|
+
return !explicitTheme && process.env["FLUID_THEME_DEV_DISABLE_SHADOW_SYNC"] !== "1";
|
|
3474
3578
|
}
|
|
3475
3579
|
/**
|
|
3476
3580
|
* Create the isolated theme used by `theme dev`.
|
|
@@ -3482,21 +3586,30 @@ function devShadowTrust(config, devTheme, assetManifestSha) {
|
|
|
3482
3586
|
*/
|
|
3483
3587
|
async function createDevelopmentTheme(api, sourceThemeId, name) {
|
|
3484
3588
|
if (sourceThemeId !== void 0) try {
|
|
3485
|
-
return
|
|
3589
|
+
return {
|
|
3590
|
+
theme: (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme,
|
|
3591
|
+
referenceCloned: true
|
|
3592
|
+
};
|
|
3486
3593
|
} catch (error) {
|
|
3487
3594
|
if (!isApiError(error) || error.status !== 404 && error.status !== 405) throw error;
|
|
3488
3595
|
console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
|
|
3489
3596
|
}
|
|
3490
|
-
return
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3597
|
+
return {
|
|
3598
|
+
theme: (await createApplicationTheme(api, { application_theme: {
|
|
3599
|
+
name,
|
|
3600
|
+
status: "development"
|
|
3601
|
+
} })).application_theme,
|
|
3602
|
+
referenceCloned: false
|
|
3603
|
+
};
|
|
3494
3604
|
}
|
|
3495
|
-
async function ensureDevTheme(api, projectKey, identifier, sourceThemeId
|
|
3605
|
+
async function ensureDevTheme(api, projectKey, identifier, sourceThemeId) {
|
|
3496
3606
|
if (identifier) {
|
|
3497
3607
|
const theme = await findTheme(api, identifier);
|
|
3498
3608
|
setLastDevThemeId(theme.id);
|
|
3499
|
-
return
|
|
3609
|
+
return {
|
|
3610
|
+
theme,
|
|
3611
|
+
referenceCloned: false
|
|
3612
|
+
};
|
|
3500
3613
|
}
|
|
3501
3614
|
const stored = getDevTheme(projectKey);
|
|
3502
3615
|
if (stored && stored.sourceThemeId === sourceThemeId) {
|
|
@@ -3510,25 +3623,24 @@ async function ensureDevTheme(api, projectKey, identifier, sourceThemeId, source
|
|
|
3510
3623
|
name: existing.name,
|
|
3511
3624
|
...sourceThemeId === void 0 ? {} : { sourceThemeId }
|
|
3512
3625
|
});
|
|
3513
|
-
return
|
|
3626
|
+
return {
|
|
3627
|
+
theme: existing,
|
|
3628
|
+
referenceCloned: false
|
|
3629
|
+
};
|
|
3514
3630
|
}
|
|
3515
3631
|
} catch {}
|
|
3516
3632
|
clearDevTheme(projectKey);
|
|
3517
3633
|
}
|
|
3518
3634
|
const { hostname } = await import("node:os");
|
|
3519
|
-
const
|
|
3635
|
+
const creation = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
|
|
3636
|
+
const { theme } = creation;
|
|
3520
3637
|
setDevTheme(projectKey, {
|
|
3521
3638
|
id: theme.id,
|
|
3522
3639
|
name: theme.name,
|
|
3523
|
-
...sourceThemeId === void 0 ? {} : { sourceThemeId }
|
|
3524
|
-
...sourceThemeId !== void 0 && sourceBaseSha ? {
|
|
3525
|
-
sourceBaseSha,
|
|
3526
|
-
remoteSha: sourceBaseSha
|
|
3527
|
-
} : {},
|
|
3528
|
-
...sourceAssetManifestSha ? { assetManifestSha: sourceAssetManifestSha } : {}
|
|
3640
|
+
...sourceThemeId === void 0 ? {} : { sourceThemeId }
|
|
3529
3641
|
});
|
|
3530
3642
|
console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
|
|
3531
|
-
return
|
|
3643
|
+
return creation;
|
|
3532
3644
|
}
|
|
3533
3645
|
function createDevCommand() {
|
|
3534
3646
|
return new Command("dev").description("Start the theme dev server with hot reload").option("--host <host>", "Local server host", "127.0.0.1").option("--port <port>", "Local server port", "9292").option("-t, --theme <name-or-id>", "Use an existing theme instead of dev theme").option("-f, --force", "Skip schema validation on upload").option("--live-reload <mode>", "Reload mode: full-page | off", "full-page").option("--navigate", "Open browser navigator after server starts").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
@@ -3568,31 +3680,16 @@ function createDevCommand() {
|
|
|
3568
3680
|
}
|
|
3569
3681
|
}
|
|
3570
3682
|
const projectKey = devThemeKey(company, themeRoot.root);
|
|
3571
|
-
const
|
|
3572
|
-
const
|
|
3683
|
+
const devTarget = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId);
|
|
3684
|
+
const { theme } = devTarget;
|
|
3573
3685
|
const assetManifest = new ThemeAssetManifest(themeRoot.root);
|
|
3574
|
-
const
|
|
3575
|
-
let
|
|
3576
|
-
if (
|
|
3577
|
-
|
|
3578
|
-
if (
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
};
|
|
3582
|
-
}
|
|
3583
|
-
let rememberRemoteSha;
|
|
3584
|
-
if (!opts.theme && config?.baseSha && devTheme) {
|
|
3585
|
-
const sourceBaseSha = config.baseSha;
|
|
3586
|
-
let rememberedDevTheme = devTheme;
|
|
3587
|
-
rememberRemoteSha = (remoteSha) => {
|
|
3588
|
-
rememberedDevTheme = {
|
|
3589
|
-
...rememberedDevTheme,
|
|
3590
|
-
sourceBaseSha,
|
|
3591
|
-
remoteSha,
|
|
3592
|
-
assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint()
|
|
3593
|
-
};
|
|
3594
|
-
setDevTheme(projectKey, rememberedDevTheme);
|
|
3595
|
-
};
|
|
3686
|
+
const baselineEnabled = devRemoteBaselineEnabled(Boolean(opts.theme));
|
|
3687
|
+
let initialRemoteState = baselineEnabled ? readDevRemoteBaseline(themeRoot.root, theme.id, assetManifest.fingerprint()) : null;
|
|
3688
|
+
if (!initialRemoteState && baselineEnabled && devTarget.referenceCloned && config?.themeId && config.baseSha) try {
|
|
3689
|
+
initialRemoteState = await devRemoteStateFromSourceShadow(themeRoot, await ShadowRepo.open(themeRoot.root, config.themeId), theme.id, config.baseSha);
|
|
3690
|
+
if (initialRemoteState) writeDevRemoteBaseline(themeRoot.root, initialRemoteState);
|
|
3691
|
+
} catch {
|
|
3692
|
+
initialRemoteState = null;
|
|
3596
3693
|
}
|
|
3597
3694
|
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
3598
3695
|
let stop;
|
|
@@ -3612,8 +3709,11 @@ function createDevCommand() {
|
|
|
3612
3709
|
port,
|
|
3613
3710
|
reloadMode,
|
|
3614
3711
|
validate: !opts.force,
|
|
3615
|
-
...
|
|
3616
|
-
...
|
|
3712
|
+
...initialRemoteState ? { initialSync: initialRemoteState } : {},
|
|
3713
|
+
...baselineEnabled ? {
|
|
3714
|
+
onRemoteState: (state) => writeDevRemoteBaseline(themeRoot.root, state),
|
|
3715
|
+
onRemoteStateInvalidated: () => removeDevRemoteBaseline(themeRoot.root)
|
|
3716
|
+
} : {}
|
|
3617
3717
|
}, (address) => {
|
|
3618
3718
|
console.log(`\n Dev server: ${address}`);
|
|
3619
3719
|
console.log(` Web editor: ${editorUrl}`);
|
|
@@ -3624,6 +3724,133 @@ function createDevCommand() {
|
|
|
3624
3724
|
});
|
|
3625
3725
|
}
|
|
3626
3726
|
//#endregion
|
|
3727
|
+
//#region src/theme/merge-push.ts
|
|
3728
|
+
/**
|
|
3729
|
+
* Compute what changed locally since the last time the shadow repo
|
|
3730
|
+
* committed a state. Replaces the sha256 `checksums` map: shadow HEAD
|
|
3731
|
+
* is the source of truth for "what the CLI last saw the server have".
|
|
3732
|
+
*
|
|
3733
|
+
* Files whose local bytes are byte-identical to their HEAD blob are
|
|
3734
|
+
* skipped; anything else — new, modified, or a locally-deleted path
|
|
3735
|
+
* that HEAD still has — is included.
|
|
3736
|
+
*/
|
|
3737
|
+
async function diffAgainstShadow(themeRoot, shadow) {
|
|
3738
|
+
const changed = [];
|
|
3739
|
+
const deleted = [];
|
|
3740
|
+
const localFiles = themeRoot.files();
|
|
3741
|
+
const localByKey = /* @__PURE__ */ new Map();
|
|
3742
|
+
const assetManifest = new ThemeAssetManifest(themeRoot.root);
|
|
3743
|
+
let headPaths;
|
|
3744
|
+
try {
|
|
3745
|
+
headPaths = await shadow.headPaths();
|
|
3746
|
+
} catch (error) {
|
|
3747
|
+
throw new Error("Could not read the local theme shadow", { cause: error });
|
|
3748
|
+
}
|
|
3749
|
+
assertNoCaseCollisions([...localFiles.map((file) => file.relativePath), ...headPaths]);
|
|
3750
|
+
const headPathSet = new Set(headPaths);
|
|
3751
|
+
for (const file of localFiles) {
|
|
3752
|
+
if (!file.exists) continue;
|
|
3753
|
+
localByKey.set(file.relativePath, file);
|
|
3754
|
+
const headBlob = await shadow.blobAtHead(file.relativePath);
|
|
3755
|
+
if (!headBlob && headPathSet.has(file.relativePath)) throw new Error(`Could not read the local theme shadow: ${file.relativePath}`);
|
|
3756
|
+
const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
3757
|
+
if (headBlob && headBlob.equals(localBuf)) continue;
|
|
3758
|
+
changed.push(file);
|
|
3759
|
+
}
|
|
3760
|
+
for (const key of headPaths) {
|
|
3761
|
+
if (localByKey.has(key)) continue;
|
|
3762
|
+
if (themeRoot.ignore.ignore(key)) continue;
|
|
3763
|
+
if (assetManifest.has(key)) continue;
|
|
3764
|
+
if (isStylesheetKey(key)) continue;
|
|
3765
|
+
deleted.push(key);
|
|
3766
|
+
}
|
|
3767
|
+
return {
|
|
3768
|
+
changed,
|
|
3769
|
+
deleted
|
|
3770
|
+
};
|
|
3771
|
+
}
|
|
3772
|
+
/**
|
|
3773
|
+
* Refuse a push when any working file still contains a conflict marker
|
|
3774
|
+
* from a previous pull. Mirrors git's "you have unresolved conflicts;
|
|
3775
|
+
* fix them and re-run" behavior — the whole point of writing markers
|
|
3776
|
+
* on pull was to hand resolution to the user, so we can't send them
|
|
3777
|
+
* upstream.
|
|
3778
|
+
*/
|
|
3779
|
+
function findUnresolvedConflicts(files) {
|
|
3780
|
+
const flagged = [];
|
|
3781
|
+
for (const file of files) {
|
|
3782
|
+
if (!file.isText) continue;
|
|
3783
|
+
const buf = readIfExists(file.absolutePath);
|
|
3784
|
+
if (!buf) continue;
|
|
3785
|
+
if (containsConflictMarker(buf)) flagged.push(file.relativePath);
|
|
3786
|
+
}
|
|
3787
|
+
return flagged;
|
|
3788
|
+
}
|
|
3789
|
+
/**
|
|
3790
|
+
* Stable, machine-readable one-liner for non-interactive callers
|
|
3791
|
+
* (Mist Desktop's publish flow parses push output). Uploading marker-
|
|
3792
|
+
* bearing files to a live theme is never acceptable, so `--auto-
|
|
3793
|
+
* baseline` pushes still refuse — but they emit this line so the
|
|
3794
|
+
* desktop can surface WHICH files block the publish instead of a
|
|
3795
|
+
* dead-end wall of prose. Format:
|
|
3796
|
+
*
|
|
3797
|
+
* FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=a.liquid,b.json
|
|
3798
|
+
*/
|
|
3799
|
+
function conflictMarkerBlockLine(files) {
|
|
3800
|
+
return `FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=${files.join(",")}`;
|
|
3801
|
+
}
|
|
3802
|
+
const CONFLICT_START = Buffer.from("<<<<<<<");
|
|
3803
|
+
const CONFLICT_MID = Buffer.from("=======");
|
|
3804
|
+
const CONFLICT_END = Buffer.from(">>>>>>>");
|
|
3805
|
+
/**
|
|
3806
|
+
* A file counts as unresolved when it contains all three marker
|
|
3807
|
+
* shapes: `<<<<<<<`, `=======`, and `>>>>>>>`. Requiring all three
|
|
3808
|
+
* avoids false positives — a line of equals signs alone (e.g. inside
|
|
3809
|
+
* an ASCII table in a template comment) doesn't trip the guard.
|
|
3810
|
+
*/
|
|
3811
|
+
function containsConflictMarker(buf) {
|
|
3812
|
+
return buf.includes(CONFLICT_START) && buf.includes(CONFLICT_MID) && buf.includes(CONFLICT_END);
|
|
3813
|
+
}
|
|
3814
|
+
/**
|
|
3815
|
+
* Commit the current working-tree state to shadow HEAD after a
|
|
3816
|
+
* successful push. Ensures the next pull's merge base is the state
|
|
3817
|
+
* we know the server just accepted.
|
|
3818
|
+
*/
|
|
3819
|
+
async function commitPushedState(themeRoot, shadow, message) {
|
|
3820
|
+
const entries = [];
|
|
3821
|
+
const assetManifest = new ThemeAssetManifest(themeRoot.root);
|
|
3822
|
+
const localKeys = /* @__PURE__ */ new Set();
|
|
3823
|
+
for (const file of themeRoot.files()) {
|
|
3824
|
+
if (!file.exists) continue;
|
|
3825
|
+
localKeys.add(file.relativePath);
|
|
3826
|
+
const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
3827
|
+
entries.push({
|
|
3828
|
+
path: file.relativePath,
|
|
3829
|
+
sha: await shadow.writeBlob(buf)
|
|
3830
|
+
});
|
|
3831
|
+
}
|
|
3832
|
+
let managedAssetSentinelSha;
|
|
3833
|
+
for (const key of assetManifest.keys()) {
|
|
3834
|
+
if (localKeys.has(key)) continue;
|
|
3835
|
+
managedAssetSentinelSha ??= await shadow.writeBlob(MANAGED_ASSET_SHADOW_SENTINEL);
|
|
3836
|
+
entries.push({
|
|
3837
|
+
path: key,
|
|
3838
|
+
sha: managedAssetSentinelSha
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3841
|
+
if (entries.length > 0 || await shadow.hasHead()) await shadow.commitState(entries, message);
|
|
3842
|
+
}
|
|
3843
|
+
/**
|
|
3844
|
+
* A target can already have every URL-backed FileResource (for example from a
|
|
3845
|
+
* reference clone), while this checkout's manifest still names its old source
|
|
3846
|
+
* theme. Adopt the target and clear any legacy binary from shadow even though
|
|
3847
|
+
* no remote write was necessary.
|
|
3848
|
+
*/
|
|
3849
|
+
async function finalizeManifestOnlyPush(syncer, themeRoot, shadow) {
|
|
3850
|
+
syncer.repointManagedAssetsToCurrentTheme();
|
|
3851
|
+
await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
3852
|
+
}
|
|
3853
|
+
//#endregion
|
|
3627
3854
|
//#region src/theme/auto-baseline.ts
|
|
3628
3855
|
/**
|
|
3629
3856
|
* `fluid theme push --auto-baseline`: when a theme directory has no
|
|
@@ -4305,7 +4532,11 @@ function createPullCommand() {
|
|
|
4305
4532
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
4306
4533
|
const actorPromise = fetchSyncActor(api);
|
|
4307
4534
|
const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
4308
|
-
const resources = await syncer.downloadAll()
|
|
4535
|
+
const resources = await syncer.downloadAll().catch((error) => {
|
|
4536
|
+
if (!(error instanceof CaseCollisionError)) throw error;
|
|
4537
|
+
spinner.fail(error.message);
|
|
4538
|
+
process.exit(1);
|
|
4539
|
+
});
|
|
4309
4540
|
const externalizedAssets = await syncer.externalizePulledAssets(resources, { delete: !opts.nodelete });
|
|
4310
4541
|
const result = await mergePull({
|
|
4311
4542
|
themeRoot,
|