hot-updater 0.22.2 → 0.23.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.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { _ as __toESM, a as nativeFingerprint, c as showFingerprintDiff, d as IosConfigParser, f as require_plist, g as __require, h as __commonJS, i as generateFingerprints, l as isFingerprintEquals, m as AndroidConfigParser, o as readLocalFingerprint, p as require_base64_js, s as getFingerprintDiff, t as createAndInjectFingerprintFiles, u as require_out } from "./fingerprint-CrCon-HQ.js";
2
+ import { _ as __toESM, a as nativeFingerprint, c as showFingerprintDiff, d as IosConfigParser, f as require_plist, g as __require, h as __commonJS, i as generateFingerprints, l as isFingerprintEquals, m as AndroidConfigParser, o as readLocalFingerprint, p as require_base64_js, s as getFingerprintDiff, t as createAndInjectFingerprintFiles, u as require_out } from "./fingerprint-11-3nNgi.js";
3
3
  import { EventEmitter, addAbortListener, on, once, setMaxListeners } from "node:events";
4
4
  import childProcess, { ChildProcess, execFile, spawn, spawnSync } from "node:child_process";
5
5
  import path from "node:path";
@@ -29,6 +29,7 @@ import { createServer } from "http";
29
29
  import { Http2ServerRequest } from "http2";
30
30
  import app from "@hot-updater/console";
31
31
  import net from "node:net";
32
+ import crypto$1 from "node:crypto";
32
33
  import { HotUpdaterDB } from "@hot-updater/server";
33
34
  import { kyselyAdapter } from "@hot-updater/server/adapters/kysely";
34
35
  import { Kysely, MysqlDialect, PostgresDialect, SqliteDialect } from "kysely";
@@ -35436,6 +35437,160 @@ const appendOutputDirectoryIntoGitignore = ({ cwd } = {}) => {
35436
35437
  });
35437
35438
  };
35438
35439
 
