@fluid-app/fluid-cli-theme-dev 0.1.32 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +146 -26
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1304,8 +1304,25 @@ function formatError(e) {
|
|
|
1304
1304
|
}
|
|
1305
1305
|
//#endregion
|
|
1306
1306
|
//#region src/theme/syncer.ts
|
|
1307
|
+
/**
|
|
1308
|
+
* Server rejected the push because the CLI's `base_sha` no longer
|
|
1309
|
+
* matches the theme's current `content_version_sha` (someone else
|
|
1310
|
+
* wrote to the theme since our last pull). Callers surface a
|
|
1311
|
+
* "pull first" message; no partial state has been written when this
|
|
1312
|
+
* throws from the preflight, and the per-file variant preserves the
|
|
1313
|
+
* atomicity guarantee mid-loop by short-circuiting the remaining
|
|
1314
|
+
* files on the first conflicting response.
|
|
1315
|
+
*/
|
|
1316
|
+
var PushConflictError = class extends Error {
|
|
1317
|
+
constructor(remoteSha) {
|
|
1318
|
+
super(remoteSha ? `Your local is behind the server. Server is at ${remoteSha}; local is stale.` : "Your local is behind the server.");
|
|
1319
|
+
this.remoteSha = remoteSha;
|
|
1320
|
+
this.name = "PushConflictError";
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1307
1323
|
var Syncer = class {
|
|
1308
1324
|
checksums = /* @__PURE__ */ new Map();
|
|
1325
|
+
lastKnownRemoteSha = null;
|
|
1309
1326
|
constructor(api, themeId, themeRoot) {
|
|
1310
1327
|
this.api = api;
|
|
1311
1328
|
this.themeId = themeId;
|
|
@@ -1314,6 +1331,14 @@ var Syncer = class {
|
|
|
1314
1331
|
async fetchChecksums() {
|
|
1315
1332
|
const body = await listThemeResources(this.api, this.themeId);
|
|
1316
1333
|
this.updateChecksums(body.application_theme_resources ?? []);
|
|
1334
|
+
this.lastKnownRemoteSha = body.content_version_sha ?? null;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Server's `content_version_sha` captured on the last `fetchChecksums()`
|
|
1338
|
+
* or `downloadAll()`. `null` when talking to a pre-003a server.
|
|
1339
|
+
*/
|
|
1340
|
+
remoteSha() {
|
|
1341
|
+
return this.lastKnownRemoteSha;
|
|
1317
1342
|
}
|
|
1318
1343
|
updateChecksums(resources) {
|
|
1319
1344
|
for (const r of resources) if (r.key && r.checksum) this.checksums.set(r.key, r.checksum);
|
|
@@ -1329,14 +1354,71 @@ var Syncer = class {
|
|
|
1329
1354
|
remoteChecksums() {
|
|
1330
1355
|
return Object.fromEntries(this.checksums);
|
|
1331
1356
|
}
|
|
1332
|
-
async uploadFile(file) {
|
|
1333
|
-
if (file.isText) await
|
|
1357
|
+
async uploadFile(file, baseSha) {
|
|
1358
|
+
if (file.isText) await this.putResource({
|
|
1334
1359
|
key: file.relativePath,
|
|
1335
1360
|
content: file.read()
|
|
1336
|
-
}
|
|
1337
|
-
else await this.uploadBinaryFile(file);
|
|
1361
|
+
}, baseSha);
|
|
1362
|
+
else await this.uploadBinaryFile(file, baseSha);
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Wraps the generated `updateThemeResource` client with the two
|
|
1366
|
+
* merge-aware Phase 003 additions: sending `base_sha` on the request
|
|
1367
|
+
* and reading the server's fresh `content_version_sha` off the
|
|
1368
|
+
* response so the caller can thread it forward on the next PUT.
|
|
1369
|
+
*
|
|
1370
|
+
* Accepts any `application_theme_resource` shape — text uploads pass
|
|
1371
|
+
* `{ key, content }`; binary uploads (after DAM + ImageKit
|
|
1372
|
+
* orchestration) pass `{ key, dam_asset: { ... } }`. Both must route
|
|
1373
|
+
* through here so `lastKnownRemoteSha` stays in lockstep with every
|
|
1374
|
+
* write the server has ack'd, mixed text/binary pushes included.
|
|
1375
|
+
*
|
|
1376
|
+
* The typed client hasn't been regenerated against the new OpenAPI
|
|
1377
|
+
* spec yet, so `base_sha` is threaded through as an extra property
|
|
1378
|
+
* (server accepts unknown fields on this endpoint) and the response
|
|
1379
|
+
* is cast to read the extra `content_version_sha`. Regeneration is
|
|
1380
|
+
* a follow-up; that PR will drop these casts.
|
|
1381
|
+
*
|
|
1382
|
+
* `PushConflictError` is thrown on a 409 so callers can distinguish
|
|
1383
|
+
* "server rejected because of stale base" from generic upload
|
|
1384
|
+
* failures.
|
|
1385
|
+
*/
|
|
1386
|
+
async putResource(resource, baseSha) {
|
|
1387
|
+
const body = { application_theme_resource: resource };
|
|
1388
|
+
if (baseSha) body["base_sha"] = baseSha;
|
|
1389
|
+
try {
|
|
1390
|
+
const response = await updateThemeResource(this.api, this.themeId, body);
|
|
1391
|
+
if (response.content_version_sha) this.lastKnownRemoteSha = response.content_version_sha;
|
|
1392
|
+
} catch (e) {
|
|
1393
|
+
throw this.rethrowIfConflict(e);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Server-side push preflight (Phase 003a). Runs once at the start of
|
|
1398
|
+
* a push loop. On 200 the server's fresh `remote_sha` is stashed on
|
|
1399
|
+
* the syncer so subsequent per-file PUTs can carry it. On 409 a
|
|
1400
|
+
* `PushConflictError` is thrown carrying the server's `remote_sha`
|
|
1401
|
+
* from the `meta` payload — the caller renders "pull first".
|
|
1402
|
+
*
|
|
1403
|
+
* Skipped when `baseSha` is null/undefined so old-behavior pushes
|
|
1404
|
+
* (no stored `baseSha` in `.fluid-theme.json`) and `--force` pushes
|
|
1405
|
+
* short-circuit past the check.
|
|
1406
|
+
*/
|
|
1407
|
+
async preflightPush(baseSha) {
|
|
1408
|
+
if (!baseSha) return;
|
|
1409
|
+
try {
|
|
1410
|
+
const response = await this.api.post(`/api/application_themes/${this.themeId}/resources/check_push`, { base_sha: baseSha });
|
|
1411
|
+
if (response.remote_sha) this.lastKnownRemoteSha = response.remote_sha;
|
|
1412
|
+
} catch (e) {
|
|
1413
|
+
throw this.rethrowIfConflict(e);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
rethrowIfConflict(e) {
|
|
1417
|
+
if ((e?.status ?? e?.response?.status) !== 409) return e;
|
|
1418
|
+
const meta = e?.data?.meta;
|
|
1419
|
+
return new PushConflictError(meta?.remote_sha ?? null);
|
|
1338
1420
|
}
|
|
1339
|
-
async uploadBinaryFile(file) {
|
|
1421
|
+
async uploadBinaryFile(file, baseSha) {
|
|
1340
1422
|
const asset = (await this.api.post("/api/dam/assets", { placeholder_asset: {
|
|
1341
1423
|
description: `Uploaded via Fluid CLI: ${file.name}`,
|
|
1342
1424
|
mime_type: file.mime.name,
|
|
@@ -1371,7 +1453,7 @@ var Syncer = class {
|
|
|
1371
1453
|
if (ikBody.height) backfillPayload["asset"]["height"] = ikBody.height;
|
|
1372
1454
|
if (ikBody.width) backfillPayload["asset"]["width"] = ikBody.width;
|
|
1373
1455
|
const backfillBody = await this.api.post("/api/dam/assets/backfill_imagekit", backfillPayload);
|
|
1374
|
-
await
|
|
1456
|
+
await this.putResource({
|
|
1375
1457
|
key: file.relativePath,
|
|
1376
1458
|
dam_asset: {
|
|
1377
1459
|
dam_asset_code: backfillBody.asset.code,
|
|
@@ -1382,7 +1464,7 @@ var Syncer = class {
|
|
|
1382
1464
|
url: backfillBody.asset.default_variant_url,
|
|
1383
1465
|
preview_image_url: ikBody.thumbnailUrl
|
|
1384
1466
|
}
|
|
1385
|
-
}
|
|
1467
|
+
}, baseSha);
|
|
1386
1468
|
}
|
|
1387
1469
|
canonicalPathToImageKitFolder(canonicalPath) {
|
|
1388
1470
|
const parts = canonicalPath.split(".");
|
|
@@ -1397,13 +1479,22 @@ var Syncer = class {
|
|
|
1397
1479
|
files: "files"
|
|
1398
1480
|
}[category] ?? "files"}/${assetCode}`;
|
|
1399
1481
|
}
|
|
1400
|
-
async deleteRemoteFile(relativePath) {
|
|
1401
|
-
|
|
1482
|
+
async deleteRemoteFile(relativePath, baseSha) {
|
|
1483
|
+
const body = { application_theme_resource: { key: relativePath } };
|
|
1484
|
+
if (baseSha) body["base_sha"] = baseSha;
|
|
1485
|
+
try {
|
|
1486
|
+
const response = await deleteThemeResource(this.api, this.themeId, body);
|
|
1487
|
+
if (response.content_version_sha) this.lastKnownRemoteSha = response.content_version_sha;
|
|
1488
|
+
} catch (e) {
|
|
1489
|
+
throw this.rethrowIfConflict(e);
|
|
1490
|
+
}
|
|
1402
1491
|
this.checksums.delete(relativePath);
|
|
1403
1492
|
}
|
|
1404
1493
|
async downloadAll() {
|
|
1405
|
-
const
|
|
1494
|
+
const body = await listThemeResources(this.api, this.themeId);
|
|
1495
|
+
const resources = body.application_theme_resources ?? [];
|
|
1406
1496
|
this.updateChecksums(resources);
|
|
1497
|
+
this.lastKnownRemoteSha = body.content_version_sha ?? null;
|
|
1407
1498
|
return resources;
|
|
1408
1499
|
}
|
|
1409
1500
|
async downloadBinaryAsset(url) {
|
|
@@ -1413,6 +1504,8 @@ var Syncer = class {
|
|
|
1413
1504
|
}
|
|
1414
1505
|
async uploadTheme(opts = {}) {
|
|
1415
1506
|
await this.fetchChecksums();
|
|
1507
|
+
await this.preflightPush(opts.baseSha);
|
|
1508
|
+
let baseSha = opts.baseSha ?? null;
|
|
1416
1509
|
const localFiles = this.themeRoot.files();
|
|
1417
1510
|
const result = {
|
|
1418
1511
|
uploaded: 0,
|
|
@@ -1436,9 +1529,11 @@ var Syncer = class {
|
|
|
1436
1529
|
let done = 0;
|
|
1437
1530
|
for (const file of toUpload) {
|
|
1438
1531
|
try {
|
|
1439
|
-
await this.uploadFile(file);
|
|
1532
|
+
await this.uploadFile(file, baseSha);
|
|
1533
|
+
baseSha = this.lastKnownRemoteSha;
|
|
1440
1534
|
result.uploaded++;
|
|
1441
1535
|
} catch (e) {
|
|
1536
|
+
if (e instanceof PushConflictError) throw e;
|
|
1442
1537
|
result.errors.push(`Upload ${file.relativePath}: ${formatError(e)}`);
|
|
1443
1538
|
}
|
|
1444
1539
|
opts.onProgress?.(++done, toUpload.length);
|
|
@@ -1447,9 +1542,11 @@ var Syncer = class {
|
|
|
1447
1542
|
const localPaths = new Set(localFiles.map((f) => f.relativePath));
|
|
1448
1543
|
const toDelete = this.remoteKeys().filter((k) => !localPaths.has(k));
|
|
1449
1544
|
for (const key of toDelete) try {
|
|
1450
|
-
await this.deleteRemoteFile(key);
|
|
1545
|
+
await this.deleteRemoteFile(key, baseSha);
|
|
1546
|
+
baseSha = this.lastKnownRemoteSha;
|
|
1451
1547
|
result.deleted++;
|
|
1452
1548
|
} catch (e) {
|
|
1549
|
+
if (e instanceof PushConflictError) throw e;
|
|
1453
1550
|
result.errors.push(`Delete ${key}: ${formatError(e)}`);
|
|
1454
1551
|
}
|
|
1455
1552
|
}
|
|
@@ -1853,7 +1950,7 @@ function detectRemoteDrift(storedChecksums, remoteChecksums, themeRoot) {
|
|
|
1853
1950
|
return conflicts;
|
|
1854
1951
|
}
|
|
1855
1952
|
function createPushCommand() {
|
|
1856
|
-
return new Command("push").description("Push local theme files to a remote theme").option("-t, --theme <name-or-id>", "Theme name or ID to push to").option("-n, --nodelete", "Do not delete remote files missing locally").option("-f, --force", "Skip schema validation").option("-p, --publish", "Publish the theme after pushing").option("-u, --unpublished", "Create a new unpublished theme and push to it").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
1953
|
+
return new Command("push").description("Push local theme files to a remote theme").option("-t, --theme <name-or-id>", "Theme name or ID to push to").option("-n, --nodelete", "Do not delete remote files missing locally").option("-f, --force", "Skip schema validation and the server-side merge check").option("-p, --publish", "Publish the theme after pushing").option("-u, --unpublished", "Create a new unpublished theme and push to it").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
1857
1954
|
requireToken();
|
|
1858
1955
|
let rootPath = opts.root;
|
|
1859
1956
|
if (rootPath === ".") {
|
|
@@ -1888,6 +1985,7 @@ function createPushCommand() {
|
|
|
1888
1985
|
console.log(` Using theme from .fluid-theme.json: ${chalk.bold(config.themeName)} (#${config.themeId})`);
|
|
1889
1986
|
theme = (await getApplicationTheme(api, config.themeId)).application_theme;
|
|
1890
1987
|
} else theme = await selectTheme(api, "Select a theme to push to");
|
|
1988
|
+
let overrideMergeCheck = false;
|
|
1891
1989
|
if (config?.checksums && !opts.force) {
|
|
1892
1990
|
const driftSpinner = ora("Checking for remote changes…").start();
|
|
1893
1991
|
const driftSyncer = new Syncer(api, theme.id, themeRoot);
|
|
@@ -1926,17 +2024,33 @@ function createPushCommand() {
|
|
|
1926
2024
|
console.log(`Run ${chalk.cyan("fluid theme pull")} first, then push again.`);
|
|
1927
2025
|
process.exit(0);
|
|
1928
2026
|
}
|
|
2027
|
+
if (resolution === "push") overrideMergeCheck = true;
|
|
1929
2028
|
}
|
|
1930
2029
|
}
|
|
1931
2030
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
1932
2031
|
const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
|
|
1933
|
-
const
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2032
|
+
const baseSha = opts.force || overrideMergeCheck ? null : config?.baseSha ?? null;
|
|
2033
|
+
let result;
|
|
2034
|
+
try {
|
|
2035
|
+
result = await syncer.uploadTheme({
|
|
2036
|
+
delete: !opts.nodelete,
|
|
2037
|
+
validate: !opts.force,
|
|
2038
|
+
baseSha,
|
|
2039
|
+
onProgress: (d, total) => {
|
|
2040
|
+
spinner.text = `Pushing ${d}/${total} files…`;
|
|
2041
|
+
}
|
|
2042
|
+
});
|
|
2043
|
+
} catch (e) {
|
|
2044
|
+
if (e instanceof PushConflictError) {
|
|
2045
|
+
spinner.fail("Server has changed since your last pull. Push aborted.");
|
|
2046
|
+
console.log();
|
|
2047
|
+
console.log(` ${chalk.cyan("Run `fluid theme pull` first")} to sync down the remote changes,`);
|
|
2048
|
+
console.log(` then push again. Use ${chalk.cyan("fluid theme push --force")} to overwrite anyway.`);
|
|
2049
|
+
console.log();
|
|
2050
|
+
process.exit(1);
|
|
1938
2051
|
}
|
|
1939
|
-
|
|
2052
|
+
throw e;
|
|
2053
|
+
}
|
|
1940
2054
|
if (result.validationFailed) {
|
|
1941
2055
|
spinner.fail(`Schema validation failed (${result.errors.length} error(s)). Use --force to skip.`);
|
|
1942
2056
|
for (const e of result.errors) console.error(` ${e}`);
|
|
@@ -1945,10 +2059,14 @@ function createPushCommand() {
|
|
|
1945
2059
|
spinner.warn(`Pushed with ${result.errors.length} error(s).`);
|
|
1946
2060
|
for (const e of result.errors) console.error(` ${e}`);
|
|
1947
2061
|
} else spinner.succeed(`Pushed ${result.uploaded} file(s), deleted ${result.deleted} remote file(s).`);
|
|
1948
|
-
if (config)
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
2062
|
+
if (config) {
|
|
2063
|
+
const remoteSha = syncer.remoteSha();
|
|
2064
|
+
writeThemeConfig(themeRoot.root, {
|
|
2065
|
+
...config,
|
|
2066
|
+
checksums: syncer.remoteChecksums(),
|
|
2067
|
+
baseSha: remoteSha ?? config.baseSha
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
1952
2070
|
if (opts.publish) {
|
|
1953
2071
|
const pubSpinner = ora("Publishing theme…").start();
|
|
1954
2072
|
try {
|
|
@@ -2005,7 +2123,7 @@ function detectConflicts(storedChecksums, remoteChecksums, themeRoot) {
|
|
|
2005
2123
|
return conflicts;
|
|
2006
2124
|
}
|
|
2007
2125
|
function createPullCommand() {
|
|
2008
|
-
return new Command("pull").description("Pull a remote theme to your local directory").option("-t, --theme <name-or-id>", "Theme name or ID to pull").option("-n, --nodelete", "Do not delete local files missing on remote").option("--root <path>", "Theme root directory").option("-y, --yes", "Skip confirmation prompt").action(async (opts) => {
|
|
2126
|
+
return new Command("pull").description("Pull a remote theme to your local directory").option("-t, --theme <name-or-id>", "Theme name or ID to pull").option("-n, --nodelete", "Do not delete local files missing on remote").option("--root <path>", "Theme root directory").option("-y, --yes", "Skip confirmation prompt").option("-f, --force", "Overwrite local changes without prompting on conflicts").action(async (opts) => {
|
|
2009
2127
|
requireToken();
|
|
2010
2128
|
const api = createApiClient();
|
|
2011
2129
|
const workspace = findWorkspace();
|
|
@@ -2025,7 +2143,7 @@ function createPullCommand() {
|
|
|
2025
2143
|
console.log();
|
|
2026
2144
|
const themeRoot = new ThemeRoot(root);
|
|
2027
2145
|
let skipKeys;
|
|
2028
|
-
if (existingConfig?.checksums) {
|
|
2146
|
+
if (existingConfig?.checksums && !opts.force) {
|
|
2029
2147
|
const fetchSpinner = ora("Checking for conflicts…").start();
|
|
2030
2148
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
2031
2149
|
await syncer.fetchChecksums();
|
|
@@ -2088,12 +2206,14 @@ function createPullCommand() {
|
|
|
2088
2206
|
const oldChecksum = existingConfig.checksums[key];
|
|
2089
2207
|
if (oldChecksum) newChecksums[key] = oldChecksum;
|
|
2090
2208
|
}
|
|
2209
|
+
const remoteSha = syncer.remoteSha();
|
|
2091
2210
|
writeThemeConfig(absoluteRoot, {
|
|
2092
2211
|
themeId: theme.id,
|
|
2093
2212
|
themeName: theme.name,
|
|
2094
2213
|
company: subdomain,
|
|
2095
2214
|
lastPulledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2096
|
-
checksums: newChecksums
|
|
2215
|
+
checksums: newChecksums,
|
|
2216
|
+
baseSha: remoteSha ?? existingConfig?.baseSha
|
|
2097
2217
|
});
|
|
2098
2218
|
const parts = [`Downloaded ${result.downloaded} file(s)`];
|
|
2099
2219
|
if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
|