@fluid-app/fluid-cli-theme-dev 0.1.47 → 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 -1
- package/dist/index.mjs +649 -322
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -7,11 +7,11 @@ import http from "node:http";
|
|
|
7
7
|
import https from "node:https";
|
|
8
8
|
import chokidar from "chokidar";
|
|
9
9
|
import net from "node:net";
|
|
10
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
10
12
|
import chalk from "chalk";
|
|
11
13
|
import prompts from "prompts";
|
|
12
14
|
import ora from "ora";
|
|
13
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
//#region ../../platform/api-client-core/src/api-error-shape.ts
|
|
17
17
|
/**
|
|
@@ -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;
|
|
@@ -1487,6 +1509,15 @@ var ThemeAssetManifest = class {
|
|
|
1487
1509
|
entries() {
|
|
1488
1510
|
return Object.entries(this.assets).map(([key, link]) => [key, copyLink(link)]);
|
|
1489
1511
|
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Stable digest of the URL references represented by this manifest.
|
|
1514
|
+
* Pull baselines exclude pending entries because those belong only to an
|
|
1515
|
+
* existing dev target and are deliberately absent from the pulled source.
|
|
1516
|
+
*/
|
|
1517
|
+
fingerprint(opts = {}) {
|
|
1518
|
+
const entries = this.entries().filter(([, link]) => !opts.excludePending || !link.pending).toSorted(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
|
1519
|
+
return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
1520
|
+
}
|
|
1490
1521
|
has(key) {
|
|
1491
1522
|
return this.assets[key] !== void 0;
|
|
1492
1523
|
}
|
|
@@ -1525,16 +1556,16 @@ var ThemeAssetManifest = class {
|
|
|
1525
1556
|
function readDocument(path) {
|
|
1526
1557
|
if (!existsSync(path)) return emptyDocument();
|
|
1527
1558
|
try {
|
|
1528
|
-
return parseDocument(JSON.parse(readFileSync(path, "utf-8")));
|
|
1559
|
+
return parseDocument$1(JSON.parse(readFileSync(path, "utf-8")));
|
|
1529
1560
|
} catch (error) {
|
|
1530
1561
|
const message = error instanceof Error ? error.message : String(error);
|
|
1531
1562
|
throw new Error(`Could not read ${MANIFEST_FILE}: ${message}`);
|
|
1532
1563
|
}
|
|
1533
1564
|
}
|
|
1534
|
-
function parseDocument(value) {
|
|
1535
|
-
if (!isRecord$
|
|
1565
|
+
function parseDocument$1(value) {
|
|
1566
|
+
if (!isRecord$2(value) || value["version"] !== MANIFEST_VERSION) throw new Error(`expected version ${MANIFEST_VERSION}`);
|
|
1536
1567
|
const rawAssets = value["assets"];
|
|
1537
|
-
if (!isRecord$
|
|
1568
|
+
if (!isRecord$2(rawAssets)) throw new Error("expected an assets object");
|
|
1538
1569
|
const assets = {};
|
|
1539
1570
|
for (const [key, rawLink] of Object.entries(rawAssets)) {
|
|
1540
1571
|
if (!isThemeAssetKey(key) || !isThemeAssetLink(rawLink)) throw new Error(`invalid asset entry for ${key}`);
|
|
@@ -1569,14 +1600,14 @@ function copyLink(link) {
|
|
|
1569
1600
|
};
|
|
1570
1601
|
}
|
|
1571
1602
|
function isThemeAssetLink(value) {
|
|
1572
|
-
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);
|
|
1573
1604
|
}
|
|
1574
1605
|
function isThemeAssetKey(key) {
|
|
1575
1606
|
if (key.includes("\\") || key.includes("\0")) return false;
|
|
1576
1607
|
const segments = key.split("/");
|
|
1577
1608
|
return segments[0] === "assets" && segments.length === 2 && segments[1] !== void 0 && segments[1].length > 0 && segments[1] !== "." && segments[1] !== "..";
|
|
1578
1609
|
}
|
|
1579
|
-
function isRecord$
|
|
1610
|
+
function isRecord$2(value) {
|
|
1580
1611
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1581
1612
|
}
|
|
1582
1613
|
//#endregion
|
|
@@ -1610,6 +1641,7 @@ var Syncer = class {
|
|
|
1610
1641
|
remoteResourceGroups = /* @__PURE__ */ new Map();
|
|
1611
1642
|
remoteResourceIndex = /* @__PURE__ */ new Map();
|
|
1612
1643
|
remoteIndexesDirty = false;
|
|
1644
|
+
remoteResourcesLoaded = false;
|
|
1613
1645
|
lastKnownRemoteSha = null;
|
|
1614
1646
|
assetManifestInstance;
|
|
1615
1647
|
constructor(api, themeId, themeRoot, assetManifest) {
|
|
@@ -1626,6 +1658,7 @@ var Syncer = class {
|
|
|
1626
1658
|
const body = await listThemeResources(this.api, this.themeId);
|
|
1627
1659
|
this.updateChecksums(body.application_theme_resources ?? []);
|
|
1628
1660
|
this.lastKnownRemoteSha = body.content_version_sha ?? null;
|
|
1661
|
+
this.remoteResourcesLoaded = true;
|
|
1629
1662
|
}
|
|
1630
1663
|
/**
|
|
1631
1664
|
* Server's `content_version_sha` captured on the last `fetchChecksums()`
|
|
@@ -1635,6 +1668,7 @@ var Syncer = class {
|
|
|
1635
1668
|
return this.lastKnownRemoteSha;
|
|
1636
1669
|
}
|
|
1637
1670
|
updateChecksums(resources) {
|
|
1671
|
+
assertNoCaseCollisions(resources.flatMap((resource) => resource.key ? [resource.key] : []));
|
|
1638
1672
|
this.rawRemoteResources.clear();
|
|
1639
1673
|
this.remoteResourceGroups.clear();
|
|
1640
1674
|
for (const resource of resources) {
|
|
@@ -1681,6 +1715,11 @@ var Syncer = class {
|
|
|
1681
1715
|
remoteKeys() {
|
|
1682
1716
|
return [...this.remoteResources.keys()];
|
|
1683
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
|
+
}
|
|
1684
1723
|
/** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
|
|
1685
1724
|
remoteChecksums() {
|
|
1686
1725
|
return Object.fromEntries(this.checksums);
|
|
@@ -1695,13 +1734,32 @@ var Syncer = class {
|
|
|
1695
1734
|
}
|
|
1696
1735
|
return urls;
|
|
1697
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
|
+
}
|
|
1698
1756
|
/**
|
|
1699
1757
|
* Adds URL-backed FileResources for manifest assets without transferring
|
|
1700
1758
|
* their bytes. The target stores the source asset's ImageKit URL.
|
|
1701
1759
|
*/
|
|
1702
1760
|
async linkManagedAssets(opts = {}) {
|
|
1703
1761
|
this.assetManifest.reload();
|
|
1704
|
-
await this.
|
|
1762
|
+
await this.ensureRemoteResourcesLoaded();
|
|
1705
1763
|
const plans = [];
|
|
1706
1764
|
for (const [key, link] of this.assetManifest.entries()) {
|
|
1707
1765
|
if (this.themeRoot.ignore.ignore(key)) continue;
|
|
@@ -1761,7 +1819,7 @@ var Syncer = class {
|
|
|
1761
1819
|
}
|
|
1762
1820
|
async fetchThemeAssetMetadata(sourceThemeId) {
|
|
1763
1821
|
const body = await getThemeAssets(this.api, sourceThemeId);
|
|
1764
|
-
if (!isRecord(body) || !Array.isArray(body["file_resources"])) throw new Error("Theme assets response did not include file_resources");
|
|
1822
|
+
if (!isRecord$1(body) || !Array.isArray(body["file_resources"])) throw new Error("Theme assets response did not include file_resources");
|
|
1765
1823
|
const assets = /* @__PURE__ */ new Map();
|
|
1766
1824
|
for (const value of body["file_resources"]) {
|
|
1767
1825
|
const asset = parseThemeAssetMetadata(value);
|
|
@@ -1916,7 +1974,12 @@ var Syncer = class {
|
|
|
1916
1974
|
key: file.relativePath,
|
|
1917
1975
|
content
|
|
1918
1976
|
}, baseSha);
|
|
1919
|
-
this.setRemoteResource(
|
|
1977
|
+
this.setRemoteResource({
|
|
1978
|
+
...resource,
|
|
1979
|
+
key: file.relativePath,
|
|
1980
|
+
content,
|
|
1981
|
+
checksum: resource.checksum ?? file.checksum()
|
|
1982
|
+
});
|
|
1920
1983
|
return content;
|
|
1921
1984
|
}
|
|
1922
1985
|
if (isNestedBinaryThemeAsset(file)) throw new Error(`Binary assets must be directly inside assets/: ${file.relativePath}`);
|
|
@@ -2117,7 +2180,7 @@ var Syncer = class {
|
|
|
2117
2180
|
const response = await deleteThemeResource(this.api, this.themeId, body);
|
|
2118
2181
|
if (response.content_version_sha) this.lastKnownRemoteSha = response.content_version_sha;
|
|
2119
2182
|
} catch (e) {
|
|
2120
|
-
throw this.rethrowIfConflict(e);
|
|
2183
|
+
if (!isNotFoundError(e)) throw this.rethrowIfConflict(e);
|
|
2121
2184
|
}
|
|
2122
2185
|
this.removeRemoteResource(relativePath);
|
|
2123
2186
|
}
|
|
@@ -2126,6 +2189,7 @@ var Syncer = class {
|
|
|
2126
2189
|
const resources = body.application_theme_resources ?? [];
|
|
2127
2190
|
this.updateChecksums(resources);
|
|
2128
2191
|
this.lastKnownRemoteSha = body.content_version_sha ?? null;
|
|
2192
|
+
this.remoteResourcesLoaded = true;
|
|
2129
2193
|
return resources;
|
|
2130
2194
|
}
|
|
2131
2195
|
async downloadBinaryAsset(url) {
|
|
@@ -2260,6 +2324,7 @@ var Syncer = class {
|
|
|
2260
2324
|
}
|
|
2261
2325
|
async uploadTheme(opts = {}) {
|
|
2262
2326
|
const localFiles = this.themeRoot.files();
|
|
2327
|
+
assertNoCaseCollisions(localFiles.map((file) => file.relativePath));
|
|
2263
2328
|
const result = {
|
|
2264
2329
|
uploaded: 0,
|
|
2265
2330
|
deleted: 0,
|
|
@@ -2279,8 +2344,9 @@ var Syncer = class {
|
|
|
2279
2344
|
return result;
|
|
2280
2345
|
}
|
|
2281
2346
|
}
|
|
2282
|
-
|
|
2283
|
-
await this.
|
|
2347
|
+
if (opts.remoteState) this.useDevRemoteState(opts.remoteState);
|
|
2348
|
+
else await this.fetchChecksums();
|
|
2349
|
+
if (!opts.skipPreflight) await this.preflightPush(opts.baseSha);
|
|
2284
2350
|
let baseSha = opts.baseSha ?? null;
|
|
2285
2351
|
if (opts.linkManagedAssets) {
|
|
2286
2352
|
result.linked = await this.linkManagedAssets(opts.linkManagedAssets);
|
|
@@ -2305,7 +2371,7 @@ var Syncer = class {
|
|
|
2305
2371
|
if (opts.delete) {
|
|
2306
2372
|
const localPaths = new Set(localFiles.map((f) => f.relativePath));
|
|
2307
2373
|
for (const key of this.assetManifest.keys()) localPaths.add(key);
|
|
2308
|
-
const toDelete = this.remoteKeys().filter((key) => !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
|
|
2374
|
+
const toDelete = this.remoteKeys().filter((key) => this.canDeleteRemoteResource(key) && !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
|
|
2309
2375
|
for (const key of toDelete) try {
|
|
2310
2376
|
await this.deleteRemoteFile(key, baseSha);
|
|
2311
2377
|
baseSha = this.lastKnownRemoteSha;
|
|
@@ -2383,6 +2449,26 @@ function isNestedBinaryThemeAsset(file) {
|
|
|
2383
2449
|
function isManagedAssetResource(resource) {
|
|
2384
2450
|
return resource?.resource_type === "FileResource" && typeof resource.url === "string" && resource.url.length > 0;
|
|
2385
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
|
+
}
|
|
2386
2472
|
function isNotFoundError(error) {
|
|
2387
2473
|
return isApiError(error) && error.status === 404;
|
|
2388
2474
|
}
|
|
@@ -2418,7 +2504,7 @@ function uploadedAssetMetadata(file, resource) {
|
|
|
2418
2504
|
};
|
|
2419
2505
|
}
|
|
2420
2506
|
function parseThemeAssetMetadata(value) {
|
|
2421
|
-
if (!isRecord(value)) return void 0;
|
|
2507
|
+
if (!isRecord$1(value)) return void 0;
|
|
2422
2508
|
const filename = nonEmptyString(value["filename"]);
|
|
2423
2509
|
const url = nonEmptyString(value["url"]);
|
|
2424
2510
|
const contentType = nonEmptyString(value["content_type"]);
|
|
@@ -2440,7 +2526,7 @@ function parseThemeAssetMetadata(value) {
|
|
|
2440
2526
|
};
|
|
2441
2527
|
}
|
|
2442
2528
|
function createdFileResourceId(value) {
|
|
2443
|
-
if (!isRecord(value) || !isRecord(value["file_resource"])) return;
|
|
2529
|
+
if (!isRecord$1(value) || !isRecord$1(value["file_resource"])) return;
|
|
2444
2530
|
return positiveInteger(value["file_resource"]["id"]);
|
|
2445
2531
|
}
|
|
2446
2532
|
function positiveInteger(value) {
|
|
@@ -2455,7 +2541,7 @@ function nonEmptyString(value) {
|
|
|
2455
2541
|
function optionalString(value) {
|
|
2456
2542
|
return typeof value === "string" ? value : void 0;
|
|
2457
2543
|
}
|
|
2458
|
-
function isRecord(value) {
|
|
2544
|
+
function isRecord$1(value) {
|
|
2459
2545
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2460
2546
|
}
|
|
2461
2547
|
//#endregion
|
|
@@ -2642,17 +2728,58 @@ function timestamp() {
|
|
|
2642
2728
|
async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
2643
2729
|
const sse = new SSEStream();
|
|
2644
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
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
};
|
|
2645
2750
|
const pendingUpdates = /* @__PURE__ */ new Set();
|
|
2646
2751
|
console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
|
|
2647
|
-
const
|
|
2752
|
+
const progress = (done, total) => {
|
|
2753
|
+
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
2754
|
+
};
|
|
2755
|
+
const uploadFromRemoteIndex = () => syncer.uploadTheme({
|
|
2648
2756
|
delete: true,
|
|
2649
2757
|
validate: opts.validate,
|
|
2650
2758
|
linkManagedAssets: { replace: true },
|
|
2651
2759
|
pendingBinaryAssets: true,
|
|
2652
|
-
onProgress:
|
|
2653
|
-
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
2654
|
-
}
|
|
2760
|
+
onProgress: progress
|
|
2655
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();
|
|
2656
2783
|
process.stdout.write("\n");
|
|
2657
2784
|
if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
|
|
2658
2785
|
if (syncResult.validationFailed) {
|
|
@@ -2660,17 +2787,28 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
|
2660
2787
|
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
2661
2788
|
process.exit(1);
|
|
2662
2789
|
} else if (syncResult.errors.length > 0) {
|
|
2790
|
+
invalidateRemoteState();
|
|
2663
2791
|
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
2664
2792
|
if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
|
|
2665
2793
|
}
|
|
2794
|
+
if (syncResult.errors.length === 0) recordRemoteState();
|
|
2666
2795
|
const SYNC_IDLE_MS = 2e3;
|
|
2667
2796
|
let lastArrivedAt = 0;
|
|
2668
2797
|
let pendingSync = null;
|
|
2669
2798
|
let syncInFlight = Promise.resolve();
|
|
2670
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
|
+
};
|
|
2671
2807
|
const sendSync = () => {
|
|
2672
2808
|
syncInFlight = syncInFlight.then(async () => {
|
|
2673
|
-
|
|
2809
|
+
const accepted = await syncer.requestSync();
|
|
2810
|
+
askOwed = !accepted;
|
|
2811
|
+
if (accepted) recordRemoteState();
|
|
2674
2812
|
});
|
|
2675
2813
|
};
|
|
2676
2814
|
const flushSyncNow = () => {
|
|
@@ -2698,7 +2836,15 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
|
2698
2836
|
sendSync();
|
|
2699
2837
|
await syncInFlight;
|
|
2700
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)}`);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2701
2846
|
const changed = [...modified, ...added];
|
|
2847
|
+
let wroteRemote = false;
|
|
2702
2848
|
for (const file of changed) {
|
|
2703
2849
|
if (opts.validate && file.isLiquid) {
|
|
2704
2850
|
const diagnostics = file.validateSchema();
|
|
@@ -2709,23 +2855,39 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
|
2709
2855
|
}
|
|
2710
2856
|
pendingUpdates.add(file.relativePath);
|
|
2711
2857
|
try {
|
|
2712
|
-
const uploadedContent = await syncer.uploadFile(file,
|
|
2858
|
+
const uploadedContent = await syncer.uploadFile(file, syncer.remoteSha(), { pendingAsset: true });
|
|
2859
|
+
wroteRemote = true;
|
|
2713
2860
|
console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
|
|
2714
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`);
|
|
2715
2862
|
if (file.isLiquid && uploadedContent !== null) for (const diagnostic of findLiquidBlockTagDiagnostics(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: ${diagnostic.message}`);
|
|
2716
2863
|
} catch (e) {
|
|
2864
|
+
if (e instanceof PushConflictError) {
|
|
2865
|
+
blockRemoteWrites(e);
|
|
2866
|
+
break;
|
|
2867
|
+
}
|
|
2868
|
+
invalidateRemoteState();
|
|
2717
2869
|
console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
|
|
2718
2870
|
} finally {
|
|
2719
2871
|
pendingUpdates.delete(file.relativePath);
|
|
2720
2872
|
}
|
|
2721
2873
|
}
|
|
2874
|
+
if (remoteWritesBlocked) return;
|
|
2722
2875
|
for (const file of removed) {
|
|
2723
2876
|
if (themeRoot.ignore.ignore(file.relativePath)) continue;
|
|
2724
2877
|
try {
|
|
2725
|
-
await syncer.deleteRemoteFile(file.relativePath);
|
|
2878
|
+
await syncer.deleteRemoteFile(file.relativePath, syncer.remoteSha());
|
|
2879
|
+
wroteRemote = true;
|
|
2726
2880
|
console.log(` ✓ removed ${file.relativePath}`);
|
|
2727
|
-
} catch {
|
|
2881
|
+
} catch (error) {
|
|
2882
|
+
if (error instanceof PushConflictError) {
|
|
2883
|
+
blockRemoteWrites(error);
|
|
2884
|
+
break;
|
|
2885
|
+
}
|
|
2886
|
+
invalidateRemoteState();
|
|
2887
|
+
}
|
|
2728
2888
|
}
|
|
2889
|
+
if (remoteWritesBlocked) return;
|
|
2890
|
+
if (wroteRemote) recordRemoteState();
|
|
2729
2891
|
if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
|
|
2730
2892
|
else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
|
|
2731
2893
|
scheduleSync();
|
|
@@ -2773,302 +2935,155 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
|
2773
2935
|
};
|
|
2774
2936
|
}
|
|
2775
2937
|
//#endregion
|
|
2776
|
-
//#region src/theme-
|
|
2777
|
-
const
|
|
2778
|
-
const
|
|
2779
|
-
function
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
}
|
|
2788
|
-
if (hasMore) choices.push({
|
|
2789
|
-
title: chalk.dim(`── Load more themes ──`),
|
|
2790
|
-
value: LOAD_MORE_VALUE
|
|
2791
|
-
});
|
|
2792
|
-
return choices;
|
|
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;
|
|
2949
|
+
}
|
|
2793
2950
|
}
|
|
2794
|
-
|
|
2795
|
-
const
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
...searchQuery ? { search_query: searchQuery } : {}
|
|
2799
|
-
});
|
|
2800
|
-
return {
|
|
2801
|
-
themes: body.application_themes ?? [],
|
|
2802
|
-
hasMore: page < (body.meta?.total_pages ?? 1)
|
|
2951
|
+
function writeDevRemoteBaseline(themeRoot, state) {
|
|
2952
|
+
const document = {
|
|
2953
|
+
version: BASELINE_VERSION,
|
|
2954
|
+
...state
|
|
2803
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
|
|
2964
|
+
});
|
|
2965
|
+
renameSync(tempPath, path);
|
|
2966
|
+
} catch (error) {
|
|
2967
|
+
rmSync(tempPath, { force: true });
|
|
2968
|
+
throw error;
|
|
2969
|
+
}
|
|
2804
2970
|
}
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
const
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
initial: initialIndex,
|
|
2828
|
-
choices,
|
|
2829
|
-
suggest: async (input, choices) => {
|
|
2830
|
-
if (!input) {
|
|
2831
|
-
searchQuery = "";
|
|
2832
|
-
searchResults = [];
|
|
2833
|
-
return choices;
|
|
2834
|
-
}
|
|
2835
|
-
if (input !== searchQuery) {
|
|
2836
|
-
searchQuery = input;
|
|
2837
|
-
try {
|
|
2838
|
-
searchResults = (await fetchThemesPage(api, 1, input)).themes;
|
|
2839
|
-
} catch {
|
|
2840
|
-
searchResults = [];
|
|
2841
|
-
}
|
|
2842
|
-
}
|
|
2843
|
-
return searchResults.map((t) => ({
|
|
2844
|
-
title: themeLabel(t),
|
|
2845
|
-
value: t.id
|
|
2846
|
-
}));
|
|
2971
|
+
function removeDevRemoteBaseline(themeRoot) {
|
|
2972
|
+
rmSync(join(themeRoot, BASELINE_FILE), { force: true });
|
|
2973
|
+
}
|
|
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;
|
|
2847
2993
|
}
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
if (!id) {
|
|
2855
|
-
console.error("No theme selected.");
|
|
2856
|
-
process.exit(1);
|
|
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
|
+
});
|
|
2857
3000
|
}
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
3001
|
+
return {
|
|
3002
|
+
themeId,
|
|
3003
|
+
remoteSha,
|
|
3004
|
+
assetManifestSha: assetManifest.fingerprint(),
|
|
3005
|
+
resources
|
|
3006
|
+
};
|
|
3007
|
+
} catch {
|
|
3008
|
+
return null;
|
|
2861
3009
|
}
|
|
2862
3010
|
}
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
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
|
|
3028
|
+
};
|
|
2880
3029
|
}
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
const
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
if (parent === dir) break;
|
|
2904
|
-
dir = parent;
|
|
2905
|
-
}
|
|
2906
|
-
return null;
|
|
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
|
|
3051
|
+
};
|
|
2907
3052
|
}
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
* cwd = /code/fluid-theme-dev/local → null
|
|
2917
|
-
*/
|
|
2918
|
-
function resolveThemeRootFromCwd(workspace) {
|
|
2919
|
-
const cwd = resolve(process.cwd());
|
|
2920
|
-
const localDir = join(workspace.root, "local");
|
|
2921
|
-
const rel = relative(localDir, cwd);
|
|
2922
|
-
if (rel.startsWith("..") || rel === ".") return null;
|
|
2923
|
-
const firstSegment = rel.split(sep)[0];
|
|
2924
|
-
if (!firstSegment) return null;
|
|
2925
|
-
return join(localDir, firstSegment);
|
|
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;
|
|
2926
3061
|
}
|
|
2927
3062
|
//#endregion
|
|
2928
|
-
//#region src/
|
|
3063
|
+
//#region src/theme/shadow-repo.ts
|
|
2929
3064
|
/**
|
|
2930
|
-
*
|
|
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.
|
|
2931
3072
|
*
|
|
2932
|
-
*
|
|
2933
|
-
*
|
|
2934
|
-
*
|
|
2935
|
-
*
|
|
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.
|
|
2936
3081
|
*/
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
|
|
2943
|
-
}
|
|
2944
|
-
return (await createApplicationTheme(api, { application_theme: {
|
|
2945
|
-
name,
|
|
2946
|
-
status: "development"
|
|
2947
|
-
} })).application_theme;
|
|
2948
|
-
}
|
|
2949
|
-
async function ensureDevTheme(api, projectKey, identifier, sourceThemeId) {
|
|
2950
|
-
if (identifier) {
|
|
2951
|
-
const theme = await findTheme(api, identifier);
|
|
2952
|
-
setLastDevThemeId(theme.id);
|
|
2953
|
-
return theme;
|
|
2954
|
-
}
|
|
2955
|
-
const stored = getDevTheme(projectKey);
|
|
2956
|
-
if (stored && stored.sourceThemeId === sourceThemeId) {
|
|
2957
|
-
try {
|
|
2958
|
-
const existing = (await getApplicationTheme(api, stored.id)).application_theme;
|
|
2959
|
-
if (existing && existing.status === "development") {
|
|
2960
|
-
console.log(`Using existing dev theme #${existing.id}`);
|
|
2961
|
-
setDevTheme(projectKey, {
|
|
2962
|
-
id: existing.id,
|
|
2963
|
-
name: existing.name,
|
|
2964
|
-
...sourceThemeId === void 0 ? {} : { sourceThemeId }
|
|
2965
|
-
});
|
|
2966
|
-
return existing;
|
|
2967
|
-
}
|
|
2968
|
-
} catch {}
|
|
2969
|
-
clearDevTheme(projectKey);
|
|
2970
|
-
}
|
|
2971
|
-
const { hostname } = await import("node:os");
|
|
2972
|
-
const theme = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
|
|
2973
|
-
setDevTheme(projectKey, {
|
|
2974
|
-
id: theme.id,
|
|
2975
|
-
name: theme.name,
|
|
2976
|
-
...sourceThemeId === void 0 ? {} : { sourceThemeId }
|
|
2977
|
-
});
|
|
2978
|
-
console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
|
|
2979
|
-
return theme;
|
|
2980
|
-
}
|
|
2981
|
-
function createDevCommand() {
|
|
2982
|
-
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) => {
|
|
2983
|
-
requireToken();
|
|
2984
|
-
let rootPath = opts.root;
|
|
2985
|
-
if (rootPath === ".") {
|
|
2986
|
-
const workspace = findWorkspace();
|
|
2987
|
-
if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
2988
|
-
}
|
|
2989
|
-
const themeRoot = new ThemeRoot(rootPath);
|
|
2990
|
-
if (!themeRoot.isValid()) {
|
|
2991
|
-
console.error(`'${rootPath}' does not look like a theme directory.`);
|
|
2992
|
-
process.exit(1);
|
|
2993
|
-
}
|
|
2994
|
-
const port = Number(opts.port);
|
|
2995
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2996
|
-
console.error(`Invalid port: '${opts.port}'. Must be an integer between 1 and 65535.`);
|
|
2997
|
-
process.exit(1);
|
|
2998
|
-
}
|
|
2999
|
-
try {
|
|
3000
|
-
await checkPortAvailable(opts.host, port);
|
|
3001
|
-
} catch (e) {
|
|
3002
|
-
if (e instanceof PortInUseError) console.error(e.message);
|
|
3003
|
-
else console.error(`Failed to check port availability: ${e}`);
|
|
3004
|
-
process.exit(1);
|
|
3005
|
-
}
|
|
3006
|
-
const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
|
|
3007
|
-
const api = createApiClient();
|
|
3008
|
-
const config = readThemeConfig(themeRoot.root);
|
|
3009
|
-
let company;
|
|
3010
|
-
if (config?.company) company = config.company;
|
|
3011
|
-
else {
|
|
3012
|
-
company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain ?? "";
|
|
3013
|
-
if (!company) {
|
|
3014
|
-
console.error("Could not determine company subdomain. Make sure your token is valid.");
|
|
3015
|
-
process.exit(1);
|
|
3016
|
-
}
|
|
3017
|
-
}
|
|
3018
|
-
const projectKey = devThemeKey(company, themeRoot.root);
|
|
3019
|
-
const theme = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId);
|
|
3020
|
-
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
3021
|
-
let stop;
|
|
3022
|
-
const cleanup = () => {
|
|
3023
|
-
stop?.();
|
|
3024
|
-
process.exit(0);
|
|
3025
|
-
};
|
|
3026
|
-
process.on("SIGINT", cleanup);
|
|
3027
|
-
process.on("SIGTERM", cleanup);
|
|
3028
|
-
stop = await startDevServer(api, {
|
|
3029
|
-
id: theme.id,
|
|
3030
|
-
name: theme.name,
|
|
3031
|
-
company,
|
|
3032
|
-
editorUrl
|
|
3033
|
-
}, themeRoot, {
|
|
3034
|
-
host: opts.host,
|
|
3035
|
-
port,
|
|
3036
|
-
reloadMode,
|
|
3037
|
-
validate: !opts.force
|
|
3038
|
-
}, (address) => {
|
|
3039
|
-
console.log(`\n Dev server: ${address}`);
|
|
3040
|
-
console.log(` Web editor: ${editorUrl}`);
|
|
3041
|
-
console.log("\n Watching for file changes…\n");
|
|
3042
|
-
if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
|
|
3043
|
-
});
|
|
3044
|
-
await new Promise(() => {});
|
|
3045
|
-
});
|
|
3046
|
-
}
|
|
3047
|
-
//#endregion
|
|
3048
|
-
//#region src/theme/shadow-repo.ts
|
|
3049
|
-
/**
|
|
3050
|
-
* A bare git repo hidden under `.fluid-theme/repo` that stands in for
|
|
3051
|
-
* git's index+HEAD when talking to the Fluid server. Every pull commits
|
|
3052
|
-
* the incoming remote state onto a single branch (`refs/heads/main`);
|
|
3053
|
-
* every successful push commits the outgoing local state onto the same
|
|
3054
|
-
* branch. HEAD therefore represents "the last state the CLI and server
|
|
3055
|
-
* agreed on", and its tree is the natural three-way-merge base for the
|
|
3056
|
-
* next pull.
|
|
3057
|
-
*
|
|
3058
|
-
* We stay in git's plumbing layer — no working tree, no index file
|
|
3059
|
-
* next to the theme content — so the shadow repo cannot interfere
|
|
3060
|
-
* with the user's own git repo (if they have one) around the theme
|
|
3061
|
-
* dir. All state lives inside `.fluid-theme/`.
|
|
3062
|
-
*
|
|
3063
|
-
* The class only wraps the small handful of plumbing commands we
|
|
3064
|
-
* actually need: hash-object, cat-file, write-tree (via a temp index),
|
|
3065
|
-
* commit-tree, update-ref, and merge-file. Everything else stays out.
|
|
3066
|
-
*/
|
|
3067
|
-
var ShadowRepo = class ShadowRepo {
|
|
3068
|
-
headExists = void 0;
|
|
3069
|
-
constructor(themeRoot, gitDir) {
|
|
3070
|
-
this.themeRoot = themeRoot;
|
|
3071
|
-
this.gitDir = gitDir;
|
|
3082
|
+
var ShadowRepo = class ShadowRepo {
|
|
3083
|
+
headExists = void 0;
|
|
3084
|
+
constructor(themeRoot, gitDir) {
|
|
3085
|
+
this.themeRoot = themeRoot;
|
|
3086
|
+
this.gitDir = gitDir;
|
|
3072
3087
|
}
|
|
3073
3088
|
/**
|
|
3074
3089
|
* Return a ShadowRepo bound to `themeId` for the given theme root.
|
|
@@ -3404,6 +3419,311 @@ async function ensureRootGitignoreHidesShadow(themeRoot) {
|
|
|
3404
3419
|
writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
|
|
3405
3420
|
}
|
|
3406
3421
|
//#endregion
|
|
3422
|
+
//#region src/theme-picker.ts
|
|
3423
|
+
const PAGE_SIZE = 50;
|
|
3424
|
+
const LOAD_MORE_VALUE = -1;
|
|
3425
|
+
function themeLabel(t) {
|
|
3426
|
+
const active = t.status === "active" ? ` ${chalk.green("[active]")}` : "";
|
|
3427
|
+
return `${t.name} (#${t.id})${active}`;
|
|
3428
|
+
}
|
|
3429
|
+
function themeChoices(themeList, hasMore) {
|
|
3430
|
+
const choices = themeList.map((t) => ({
|
|
3431
|
+
title: themeLabel(t),
|
|
3432
|
+
value: t.id
|
|
3433
|
+
}));
|
|
3434
|
+
if (hasMore) choices.push({
|
|
3435
|
+
title: chalk.dim(`── Load more themes ──`),
|
|
3436
|
+
value: LOAD_MORE_VALUE
|
|
3437
|
+
});
|
|
3438
|
+
return choices;
|
|
3439
|
+
}
|
|
3440
|
+
async function fetchThemesPage(api, page, searchQuery) {
|
|
3441
|
+
const body = await listApplicationThemes(api, {
|
|
3442
|
+
per_page: PAGE_SIZE,
|
|
3443
|
+
page,
|
|
3444
|
+
...searchQuery ? { search_query: searchQuery } : {}
|
|
3445
|
+
});
|
|
3446
|
+
return {
|
|
3447
|
+
themes: body.application_themes ?? [],
|
|
3448
|
+
hasMore: page < (body.meta?.total_pages ?? 1)
|
|
3449
|
+
};
|
|
3450
|
+
}
|
|
3451
|
+
async function selectTheme(api, message) {
|
|
3452
|
+
const allThemes = [];
|
|
3453
|
+
let page = 1;
|
|
3454
|
+
let hasMore = true;
|
|
3455
|
+
let initialIndex = 0;
|
|
3456
|
+
let searchQuery = "";
|
|
3457
|
+
let searchResults = [];
|
|
3458
|
+
while (true) {
|
|
3459
|
+
if (hasMore && allThemes.length < page * PAGE_SIZE) {
|
|
3460
|
+
const result = await fetchThemesPage(api, page);
|
|
3461
|
+
allThemes.push(...result.themes);
|
|
3462
|
+
hasMore = result.hasMore;
|
|
3463
|
+
}
|
|
3464
|
+
if (!allThemes.length) {
|
|
3465
|
+
console.error("No themes found.");
|
|
3466
|
+
process.exit(1);
|
|
3467
|
+
}
|
|
3468
|
+
const choices = themeChoices(allThemes, hasMore);
|
|
3469
|
+
const { id } = await prompts({
|
|
3470
|
+
type: "autocomplete",
|
|
3471
|
+
name: "id",
|
|
3472
|
+
message,
|
|
3473
|
+
initial: initialIndex,
|
|
3474
|
+
choices,
|
|
3475
|
+
suggest: async (input, choices) => {
|
|
3476
|
+
if (!input) {
|
|
3477
|
+
searchQuery = "";
|
|
3478
|
+
searchResults = [];
|
|
3479
|
+
return choices;
|
|
3480
|
+
}
|
|
3481
|
+
if (input !== searchQuery) {
|
|
3482
|
+
searchQuery = input;
|
|
3483
|
+
try {
|
|
3484
|
+
searchResults = (await fetchThemesPage(api, 1, input)).themes;
|
|
3485
|
+
} catch {
|
|
3486
|
+
searchResults = [];
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
return searchResults.map((t) => ({
|
|
3490
|
+
title: themeLabel(t),
|
|
3491
|
+
value: t.id
|
|
3492
|
+
}));
|
|
3493
|
+
}
|
|
3494
|
+
}, { onCancel: () => process.exit(130) });
|
|
3495
|
+
if (id === LOAD_MORE_VALUE) {
|
|
3496
|
+
initialIndex = allThemes.length;
|
|
3497
|
+
page++;
|
|
3498
|
+
continue;
|
|
3499
|
+
}
|
|
3500
|
+
if (!id) {
|
|
3501
|
+
console.error("No theme selected.");
|
|
3502
|
+
process.exit(1);
|
|
3503
|
+
}
|
|
3504
|
+
const found = allThemes.find((t) => t.id === id) ?? searchResults.find((t) => t.id === id);
|
|
3505
|
+
if (found) return found;
|
|
3506
|
+
return (await getApplicationTheme(api, id)).application_theme;
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
async function findTheme(api, identifier) {
|
|
3510
|
+
const idNum = Number(identifier);
|
|
3511
|
+
if (Number.isInteger(idNum) && idNum > 0) try {
|
|
3512
|
+
const body = await getApplicationTheme(api, idNum);
|
|
3513
|
+
if (body.application_theme) return body.application_theme;
|
|
3514
|
+
} catch {}
|
|
3515
|
+
let page = 1;
|
|
3516
|
+
let hasMore = true;
|
|
3517
|
+
while (hasMore) {
|
|
3518
|
+
const result = await fetchThemesPage(api, page, identifier);
|
|
3519
|
+
const found = result.themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
|
|
3520
|
+
if (found) return found;
|
|
3521
|
+
hasMore = result.hasMore;
|
|
3522
|
+
page++;
|
|
3523
|
+
}
|
|
3524
|
+
console.error(`No theme found with identifier: ${identifier}`);
|
|
3525
|
+
process.exit(1);
|
|
3526
|
+
}
|
|
3527
|
+
//#endregion
|
|
3528
|
+
//#region src/workspace.ts
|
|
3529
|
+
const WORKSPACE_FILE = ".fluid-workspace.json";
|
|
3530
|
+
/**
|
|
3531
|
+
* Walk up from `startDir` looking for `.fluid-workspace.json`.
|
|
3532
|
+
* Returns the workspace info if found, or `null` if not in a workspace.
|
|
3533
|
+
*/
|
|
3534
|
+
function findWorkspace(startDir) {
|
|
3535
|
+
let dir = resolve(startDir ?? process.cwd());
|
|
3536
|
+
while (true) {
|
|
3537
|
+
const candidate = join(dir, WORKSPACE_FILE);
|
|
3538
|
+
if (existsSync(candidate)) try {
|
|
3539
|
+
const raw = readFileSync(candidate, "utf-8");
|
|
3540
|
+
const config = JSON.parse(raw);
|
|
3541
|
+
return {
|
|
3542
|
+
root: dir,
|
|
3543
|
+
config
|
|
3544
|
+
};
|
|
3545
|
+
} catch {
|
|
3546
|
+
return null;
|
|
3547
|
+
}
|
|
3548
|
+
const parent = dirname(dir);
|
|
3549
|
+
if (parent === dir) break;
|
|
3550
|
+
dir = parent;
|
|
3551
|
+
}
|
|
3552
|
+
return null;
|
|
3553
|
+
}
|
|
3554
|
+
/**
|
|
3555
|
+
* If cwd is already inside `{workspace}/local/{company}/...`, return that
|
|
3556
|
+
* theme root directory. Otherwise return null.
|
|
3557
|
+
*
|
|
3558
|
+
* Examples (workspace root = /code/fluid-theme-dev):
|
|
3559
|
+
* cwd = /code/fluid-theme-dev/local/acme-co → /code/fluid-theme-dev/local/acme-co
|
|
3560
|
+
* cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
|
|
3561
|
+
* cwd = /code/fluid-theme-dev → null
|
|
3562
|
+
* cwd = /code/fluid-theme-dev/local → null
|
|
3563
|
+
*/
|
|
3564
|
+
function resolveThemeRootFromCwd(workspace) {
|
|
3565
|
+
const cwd = resolve(process.cwd());
|
|
3566
|
+
const localDir = join(workspace.root, "local");
|
|
3567
|
+
const rel = relative(localDir, cwd);
|
|
3568
|
+
if (rel.startsWith("..") || rel === ".") return null;
|
|
3569
|
+
const firstSegment = rel.split(sep)[0];
|
|
3570
|
+
if (!firstSegment) return null;
|
|
3571
|
+
return join(localDir, firstSegment);
|
|
3572
|
+
}
|
|
3573
|
+
//#endregion
|
|
3574
|
+
//#region src/commands/dev.ts
|
|
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";
|
|
3578
|
+
}
|
|
3579
|
+
/**
|
|
3580
|
+
* Create the isolated theme used by `theme dev`.
|
|
3581
|
+
*
|
|
3582
|
+
* A checkout from `theme pull` has a source theme id. New servers clone that
|
|
3583
|
+
* source by reference, preserving its DAM/ImageKit assets without moving
|
|
3584
|
+
* bytes. A 404/405 keeps older deployments compatible with the established
|
|
3585
|
+
* empty-theme flow; other failures must remain visible to the developer.
|
|
3586
|
+
*/
|
|
3587
|
+
async function createDevelopmentTheme(api, sourceThemeId, name) {
|
|
3588
|
+
if (sourceThemeId !== void 0) try {
|
|
3589
|
+
return {
|
|
3590
|
+
theme: (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme,
|
|
3591
|
+
referenceCloned: true
|
|
3592
|
+
};
|
|
3593
|
+
} catch (error) {
|
|
3594
|
+
if (!isApiError(error) || error.status !== 404 && error.status !== 405) throw error;
|
|
3595
|
+
console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
|
|
3596
|
+
}
|
|
3597
|
+
return {
|
|
3598
|
+
theme: (await createApplicationTheme(api, { application_theme: {
|
|
3599
|
+
name,
|
|
3600
|
+
status: "development"
|
|
3601
|
+
} })).application_theme,
|
|
3602
|
+
referenceCloned: false
|
|
3603
|
+
};
|
|
3604
|
+
}
|
|
3605
|
+
async function ensureDevTheme(api, projectKey, identifier, sourceThemeId) {
|
|
3606
|
+
if (identifier) {
|
|
3607
|
+
const theme = await findTheme(api, identifier);
|
|
3608
|
+
setLastDevThemeId(theme.id);
|
|
3609
|
+
return {
|
|
3610
|
+
theme,
|
|
3611
|
+
referenceCloned: false
|
|
3612
|
+
};
|
|
3613
|
+
}
|
|
3614
|
+
const stored = getDevTheme(projectKey);
|
|
3615
|
+
if (stored && stored.sourceThemeId === sourceThemeId) {
|
|
3616
|
+
try {
|
|
3617
|
+
const existing = (await getApplicationTheme(api, stored.id)).application_theme;
|
|
3618
|
+
if (existing && existing.status === "development") {
|
|
3619
|
+
console.log(`Using existing dev theme #${existing.id}`);
|
|
3620
|
+
setDevTheme(projectKey, {
|
|
3621
|
+
...stored,
|
|
3622
|
+
id: existing.id,
|
|
3623
|
+
name: existing.name,
|
|
3624
|
+
...sourceThemeId === void 0 ? {} : { sourceThemeId }
|
|
3625
|
+
});
|
|
3626
|
+
return {
|
|
3627
|
+
theme: existing,
|
|
3628
|
+
referenceCloned: false
|
|
3629
|
+
};
|
|
3630
|
+
}
|
|
3631
|
+
} catch {}
|
|
3632
|
+
clearDevTheme(projectKey);
|
|
3633
|
+
}
|
|
3634
|
+
const { hostname } = await import("node:os");
|
|
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;
|
|
3637
|
+
setDevTheme(projectKey, {
|
|
3638
|
+
id: theme.id,
|
|
3639
|
+
name: theme.name,
|
|
3640
|
+
...sourceThemeId === void 0 ? {} : { sourceThemeId }
|
|
3641
|
+
});
|
|
3642
|
+
console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
|
|
3643
|
+
return creation;
|
|
3644
|
+
}
|
|
3645
|
+
function createDevCommand() {
|
|
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) => {
|
|
3647
|
+
requireToken();
|
|
3648
|
+
let rootPath = opts.root;
|
|
3649
|
+
if (rootPath === ".") {
|
|
3650
|
+
const workspace = findWorkspace();
|
|
3651
|
+
if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
3652
|
+
}
|
|
3653
|
+
const themeRoot = new ThemeRoot(rootPath);
|
|
3654
|
+
if (!themeRoot.isValid()) {
|
|
3655
|
+
console.error(`'${rootPath}' does not look like a theme directory.`);
|
|
3656
|
+
process.exit(1);
|
|
3657
|
+
}
|
|
3658
|
+
const port = Number(opts.port);
|
|
3659
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
3660
|
+
console.error(`Invalid port: '${opts.port}'. Must be an integer between 1 and 65535.`);
|
|
3661
|
+
process.exit(1);
|
|
3662
|
+
}
|
|
3663
|
+
try {
|
|
3664
|
+
await checkPortAvailable(opts.host, port);
|
|
3665
|
+
} catch (e) {
|
|
3666
|
+
if (e instanceof PortInUseError) console.error(e.message);
|
|
3667
|
+
else console.error(`Failed to check port availability: ${e}`);
|
|
3668
|
+
process.exit(1);
|
|
3669
|
+
}
|
|
3670
|
+
const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
|
|
3671
|
+
const api = createApiClient();
|
|
3672
|
+
const config = readThemeConfig(themeRoot.root);
|
|
3673
|
+
let company;
|
|
3674
|
+
if (config?.company) company = config.company;
|
|
3675
|
+
else {
|
|
3676
|
+
company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain ?? "";
|
|
3677
|
+
if (!company) {
|
|
3678
|
+
console.error("Could not determine company subdomain. Make sure your token is valid.");
|
|
3679
|
+
process.exit(1);
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
const projectKey = devThemeKey(company, themeRoot.root);
|
|
3683
|
+
const devTarget = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId);
|
|
3684
|
+
const { theme } = devTarget;
|
|
3685
|
+
const assetManifest = new ThemeAssetManifest(themeRoot.root);
|
|
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;
|
|
3693
|
+
}
|
|
3694
|
+
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
3695
|
+
let stop;
|
|
3696
|
+
const cleanup = () => {
|
|
3697
|
+
stop?.();
|
|
3698
|
+
process.exit(0);
|
|
3699
|
+
};
|
|
3700
|
+
process.on("SIGINT", cleanup);
|
|
3701
|
+
process.on("SIGTERM", cleanup);
|
|
3702
|
+
stop = await startDevServer(api, {
|
|
3703
|
+
id: theme.id,
|
|
3704
|
+
name: theme.name,
|
|
3705
|
+
company,
|
|
3706
|
+
editorUrl
|
|
3707
|
+
}, themeRoot, {
|
|
3708
|
+
host: opts.host,
|
|
3709
|
+
port,
|
|
3710
|
+
reloadMode,
|
|
3711
|
+
validate: !opts.force,
|
|
3712
|
+
...initialRemoteState ? { initialSync: initialRemoteState } : {},
|
|
3713
|
+
...baselineEnabled ? {
|
|
3714
|
+
onRemoteState: (state) => writeDevRemoteBaseline(themeRoot.root, state),
|
|
3715
|
+
onRemoteStateInvalidated: () => removeDevRemoteBaseline(themeRoot.root)
|
|
3716
|
+
} : {}
|
|
3717
|
+
}, (address) => {
|
|
3718
|
+
console.log(`\n Dev server: ${address}`);
|
|
3719
|
+
console.log(` Web editor: ${editorUrl}`);
|
|
3720
|
+
console.log("\n Watching for file changes…\n");
|
|
3721
|
+
if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
|
|
3722
|
+
});
|
|
3723
|
+
await new Promise(() => {});
|
|
3724
|
+
});
|
|
3725
|
+
}
|
|
3726
|
+
//#endregion
|
|
3407
3727
|
//#region src/theme/merge-push.ts
|
|
3408
3728
|
/**
|
|
3409
3729
|
* Compute what changed locally since the last time the shadow repo
|
|
@@ -3420,15 +3740,24 @@ async function diffAgainstShadow(themeRoot, shadow) {
|
|
|
3420
3740
|
const localFiles = themeRoot.files();
|
|
3421
3741
|
const localByKey = /* @__PURE__ */ new Map();
|
|
3422
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);
|
|
3423
3751
|
for (const file of localFiles) {
|
|
3424
3752
|
if (!file.exists) continue;
|
|
3425
3753
|
localByKey.set(file.relativePath, file);
|
|
3426
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}`);
|
|
3427
3756
|
const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
3428
3757
|
if (headBlob && headBlob.equals(localBuf)) continue;
|
|
3429
3758
|
changed.push(file);
|
|
3430
3759
|
}
|
|
3431
|
-
|
|
3760
|
+
for (const key of headPaths) {
|
|
3432
3761
|
if (localByKey.has(key)) continue;
|
|
3433
3762
|
if (themeRoot.ignore.ignore(key)) continue;
|
|
3434
3763
|
if (assetManifest.has(key)) continue;
|
|
@@ -3441,15 +3770,6 @@ async function diffAgainstShadow(themeRoot, shadow) {
|
|
|
3441
3770
|
};
|
|
3442
3771
|
}
|
|
3443
3772
|
/**
|
|
3444
|
-
* Read every path recorded under HEAD's tree. Used to detect local
|
|
3445
|
-
* deletions (paths in HEAD, absent from the working tree). Implemented
|
|
3446
|
-
* with `git ls-tree -r HEAD --name-only` piped through the shadow
|
|
3447
|
-
* repo's plumbing.
|
|
3448
|
-
*/
|
|
3449
|
-
async function listHeadPaths(shadow) {
|
|
3450
|
-
return shadow.headPaths();
|
|
3451
|
-
}
|
|
3452
|
-
/**
|
|
3453
3773
|
* Refuse a push when any working file still contains a conflict marker
|
|
3454
3774
|
* from a previous pull. Mirrors git's "you have unresolved conflicts;
|
|
3455
3775
|
* fix them and re-run" behavior — the whole point of writing markers
|
|
@@ -3917,7 +4237,8 @@ function createPushCommand() {
|
|
|
3917
4237
|
themeId: theme.id,
|
|
3918
4238
|
themeName: theme.name,
|
|
3919
4239
|
company: config.company,
|
|
3920
|
-
baseSha: baseSha ?? void 0
|
|
4240
|
+
baseSha: baseSha ?? void 0,
|
|
4241
|
+
assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint({ excludePending: true })
|
|
3921
4242
|
});
|
|
3922
4243
|
return;
|
|
3923
4244
|
}
|
|
@@ -3929,7 +4250,8 @@ function createPushCommand() {
|
|
|
3929
4250
|
themeId: theme.id,
|
|
3930
4251
|
themeName: theme.name,
|
|
3931
4252
|
company: subdomain,
|
|
3932
|
-
baseSha: baseSha ?? void 0
|
|
4253
|
+
baseSha: baseSha ?? void 0,
|
|
4254
|
+
assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint({ excludePending: true })
|
|
3933
4255
|
});
|
|
3934
4256
|
} catch {}
|
|
3935
4257
|
}
|
|
@@ -4210,7 +4532,11 @@ function createPullCommand() {
|
|
|
4210
4532
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
4211
4533
|
const actorPromise = fetchSyncActor(api);
|
|
4212
4534
|
const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
4213
|
-
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
|
+
});
|
|
4214
4540
|
const externalizedAssets = await syncer.externalizePulledAssets(resources, { delete: !opts.nodelete });
|
|
4215
4541
|
const result = await mergePull({
|
|
4216
4542
|
themeRoot,
|
|
@@ -4252,7 +4578,8 @@ function createPullCommand() {
|
|
|
4252
4578
|
themeId: theme.id,
|
|
4253
4579
|
themeName: theme.name,
|
|
4254
4580
|
company: subdomain,
|
|
4255
|
-
baseSha: remoteSha ?? existingConfig?.baseSha
|
|
4581
|
+
baseSha: remoteSha ?? existingConfig?.baseSha,
|
|
4582
|
+
assetManifestSha: new ThemeAssetManifest(absoluteRoot).fingerprint({ excludePending: true })
|
|
4256
4583
|
});
|
|
4257
4584
|
if (result.conflicts.length > 0) process.exit(1);
|
|
4258
4585
|
});
|