35440
+ //#endregion
35441
+ //#region src/utils/signing/keyGeneration.ts
35442
+ /**
35443
+ * Generate RSA key pair for bundle signing.
35444
+ * @param keySize Key size in bits (2048 or 4096)
35445
+ * @returns Promise resolving to key pair in PEM format
35446
+ */
35447
+ async function generateKeyPair(keySize = 4096) {
35448
+ return new Promise((resolve, reject) => {
35449
+ crypto$1.generateKeyPair("rsa", {
35450
+ modulusLength: keySize,
35451
+ publicKeyEncoding: {
35452
+ type: "spki",
35453
+ format: "pem"
35454
+ },
35455
+ privateKeyEncoding: {
35456
+ type: "pkcs8",
35457
+ format: "pem"
35458
+ }
35459
+ }, (err, publicKey, privateKey) => {
35460
+ if (err) reject(err);
35461
+ else resolve({
35462
+ privateKey,
35463
+ publicKey
35464
+ });
35465
+ });
35466
+ });
35467
+ }
35468
+ /**
35469
+ * Save key pair to disk with secure permissions.
35470
+ * @param keyPair Generated key pair
35471
+ * @param outputDir Directory to save keys
35472
+ */
35473
+ async function saveKeyPair(keyPair, outputDir) {
35474
+ await fs$2.mkdir(outputDir, { recursive: true });
35475
+ const privateKeyPath = path.join(outputDir, "private-key.pem");
35476
+ const publicKeyPath = path.join(outputDir, "public-key.pem");
35477
+ await fs$2.writeFile(privateKeyPath, keyPair.privateKey, { mode: 384 });
35478
+ await fs$2.writeFile(publicKeyPath, keyPair.publicKey, { mode: 420 });
35479
+ }
35480
+ /**
35481
+ * Load private key from PEM file.
35482
+ * @param privateKeyPath Path to private key file
35483
+ * @returns Private key in PEM format
35484
+ * @throws Error if file not found or invalid format
35485
+ */
35486
+ async function loadPrivateKey(privateKeyPath) {
35487
+ try {
35488
+ const privateKey = await fs$2.readFile(privateKeyPath, "utf-8");
35489
+ crypto$1.createPrivateKey(privateKey);
35490
+ return privateKey;
35491
+ } catch (error) {
35492
+ throw new Error(`Failed to load private key from ${privateKeyPath}: ${error.message}`);
35493
+ }
35494
+ }
35495
+ /**
35496
+ * Extract public key from private key.
35497
+ * @param privateKeyPEM Private key in PEM format
35498
+ * @returns Public key in PEM format
35499
+ */
35500
+ function getPublicKeyFromPrivate(privateKeyPEM) {
35501
+ const privateKey = crypto$1.createPrivateKey(privateKeyPEM);
35502
+ return crypto$1.createPublicKey(privateKey).export({
35503
+ type: "spki",
35504
+ format: "pem"
35505
+ });
35506
+ }
35507
+
35508
+ //#endregion
35509
+ //#region src/utils/signing/bundleSigning.ts
35510
+ /**
35511
+ * Sign bundle fileHash with private key.
35512
+ * @param fileHash SHA-256 hash of bundle (hex string)
35513
+ * @param privateKeyPath Path to private key file
35514
+ * @returns Base64-encoded RSA-SHA256 signature
35515
+ */
35516
+ async function signBundle(fileHash, privateKeyPath) {
35517
+ const privateKeyPEM = await loadPrivateKey(privateKeyPath);
35518
+ const fileHashBuffer = Buffer.from(fileHash, "hex");
35519
+ const sign = crypto$1.createSign("RSA-SHA256");
35520
+ sign.update(fileHashBuffer);
35521
+ sign.end();
35522
+ return sign.sign(privateKeyPEM).toString("base64");
35523
+ }
35524
+
35525
+ //#endregion
35526
+ //#region src/utils/signing/validateSigningConfig.ts
35527
+ const ANDROID_KEY$1 = "hot_updater_public_key";
35528
+ const IOS_KEY$1 = "HOT_UPDATER_PUBLIC_KEY";
35529
+ /**
35530
+ * Validates signing configuration consistency between config file and native files.
35531
+ * Detects mismatches that would cause OTA updates to fail.
35532
+ */
35533
+ async function validateSigningConfig(config) {
35534
+ const signingEnabled = config.signing?.enabled ?? false;
35535
+ const iosParser = new IosConfigParser(config.platform.ios.infoPlistPaths);
35536
+ const androidParser = new AndroidConfigParser(config.platform.android.stringResourcePaths);
35537
+ const [iosExists, androidExists] = await Promise.all([iosParser.exists(), androidParser.exists()]);
35538
+ const [iosResult, androidResult] = await Promise.all([iosExists ? iosParser.get(IOS_KEY$1) : Promise.resolve({
35539
+ value: null,
35540
+ paths: []
35541
+ }), androidExists ? androidParser.get(ANDROID_KEY$1) : Promise.resolve({
35542
+ value: null,
35543
+ paths: []
35544
+ })]);
35545
+ const issues = [];
35546
+ if (signingEnabled) {
35547
+ if (!iosResult.value && iosExists) issues.push({
35548
+ type: "error",
35549
+ platform: "ios",
35550
+ code: "MISSING_PUBLIC_KEY",
35551
+ message: "Signing is enabled but HOT_UPDATER_PUBLIC_KEY is missing from Info.plist",
35552
+ resolution: "Run `npx hot-updater keys export-public` to add the public key, then rebuild your iOS app."
35553
+ });
35554
+ if (!androidResult.value && androidExists) issues.push({
35555
+ type: "error",
35556
+ platform: "android",
35557
+ code: "MISSING_PUBLIC_KEY",
35558
+ message: "Signing is enabled but hot_updater_public_key is missing from strings.xml",
35559
+ resolution: "Run `npx hot-updater keys export-public` to add the public key, then rebuild your Android app."
35560
+ });
35561
+ } else {
35562
+ if (iosResult.value) issues.push({
35563
+ type: "warning",
35564
+ platform: "ios",
35565
+ code: "ORPHAN_PUBLIC_KEY",
35566
+ message: "Signing is disabled but HOT_UPDATER_PUBLIC_KEY exists in Info.plist. This will cause OTA updates to be rejected.",
35567
+ resolution: "Run `npx hot-updater keys remove` to remove public keys, or enable signing in hot-updater.config.ts"
35568
+ });
35569
+ if (androidResult.value) issues.push({
35570
+ type: "warning",
35571
+ platform: "android",
35572
+ code: "ORPHAN_PUBLIC_KEY",
35573
+ message: "Signing is disabled but hot_updater_public_key exists in strings.xml. This will cause OTA updates to be rejected.",
35574
+ resolution: "Run `npx hot-updater keys remove` to remove public keys, or enable signing in hot-updater.config.ts"
35575
+ });
35576
+ }
35577
+ return {
35578
+ isValid: issues.filter((i$1) => i$1.type === "error").length === 0,
35579
+ signingEnabled,
35580
+ nativePublicKeys: {
35581
+ ios: {
35582
+ exists: !!iosResult.value,
35583
+ paths: iosResult.paths
35584
+ },
35585
+ android: {
35586
+ exists: !!androidResult.value,
35587
+ paths: androidResult.paths
35588
+ }
35589
+ },
35590
+ issues
35591
+ };
35592
+ }
35593
+
35439
35594
  //#endregion
