hot-updater 0.35.12 → 0.36.0

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.
Files changed (2) hide show
  1. package/dist/index.mjs +374 -33
  2. package/package.json +14 -14
package/dist/index.mjs CHANGED
@@ -29,11 +29,12 @@ import { Duplex, PassThrough, Readable, Transform, Writable, getDefaultHighWater
29
29
  import { Buffer as Buffer$1 } from "node:buffer";
30
30
  import { pipeline } from "stream/promises";
31
31
  import { constants as constants$2, createBrotliCompress } from "zlib";
32
- import { assertNodeStoragePlugin, getContentAddressedAssetStoragePath } from "@hot-updater/plugin-core";
32
+ import { BUNDLE_STORAGE_PREFIX, assertNodeStoragePlugin, createBundleStorageKey, createStorageRootUriWithPath, getContentAddressedAssetStoragePath, getManifestAssetDownloadPath, isContentAddressedAssetBaseStorageUri, resolveManifestAssetStorageUri } from "@hot-updater/plugin-core";
33
33
  import { createBundleDiff, createMigrator, generateSchema } from "@hot-updater/server/db";
34
34
  import net from "node:net";
35
35
  import { setTimeout as setTimeout$1 } from "timers/promises";
36
36
  import { createJiti } from "jiti";
37
+ import { getAssetBaseStorageUri, getBundlePatches, getManifestStorageUri, getPatchStorageUri } from "@hot-updater/core";
37
38
  //#region ../../node_modules/.pnpm/commander@14.0.3/node_modules/commander/lib/error.js
38
39
  var require_error = /* @__PURE__ */ __commonJSMin(((exports) => {
39
40
  /**
@@ -26302,22 +26303,13 @@ const getRelativeStorageDir = (relativePath) => {
26302
26303
  const dirname = path.posix.dirname(normalized);
26303
26304
  return dirname === "." ? "" : dirname;
26304
26305
  };
26305
- const isBrotliManifestBundleAsset = (relativePath) => /(^|\/)index\.[^/]+\.bundle$/.test(relativePath.replace(/\\/g, "/"));
26306
- const getManifestAssetUploadName = (relativePath) => isBrotliManifestBundleAsset(relativePath) ? `${relativePath}.br` : relativePath;
26307
- const replaceBundleStorageUriPath = (storageUri, bundleId, nextPath) => {
26308
- const storageUrl = new URL(storageUri);
26309
- const segments = storageUrl.pathname.split("/").filter(Boolean);
26310
- const bundleIndex = segments.lastIndexOf(bundleId);
26311
- storageUrl.pathname = `/${[...bundleIndex >= 0 ? segments.slice(0, bundleIndex) : segments.slice(0, -2), nextPath].filter(Boolean).map((segment) => encodeURIComponent(segment)).join("/")}`;
26312
- return storageUrl.toString();
26313
- };
26314
26306
  const createStorageUriWithRelativePath = (baseStorageUri, relativePath) => {
26315
26307
  const storageUrl = new URL(baseStorageUri);
26316
26308
  storageUrl.pathname = `${storageUrl.pathname.replace(/\/+$/, "")}/${relativePath.replace(/\\/g, "/").split("/").filter(Boolean).map((segment) => encodeURIComponent(segment)).join("/")}`;
26317
26309
  return storageUrl.toString();
26318
26310
  };
26319
26311
  const ensureUploadSourcePath = async ({ outputPath, targetFile, uploadFilename }) => {
26320
- const uploadName = getManifestAssetUploadName(targetFile.name);
26312
+ const uploadName = getManifestAssetDownloadPath(targetFile.name);
26321
26313
  const expectedFilename = uploadFilename ?? path.posix.basename(uploadName);
26322
26314
  const actualFilename = path.basename(targetFile.path);
26323
26315
  if (uploadName === targetFile.name && expectedFilename === actualFilename) return targetFile.path;
@@ -26334,7 +26326,7 @@ const getUniqueContentAddressedAssetUploadTargets = ({ manifest, targetFiles })
26334
26326
  const manifestAsset = manifest.assets[targetFile.name];
26335
26327
  if (!manifestAsset?.fileHash) throw new Error(`Manifest file hash not found for ${targetFile.name}`);
26336
26328
  const storagePath = getContentAddressedAssetStoragePath({
26337
- assetPath: getManifestAssetUploadName(targetFile.name),
26329
+ assetPath: getManifestAssetDownloadPath(targetFile.name),
26338
26330
  fileHash: manifestAsset.fileHash
26339
26331
  });
26340
26332
  if (!targets.has(storagePath)) targets.set(storagePath, {
@@ -26592,11 +26584,11 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
26592
26584
  message(formatUploadProgress(uploadedStepCount, uploadStepCount, skippedUploadCount));
26593
26585
  };
26594
26586
  updateUploadProgress();
26595
- const { storageUri } = await storagePlugin.profiles.node.upload(bundleId, bundlePath);
26587
+ const { storageUri } = await storagePlugin.profiles.node.upload(createBundleStorageKey(bundleId), bundlePath);
26596
26588
  taskRef.storageUri = storageUri;
26597
26589
  uploadedStepCount += 1;
26598
26590
  updateUploadProgress();
26599
- taskRef.assetBaseStorageUri = replaceBundleStorageUriPath(storageUri, bundleId, "assets");
26591
+ taskRef.assetBaseStorageUri = createStorageRootUriWithPath(storageUri, bundleId, "assets");
26600
26592
  await runWithConcurrency(assetUploadTargets, MANIFEST_ASSET_UPLOAD_CONCURRENCY, async ({ storagePath, targetFile }) => {
26601
26593
  const storageUri = createStorageUriWithRelativePath(taskRef.assetBaseStorageUri, storagePath);
26602
26594
  const uploadKey = ["assets", getRelativeStorageDir(storagePath)].filter(Boolean).join("/");
@@ -26610,7 +26602,7 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
26610
26602
  uploadedStepCount += 1;
26611
26603
  updateUploadProgress();
26612
26604
  });
26613
- taskRef.manifestStorageUri = (await storagePlugin.profiles.node.upload(bundleId, taskRef.manifestPath)).storageUri;
26605
+ taskRef.manifestStorageUri = (await storagePlugin.profiles.node.upload(createBundleStorageKey(bundleId), taskRef.manifestPath)).storageUri;
26614
26606
  uploadedStepCount += 1;
26615
26607
  updateUploadProgress();
26616
26608
  } catch (e) {
@@ -27522,6 +27514,8 @@ const LIST_COLUMNS = [
27522
27514
  const DEFAULT_LIMIT = 20;
27523
27515
  const DELETE_VERIFY_ATTEMPTS = 12;
27524
27516
  const DELETE_VERIFY_DELAY_MS = 1e3;
27517
+ const STANDALONE_DATABASE_NAME$1 = "standalone-repository";
27518
+ const STANDALONE_DELETE_LOOKUP_LIMIT = 100;
27525
27519
  const formatRow = (bundle) => {
27526
27520
  const out = {};
27527
27521
  for (const field of LIST_FIELDS) {
@@ -27557,7 +27551,7 @@ const refuseNonInteractiveMutation = (action) => {
27557
27551
  p.log.error(`Cannot ${action} a bundle without confirmation in a non-interactive shell. Re-run with -y, or use a TTY.`);
27558
27552
  process.exit(1);
27559
27553
  };
27560
- const safeOnUnmount$2 = async (databasePlugin) => {
27554
+ const safeOnUnmount$3 = async (databasePlugin) => {
27561
27555
  try {
27562
27556
  await databasePlugin.onUnmount?.();
27563
27557
  } catch (err) {
@@ -27572,13 +27566,14 @@ const handleBundleList = async (options = {}) => {
27572
27566
  const result = await databasePlugin.getBundles({
27573
27567
  where: {
27574
27568
  channel: options.channel,
27575
- platform: options.platform
27569
+ platform: options.platform,
27570
+ targetAppVersion: options.targetAppVersion
27576
27571
  },
27577
27572
  limit
27578
27573
  });
27579
27574
  console.log(options.json ? JSON.stringify(result, null, 2) : tabulate(result.data));
27580
27575
  } finally {
27581
- await safeOnUnmount$2(databasePlugin);
27576
+ await safeOnUnmount$3(databasePlugin);
27582
27577
  }
27583
27578
  };
27584
27579
  const handleBundleShow = async (bundleId, options = {}) => {
@@ -27596,7 +27591,7 @@ const handleBundleShow = async (bundleId, options = {}) => {
27596
27591
  }
27597
27592
  p.log.message(formatBundleSummary(bundle));
27598
27593
  } finally {
27599
- await safeOnUnmount$2(databasePlugin);
27594
+ await safeOnUnmount$3(databasePlugin);
27600
27595
  }
27601
27596
  };
27602
27597
  const handleBundleSetEnabled = async (bundleId, nextEnabled, options = {}) => {
@@ -27637,7 +27632,7 @@ const handleBundleSetEnabled = async (bundleId, nextEnabled, options = {}) => {
27637
27632
  p.log.info(` ${ui.id(bundleId)}`);
27638
27633
  }
27639
27634
  } finally {
27640
- await safeOnUnmount$2(databasePlugin);
27635
+ await safeOnUnmount$3(databasePlugin);
27641
27636
  }
27642
27637
  };
27643
27638
  const handleBundleUpdate = async (bundleId, options = {}) => {
@@ -27685,23 +27680,35 @@ const handleBundleUpdate = async (bundleId, options = {}) => {
27685
27680
  p.log.success("Updated bundle.");
27686
27681
  p.log.info(` ${ui.id(bundleId)}`);
27687
27682
  } finally {
27688
- await safeOnUnmount$2(databasePlugin);
27683
+ await safeOnUnmount$3(databasePlugin);
27689
27684
  }
27690
27685
  };
27691
27686
  const handleBundleDelete = async (bundleIds, options = {}) => {
27692
27687
  printBanner$1();
27693
- const ids = bundleIds ?? [];
27688
+ const ids = [...new Set(bundleIds ?? [])];
27694
27689
  if (ids.length === 0) {
27695
27690
  p.log.error("Provide at least one bundle id.");
27696
27691
  process.exit(1);
27697
27692
  }
27698
27693
  const databasePlugin = await (await loadConfig(null)).database();
27699
27694
  try {
27700
- const fetched = await Promise.all(ids.map((id) => databasePlugin.getBundleById(id)));
27701
- const targets = [];
27702
- fetched.forEach((bundle, index) => {
27703
- if (bundle) targets.push(bundle);
27704
- else p.log.info(`No bundle with id ${ids[index]}. Skipping.`);
27695
+ const lookupBatches = databasePlugin.name === STANDALONE_DATABASE_NAME$1 ? Array.from({ length: Math.ceil(ids.length / STANDALONE_DELETE_LOOKUP_LIMIT) }, (_, index) => ids.slice(index * STANDALONE_DELETE_LOOKUP_LIMIT, (index + 1) * STANDALONE_DELETE_LOOKUP_LIMIT)) : [ids];
27696
+ const matchedBundles = [];
27697
+ for (const batch of lookupBatches) {
27698
+ const { data } = await databasePlugin.getBundles({
27699
+ where: { id: { in: batch } },
27700
+ limit: batch.length
27701
+ });
27702
+ matchedBundles.push(...data);
27703
+ }
27704
+ const matchedById = new Map(matchedBundles.map((bundle) => [bundle.id, bundle]));
27705
+ const targets = ids.flatMap((id) => {
27706
+ const bundle = matchedById.get(id);
27707
+ if (!bundle) {
27708
+ p.log.info(`No bundle with id ${id}. Skipping.`);
27709
+ return [];
27710
+ }
27711
+ return [bundle];
27705
27712
  });
27706
27713
  if (targets.length === 0) {
27707
27714
  p.log.info("No matching bundle records. No changes.");
@@ -27734,7 +27741,7 @@ const handleBundleDelete = async (bundleIds, options = {}) => {
27734
27741
  p.log.info(` ${ui.id(firstTarget.id)}`);
27735
27742
  } else p.log.success(`Deleted ${targets.length} bundle records.`);
27736
27743
  } finally {
27737
- await safeOnUnmount$2(databasePlugin);
27744
+ await safeOnUnmount$3(databasePlugin);
27738
27745
  }
27739
27746
  };
27740
27747
  async function waitForDeletedBundle(databasePlugin, bundleId) {
@@ -50298,7 +50305,7 @@ async function migrateWithMigrator(hotUpdater, skipConfirm, s) {
50298
50305
  }
50299
50306
  //#endregion
50300
50307
  //#region src/commands/promote.ts
50301
- const safeOnUnmount$1 = async (databasePlugin) => {
50308
+ const safeOnUnmount$2 = async (databasePlugin) => {
50302
50309
  try {
50303
50310
  await databasePlugin.onUnmount?.();
50304
50311
  } catch (err) {
@@ -50376,14 +50383,14 @@ const handlePromote = async (bundleId, options) => {
50376
50383
  p.log.info(` ${ui.id(promoted.id)}`);
50377
50384
  }
50378
50385
  } finally {
50379
- await safeOnUnmount$1(databasePlugin);
50386
+ await safeOnUnmount$2(databasePlugin);
50380
50387
  }
50381
50388
  };
50382
50389
  //#endregion
50383
50390
  //#region src/commands/rollback.ts
50384
50391
  const summarizeTarget = (target) => ui.block(`${target.platform}`, [ui.kv("Disable", ui.id(target.bundle.id)), target.fallbackId ? ui.kv("Fallback", ui.id(target.fallbackId)) : ui.kv("Fallback", ui.warning("binary-shipped JS"))]);
50385
50392
  const formatRetryHint = (channel, target) => `Re-run with: hot-updater rollback ${channel} -p ${target.platform} --target ${target.bundle.id}`;
50386
- const safeOnUnmount = async (databasePlugin) => {
50393
+ const safeOnUnmount$1 = async (databasePlugin) => {
50387
50394
  try {
50388
50395
  await databasePlugin.onUnmount?.();
50389
50396
  } catch (err) {
@@ -50497,10 +50504,329 @@ const handleRollback = async (channel, options = {}) => {
50497
50504
  process.exit(1);
50498
50505
  }
50499
50506
  } finally {
50500
- await safeOnUnmount(databasePlugin);
50507
+ await safeOnUnmount$1(databasePlugin);
50501
50508
  }
50502
50509
  };
50503
50510
  //#endregion
50511
+ //#region src/commands/storage.ts
50512
+ const BUNDLE_PAGE_SIZE = 1e4;
50513
+ const STANDALONE_BUNDLE_PAGE_SIZE = 100;
50514
+ const STANDALONE_DATABASE_NAME = "standalone-repository";
50515
+ const MANIFEST_READ_CONCURRENCY = 4;
50516
+ const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
50517
+ const CONTENT_ADDRESSED_ASSET_KEY_RE = /^assets\/sha256\/[0-9a-f]{2}\/[0-9a-f]{64}(?:\.[^/]+)?$/i;
50518
+ const DEFAULT_STORAGE_PRUNE_PROTECTION_MS = 1440 * 60 * 1e3;
50519
+ function parseStoragePruneProtection(value) {
50520
+ const match = value.trim().match(/^(\d+)(m|h|d|w)$/i);
50521
+ if (!match) throw new Error("must use a duration such as 30m, 24h, or 7d");
50522
+ const amount = Number.parseInt(match[1], 10);
50523
+ const unit = match[2].toLowerCase();
50524
+ return amount * (unit === "m" ? 60 * 1e3 : unit === "h" ? 3600 * 1e3 : unit === "d" ? 1440 * 60 * 1e3 : 10080 * 60 * 1e3);
50525
+ }
50526
+ function formatBytes(value) {
50527
+ if (value < 1024) return `${value} B`;
50528
+ const units = [
50529
+ "KB",
50530
+ "MB",
50531
+ "GB",
50532
+ "TB"
50533
+ ];
50534
+ let size = value / 1024;
50535
+ let unitIndex = 0;
50536
+ while (size >= 1024 && unitIndex < units.length - 1) {
50537
+ size /= 1024;
50538
+ unitIndex += 1;
50539
+ }
50540
+ return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
50541
+ }
50542
+ function formatDuration(value) {
50543
+ const hour = 3600 * 1e3;
50544
+ const day = 24 * hour;
50545
+ if (value % day === 0) return `${value / day}d`;
50546
+ if (value % hour === 0) return `${value / hour}h`;
50547
+ return `${value / (60 * 1e3)}m`;
50548
+ }
50549
+ const PRUNE_CANDIDATE_COLUMNS = [
50550
+ {
50551
+ key: "type",
50552
+ label: "Type"
50553
+ },
50554
+ {
50555
+ key: "size",
50556
+ label: "Size"
50557
+ },
50558
+ {
50559
+ key: "modified",
50560
+ label: "Modified",
50561
+ format: ui.muted
50562
+ },
50563
+ {
50564
+ key: "key",
50565
+ label: "Key",
50566
+ format: ui.path
50567
+ }
50568
+ ];
50569
+ function formatPruneCandidateTable(candidates) {
50570
+ const rows = candidates.map((candidate) => ({
50571
+ key: candidate.key,
50572
+ modified: candidate.lastModifiedAt?.toISOString() ?? "-",
50573
+ size: formatBytes(candidate.size),
50574
+ type: candidate.reason === "asset" ? "shared asset" : "bundle data"
50575
+ }));
50576
+ return ui.table(PRUNE_CANDIDATE_COLUMNS, rows);
50577
+ }
50578
+ function normalizeStorageUri(storageUri) {
50579
+ return new URL(storageUri).toString();
50580
+ }
50581
+ function isLegacyBundleArtifactPath(segments) {
50582
+ const [firstSegment] = segments;
50583
+ return firstSegment === "manifest.json" || firstSegment === "files" || firstSegment === "patches" || firstSegment !== void 0 && /^bundle(?:\..+)?$/.test(firstSegment);
50584
+ }
50585
+ function getBundleIdFromStorageKey(key) {
50586
+ const segments = key.split("/").filter(Boolean);
50587
+ if (segments[0] === BUNDLE_STORAGE_PREFIX) {
50588
+ const bundleId = segments[1];
50589
+ return bundleId && UUID_V7_RE.test(bundleId) ? bundleId.toLowerCase() : null;
50590
+ }
50591
+ const bundleId = segments[0];
50592
+ return bundleId && UUID_V7_RE.test(bundleId) && isLegacyBundleArtifactPath(segments.slice(1)) ? bundleId.toLowerCase() : null;
50593
+ }
50594
+ function isBundleManifest(value, bundleId) {
50595
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
50596
+ const candidate = value;
50597
+ if (candidate.bundleId !== bundleId) return false;
50598
+ const assets = candidate.assets;
50599
+ if (!assets || typeof assets !== "object" || Array.isArray(assets)) return false;
50600
+ return Object.values(assets).every((asset) => {
50601
+ return asset !== null && typeof asset === "object" && !Array.isArray(asset) && typeof asset.fileHash === "string" && /^[0-9a-f]{64}$/i.test(asset.fileHash);
50602
+ });
50603
+ }
50604
+ async function forEachWithConcurrency(values, concurrency, callback) {
50605
+ let nextIndex = 0;
50606
+ const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
50607
+ while (nextIndex < values.length) {
50608
+ const index = nextIndex;
50609
+ nextIndex += 1;
50610
+ const value = values[index];
50611
+ if (value === void 0) return;
50612
+ await callback(value, index);
50613
+ }
50614
+ });
50615
+ const failure = (await Promise.allSettled(workers)).find((result) => result.status === "rejected");
50616
+ if (failure) throw failure.reason;
50617
+ }
50618
+ async function loadAllBundles(databasePlugin) {
50619
+ const bundles = [];
50620
+ const seenCursors = /* @__PURE__ */ new Set();
50621
+ const pageSize = databasePlugin.name === STANDALONE_DATABASE_NAME ? STANDALONE_BUNDLE_PAGE_SIZE : BUNDLE_PAGE_SIZE;
50622
+ let after;
50623
+ while (true) {
50624
+ const { data, pagination } = await databasePlugin.getBundles({
50625
+ cursor: after ? { after } : void 0,
50626
+ limit: pageSize,
50627
+ orderBy: {
50628
+ direction: "desc",
50629
+ field: "id"
50630
+ }
50631
+ });
50632
+ bundles.push(...data);
50633
+ const nextCursor = pagination.nextCursor ?? void 0;
50634
+ if (pagination.hasNextPage && !nextCursor) throw new Error("Database cannot provide safe cursor pagination for storage prune.");
50635
+ if (!nextCursor) return bundles;
50636
+ if (seenCursors.has(nextCursor)) throw new Error(`Database returned a repeated cursor: ${nextCursor}`);
50637
+ seenCursors.add(nextCursor);
50638
+ after = nextCursor;
50639
+ }
50640
+ }
50641
+ async function readManifest(bundle, storagePlugin, workDir, index) {
50642
+ const manifestStorageUri = getManifestStorageUri(bundle);
50643
+ if (!manifestStorageUri) throw new Error(`Cannot prune shared assets: bundle ${bundle.id} has no manifest URI.`);
50644
+ const protocol = new URL(manifestStorageUri).protocol.replace(":", "");
50645
+ let manifestText;
50646
+ if (protocol === "http" || protocol === "https") {
50647
+ const response = await fetch(manifestStorageUri);
50648
+ if (!response.ok) throw new Error(`Cannot prune shared assets: failed to read manifest for bundle ${bundle.id}.`);
50649
+ manifestText = await response.text();
50650
+ } else {
50651
+ if (protocol !== storagePlugin.supportedProtocol) throw new Error(`No storage plugin for protocol: ${protocol}`);
50652
+ const manifestPath = path$1.join(workDir, `${index}.json`);
50653
+ try {
50654
+ await storagePlugin.profiles.node.downloadFile(manifestStorageUri, manifestPath);
50655
+ manifestText = await fs$1.readFile(manifestPath, "utf8");
50656
+ } catch (error) {
50657
+ throw new Error(`Cannot prune shared assets: failed to read manifest for bundle ${bundle.id}.`, { cause: error });
50658
+ }
50659
+ }
50660
+ let manifest;
50661
+ try {
50662
+ manifest = JSON.parse(manifestText);
50663
+ } catch (error) {
50664
+ throw new Error(`Cannot prune shared assets: invalid manifest for bundle ${bundle.id}.`, { cause: error });
50665
+ }
50666
+ if (!isBundleManifest(manifest, bundle.id)) throw new Error(`Cannot prune shared assets: invalid manifest for bundle ${bundle.id}.`);
50667
+ return manifest;
50668
+ }
50669
+ async function collectReferencedAssetUris(bundles, storagePlugin) {
50670
+ const bundlesWithSharedAssets = bundles.filter((bundle) => {
50671
+ const assetBaseStorageUri = getAssetBaseStorageUri(bundle);
50672
+ if (assetBaseStorageUri === null || !isContentAddressedAssetBaseStorageUri(assetBaseStorageUri)) return false;
50673
+ const protocol = new URL(assetBaseStorageUri).protocol.replace(":", "");
50674
+ if (protocol !== storagePlugin.supportedProtocol) throw new Error(`Cannot prune shared assets: bundle ${bundle.id} uses ${protocol} asset storage, but the configured storage plugin uses ${storagePlugin.supportedProtocol}.`);
50675
+ return true;
50676
+ });
50677
+ const referencedUris = /* @__PURE__ */ new Set();
50678
+ if (bundlesWithSharedAssets.length === 0) return {
50679
+ manifestCount: 0,
50680
+ referencedUris
50681
+ };
50682
+ const workDir = await fs$1.mkdtemp(path$1.join(os.tmpdir(), "hot-updater-storage-prune-"));
50683
+ try {
50684
+ await forEachWithConcurrency(bundlesWithSharedAssets, MANIFEST_READ_CONCURRENCY, async (bundle, index) => {
50685
+ const assetBaseStorageUri = getAssetBaseStorageUri(bundle);
50686
+ const manifest = await readManifest(bundle, storagePlugin, workDir, index);
50687
+ for (const [assetPath, asset] of Object.entries(manifest.assets)) {
50688
+ const downloadPath = getManifestAssetDownloadPath(assetPath);
50689
+ referencedUris.add(normalizeStorageUri(resolveManifestAssetStorageUri({
50690
+ assetBaseStorageUri,
50691
+ assetPath: downloadPath,
50692
+ fileHash: asset.fileHash
50693
+ })));
50694
+ }
50695
+ });
50696
+ } finally {
50697
+ await fs$1.rm(workDir, {
50698
+ force: true,
50699
+ recursive: true
50700
+ });
50701
+ }
50702
+ return {
50703
+ manifestCount: bundlesWithSharedAssets.length,
50704
+ referencedUris
50705
+ };
50706
+ }
50707
+ function collectBundleStorageReferences(bundles) {
50708
+ const exactUris = /* @__PURE__ */ new Set();
50709
+ const prefixUris = /* @__PURE__ */ new Set();
50710
+ const addExactUri = (storageUri) => {
50711
+ if (storageUri) exactUris.add(normalizeStorageUri(storageUri));
50712
+ };
50713
+ for (const bundle of bundles) {
50714
+ addExactUri(bundle.storageUri);
50715
+ addExactUri(getManifestStorageUri(bundle));
50716
+ addExactUri(getPatchStorageUri(bundle));
50717
+ for (const patch of getBundlePatches(bundle)) addExactUri(patch.patchStorageUri);
50718
+ const assetBaseStorageUri = getAssetBaseStorageUri(bundle);
50719
+ if (assetBaseStorageUri && !isContentAddressedAssetBaseStorageUri(assetBaseStorageUri)) prefixUris.add(`${normalizeStorageUri(assetBaseStorageUri).replace(/\/+$/, "")}/`);
50720
+ }
50721
+ return {
50722
+ exactUris,
50723
+ prefixUris: [...prefixUris]
50724
+ };
50725
+ }
50726
+ function getPruneCandidates({ bundleStorageReferences, objects, liveBundleIds, referencedAssetUris }) {
50727
+ const candidates = [];
50728
+ for (const object of objects) {
50729
+ const normalizedStorageUri = normalizeStorageUri(object.storageUri);
50730
+ if (bundleStorageReferences.exactUris.has(normalizedStorageUri) || bundleStorageReferences.prefixUris.some((prefix) => normalizedStorageUri.startsWith(prefix))) continue;
50731
+ const bundleId = getBundleIdFromStorageKey(object.key);
50732
+ if (bundleId && !liveBundleIds.has(bundleId)) {
50733
+ candidates.push({
50734
+ ...object,
50735
+ reason: "bundle"
50736
+ });
50737
+ continue;
50738
+ }
50739
+ if (CONTENT_ADDRESSED_ASSET_KEY_RE.test(object.key) && !referencedAssetUris.has(normalizedStorageUri)) candidates.push({
50740
+ ...object,
50741
+ reason: "asset"
50742
+ });
50743
+ }
50744
+ return candidates;
50745
+ }
50746
+ async function safeOnUnmount(databasePlugin) {
50747
+ try {
50748
+ await databasePlugin.onUnmount?.();
50749
+ } catch (error) {
50750
+ p.log.warn(`Database plugin onUnmount failed: ${error instanceof Error ? error.message : String(error)}`);
50751
+ }
50752
+ }
50753
+ async function handleStoragePrune(options = {}) {
50754
+ printBanner$1();
50755
+ if (options.dryRun && options.yes) throw new Error("Storage prune --dry-run cannot be used with --yes.");
50756
+ const protectNewerThan = options.protectNewerThan ?? 864e5;
50757
+ if (!Number.isFinite(protectNewerThan) || protectNewerThan < 0) throw new Error("Storage prune protection must be a non-negative duration.");
50758
+ const config = await loadConfig(null);
50759
+ const [databasePlugin, loadedStoragePlugin] = await Promise.all([config.database(), config.storage()]);
50760
+ assertNodeStoragePlugin(loadedStoragePlugin);
50761
+ const storagePlugin = loadedStoragePlugin;
50762
+ try {
50763
+ const listObjects = storagePlugin.profiles.node.listObjects;
50764
+ if (!listObjects) throw new Error(`Storage plugin "${storagePlugin.name}" does not support storage prune.`);
50765
+ const deleteObjects = storagePlugin.profiles.node.deleteObjects;
50766
+ if (options.yes && !deleteObjects) throw new Error(`Storage plugin "${storagePlugin.name}" does not support exact object deletion.`);
50767
+ if (options.yes) {
50768
+ p.log.warn("Storage prune requires exclusive access. Stop deploy and promote operations first.");
50769
+ p.log.warn("The current database must own every object under this storage prefix. Use a separate storage basePath for each database or environment.");
50770
+ }
50771
+ const bundles = await loadAllBundles(databasePlugin);
50772
+ const liveBundleIds = new Set(bundles.map((bundle) => bundle.id.toLowerCase()));
50773
+ const bundleStorageReferences = collectBundleStorageReferences(bundles);
50774
+ const { manifestCount, referencedUris } = await collectReferencedAssetUris(bundles, storagePlugin);
50775
+ const objects = await listObjects();
50776
+ const unreferenced = getPruneCandidates({
50777
+ bundleStorageReferences,
50778
+ liveBundleIds,
50779
+ objects,
50780
+ referencedAssetUris: referencedUris
50781
+ });
50782
+ const cutoff = Date.now() - protectNewerThan;
50783
+ let candidates = unreferenced.filter((object) => {
50784
+ const modifiedAt = object.lastModifiedAt?.getTime();
50785
+ return modifiedAt !== void 0 && modifiedAt <= cutoff;
50786
+ });
50787
+ if (options.yes && candidates.length > 0) {
50788
+ const refreshedBundles = await loadAllBundles(databasePlugin);
50789
+ const refreshedBundleIds = new Set(refreshedBundles.map((bundle) => bundle.id.toLowerCase()));
50790
+ const refreshedBundleStorageReferences = collectBundleStorageReferences(refreshedBundles);
50791
+ const { referencedUris: refreshedReferencedUris } = await collectReferencedAssetUris(refreshedBundles, storagePlugin);
50792
+ candidates = getPruneCandidates({
50793
+ bundleStorageReferences: refreshedBundleStorageReferences,
50794
+ liveBundleIds: refreshedBundleIds,
50795
+ objects: candidates,
50796
+ referencedAssetUris: refreshedReferencedUris
50797
+ });
50798
+ }
50799
+ const protectedCount = unreferenced.length - candidates.length;
50800
+ const bundleObjects = candidates.filter((candidate) => candidate.reason === "bundle");
50801
+ const assetObjects = candidates.filter((candidate) => candidate.reason === "asset");
50802
+ const candidateBytes = candidates.reduce((total, candidate) => total + candidate.size, 0);
50803
+ p.log.message(ui.block("Storage prune", [
50804
+ ui.kv("Storage", storagePlugin.name),
50805
+ ui.kv("Bundles", bundles.length),
50806
+ ui.kv("Manifests", manifestCount),
50807
+ ui.kv("Objects", objects.length),
50808
+ ui.kv("Protect newer", formatDuration(protectNewerThan)),
50809
+ ui.kv("Bundle data", bundleObjects.length),
50810
+ ui.kv("Shared assets", assetObjects.length),
50811
+ ui.kv("Reclaimable", formatBytes(candidateBytes)),
50812
+ protectedCount > 0 ? ui.kv("Protected", `${protectedCount} newer/undated objects`) : null
50813
+ ].filter((line) => line !== null)));
50814
+ if (candidates.length === 0) {
50815
+ p.log.success("No objects are eligible for pruning.");
50816
+ return;
50817
+ }
50818
+ if (!options.yes) {
50819
+ p.log.message(ui.block("Eligible objects", [formatPruneCandidateTable(candidates)]));
50820
+ p.log.info(`Dry run only. Delete with ${ui.command(`hot-updater storage prune --protect-newer-than ${formatDuration(protectNewerThan)} --yes`)}.`);
50821
+ return;
50822
+ }
50823
+ await deleteObjects(candidates.map((candidate) => candidate.key));
50824
+ p.log.success(`Pruned ${candidates.length} objects (${formatBytes(candidateBytes)}).`);
50825
+ } finally {
50826
+ await safeOnUnmount(databasePlugin);
50827
+ }
50828
+ }
50829
+ //#endregion
50504
50830
  //#region src/index.ts
50505
50831
  const DEFAULT_CHANNEL = "production";
50506
50832
  const parseBooleanOption = (value) => {
@@ -50528,7 +50854,7 @@ const channelCommand = program.command("channel").description("Manage channels")
50528
50854
  channelCommand.action(handleChannel);
50529
50855
  channelCommand.command("set").description("Set the channel for Android (BuildConfig) and iOS (Info.plist)").argument("<channel>", "the channel to set").action(handleSetChannel);
50530
50856
  const bundleCommand = program.command("bundle").description("Manage bundles");
50531
- bundleCommand.command("list").description("List bundles, most recent first").option("-c, --channel <channel>", "filter by channel").option("--json", "output raw bundle data as JSON").addOption(platformCommandOption).option("--limit <n>", "limit the number of results", (value) => {
50857
+ bundleCommand.command("list").description("List bundles, most recent first").option("-c, --channel <channel>", "filter by channel").option("--target-app-version <targetAppVersion>", "filter by exact target app version").option("--json", "output raw bundle data as JSON").addOption(platformCommandOption).option("--limit <n>", "limit the number of results", (value) => {
50532
50858
  const n = Number.parseInt(value, 10);
50533
50859
  if (!Number.isInteger(n) || n <= 0) throw new InvalidArgumentError("must be a positive integer");
50534
50860
  return n;
@@ -50539,6 +50865,21 @@ bundleCommand.command("enable").description("Re-enable a previously disabled bun
50539
50865
  bundleCommand.command("update").description("Update bundle rollout and targeting metadata").argument("<bundle-id>", "the bundle id to update").option("--rollout-cohort-count <count>", "rollout cohort count from 0 to 1000", parseRolloutCohortCount).option("--force-update <value>", "set force update flag (true or false)", parseBooleanOption).option("--target-cohorts <cohorts>", "comma-separated target cohorts").option("--clear-target-cohorts", "clear target cohorts").option("--json", "output the updated bundle as JSON").option("-y, --yes", "skip confirmation prompt").action(handleBundleUpdate);
50540
50866
  bundleCommand.command("delete").description("Delete one or more bundle records by id").argument("<bundle-ids...>", "the bundle id(s) to delete").option("-y, --yes", "skip confirmation prompt").action((bundleIds, options) => handleBundleDelete(bundleIds, options));
50541
50867
  bundleCommand.command("promote").description("Move or copy a bundle to a different channel").argument("<bundle-id>", "the id of the bundle to promote").requiredOption("-t, --target <channel>", "channel to promote the bundle to").addOption(new Option("-a, --action <action>", "promote action (copy creates a new bundle id; move keeps the id)").choices(["copy", "move"]).default("copy")).option("-y, --yes", "skip confirmation prompt").action((bundleId, options) => handlePromote(bundleId, options));
50868
+ program.command("storage").description("Manage stored bundle artifacts").command("prune").description("Find or delete unreferenced bundle objects and shared assets").addOption(new Option("--protect-newer-than <duration>", "protect unreferenced objects modified within this duration").argParser((value) => {
50869
+ try {
50870
+ return parseStoragePruneProtection(value);
50871
+ } catch (error) {
50872
+ throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
50873
+ }
50874
+ }).default(DEFAULT_STORAGE_PRUNE_PROTECTION_MS, "24h")).addOption(new Option("--dry-run", "list eligible objects without deleting them (default)").conflicts("yes")).addOption(new Option("-y, --yes", "delete eligible objects after reference validation").conflicts("dryRun")).addHelpText("after", `
50875
+ Examples:
50876
+ $ hot-updater storage prune --dry-run
50877
+ $ hot-updater storage prune --protect-newer-than 24h --yes
50878
+
50879
+ Only unreferenced bundle objects and shared assets are eligible.
50880
+ Protection uses object modification time, not time since bundle deletion.
50881
+ Deletion requires exclusive storage access; stop deploy and promote first.
50882
+ `).action((options) => handleStoragePrune(options));
50542
50883
  const keysCommand = program.command("keys").description("Code signing key management");
50543
50884
  keysCommand.command("generate").description("Generate RSA key pair for code signing").option("-o, --output <dir>", "output directory for keys", "./keys").option("-k, --key-size <size>", "key size (2048 or 4096)", (value) => {
50544
50885
  const size = Number.parseInt(value, 10);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hot-updater",
3
3
  "type": "module",
4
- "version": "0.35.12",
4
+ "version": "0.36.0",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
7
7
  },
@@ -49,13 +49,13 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "jiti": "2.6.1",
52
- "@hot-updater/android-helper": "0.35.12",
53
- "@hot-updater/apple-helper": "0.35.12",
54
- "@hot-updater/cli-tools": "0.35.12",
55
- "@hot-updater/core": "0.35.12",
56
- "@hot-updater/console": "0.35.12",
57
- "@hot-updater/plugin-core": "0.35.12",
58
- "@hot-updater/server": "0.35.12"
52
+ "@hot-updater/android-helper": "0.36.0",
53
+ "@hot-updater/apple-helper": "0.36.0",
54
+ "@hot-updater/cli-tools": "0.36.0",
55
+ "@hot-updater/console": "0.36.0",
56
+ "@hot-updater/core": "0.36.0",
57
+ "@hot-updater/plugin-core": "0.36.0",
58
+ "@hot-updater/server": "0.36.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@bacons/xcode": "1.0.0-alpha.33",
@@ -78,12 +78,12 @@
78
78
  "@typescript/native": "npm:typescript@7.0.2",
79
79
  "typescript": "npm:@typescript/typescript6@6.0.2",
80
80
  "verkit": "0.3.2",
81
- "@hot-updater/aws": "0.35.12",
82
- "@hot-updater/firebase": "0.35.12",
83
- "@hot-updater/server": "0.35.12",
84
- "@hot-updater/cloudflare": "0.35.12",
85
- "@hot-updater/test-utils": "0.35.12",
86
- "@hot-updater/supabase": "0.35.12"
81
+ "@hot-updater/cloudflare": "0.36.0",
82
+ "@hot-updater/aws": "0.36.0",
83
+ "@hot-updater/server": "0.36.0",
84
+ "@hot-updater/supabase": "0.36.0",
85
+ "@hot-updater/firebase": "0.36.0",
86
+ "@hot-updater/test-utils": "0.36.0"
87
87
  },
88
88
  "peerDependencies": {
89
89
  "@expo/fingerprint": "*",