hot-updater 0.30.12 → 0.31.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 +365 -22
  2. package/package.json +14 -14
package/dist/index.mjs CHANGED
@@ -25,8 +25,11 @@ import { finished } from "node:stream/promises";
25
25
  import { Duplex, PassThrough, Readable, Transform, Writable, getDefaultHighWaterMark } from "node:stream";
26
26
  import { Buffer as Buffer$1 } from "node:buffer";
27
27
  import { SourceSkips, createFingerprintAsync, diffFingerprintChangesAsync } from "@expo/fingerprint";
28
+ import { brotliCompress, constants as constants$1 } from "zlib";
29
+ import { assertNodeStoragePlugin } from "@hot-updater/plugin-core";
30
+ import { createBundleDiff } from "@hot-updater/server";
28
31
  import net from "node:net";
29
- import fs$3, { constants as constants$1 } from "node:fs/promises";
32
+ import fs$3, { constants as constants$2 } from "node:fs/promises";
30
33
  import crypto$1 from "node:crypto";
31
34
  import { Kysely, MysqlDialect, PostgresDialect, SqliteDialect } from "kysely";
32
35
  import { format } from "sql-formatter";
@@ -47497,7 +47500,7 @@ const getWslDrivesMountPoint = (() => {
47497
47500
  const configFilePath = "/etc/wsl.conf";
47498
47501
  let isConfigFileExists = false;
47499
47502
  try {
47500
- await fs$3.access(configFilePath, constants$1.F_OK);
47503
+ await fs$3.access(configFilePath, constants$2.F_OK);
47501
47504
  isConfigFileExists = true;
47502
47505
  } catch {}
47503
47506
  if (!isConfigFileExists) return defaultMountPoint;
@@ -47599,7 +47602,7 @@ const baseOpen = async (options) => {
47599
47602
  const isBundled = !__dirname || __dirname === "/";
47600
47603
  let exeLocalXdgOpen = false;
47601
47604
  try {
47602
- await fs$3.access(localXdgOpenPath, constants$1.X_OK);
47605
+ await fs$3.access(localXdgOpenPath, constants$2.X_OK);
47603
47606
  exeLocalXdgOpen = true;
47604
47607
  } catch {}
47605
47608
  command = process$1.versions.electron ?? (platform$1 === "android" || isBundled || !exeLocalXdgOpen) ? "xdg-open" : localXdgOpenPath;
@@ -47759,15 +47762,23 @@ const getFileHashFromFile = async (filepath) => {
47759
47762
  };
47760
47763
  //#endregion
47761
47764
  //#region src/utils/bundleManifest.ts
47762
- const createBundleManifest = async ({ bundleId, targetFiles }) => {
47765
+ const createBundleManifest = async ({ bundleId, signFileHash, targetFiles }) => {
47763
47766
  return {
47764
47767
  bundleId,
47765
- assets: Object.fromEntries(await Promise.all([...targetFiles].sort((left, right) => left.name.localeCompare(right.name)).map(async (target) => [target.name, { fileHash: await getFileHashFromFile(target.path) }])))
47768
+ assets: Object.fromEntries(await Promise.all([...targetFiles].sort((left, right) => left.name.localeCompare(right.name)).map(async (target) => {
47769
+ const fileHash = await getFileHashFromFile(target.path);
47770
+ const signature = signFileHash ? await signFileHash(fileHash) : void 0;
47771
+ return [target.name, {
47772
+ fileHash,
47773
+ ...signature ? { signature } : {}
47774
+ }];
47775
+ })))
47766
47776
  };
47767
47777
  };
47768
- const writeBundleManifest = async ({ buildPath, bundleId, targetFiles }) => {
47778
+ const writeBundleManifest = async ({ buildPath, bundleId, signFileHash, targetFiles }) => {
47769
47779
  const manifest = await createBundleManifest({
47770
47780
  bundleId,
47781
+ signFileHash,
47771
47782
  targetFiles
47772
47783
  });
47773
47784
  const manifestPath = path$1.join(buildPath, "manifest.json");
@@ -48035,6 +48046,7 @@ const getDefaultTargetAppVersion = async (platform) => {
48035
48046
  };
48036
48047
  //#endregion
48037
48048
  //#region src/commands/deploy.ts
48049
+ const compressBrotli = promisify(brotliCompress);
48038
48050
  const normalizeRolloutPercentage = (rollout) => {
48039
48051
  if (rollout === void 0) return 100;
48040
48052
  const parsedRollout = typeof rollout === "number" ? rollout : Number(rollout);
@@ -48044,6 +48056,64 @@ const normalizeRolloutPercentage = (rollout) => {
48044
48056
  const getRolloutCohortCountFromPercentage = (rolloutPercentage) => {
48045
48057
  return rolloutPercentage * 10;
48046
48058
  };
48059
+ const normalizePatchMaxBaseBundles = (maxBaseBundles) => {
48060
+ if (maxBaseBundles === void 0) return 3;
48061
+ if (!Number.isInteger(maxBaseBundles) || maxBaseBundles < 1) throw new Error("Patch maxBaseBundles must be a positive integer");
48062
+ return maxBaseBundles;
48063
+ };
48064
+ const getPatchBaseBundles = async ({ bundleId, channel, databasePlugin, maxBaseBundles, platform, target }) => {
48065
+ const where = {
48066
+ channel,
48067
+ enabled: true,
48068
+ id: { lt: bundleId },
48069
+ platform,
48070
+ ...target.fingerprintHash ? { fingerprintHash: target.fingerprintHash } : {
48071
+ targetAppVersion: target.appVersion,
48072
+ targetAppVersionNotNull: true
48073
+ }
48074
+ };
48075
+ const { data } = await databasePlugin.getBundles({
48076
+ limit: maxBaseBundles,
48077
+ orderBy: {
48078
+ direction: "desc",
48079
+ field: "id"
48080
+ },
48081
+ where
48082
+ });
48083
+ return data.filter((bundle) => bundle.id !== bundleId).slice(0, maxBaseBundles);
48084
+ };
48085
+ const createAutoPatches = async ({ bundleId, channel, databasePlugin, maxBaseBundles, platform, storagePlugin, target }) => {
48086
+ const baseBundles = await getPatchBaseBundles({
48087
+ bundleId,
48088
+ channel,
48089
+ databasePlugin,
48090
+ maxBaseBundles,
48091
+ platform,
48092
+ target
48093
+ });
48094
+ const failures = [];
48095
+ let createdCount = 0;
48096
+ for (const baseBundle of baseBundles) try {
48097
+ await createBundleDiff({
48098
+ baseBundleId: baseBundle.id,
48099
+ bundleId
48100
+ }, {
48101
+ databasePlugin,
48102
+ storagePlugin
48103
+ }, { makePrimary: createdCount === 0 });
48104
+ createdCount += 1;
48105
+ } catch (error) {
48106
+ failures.push({
48107
+ baseBundleId: baseBundle.id,
48108
+ message: error instanceof Error ? error.message : "Unknown patch error"
48109
+ });
48110
+ }
48111
+ return {
48112
+ candidateCount: baseBundles.length,
48113
+ createdCount,
48114
+ failures
48115
+ };
48116
+ };
48047
48117
  const getExtensionFromCompressStrategy = (compressStrategy) => {
48048
48118
  switch (compressStrategy) {
48049
48119
  case "tar.br": return ".tar.br";
@@ -48052,6 +48122,33 @@ const getExtensionFromCompressStrategy = (compressStrategy) => {
48052
48122
  default: throw new Error(`Unsupported compress strategy: ${compressStrategy}`);
48053
48123
  }
48054
48124
  };
48125
+ const getRelativeStorageDir = (relativePath) => {
48126
+ const normalized = relativePath.replace(/\\/g, "/");
48127
+ const dirname = path$1.posix.dirname(normalized);
48128
+ return dirname === "." ? "" : dirname;
48129
+ };
48130
+ const isBrotliManifestBundleAsset = (relativePath) => /(^|\/)index\.[^/]+\.bundle$/.test(relativePath.replace(/\\/g, "/"));
48131
+ const replaceStorageUriLeaf = (storageUri, nextLeaf) => {
48132
+ const storageUrl = new URL(storageUri);
48133
+ const normalizedPath = storageUrl.pathname.replace(/\/+$/, "");
48134
+ const lastSlashIndex = normalizedPath.lastIndexOf("/");
48135
+ storageUrl.pathname = `${lastSlashIndex >= 0 ? normalizedPath.slice(0, lastSlashIndex) : ""}/${nextLeaf}`;
48136
+ return storageUrl.toString();
48137
+ };
48138
+ const ensureUploadSourcePath = async ({ outputPath, targetFile }) => {
48139
+ const uploadName = isBrotliManifestBundleAsset(targetFile.name) ? `${targetFile.name}.br` : targetFile.name;
48140
+ const expectedFilename = path$1.posix.basename(uploadName);
48141
+ const actualFilename = path$1.basename(targetFile.path);
48142
+ if (uploadName === targetFile.name && expectedFilename === actualFilename) return targetFile.path;
48143
+ const aliasDir = path$1.join(outputPath, "upload-artifacts", getRelativeStorageDir(uploadName));
48144
+ await fs$2.promises.mkdir(aliasDir, { recursive: true });
48145
+ const aliasPath = path$1.join(aliasDir, expectedFilename);
48146
+ if (uploadName !== targetFile.name) {
48147
+ const source = await fs$2.promises.readFile(targetFile.path);
48148
+ await fs$2.promises.writeFile(aliasPath, await compressBrotli(source, { params: { [constants$1.BROTLI_PARAM_QUALITY]: 11 } }));
48149
+ } else await fs$2.promises.copyFile(targetFile.path, aliasPath);
48150
+ return aliasPath;
48151
+ };
48055
48152
  const getPlatformName = (platform) => platform === "ios" ? "iOS" : "Android";
48056
48153
  const getDeployPlatforms = async (options) => {
48057
48154
  if (options.platform) return [options.platform];
@@ -48099,6 +48196,7 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48099
48196
  console.error("No config found. Please run `hot-updater init` first.");
48100
48197
  process.exit(1);
48101
48198
  }
48199
+ const maxPatchBaseBundles = config.patch.enabled ? normalizePatchMaxBaseBundles(config.patch.maxBaseBundles) : 0;
48102
48200
  const signingValidation = await validateSigningConfig(config);
48103
48201
  if (signingValidation.issues.length > 0) {
48104
48202
  const errors = signingValidation.issues.filter((i) => i.type === "error");
@@ -48180,6 +48278,7 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48180
48278
  const outputPath = options.bundleOutputPath ?? HotUpdateDirUtil.getDefaultOutputPath({ cwd });
48181
48279
  let bundleId = null;
48182
48280
  let fileHash;
48281
+ let manifestFileHash = null;
48183
48282
  const platformName = getPlatformName(platform);
48184
48283
  const outputRoot = getBundleOutputRoot({
48185
48284
  cwd,
@@ -48204,9 +48303,14 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48204
48303
  config.storage(),
48205
48304
  config.database()
48206
48305
  ]);
48306
+ assertNodeStoragePlugin(storagePlugin);
48207
48307
  try {
48208
48308
  const taskRef = {
48209
48309
  buildResult: null,
48310
+ targetFiles: [],
48311
+ manifestPath: null,
48312
+ manifestStorageUri: null,
48313
+ assetBaseStorageUri: null,
48210
48314
  storageUri: null
48211
48315
  };
48212
48316
  await p.tasks([{
@@ -48222,12 +48326,15 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48222
48326
  const { manifestPath } = await writeBundleManifest({
48223
48327
  buildPath,
48224
48328
  bundleId: currentBundleId,
48329
+ signFileHash: config.signing?.enabled && config.signing.privateKeyPath ? (assetFileHash) => signBundle(assetFileHash, config.signing.privateKeyPath) : void 0,
48225
48330
  targetFiles
48226
48331
  });
48227
48332
  const bundleTargetFiles = [...targetFiles, {
48228
48333
  path: manifestPath,
48229
48334
  name: "manifest.json"
48230
48335
  }];
48336
+ taskRef.targetFiles = targetFiles;
48337
+ taskRef.manifestPath = manifestPath;
48231
48338
  switch (compressStrategy) {
48232
48339
  case "tar.br":
48233
48340
  await createTarBrTargetFiles({
@@ -48260,6 +48367,11 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48260
48367
  throw error;
48261
48368
  }
48262
48369
  }
48370
+ manifestFileHash = await getFileHashFromFile(manifestPath);
48371
+ if (config.signing?.enabled) {
48372
+ if (!config.signing.privateKeyPath) throw new Error("privateKeyPath is required when signing is enabled. Please provide a valid path to your RSA private key in hot-updater.config.ts");
48373
+ manifestFileHash = createSignedFileHash(await signBundle(manifestFileHash, config.signing.privateKeyPath));
48374
+ }
48263
48375
  return `✅ Build Complete (${buildPlugin.name})`;
48264
48376
  }
48265
48377
  }]);
@@ -48270,8 +48382,25 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48270
48382
  task: async () => {
48271
48383
  if (!bundleId) throw new Error("Bundle ID not found");
48272
48384
  try {
48273
- const { storageUri } = await storagePlugin.upload(bundleId, bundlePath);
48385
+ const { storageUri } = await storagePlugin.profiles.node.upload(bundleId, bundlePath);
48274
48386
  taskRef.storageUri = storageUri;
48387
+ if (!taskRef.manifestPath) throw new Error("Manifest path not found");
48388
+ const manifestUpload = await storagePlugin.profiles.node.upload(bundleId, taskRef.manifestPath);
48389
+ taskRef.manifestStorageUri = manifestUpload.storageUri;
48390
+ taskRef.assetBaseStorageUri = replaceStorageUriLeaf(manifestUpload.storageUri, "files");
48391
+ await Promise.all(taskRef.targetFiles.map(async (targetFile) => {
48392
+ const relativeDir = getRelativeStorageDir(isBrotliManifestBundleAsset(targetFile.name) ? `${targetFile.name}.br` : targetFile.name);
48393
+ const uploadKey = [
48394
+ bundleId,
48395
+ "files",
48396
+ relativeDir
48397
+ ].filter(Boolean).join("/");
48398
+ const uploadSourcePath = await ensureUploadSourcePath({
48399
+ outputPath: outputRoot,
48400
+ targetFile
48401
+ });
48402
+ return storagePlugin.profiles.node.upload(uploadKey, uploadSourcePath);
48403
+ }));
48275
48404
  } catch (e) {
48276
48405
  if (e instanceof Error) p.log.error(e.message);
48277
48406
  throw new Error("Failed to upload bundle to storage");
@@ -48283,6 +48412,7 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48283
48412
  task: async () => {
48284
48413
  if (!bundleId) throw new Error("Bundle ID not found");
48285
48414
  if (!taskRef.storageUri) throw new Error("Storage URI not found");
48415
+ if (!manifestFileHash) throw new Error("Manifest file hash not found");
48286
48416
  const appVersion = await getNativeAppVersion(platform);
48287
48417
  try {
48288
48418
  await databasePlugin.appendBundle({
@@ -48298,6 +48428,9 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48298
48428
  fingerprintHash: target.fingerprintHash,
48299
48429
  storageUri: taskRef.storageUri,
48300
48430
  metadata: appVersion ? { app_version: appVersion } : {},
48431
+ assetBaseStorageUri: taskRef.assetBaseStorageUri,
48432
+ manifestFileHash,
48433
+ manifestStorageUri: taskRef.manifestStorageUri,
48301
48434
  rolloutCohortCount
48302
48435
  });
48303
48436
  await databasePlugin.commitBundle();
@@ -48305,18 +48438,53 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48305
48438
  if (e instanceof Error) p.log.error(e.message);
48306
48439
  throw e;
48307
48440
  }
48308
- await databasePlugin.onUnmount?.();
48309
48441
  return `✅ Update Complete (${databasePlugin.name})`;
48310
48442
  }
48311
48443
  }]);
48312
48444
  if (!bundleId) throw new Error("Bundle ID not found");
48445
+ const confirmedBundleId = bundleId;
48446
+ if (config.patch.enabled) {
48447
+ let patchSummary = {
48448
+ candidateCount: 0,
48449
+ createdCount: 0,
48450
+ failures: []
48451
+ };
48452
+ await p.tasks([{
48453
+ title: "⚡ Optimizing Delivery",
48454
+ task: async () => {
48455
+ try {
48456
+ patchSummary = await createAutoPatches({
48457
+ bundleId: confirmedBundleId,
48458
+ channel,
48459
+ databasePlugin,
48460
+ maxBaseBundles: maxPatchBaseBundles,
48461
+ platform,
48462
+ storagePlugin,
48463
+ target
48464
+ });
48465
+ } catch (error) {
48466
+ const message = error instanceof Error ? error.message : "Unknown patch optimization error";
48467
+ p.log.warn(`Partial updates unavailable: ${message}`);
48468
+ patchSummary = {
48469
+ candidateCount: 0,
48470
+ createdCount: 0,
48471
+ failures: []
48472
+ };
48473
+ }
48474
+ if (!patchSummary.candidateCount) return "Skipped (no compatible base bundles)";
48475
+ if (!patchSummary.createdCount) return "Skipped (no patch artifacts created)";
48476
+ return `✅ Prepared ${patchSummary.createdCount} partial update path(s)`;
48477
+ }
48478
+ }]);
48479
+ for (const failure of patchSummary.failures) p.log.warn(`Partial update skipped for ${failure.baseBundleId.slice(0, 8)}: ${failure.message}`);
48480
+ }
48313
48481
  if (options.interactive) {
48314
48482
  const port = await getConsolePort(config);
48315
48483
  const isConsoleOpen = await isPortReachable(port, { host: "localhost" });
48316
48484
  const openUrl = new URL(`http://localhost:${port}`);
48317
48485
  openUrl.searchParams.set("channel", channel);
48318
48486
  openUrl.searchParams.set("platform", platform);
48319
- openUrl.searchParams.set("bundleId", bundleId);
48487
+ openUrl.searchParams.set("bundleId", confirmedBundleId);
48320
48488
  const url = openUrl.toString();
48321
48489
  const note = `Console: ${url}`;
48322
48490
  if (!isConsoleOpen) {
@@ -48331,15 +48499,15 @@ const deployPlatform = async ({ options, platform, platformIndex, platformCount
48331
48499
  p.note(note);
48332
48500
  }
48333
48501
  if (multiPlatform) {
48334
- p.log.success(`✅ ${platformName} Deployment Successful (${bundleId})`);
48502
+ p.log.success(`✅ ${platformName} Deployment Successful (${confirmedBundleId})`);
48335
48503
  return {
48336
- bundleId,
48504
+ bundleId: confirmedBundleId,
48337
48505
  platform
48338
48506
  };
48339
48507
  }
48340
- p.outro(`🚀 Deployment Successful (${bundleId})`);
48508
+ p.outro(`🚀 Deployment Successful (${confirmedBundleId})`);
48341
48509
  return {
48342
- bundleId,
48510
+ bundleId: confirmedBundleId,
48343
48511
  platform
48344
48512
  };
48345
48513
  } catch (e) {
@@ -48512,6 +48680,43 @@ const init = async () => {
48512
48680
  ] })) p.log.info(".gitignore has been modified to include hot-updater entries");
48513
48681
  };
48514
48682
  //#endregion
48683
+ //#region src/commands/patch.ts
48684
+ const createPatch = async (options) => {
48685
+ printBanner$1();
48686
+ const platform = options.platform ?? (options.interactive ? await getPlatform("Which platform does this patch target?") : null);
48687
+ if (p.isCancel(platform)) return;
48688
+ if (!platform) {
48689
+ p.log.error("Platform not found. -p <ios | android> or --platform <ios | android>");
48690
+ return;
48691
+ }
48692
+ const config = await loadConfig({
48693
+ channel: options.channel,
48694
+ platform
48695
+ });
48696
+ const [databasePlugin, storagePlugin] = await Promise.all([config.database(), config.storage()]);
48697
+ try {
48698
+ p.note([
48699
+ `Channel: ${options.channel}`,
48700
+ `Platform: ${platform === "ios" ? "iOS" : "Android"}`,
48701
+ `Base bundle: ${options.baseBundleId}`,
48702
+ `Target bundle: ${options.bundleId}`
48703
+ ].join("\n"), "Patch");
48704
+ const updatedBundle = await createBundleDiff({
48705
+ baseBundleId: options.baseBundleId,
48706
+ bundleId: options.bundleId
48707
+ }, {
48708
+ databasePlugin,
48709
+ storagePlugin
48710
+ }, { makePrimary: true });
48711
+ p.outro(`⚡ Patch Ready (${updatedBundle.id})`);
48712
+ } catch (error) {
48713
+ console.error(error);
48714
+ process.exit(1);
48715
+ } finally {
48716
+ await databasePlugin.onUnmount?.();
48717
+ }
48718
+ };
48719
+ //#endregion
48515
48720
  //#region src/commands/runNative.ts
48516
48721
  const runNativeInternal = async ({ options, platform }) => {
48517
48722
  printBanner$1();
@@ -48695,6 +48900,10 @@ const formatBundleSummary = (bundle, nextEnabled) => {
48695
48900
  ].filter((line) => line !== null);
48696
48901
  return ui.block("Bundle", lines);
48697
48902
  };
48903
+ const parseTargetCohorts = (value) => {
48904
+ if (value === void 0) return null;
48905
+ return value.split(",").map((cohort) => cohort.trim()).filter(Boolean);
48906
+ };
48698
48907
  const refuseNonInteractiveMutation = (action) => {
48699
48908
  p.log.error(`Cannot ${action} a bundle without confirmation in a non-interactive shell. Re-run with -y, or use a TTY.`);
48700
48909
  process.exit(1);
@@ -48723,6 +48932,24 @@ const handleBundleList = async (options = {}) => {
48723
48932
  await safeOnUnmount$2(databasePlugin);
48724
48933
  }
48725
48934
  };
48935
+ const handleBundleShow = async (bundleId, options = {}) => {
48936
+ if (!options.json) printBanner$1();
48937
+ const databasePlugin = await (await loadConfig(null)).database();
48938
+ try {
48939
+ const bundle = await databasePlugin.getBundleById(bundleId);
48940
+ if (!bundle) {
48941
+ p.log.error(`No bundle with id ${bundleId}.`);
48942
+ process.exit(1);
48943
+ }
48944
+ if (options.json) {
48945
+ console.log(JSON.stringify(bundle, null, 2));
48946
+ return;
48947
+ }
48948
+ p.log.message(formatBundleSummary(bundle));
48949
+ } finally {
48950
+ await safeOnUnmount$2(databasePlugin);
48951
+ }
48952
+ };
48726
48953
  const handleBundleSetEnabled = async (bundleId, nextEnabled, options = {}) => {
48727
48954
  const action = nextEnabled ? "enable" : "disable";
48728
48955
  printBanner$1();
@@ -48764,6 +48991,87 @@ const handleBundleSetEnabled = async (bundleId, nextEnabled, options = {}) => {
48764
48991
  await safeOnUnmount$2(databasePlugin);
48765
48992
  }
48766
48993
  };
48994
+ const handleBundleUpdate = async (bundleId, options = {}) => {
48995
+ if (!options.json) printBanner$1();
48996
+ const targetCohorts = parseTargetCohorts(options.targetCohorts);
48997
+ const patch = {};
48998
+ if (options.rolloutCohortCount !== void 0) patch.rolloutCohortCount = options.rolloutCohortCount;
48999
+ if (options.forceUpdate !== void 0) patch.shouldForceUpdate = options.forceUpdate;
49000
+ if (targetCohorts !== null) patch.targetCohorts = targetCohorts;
49001
+ else if (options.clearTargetCohorts) patch.targetCohorts = null;
49002
+ if (Object.keys(patch).length === 0) {
49003
+ p.log.error("No bundle update fields were provided.");
49004
+ process.exit(1);
49005
+ }
49006
+ const databasePlugin = await (await loadConfig(null)).database();
49007
+ try {
49008
+ const bundle = await databasePlugin.getBundleById(bundleId);
49009
+ if (!bundle) {
49010
+ p.log.error(`No bundle with id ${bundleId}.`);
49011
+ process.exit(1);
49012
+ }
49013
+ if (!options.json) p.log.message(formatBundleSummary(bundle));
49014
+ if (!options.yes) {
49015
+ if (!process.stdin.isTTY) refuseNonInteractiveMutation("update");
49016
+ const confirmed = await p.confirm({
49017
+ message: "Update this bundle?",
49018
+ initialValue: false
49019
+ });
49020
+ if (p.isCancel(confirmed) || !confirmed) {
49021
+ p.log.info("Aborted.");
49022
+ process.exit(2);
49023
+ }
49024
+ }
49025
+ await databasePlugin.updateBundle(bundleId, patch);
49026
+ await databasePlugin.commitBundle();
49027
+ const refetched = await databasePlugin.getBundleById(bundleId);
49028
+ if (!refetched) {
49029
+ p.log.error(`Verification failed: ${bundleId} is missing after update.`);
49030
+ process.exit(1);
49031
+ }
49032
+ if (options.json) {
49033
+ console.log(JSON.stringify(refetched, null, 2));
49034
+ return;
49035
+ }
49036
+ p.log.success("Updated bundle.");
49037
+ p.log.info(` ${ui.id(bundleId)}`);
49038
+ } finally {
49039
+ await safeOnUnmount$2(databasePlugin);
49040
+ }
49041
+ };
49042
+ const handleBundleDelete = async (bundleId, options = {}) => {
49043
+ printBanner$1();
49044
+ const databasePlugin = await (await loadConfig(null)).database();
49045
+ try {
49046
+ const bundle = await databasePlugin.getBundleById(bundleId);
49047
+ if (!bundle) {
49048
+ p.log.info(`No bundle with id ${bundleId}. No changes.`);
49049
+ return;
49050
+ }
49051
+ p.log.message(formatBundleSummary(bundle));
49052
+ if (!options.yes) {
49053
+ if (!process.stdin.isTTY) refuseNonInteractiveMutation("delete");
49054
+ const confirmed = await p.confirm({
49055
+ message: "Delete this bundle record?",
49056
+ initialValue: false
49057
+ });
49058
+ if (p.isCancel(confirmed) || !confirmed) {
49059
+ p.log.info("Aborted.");
49060
+ process.exit(2);
49061
+ }
49062
+ }
49063
+ await databasePlugin.deleteBundle(bundle);
49064
+ await databasePlugin.commitBundle();
49065
+ if (await databasePlugin.getBundleById(bundleId)) {
49066
+ p.log.error(`Verification failed: ${bundleId} still exists.`);
49067
+ process.exit(1);
49068
+ }
49069
+ p.log.success("Deleted bundle record.");
49070
+ p.log.info(` ${ui.id(bundleId)}`);
49071
+ } finally {
49072
+ await safeOnUnmount$2(databasePlugin);
49073
+ }
49074
+ };
48767
49075
  //#endregion
48768
49076
  //#region ../../node_modules/.pnpm/es-toolkit@1.32.0/node_modules/es-toolkit/dist/predicate/isPlainObject.mjs
48769
49077
  function isPlainObject(value) {
@@ -49402,6 +49710,11 @@ var import_semver = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exp
49402
49710
  rcompareIdentifiers: identifiers.rcompareIdentifiers
49403
49711
  };
49404
49712
  })))(), 1);
49713
+ const INFRASTRUCTURE_RECOVERY_COMMANDS = [
49714
+ "hot-updater init",
49715
+ "hot-updater db migrate",
49716
+ "hot-updater db generate"
49717
+ ];
49405
49718
  const INFRASTRUCTURE_UPDATE_TARGETS = [
49406
49719
  {
49407
49720
  version: "0.13.0",
@@ -49422,6 +49735,10 @@ const INFRASTRUCTURE_UPDATE_TARGETS = [
49422
49735
  {
49423
49736
  version: "0.30.0",
49424
49737
  note: "Target cohort rollout behavior"
49738
+ },
49739
+ {
49740
+ version: "0.31.0",
49741
+ note: "Bundle artifact storage fields"
49425
49742
  }
49426
49743
  ];
49427
49744
  const getInfrastructureTargetVersionAt = (index) => {
@@ -49463,6 +49780,7 @@ function resolveVersionEndpoint(serverBaseUrl) {
49463
49780
  url.pathname = `${pathname}/version`;
49464
49781
  return url.toString();
49465
49782
  }
49783
+ const createInfrastructureRemediation = () => ({ commands: [...INFRASTRUCTURE_RECOVERY_COMMANDS] });
49466
49784
  async function checkInfrastructureStatus({ serverBaseUrl, fetchImpl = fetch, requiredVersion = getRequiredInfrastructureVersion() }) {
49467
49785
  const versionEndpoint = resolveVersionEndpoint(serverBaseUrl);
49468
49786
  const baseUrl = serverBaseUrl.trim();
@@ -49546,11 +49864,14 @@ async function doctor(options = {}) {
49546
49864
  packageJsonPath,
49547
49865
  installedHotUpdaterPackages: hotUpdaterPackages
49548
49866
  };
49549
- if (serverBaseUrl) details.infrastructure = await checkInfrastructureStatus({
49550
- serverBaseUrl,
49551
- fetchImpl,
49552
- requiredVersion: getRequiredInfrastructureVersion(hotUpdaterVersion)
49553
- });
49867
+ if (serverBaseUrl) {
49868
+ details.infrastructure = await checkInfrastructureStatus({
49869
+ serverBaseUrl,
49870
+ fetchImpl,
49871
+ requiredVersion: getRequiredInfrastructureVersion(hotUpdaterVersion)
49872
+ });
49873
+ if (details.infrastructure.error !== void 0 || details.infrastructure.needsUpdate === true) details.infrastructure.remediation = createInfrastructureRemediation();
49874
+ }
49554
49875
  if (versionMismatches.length > 0) details.versionMismatches = versionMismatches;
49555
49876
  const hasInfrastructureIssue = details.infrastructure?.error !== void 0 || details.infrastructure?.needsUpdate === true;
49556
49877
  if (versionMismatches.length > 0 || hasInfrastructureIssue) return {
@@ -49620,6 +49941,12 @@ const handleDoctor = async ({ serverBaseUrl } = {}) => {
49620
49941
  if (infrastructure.updateReason) p.log.info(`Reason: ${infrastructure.updateReason}`);
49621
49942
  } else if (infrastructure.error) p.log.error(`Infrastructure check failed: ${infrastructure.error}`);
49622
49943
  else p.log.success("Infrastructure is up to date.");
49944
+ if (infrastructure.remediation) p.log.message(ui.block("Recovery", [ui.kv("Managed", ui.command("hot-updater init")), ui.kv("@hot-updater/server (self-hosted)", ui.line([
49945
+ ui.command("hot-updater db generate"),
49946
+ "or",
49947
+ ui.command("hot-updater db migrate"),
49948
+ "then redeploy server"
49949
+ ]))]));
49623
49950
  }
49624
49951
  if (details?.versionMismatches && details.versionMismatches.length > 0) {
49625
49952
  p.log.warn("Version mismatches found:");
@@ -50209,12 +50536,11 @@ async function generateStandaloneSQL(options) {
50209
50536
  const s = p.spinner();
50210
50537
  s.start("Generating SQL from database schema");
50211
50538
  const db = new Kysely({ dialect: createDialect(dbType) });
50212
- const [{ HotUpdaterDB }, { kyselyAdapter }] = await Promise.all([import("@hot-updater/server"), import("@hot-updater/server/adapters/kysely")]);
50213
- const adapter = kyselyAdapter({
50539
+ const [{ createHotUpdater }, { kyselyAdapter }] = await Promise.all([import("@hot-updater/server"), import("@hot-updater/server/adapters/kysely")]);
50540
+ const result = await createHotUpdater({ database: kyselyAdapter({
50214
50541
  db,
50215
50542
  provider: dbType
50216
- });
50217
- const result = await HotUpdaterDB.client(adapter).createMigrator().migrateToLatest({
50543
+ }) }).createMigrator().migrateToLatest({
50218
50544
  mode: "from-schema",
50219
50545
  updateSettings: false
50220
50546
  });
@@ -50734,6 +51060,7 @@ const handlePromote = async (bundleId, options) => {
50734
51060
  let storagePlugin = null;
50735
51061
  try {
50736
51062
  storagePlugin = await config.storage();
51063
+ assertNodeStoragePlugin(storagePlugin);
50737
51064
  } catch {
50738
51065
  storagePlugin = null;
50739
51066
  }
@@ -50910,6 +51237,16 @@ const handleRollback = async (channel, options = {}) => {
50910
51237
  //#endregion
50911
51238
  //#region src/index.ts
50912
51239
  const DEFAULT_CHANNEL = "production";
51240
+ const parseBooleanOption = (value) => {
51241
+ if (value === "true") return true;
51242
+ if (value === "false") return false;
51243
+ throw new InvalidArgumentError("must be true or false");
51244
+ };
51245
+ const parseRolloutCohortCount = (value) => {
51246
+ const count = Number.parseInt(value, 10);
51247
+ if (!Number.isInteger(count) || count < 0 || count > 1e3) throw new InvalidArgumentError("must be an integer between 0 and 1000");
51248
+ return count;
51249
+ };
50913
51250
  const program = new Command();
50914
51251
  program.name("hot-updater").description(banner(version)).version(version);
50915
51252
  program.command("init").description("Initialize Hot Updater").action(init);
@@ -50926,8 +51263,11 @@ bundleCommand.command("list").description("List bundles, most recent first").opt
50926
51263
  if (!Number.isInteger(n) || n <= 0) throw new InvalidArgumentError("must be a positive integer");
50927
51264
  return n;
50928
51265
  }, 20).action(handleBundleList);
51266
+ bundleCommand.command("show").description("Show one bundle by id").argument("<bundle-id>", "the bundle id to show").option("--json", "output raw bundle data as JSON").action((bundleId, options) => handleBundleShow(bundleId, options));
50929
51267
  bundleCommand.command("disable").description("Disable a bundle by id").argument("<bundle-id>", "the id of the bundle to disable").option("-y, --yes", "skip confirmation prompt").action((bundleId, options) => handleBundleSetEnabled(bundleId, false, options));
50930
51268
  bundleCommand.command("enable").description("Re-enable a previously disabled bundle by id").argument("<bundle-id>", "the id of the bundle to enable").option("-y, --yes", "skip confirmation prompt").action((bundleId, options) => handleBundleSetEnabled(bundleId, true, options));
51269
+ 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);
51270
+ bundleCommand.command("delete").description("Delete a bundle record by id").argument("<bundle-id>", "the bundle id to delete").option("-y, --yes", "skip confirmation prompt").action((bundleId, options) => handleBundleDelete(bundleId, options));
50931
51271
  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));
50932
51272
  const keysCommand = program.command("keys").description("Code signing key management");
50933
51273
  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) => {
@@ -50954,6 +51294,9 @@ program.command("deploy").description("deploy a new version").addOption(platform
50954
51294
  process.exit(1);
50955
51295
  }
50956
51296
  }).default(100)).addOption(interactiveCommandOption).addOption(new Option("-c, --channel <channel>", "specify the channel to deploy").default(DEFAULT_CHANNEL)).addOption(new Option("-m, --message <message>", "Specify a custom message for this deployment. If not provided, the latest git commit message will be used as the deployment message")).action(async (options) => deploy(options));
51297
+ program.command("patch").description("create patch artifacts for a deployed bundle").requiredOption("-b, --bundle-id <bundleId>", "target bundle id that should receive the patch artifact").requiredOption("--base-bundle-id <baseBundleId>", "older bundle id to use as the patch base").addOption(platformCommandOption).addOption(interactiveCommandOption).addOption(new Option("-c, --channel <channel>", "specify the channel used to load config").default(DEFAULT_CHANNEL)).action(async (options) => {
51298
+ await createPatch(options);
51299
+ });
50957
51300
  program.command("rollback").description("Disable the most recent enabled bundle on a channel").argument("<channel>", "the channel to roll back").addOption(platformCommandOption).option("-y, --yes", "skip confirmation prompt").option("--target <bundle-id>", "scope rollback to exactly this bundle id (use to retry a failed rollback)").action((channel, options) => handleRollback(channel, options));
50958
51301
  program.command("console").description("open the console").action(async () => {
50959
51302
  printBanner$1();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hot-updater",
3
3
  "type": "module",
4
- "version": "0.30.12",
4
+ "version": "0.31.0",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
7
7
  },
@@ -49,13 +49,13 @@
49
49
  "jiti": "2.6.1",
50
50
  "kysely": "0.28.16",
51
51
  "sql-formatter": "15.6.10",
52
- "@hot-updater/cli-tools": "0.30.12",
53
- "@hot-updater/apple-helper": "0.30.12",
54
- "@hot-updater/console": "0.30.12",
55
- "@hot-updater/core": "0.30.12",
56
- "@hot-updater/android-helper": "0.30.12",
57
- "@hot-updater/plugin-core": "0.30.12",
58
- "@hot-updater/server": "0.30.12"
52
+ "@hot-updater/cli-tools": "0.31.0",
53
+ "@hot-updater/android-helper": "0.31.0",
54
+ "@hot-updater/console": "0.31.0",
55
+ "@hot-updater/core": "0.31.0",
56
+ "@hot-updater/plugin-core": "0.31.0",
57
+ "@hot-updater/apple-helper": "0.31.0",
58
+ "@hot-updater/server": "0.31.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@bacons/xcode": "1.0.0-alpha.24",
@@ -74,12 +74,12 @@
74
74
  "plist": "^3.1.0",
75
75
  "semver": "^7.6.3",
76
76
  "typescript": "6.0.2",
77
- "@hot-updater/aws": "0.30.12",
78
- "@hot-updater/firebase": "0.30.12",
79
- "@hot-updater/server": "0.30.12",
80
- "@hot-updater/test-utils": "0.30.12",
81
- "@hot-updater/cloudflare": "0.30.12",
82
- "@hot-updater/supabase": "0.30.12"
77
+ "@hot-updater/aws": "0.31.0",
78
+ "@hot-updater/cloudflare": "0.31.0",
79
+ "@hot-updater/firebase": "0.31.0",
80
+ "@hot-updater/server": "0.31.0",
81
+ "@hot-updater/supabase": "0.31.0",
82
+ "@hot-updater/test-utils": "0.31.0"
83
83
  },
84
84
  "peerDependencies": {
85
85
  "@hot-updater/aws": "*",