35440
35595
  //#region src/utils/version/getDefaultTargetAppVersion.ts
35441
35596
  var import_valid$2 = /* @__PURE__ */ __toESM(require_valid$2(), 1);
@@ -35455,6 +35610,64 @@ const getDefaultTargetAppVersion = async (platform$2) => {
35455
35610
  return version$1;
35456
35611
  };
35457
35612
 
35613
+ //#endregion
35614
+ //#region src/signedHashUtils.ts
35615
+ /**
35616
+ * Utilities for handling signed file hashes in Hot Updater.
35617
+ *
35618
+ * The signed hash format uses a simple prefix to indicate signing:
35619
+ * - Signed: `sig:<base64_signature>`
35620
+ * - Unsigned: `<hex_hash>` (plain SHA256)
35621
+ *
35622
+ * Signature verification implicitly verifies hash integrity,
35623
+ * so we only need to store the signature for signed bundles.
35624
+ *
35625
+ * @module signedHashUtils
35626
+ */
35627
+ /**
35628
+ * Prefix indicating a signed file hash.
35629
+ * @example "sig:MEUCIQDx..."
35630
+ */
35631
+ const SIGNED_HASH_PREFIX = "sig:";
35632
+ /**
35633
+ * Custom error class for signed hash format errors.
35634
+ */
35635
+ var SignedHashFormatError = class extends Error {
35636
+ /**
35637
+ * Creates a new SignedHashFormatError.
35638
+ *
35639
+ * @param message - Description of the format error
35640
+ * @param input - The malformed input string that caused the error
35641
+ */
35642
+ constructor(message, input) {
35643
+ super(message);
35644
+ this.input = input;
35645
+ this.name = "SignedHashFormatError";
35646
+ }
35647
+ };
35648
+ /**
35649
+ * Creates a signed file hash from a signature.
35650
+ *
35651
+ * The format is: `sig:<base64_signature>`
35652
+ *
35653
+ * Note: The hash is not stored because signature verification
35654
+ * implicitly verifies the hash (the signature is computed over the hash).
35655
+ *
35656
+ * @param signature - The Base64-encoded RSA-SHA256 signature
35657
+ * @returns The signed file hash string
35658
+ * @throws {SignedHashFormatError} If the signature is empty
35659
+ *
35660
+ * @example
35661
+ * ```typescript
35662
+ * const signedHash = createSignedFileHash("MEUCIQDx...");
35663
+ * // Returns: "sig:MEUCIQDx..."
35664
+ * ```
35665
+ */
35666
+ function createSignedFileHash(signature) {
35667
+ if (!signature || signature.trim().length === 0) throw new SignedHashFormatError("Invalid signature: signature cannot be empty", signature ?? "");
35668
+ return `${SIGNED_HASH_PREFIX}${signature}`;
35669
+ }
35670
+
35458
35671
  //#endregion
35459
35672
  //#region src/commands/deploy.ts
35460
35673
  var import_valid$1 = /* @__PURE__ */ __toESM(require_valid$2(), 1);
@@ -35486,6 +35699,31 @@ const deploy = async (options) => {
35486
35699
  console.error("No config found. Please run `hot-updater init` first.");
35487
35700
  process.exit(1);
35488
35701
  }
