@fluid-app/fluid-cli-theme-dev 0.1.32 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -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 updateThemeResource(this.api, this.themeId, { application_theme_resource: {
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 updateThemeResource(this.api, this.themeId, { application_theme_resource: {
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
- await deleteThemeResource(this.api, this.themeId, { application_theme_resource: { key: relativePath } });
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 resources = (await listThemeResources(this.api, this.themeId)).application_theme_resources ?? [];
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 result = await syncer.uploadTheme({
1934
- delete: !opts.nodelete,
1935
- validate: !opts.force,
1936
- onProgress: (d, total) => {
1937
- spinner.text = `Pushing ${d}/${total} files…`;
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) writeThemeConfig(themeRoot.root, {
1949
- ...config,
1950
- checksums: syncer.remoteChecksums()
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)`);