35702
+ const signingValidation = await validateSigningConfig(config);
35703
+ if (signingValidation.issues.length > 0) {
35704
+ const errors = signingValidation.issues.filter((i$1) => i$1.type === "error");
35705
+ const warnings = signingValidation.issues.filter((i$1) => i$1.type === "warning");
35706
+ if (errors.length > 0) {
35707
+ console.log("");
35708
+ p.log.error("Signing configuration error:");
35709
+ for (const issue of errors) {
35710
+ p.log.error(` ${issue.message}`);
35711
+ p.log.info(` Resolution: ${issue.resolution}`);
35712
+ }
35713
+ console.log("");
35714
+ p.log.error("Deployment blocked. Fix the signing configuration and try again.");
35715
+ process.exit(1);
35716
+ }
35717
+ if (warnings.length > 0) {
35718
+ console.log("");
35719
+ p.log.warn("Signing configuration warning:");
35720
+ for (const warning of warnings) {
35721
+ p.log.warn(` ${warning.message}`);
35722
+ p.log.info(` Resolution: ${warning.resolution}`);
35723
+ }
35724
+ console.log("");
35725
+ }
35726
+ }
35489
35727
  const target = {
35490
35728
  appVersion: null,
35491
35729
  fingerprintHash: null
@@ -35589,6 +35827,20 @@ const deploy = async (options) => {
35589
35827
  }
35590
35828
  bundleId = taskRef.buildResult.bundleId;
35591
35829
  fileHash = await getFileHashFromFile(bundlePath);
35830
+ if (config.signing?.enabled) {
35831
+ 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");
35832
+ const s$1 = p.spinner();
35833
+ s$1.start("Signing bundle");
35834
+ try {
35835
+ fileHash = createSignedFileHash(await signBundle(fileHash, config.signing.privateKeyPath));
35836
+ s$1.stop("Bundle signed successfully");
35837
+ } catch (error) {
35838
+ s$1.stop("Failed to sign bundle", 1);
35839
+ p.log.error(`Signing error: ${error.message}`);
35840
+ p.log.error("Ensure private key path is correct and file has proper permissions");
35841
+ throw error;
35842
+ }
35843
+ }
35592
35844
  p.log.success(`Bundle stored at ${colors.blueBright(path$1.relative(cwd, bundlePath))}`);
35593
35845
  return `✅ Build Complete (${buildPlugin.name})`;
35594
35846
  }
@@ -37266,6 +37518,319 @@ async function generateStandaloneSQL(options) {
37266
37518
  }
37267
37519
  }
37268
37520
 
37521
+ //#endregion
37522
+ //#region src/commands/keys.ts
37523
+ const ANDROID_KEY = "hot_updater_public_key";
37524
+ const IOS_KEY = "HOT_UPDATER_PUBLIC_KEY";
37525
+ /**
37526
+ * Generate RSA key pair for code signing.
37527
+ * Usage: npx hot-updater keys:generate [--output ./keys] [--key-size 4096]
37528
+ */
37529
+ const keysGenerate = async (options = {}) => {
37530
+ const cwd = getCwd();
37531
+ const outputDir = options.output ? path.isAbsolute(options.output) ? options.output : path.join(cwd, options.output) : path.join(cwd, "keys");
37532
+ const keySize = options.keySize ?? 4096;
37533
+ p.log.info(`Generating ${keySize}-bit RSA key pair...`);
37534
+ const spinner = p.spinner();
37535
+ spinner.start("Generating keys");
37536
+ try {
37537
+ await saveKeyPair(await generateKeyPair(keySize), outputDir);
37538
+ spinner.stop("Keys generated successfully");
37539
+ p.log.success(`Private key: ${path.join(outputDir, "private-key.pem")}`);
37540
+ p.log.success(`Public key: ${path.join(outputDir, "public-key.pem")}`);
37541
+ console.log("");
37542
+ p.log.warn("⚠️ Keep private key secure!");
37543
+ p.log.warn(" - Add keys/ to .gitignore");
37544
+ p.log.warn(" - Use secure storage for CI/CD (AWS Secrets Manager, etc.)");
37545
+ console.log("");
37546
+ p.log.info("Next steps:");
37547
+ p.log.info("1. Add to hot-updater.config.ts:");
37548
+ p.log.info(" signing: { enabled: true, privateKeyPath: \"./keys/private-key.pem\" }");
37549
+ p.log.info("2. Run: npx hot-updater keys export-public");
37550
+ p.log.info("3. Embed public key in iOS Info.plist and Android strings.xml");
37551
+ p.log.info("4. Rebuild native app");
37552
+ } catch (error) {
37553
+ spinner.stop("Failed to generate keys", 1);
37554
+ p.log.error(error.message);
37555
+ process.exit(1);
37556
+ }
37557
+ };
37558
+ async function writePublicKeyToAndroid(publicKey, customPaths) {
37559
+ try {
37560
+ const androidParser = new AndroidConfigParser(customPaths);
37561
+ if (!await androidParser.exists()) return {
37562
+ platform: "android",
37563
+ paths: [],
37564
+ success: false,
37565
+ error: "No strings.xml files found"
37566
+ };
37567
+ return {
37568
+ platform: "android",
37569
+ paths: (await androidParser.set(ANDROID_KEY, publicKey)).paths,
37570
+ success: true
37571
+ };
37572
+ } catch (error) {
37573
+ return {
37574
+ platform: "android",
37575
+ paths: [],
37576
+ success: false,
37577
+ error: error.message
37578
+ };
37579
+ }
37580
+ }
37581
+ async function writePublicKeyToIos(publicKey, customPaths) {
37582
+ try {
37583
+ const iosParser = new IosConfigParser(customPaths);
37584
+ if (!await iosParser.exists()) return {
37585
+ platform: "ios",
37586
+ paths: [],
37587
+ success: false,
37588
+ error: "No Info.plist files found"
37589
+ };
37590
+ return {
37591
+ platform: "ios",
37592
+ paths: (await iosParser.set(IOS_KEY, publicKey)).paths,
37593
+ success: true
37594
+ };
37595
+ } catch (error) {
37596
+ return {
37597
+ platform: "ios",
37598
+ paths: [],
37599
+ success: false,
37600
+ error: error.message
37601
+ };
37602
+ }
37603
+ }
37604
+ function printPublicKeyInstructions(publicKeyPEM) {
37605
+ console.log("");
37606
+ console.log(colors.cyan("═══════════════════════════════════════════════════════"));
37607
+ console.log(colors.cyan("Public Key (embed in native configuration)"));
37608
+ console.log(colors.cyan("═══════════════════════════════════════════════════════"));
37609
+ console.log("");
37610
+ console.log(publicKeyPEM);
37611
+ console.log("");
37612
+ console.log(colors.yellow("iOS Configuration (Info.plist):"));
37613
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
37614
+ console.log("<key>HOT_UPDATER_PUBLIC_KEY</key>");
37615
+ console.log(`<string>${publicKeyPEM.trim().replace(/\n/g, "\\n")}</string>`);
37616
+ console.log("");
37617
+ console.log(colors.yellow("Android Configuration (res/values/strings.xml):"));
37618
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
37619
+ console.log("<string name=\"hot_updater_public_key\">");
37620
+ console.log(publicKeyPEM.trim());
37621
+ console.log("</string>");
37622
+ console.log("");
37623
+ console.log(colors.cyan("═══════════════════════════════════════════════════════"));
37624
+ }
37625
+ /**
37626
+ * Export public key for embedding in native configuration.
37627
+ * By default, writes the public key to iOS Info.plist and Android strings.xml.
37628
+ * Use --print-only to only display the key without modifying files.
37629
+ *
37630
+ * The private key path is read from hot-updater.config.ts (signing.privateKeyPath)
37631
+ * unless overridden with --input.
37632
+ *
37633
+ * Usage: npx hot-updater keys export-public [--input ./keys/private-key.pem] [--print-only] [--yes]
37634
+ */
37635
+ const keysExportPublic = async (options = {}) => {
37636
+ const cwd = getCwd();
37637
+ const config = await loadConfig(null);
37638
+ const configPrivateKeyPath = config.signing?.privateKeyPath;
37639
+ let privateKeyPath;
37640
+ if (options.input) privateKeyPath = path.isAbsolute(options.input) ? options.input : path.join(cwd, options.input);
37641
+ else if (configPrivateKeyPath) privateKeyPath = path.isAbsolute(configPrivateKeyPath) ? configPrivateKeyPath : path.join(cwd, configPrivateKeyPath);
37642
+ else privateKeyPath = path.join(cwd, "keys", "private-key.pem");
37643
+ try {
37644
+ const publicKeyPEM = getPublicKeyFromPrivate(await loadPrivateKey(privateKeyPath));
37645
+ if (options.printOnly) {
37646
+ printPublicKeyInstructions(publicKeyPEM);
37647
+ return;
37648
+ }
37649
+ p.log.info("Preparing to write public key to native configuration files...");
37650
+ const androidParser = new AndroidConfigParser(config.platform.android.stringResourcePaths);
37651
+ const iosParser = new IosConfigParser(config.platform.ios.infoPlistPaths);
37652
+ const androidExists = await androidParser.exists();
37653
+ const iosExists = await iosParser.exists();
37654
+ if (!androidExists && !iosExists) {
37655
+ p.log.error("No native configuration files found.");
37656
+ p.log.info("Tip: Use --print-only to display the key for manual configuration.");
37657
+ process.exit(1);
37658
+ }
37659
+ console.log("");
37660
+ p.log.step("Files to be updated:");
37661
+ if (androidExists) {
37662
+ const androidPaths = config.platform.android.stringResourcePaths;
37663
+ if (androidPaths.length === 1) p.log.info(` Android: ${androidPaths[0]} (${ANDROID_KEY})`);
37664
+ else {
37665
+ p.log.info(` Android (${ANDROID_KEY}):`);
37666
+ for (const androidPath of androidPaths) p.log.info(` - ${androidPath}`);
37667
+ }
37668
+ }
37669
+ if (iosExists) {
37670
+ const iosPaths = config.platform.ios.infoPlistPaths;
37671
+ if (iosPaths.length === 1) p.log.info(` iOS: ${iosPaths[0]} (${IOS_KEY})`);
37672
+ else {
37673
+ p.log.info(` iOS (${IOS_KEY}):`);
37674
+ for (const iosPath of iosPaths) p.log.info(` - ${iosPath}`);
37675
+ }
37676
+ }
37677
+ console.log("");
37678
+ if (!options.yes) {
37679
+ const shouldContinue = await p.confirm({
37680
+ message: "Write public key to native files?",
37681
+ initialValue: true
37682
+ });
37683
+ if (p.isCancel(shouldContinue) || !shouldContinue) {
37684
+ p.cancel("Operation cancelled");
37685
+ process.exit(0);
37686
+ }
37687
+ }
37688
+ const results = [];
37689
+ if (androidExists) results.push(await writePublicKeyToAndroid(publicKeyPEM.trim(), config.platform.android.stringResourcePaths));
37690
+ if (iosExists) results.push(await writePublicKeyToIos(publicKeyPEM.trim(), config.platform.ios.infoPlistPaths));
37691
+ console.log("");
37692
+ for (const result of results) if (result.success) p.log.success(`${result.platform}: Updated ${result.paths.join(", ")}`);
37693
+ else p.log.error(`${result.platform}: ${result.error}`);
37694
+ const successCount = results.filter((r) => r.success).length;
37695
+ console.log("");
37696
+ if (successCount === results.length) {
37697
+ p.log.success("Public key written to all native files!");
37698
+ p.log.info("Next step: Rebuild your native app to apply the changes.");
37699
+ } else if (successCount > 0) p.log.warn("Public key written to some files. Check errors above.");
37700
+ else {
37701
+ p.log.error("Failed to write public key to any native files.");
37702
+ process.exit(1);
37703
+ }
37704
+ } catch (error) {
37705
+ p.log.error(`Failed to export public key: ${error.message}`);
37706
+ process.exit(1);
37707
+ }
37708
+ };
37709
+ async function removePublicKeyFromAndroid(customPaths) {
37710
+ try {
37711
+ const androidParser = new AndroidConfigParser(customPaths);
37712
+ if (!await androidParser.exists()) return {
37713
+ platform: "android",
37714
+ paths: [],
37715
+ success: true,
37716
+ found: false
37717
+ };
37718
+ const existing = await androidParser.get(ANDROID_KEY);
37719
+ if (!existing.value) return {
37720
+ platform: "android",
37721
+ paths: existing.paths,
37722
+ success: true,
37723
+ found: false
37724
+ };
37725
+ return {
37726
+ platform: "android",
37727
+ paths: (await androidParser.remove(ANDROID_KEY)).paths,
37728
+ success: true,
37729
+ found: true
37730
+ };
37731
+ } catch (error) {
37732
+ return {
37733
+ platform: "android",
37734
+ paths: [],
37735
+ success: false,
37736
+ found: true,
37737
+ error: error.message
37738
+ };
37739
+ }
37740
+ }
37741
+ async function removePublicKeyFromIos(customPaths) {
37742
+ try {
37743
+ const iosParser = new IosConfigParser(customPaths);
37744
+ if (!await iosParser.exists()) return {
37745
+ platform: "ios",
37746
+ paths: [],
37747
+ success: true,
37748
+ found: false
37749
+ };
37750
+ const existing = await iosParser.get(IOS_KEY);
37751
+ if (!existing.value) return {
37752
+ platform: "ios",
37753
+ paths: existing.paths,
37754
+ success: true,
37755
+ found: false
37756
+ };
37757
+ return {
37758
+ platform: "ios",
37759
+ paths: (await iosParser.remove(IOS_KEY)).paths,
37760
+ success: true,
37761
+ found: true
37762
+ };
37763
+ } catch (error) {
37764
+ return {
37765
+ platform: "ios",
37766
+ paths: [],
37767
+ success: false,
37768
+ found: true,
37769
+ error: error.message
37770
+ };
37771
+ }
37772
+ }
37773
+ /**
37774
+ * Remove public keys from native configuration files.
37775
+ * Automatically detects and removes keys from both iOS and Android.
37776
+ *
37777
+ * Usage: npx hot-updater keys remove [--yes]
37778
+ */
37779
+ const keysRemove = async (options = {}) => {
37780
+ const config = await loadConfig(null);
37781
+ const androidParser = new AndroidConfigParser(config.platform.android.stringResourcePaths);
37782
+ const iosParser = new IosConfigParser(config.platform.ios.infoPlistPaths);
37783
+ const [androidExists, iosExists] = await Promise.all([androidParser.exists(), iosParser.exists()]);
37784
+ if (!androidExists && !iosExists) {
37785
+ p.log.info("No native configuration files found.");
37786
+ return;
37787
+ }
37788
+ const [androidKey, iosKey] = await Promise.all([androidExists ? androidParser.get(ANDROID_KEY) : Promise.resolve({
37789
+ value: null,
37790
+ paths: []
37791
+ }), iosExists ? iosParser.get(IOS_KEY) : Promise.resolve({
37792
+ value: null,
37793
+ paths: []
37794
+ })]);
37795
+ const foundKeys = [];
37796
+ if (iosKey.value) foundKeys.push(`iOS: ${iosKey.paths.join(", ")}`);
37797
+ if (androidKey.value) foundKeys.push(`Android: ${androidKey.paths.join(", ")}`);
37798
+ if (foundKeys.length === 0) {
37799
+ p.log.info("No public keys found in native files.");
37800
+ return;
37801
+ }
37802
+ console.log("");
37803
+ p.log.step("Found public keys in:");
37804
+ for (const key of foundKeys) p.log.info(` • ${key}`);
37805
+ console.log("");
37806
+ if (!options.yes) {
37807
+ const shouldContinue = await p.confirm({
37808
+ message: "Remove public keys from these files?",
37809
+ initialValue: false
37810
+ });
37811
+ if (p.isCancel(shouldContinue) || !shouldContinue) {
37812
+ p.cancel("Operation cancelled");
37813
+ return;
37814
+ }
37815
+ }
37816
+ const results = [];
37817
+ if (iosKey.value) results.push(await removePublicKeyFromIos(config.platform.ios.infoPlistPaths));
37818
+ if (androidKey.value) results.push(await removePublicKeyFromAndroid(config.platform.android.stringResourcePaths));
37819
+ console.log("");
37820
+ for (const result of results) if (result.success && result.found) p.log.success(`Removed ${result.platform === "ios" ? IOS_KEY : ANDROID_KEY} from ${result.paths.join(", ")}`);
37821
+ else if (!result.success) p.log.error(`${result.platform}: ${result.error}`);
37822
+ const successCount = results.filter((r) => r.success && r.found).length;
37823
+ console.log("");
37824
+ if (successCount > 0) {
37825
+ p.log.success("Public keys removed from native files!");
37826
+ console.log("");
37827
+ p.log.info("Next steps:");
37828
+ p.log.info(" 1. Rebuild your native apps");
37829
+ p.log.info(" 2. Release to app stores");
37830
+ p.log.info(" 3. Deploy unsigned bundles with `npx hot-updater deploy`");
37831
+ }
37832
+ };
37833
+
37269
37834
  //#endregion
37270
37835
  //#region src/commands/migrate.ts
37271
37836
  /**
@@ -37447,6 +38012,17 @@ fingerprintCommand.command("create").description("Create fingerprint").action(ha
37447
38012
  const channelCommand = program.command("channel").description("Manage channels");
37448
38013
  channelCommand.action(handleChannel);
37449
38014
  channelCommand.command("set").description("Set the channel for Android (BuildConfig) and iOS (Info.plist)").argument("<channel>", "the channel to set").action(handleSetChannel);
38015
+ const keysCommand = program.command("keys").description("Code signing key management");
38016
+ 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) => {
38017
+ const size = Number.parseInt(value, 10);
38018
+ if (size !== 2048 && size !== 4096) {
38019
+ p.log.error("Key size must be 2048 or 4096");
38020
+ process.exit(1);
38021
+ }
38022
+ return size;
38023
+ }, 4096).action(keysGenerate);
38024
+ keysCommand.command("export-public").description("Export public key for native configuration").option("-i, --input <path>", "path to private key file (default: from config signing.privateKeyPath in hot-updater.config.ts)").option("-p, --print-only", "only print the public key without writing to native files").option("-y, --yes", "skip confirmation prompt when writing to native files").action(keysExportPublic);
38025
+ keysCommand.command("remove").description("Remove public keys from native configuration files").option("-y, --yes", "skip confirmation prompt").action(keysRemove);
37450
38026
  program.command("deploy").description("deploy a new version").addOption(platformCommandOption).addOption(new Option("-t, --target-app-version <targetAppVersion>", "specify the target app version (semver format e.g. 1.0.0, 1.x.x)").argParser((value) => {
37451
38027
  if (!(0, import_valid.default)(value)) {
37452
38028
  p.log.error("Invalid semver format (e.g. 1.0.0, 1.x.x)");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hot-updater",
3
3
  "type": "module",
4
- "version": "0.22.2",
4
+ "version": "0.23.0",
5
5
  "bin": {
6
6
  "hot-updater": "./dist/index.js"
7
7
  },
@@ -55,11 +55,11 @@
55
55
  "kysely": "0.28.8",
56
56
  "sql-formatter": "15.6.10",
57
57
  "cosmiconfig-typescript-loader": "5.0.0",
58
- "@hot-updater/cli-tools": "0.22.2",
59
- "@hot-updater/console": "0.22.2",
60
- "@hot-updater/core": "0.22.2",
61
- "@hot-updater/plugin-core": "0.22.2",
62
- "@hot-updater/server": "0.22.2"
58
+ "@hot-updater/cli-tools": "0.23.0",
59
+ "@hot-updater/console": "0.23.0",
60
+ "@hot-updater/core": "0.23.0",
61
+ "@hot-updater/plugin-core": "0.23.0",
62
+ "@hot-updater/server": "0.23.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "semver": "^7.6.3",
@@ -89,12 +89,12 @@
89
89
  "plist": "^3.1.0",
90
90
  "read-package-up": "^11.0.0",
91
91
  "uuidv7": "^1.0.2",
92
- "@hot-updater/aws": "0.22.2",
93
- "@hot-updater/server": "0.22.2",
94
- "@hot-updater/firebase": "0.22.2",
95
- "@hot-updater/cloudflare": "0.22.2",
96
- "@hot-updater/supabase": "0.22.2",
97
- "@hot-updater/test-utils": "0.22.2"
92
+ "@hot-updater/aws": "0.23.0",
93
+ "@hot-updater/server": "0.23.0",
94
+ "@hot-updater/firebase": "0.23.0",
95
+ "@hot-updater/cloudflare": "0.23.0",
96
+ "@hot-updater/supabase": "0.23.0",
97
+ "@hot-updater/test-utils": "0.23.0"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "@hot-updater/aws": "*",