hot-updater 0.22.2 → 0.23.1

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 require_base64_js, a as createAndInjectFingerprintFiles, b as __require, c as generateFingerprints, d as getFingerprintDiff, f as showFingerprintDiff, g as require_plist, h as IosConfigParser, i as saveKeyPair, l as nativeFingerprint, m as require_out, n as getPublicKeyFromPrivate, p as isFingerprintEquals, r as loadPrivateKey, t as generateKeyPair, u as readLocalFingerprint, v as AndroidConfigParser, x as __toESM, y as __commonJS } from "./keyGeneration-D_2zTEmt.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,92 @@ const appendOutputDirectoryIntoGitignore = ({ cwd } = {}) => {
35436
35437
  });
35437
35438
  };
35438
35439
 
35440
+ //#endregion
35441
+ //#region src/utils/signing/bundleSigning.ts
35442
+ /**
35443
+ * Sign bundle fileHash with private key.
35444
+ * @param fileHash SHA-256 hash of bundle (hex string)
35445
+ * @param privateKeyPath Path to private key file
35446
+ * @returns Base64-encoded RSA-SHA256 signature
35447
+ */
35448
+ async function signBundle(fileHash, privateKeyPath) {
35449
+ const privateKeyPEM = await loadPrivateKey(privateKeyPath);
35450
+ const fileHashBuffer = Buffer.from(fileHash, "hex");
35451
+ const sign = crypto$1.createSign("RSA-SHA256");
35452
+ sign.update(fileHashBuffer);
35453
+ sign.end();
35454
+ return sign.sign(privateKeyPEM).toString("base64");
35455
+ }
35456
+
35457
+ //#endregion
35458
+ //#region src/utils/signing/validateSigningConfig.ts
35459
+ const ANDROID_KEY$1 = "hot_updater_public_key";
35460
+ const IOS_KEY$1 = "HOT_UPDATER_PUBLIC_KEY";
35461
+ /**
35462
+ * Validates signing configuration consistency between config file and native files.
35463
+ * Detects mismatches that would cause OTA updates to fail.
35464
+ */
35465
+ async function validateSigningConfig(config) {
35466
+ const signingEnabled = config.signing?.enabled ?? false;
35467
+ const iosParser = new IosConfigParser(config.platform.ios.infoPlistPaths);
35468
+ const androidParser = new AndroidConfigParser(config.platform.android.stringResourcePaths);
35469
+ const [iosExists, androidExists] = await Promise.all([iosParser.exists(), androidParser.exists()]);
35470
+ const [iosResult, androidResult] = await Promise.all([iosExists ? iosParser.get(IOS_KEY$1) : Promise.resolve({
35471
+ value: null,
35472
+ paths: []
35473
+ }), androidExists ? androidParser.get(ANDROID_KEY$1) : Promise.resolve({
35474
+ value: null,
35475
+ paths: []
35476
+ })]);
35477
+ const issues = [];
35478
+ if (signingEnabled) {
35479
+ if (!iosResult.value && iosExists) issues.push({
35480
+ type: "error",
35481
+ platform: "ios",
35482
+ code: "MISSING_PUBLIC_KEY",
35483
+ message: "Signing is enabled but HOT_UPDATER_PUBLIC_KEY is missing from Info.plist",
35484
+ resolution: "Run `npx hot-updater keys export-public` to add the public key, then rebuild your iOS app."
35485
+ });
35486
+ if (!androidResult.value && androidExists) issues.push({
35487
+ type: "error",
35488
+ platform: "android",
35489
+ code: "MISSING_PUBLIC_KEY",
35490
+ message: "Signing is enabled but hot_updater_public_key is missing from strings.xml",
35491
+ resolution: "Run `npx hot-updater keys export-public` to add the public key, then rebuild your Android app."
35492
+ });
35493
+ } else {
35494
+ if (iosResult.value) issues.push({
35495
+ type: "warning",
35496
+ platform: "ios",
35497
+ code: "ORPHAN_PUBLIC_KEY",
35498
+ message: "Signing is disabled but HOT_UPDATER_PUBLIC_KEY exists in Info.plist. This will cause OTA updates to be rejected.",
35499
+ resolution: "Run `npx hot-updater keys remove` to remove public keys, or enable signing in hot-updater.config.ts"
35500
+ });
35501
+ if (androidResult.value) issues.push({
35502
+ type: "warning",
35503
+ platform: "android",
35504
+ code: "ORPHAN_PUBLIC_KEY",
35505
+ message: "Signing is disabled but hot_updater_public_key exists in strings.xml. This will cause OTA updates to be rejected.",
35506
+ resolution: "Run `npx hot-updater keys remove` to remove public keys, or enable signing in hot-updater.config.ts"
35507
+ });
35508
+ }
35509
+ return {
35510
+ isValid: issues.filter((i$1) => i$1.type === "error").length === 0,
35511
+ signingEnabled,
35512
+ nativePublicKeys: {
35513
+ ios: {
35514
+ exists: !!iosResult.value,
35515
+ paths: iosResult.paths
35516
+ },
35517
+ android: {
35518
+ exists: !!androidResult.value,
35519
+ paths: androidResult.paths
35520
+ }
35521
+ },
35522
+ issues
35523
+ };
35524
+ }
35525
+
35439
35526
  //#endregion
35440
35527
  //#region src/utils/version/getDefaultTargetAppVersion.ts
35441
35528
  var import_valid$2 = /* @__PURE__ */ __toESM(require_valid$2(), 1);
@@ -35455,6 +35542,64 @@ const getDefaultTargetAppVersion = async (platform$2) => {
35455
35542
  return version$1;
35456
35543
  };
35457
35544
 
35545
+ //#endregion
35546
+ //#region src/signedHashUtils.ts
35547
+ /**
35548
+ * Utilities for handling signed file hashes in Hot Updater.
35549
+ *
35550
+ * The signed hash format uses a simple prefix to indicate signing:
35551
+ * - Signed: `sig:<base64_signature>`
35552
+ * - Unsigned: `<hex_hash>` (plain SHA256)
35553
+ *
35554
+ * Signature verification implicitly verifies hash integrity,
35555
+ * so we only need to store the signature for signed bundles.
35556
+ *
35557
+ * @module signedHashUtils
35558
+ */
35559
+ /**
35560
+ * Prefix indicating a signed file hash.
35561
+ * @example "sig:MEUCIQDx..."
35562
+ */
35563
+ const SIGNED_HASH_PREFIX = "sig:";
35564
+ /**
35565
+ * Custom error class for signed hash format errors.
35566
+ */
35567
+ var SignedHashFormatError = class extends Error {
35568
+ /**
35569
+ * Creates a new SignedHashFormatError.
35570
+ *
35571
+ * @param message - Description of the format error
35572
+ * @param input - The malformed input string that caused the error
35573
+ */
35574
+ constructor(message, input) {
35575
+ super(message);
35576
+ this.input = input;
35577
+ this.name = "SignedHashFormatError";
35578
+ }
35579
+ };
35580
+ /**
35581
+ * Creates a signed file hash from a signature.
35582
+ *
35583
+ * The format is: `sig:<base64_signature>`
35584
+ *
35585
+ * Note: The hash is not stored because signature verification
35586
+ * implicitly verifies the hash (the signature is computed over the hash).
35587
+ *
35588
+ * @param signature - The Base64-encoded RSA-SHA256 signature
35589
+ * @returns The signed file hash string
35590
+ * @throws {SignedHashFormatError} If the signature is empty
35591
+ *
35592
+ * @example
35593
+ * ```typescript
35594
+ * const signedHash = createSignedFileHash("MEUCIQDx...");
35595
+ * // Returns: "sig:MEUCIQDx..."
35596
+ * ```
35597
+ */
35598
+ function createSignedFileHash(signature) {
35599
+ if (!signature || signature.trim().length === 0) throw new SignedHashFormatError("Invalid signature: signature cannot be empty", signature ?? "");
35600
+ return `${SIGNED_HASH_PREFIX}${signature}`;
35601
+ }
35602
+
35458
35603
  //#endregion
35459
35604
  //#region src/commands/deploy.ts
35460
35605
  var import_valid$1 = /* @__PURE__ */ __toESM(require_valid$2(), 1);
@@ -35486,6 +35631,31 @@ const deploy = async (options) => {
35486
35631
  console.error("No config found. Please run `hot-updater init` first.");
35487
35632
  process.exit(1);
35488
35633
  }
35634
+ const signingValidation = await validateSigningConfig(config);
35635
+ if (signingValidation.issues.length > 0) {
35636
+ const errors = signingValidation.issues.filter((i$1) => i$1.type === "error");
35637
+ const warnings = signingValidation.issues.filter((i$1) => i$1.type === "warning");
35638
+ if (errors.length > 0) {
35639
+ console.log("");
35640
+ p.log.error("Signing configuration error:");
35641
+ for (const issue of errors) {
35642
+ p.log.error(` ${issue.message}`);
35643
+ p.log.info(` Resolution: ${issue.resolution}`);
35644
+ }
35645
+ console.log("");
35646
+ p.log.error("Deployment blocked. Fix the signing configuration and try again.");
35647
+ process.exit(1);
35648
+ }
35649
+ if (warnings.length > 0) {
35650
+ console.log("");
35651
+ p.log.warn("Signing configuration warning:");
35652
+ for (const warning of warnings) {
35653
+ p.log.warn(` ${warning.message}`);
35654
+ p.log.info(` Resolution: ${warning.resolution}`);
35655
+ }
35656
+ console.log("");
35657
+ }
35658
+ }
35489
35659
  const target = {
35490
35660
  appVersion: null,
35491
35661
  fingerprintHash: null
@@ -35589,6 +35759,20 @@ const deploy = async (options) => {
35589
35759
  }
35590
35760
  bundleId = taskRef.buildResult.bundleId;
35591
35761
  fileHash = await getFileHashFromFile(bundlePath);
35762
+ if (config.signing?.enabled) {
35763
+ 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");
35764
+ const s$1 = p.spinner();
35765
+ s$1.start("Signing bundle");
35766
+ try {
35767
+ fileHash = createSignedFileHash(await signBundle(fileHash, config.signing.privateKeyPath));
35768
+ s$1.stop("Bundle signed successfully");
35769
+ } catch (error) {
35770
+ s$1.stop("Failed to sign bundle", 1);
35771
+ p.log.error(`Signing error: ${error.message}`);
35772
+ p.log.error("Ensure private key path is correct and file has proper permissions");
35773
+ throw error;
35774
+ }
35775
+ }
35592
35776
  p.log.success(`Bundle stored at ${colors.blueBright(path$1.relative(cwd, bundlePath))}`);
35593
35777
  return `✅ Build Complete (${buildPlugin.name})`;
35594
35778
  }
@@ -37266,6 +37450,323 @@ async function generateStandaloneSQL(options) {
37266
37450
  }
37267
37451
  }
37268
37452
 
37453
+ //#endregion
37454
+ //#region src/commands/keys.ts
37455
+ const ANDROID_KEY = "hot_updater_public_key";
37456
+ const IOS_KEY = "HOT_UPDATER_PUBLIC_KEY";
37457
+ /**
37458
+ * Generate RSA key pair for code signing.
37459
+ * Usage: npx hot-updater keys:generate [--output ./keys] [--key-size 4096]
37460
+ */
37461
+ const keysGenerate = async (options = {}) => {
37462
+ const cwd = getCwd();
37463
+ const outputDir = options.output ? path.isAbsolute(options.output) ? options.output : path.join(cwd, options.output) : path.join(cwd, "keys");
37464
+ const keySize = options.keySize ?? 4096;
37465
+ p.log.info(`Generating ${keySize}-bit RSA key pair...`);
37466
+ const spinner = p.spinner();
37467
+ spinner.start("Generating keys");
37468
+ try {
37469
+ await saveKeyPair(await generateKeyPair(keySize), outputDir);
37470
+ spinner.stop("Keys generated successfully");
37471
+ p.log.success(`Private key: ${path.join(outputDir, "private-key.pem")}`);
37472
+ p.log.success(`Public key: ${path.join(outputDir, "public-key.pem")}`);
37473
+ const keysDir = path.basename(outputDir);
37474
+ if (appendToProjectRootGitignore({
37475
+ cwd,
37476
+ globLines: [`${keysDir}/`]
37477
+ })) p.log.success(`Added ${keysDir}/ to .gitignore`);
37478
+ console.log("");
37479
+ p.log.warn("⚠️ Keep private key secure!");
37480
+ p.log.warn(" - Use secure storage for CI/CD (AWS Secrets Manager, etc.)");
37481
+ console.log("");
37482
+ p.log.info("Next steps:");
37483
+ p.log.info("1. Add to hot-updater.config.ts:");
37484
+ p.log.info(" signing: { enabled: true, privateKeyPath: \"./keys/private-key.pem\" }");
37485
+ p.log.info("2. Run: npx hot-updater keys export-public");
37486
+ p.log.info("3. Embed public key in iOS Info.plist and Android strings.xml");
37487
+ p.log.info("4. Rebuild native app");
37488
+ } catch (error) {
37489
+ spinner.stop("Failed to generate keys", 1);
37490
+ p.log.error(error.message);
37491
+ process.exit(1);
37492
+ }
37493
+ };
37494
+ async function writePublicKeyToAndroid(publicKey, customPaths) {
37495
+ try {
37496
+ const androidParser = new AndroidConfigParser(customPaths);
37497
+ if (!await androidParser.exists()) return {
37498
+ platform: "android",
37499
+ paths: [],
37500
+ success: false,
37501
+ error: "No strings.xml files found"
37502
+ };
37503
+ return {
37504
+ platform: "android",
37505
+ paths: (await androidParser.set(ANDROID_KEY, publicKey)).paths,
37506
+ success: true
37507
+ };
37508
+ } catch (error) {
37509
+ return {
37510
+ platform: "android",
37511
+ paths: [],
37512
+ success: false,
37513
+ error: error.message
37514
+ };
37515
+ }
37516
+ }
37517
+ async function writePublicKeyToIos(publicKey, customPaths) {
37518
+ try {
37519
+ const iosParser = new IosConfigParser(customPaths);
37520
+ if (!await iosParser.exists()) return {
37521
+ platform: "ios",
37522
+ paths: [],
37523
+ success: false,
37524
+ error: "No Info.plist files found"
37525
+ };
37526
+ return {
37527
+ platform: "ios",
37528
+ paths: (await iosParser.set(IOS_KEY, publicKey)).paths,
37529
+ success: true
37530
+ };
37531
+ } catch (error) {
37532
+ return {
37533
+ platform: "ios",
37534
+ paths: [],
37535
+ success: false,
37536
+ error: error.message
37537
+ };
37538
+ }
37539
+ }
37540
+ function printPublicKeyInstructions(publicKeyPEM) {
37541
+ console.log("");
37542
+ console.log(colors.cyan("═══════════════════════════════════════════════════════"));
37543
+ console.log(colors.cyan("Public Key (embed in native configuration)"));
37544
+ console.log(colors.cyan("═══════════════════════════════════════════════════════"));
37545
+ console.log("");
37546
+ console.log(publicKeyPEM);
37547
+ console.log("");
37548
+ console.log(colors.yellow("iOS Configuration (Info.plist):"));
37549
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
37550
+ console.log("<key>HOT_UPDATER_PUBLIC_KEY</key>");
37551
+ console.log(`<string>${publicKeyPEM.trim().replace(/\n/g, "\\n")}</string>`);
37552
+ console.log("");
37553
+ console.log(colors.yellow("Android Configuration (res/values/strings.xml):"));
37554
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
37555
+ console.log("<string name=\"hot_updater_public_key\">");
37556
+ console.log(publicKeyPEM.trim());
37557
+ console.log("</string>");
37558
+ console.log("");
37559
+ console.log(colors.cyan("═══════════════════════════════════════════════════════"));
37560
+ }
37561
+ /**
37562
+ * Export public key for embedding in native configuration.
37563
+ * By default, writes the public key to iOS Info.plist and Android strings.xml.
37564
+ * Use --print-only to only display the key without modifying files.
37565
+ *
37566
+ * The private key path is read from hot-updater.config.ts (signing.privateKeyPath)
37567
+ * unless overridden with --input.
37568
+ *
37569
+ * Usage: npx hot-updater keys export-public [--input ./keys/private-key.pem] [--print-only] [--yes]
37570
+ */
37571
+ const keysExportPublic = async (options = {}) => {
37572
+ const cwd = getCwd();
37573
+ const config = await loadConfig(null);
37574
+ const configPrivateKeyPath = config.signing?.privateKeyPath;
37575
+ let privateKeyPath;
37576
+ if (options.input) privateKeyPath = path.isAbsolute(options.input) ? options.input : path.join(cwd, options.input);
37577
+ else if (configPrivateKeyPath) privateKeyPath = path.isAbsolute(configPrivateKeyPath) ? configPrivateKeyPath : path.join(cwd, configPrivateKeyPath);
37578
+ else privateKeyPath = path.join(cwd, "keys", "private-key.pem");
37579
+ try {
37580
+ const publicKeyPEM = getPublicKeyFromPrivate(await loadPrivateKey(privateKeyPath));
37581
+ if (options.printOnly) {
37582
+ printPublicKeyInstructions(publicKeyPEM);
37583
+ return;
37584
+ }
37585
+ p.log.info("Preparing to write public key to native configuration files...");
37586
+ const androidParser = new AndroidConfigParser(config.platform.android.stringResourcePaths);
37587
+ const iosParser = new IosConfigParser(config.platform.ios.infoPlistPaths);
37588
+ const androidExists = await androidParser.exists();
37589
+ const iosExists = await iosParser.exists();
37590
+ if (!androidExists && !iosExists) {
37591
+ p.log.error("No native configuration files found.");
37592
+ p.log.info("Tip: Use --print-only to display the key for manual configuration.");
37593
+ process.exit(1);
37594
+ }
37595
+ console.log("");
37596
+ p.log.step("Files to be updated:");
37597
+ if (androidExists) {
37598
+ const androidPaths = config.platform.android.stringResourcePaths;
37599
+ if (androidPaths.length === 1) p.log.info(` Android: ${androidPaths[0]} (${ANDROID_KEY})`);
37600
+ else {
37601
+ p.log.info(` Android (${ANDROID_KEY}):`);
37602
+ for (const androidPath of androidPaths) p.log.info(` - ${androidPath}`);
37603
+ }
37604
+ }
37605
+ if (iosExists) {
37606
+ const iosPaths = config.platform.ios.infoPlistPaths;
37607
+ if (iosPaths.length === 1) p.log.info(` iOS: ${iosPaths[0]} (${IOS_KEY})`);
37608
+ else {
37609
+ p.log.info(` iOS (${IOS_KEY}):`);
37610
+ for (const iosPath of iosPaths) p.log.info(` - ${iosPath}`);
37611
+ }
37612
+ }
37613
+ console.log("");
37614
+ if (!options.yes) {
37615
+ const shouldContinue = await p.confirm({
37616
+ message: "Write public key to native files?",
37617
+ initialValue: true
37618
+ });
37619
+ if (p.isCancel(shouldContinue) || !shouldContinue) {
37620
+ p.cancel("Operation cancelled");
37621
+ process.exit(0);
37622
+ }
37623
+ }
37624
+ const results = [];
37625
+ if (androidExists) results.push(await writePublicKeyToAndroid(publicKeyPEM.trim(), config.platform.android.stringResourcePaths));
37626
+ if (iosExists) results.push(await writePublicKeyToIos(publicKeyPEM.trim(), config.platform.ios.infoPlistPaths));
37627
+ console.log("");
37628
+ for (const result of results) if (result.success) p.log.success(`${result.platform}: Updated ${result.paths.join(", ")}`);
37629
+ else p.log.error(`${result.platform}: ${result.error}`);
37630
+ const successCount = results.filter((r) => r.success).length;
37631
+ console.log("");
37632
+ if (successCount === results.length) {
37633
+ p.log.success("Public key written to all native files!");
37634
+ p.log.info("Next step: Rebuild your native app to apply the changes.");
37635
+ } else if (successCount > 0) p.log.warn("Public key written to some files. Check errors above.");
37636
+ else {
37637
+ p.log.error("Failed to write public key to any native files.");
37638
+ process.exit(1);
37639
+ }
37640
+ } catch (error) {
37641
+ p.log.error(`Failed to export public key: ${error.message}`);
37642
+ process.exit(1);
37643
+ }
37644
+ };
37645
+ async function removePublicKeyFromAndroid(customPaths) {
37646
+ try {
37647
+ const androidParser = new AndroidConfigParser(customPaths);
37648
+ if (!await androidParser.exists()) return {
37649
+ platform: "android",
37650
+ paths: [],
37651
+ success: true,
37652
+ found: false
37653
+ };
37654
+ const existing = await androidParser.get(ANDROID_KEY);
37655
+ if (!existing.value) return {
37656
+ platform: "android",
37657
+ paths: existing.paths,
37658
+ success: true,
37659
+ found: false
37660
+ };
37661
+ return {
37662
+ platform: "android",
37663
+ paths: (await androidParser.remove(ANDROID_KEY)).paths,
37664
+ success: true,
37665
+ found: true
37666
+ };
37667
+ } catch (error) {
37668
+ return {
37669
+ platform: "android",
37670
+ paths: [],
37671
+ success: false,
37672
+ found: true,
37673
+ error: error.message
37674
+ };
37675
+ }
37676
+ }
37677
+ async function removePublicKeyFromIos(customPaths) {
37678
+ try {
37679
+ const iosParser = new IosConfigParser(customPaths);
37680
+ if (!await iosParser.exists()) return {
37681
+ platform: "ios",
37682
+ paths: [],
37683
+ success: true,
37684
+ found: false
37685
+ };
37686
+ const existing = await iosParser.get(IOS_KEY);
37687
+ if (!existing.value) return {
37688
+ platform: "ios",
37689
+ paths: existing.paths,
37690
+ success: true,
37691
+ found: false
37692
+ };
37693
+ return {
37694
+ platform: "ios",
37695
+ paths: (await iosParser.remove(IOS_KEY)).paths,
37696
+ success: true,
37697
+ found: true
37698
+ };
37699
+ } catch (error) {
37700
+ return {
37701
+ platform: "ios",
37702
+ paths: [],
37703
+ success: false,
37704
+ found: true,
37705
+ error: error.message
37706
+ };
37707
+ }
37708
+ }
37709
+ /**
37710
+ * Remove public keys from native configuration files.
37711
+ * Automatically detects and removes keys from both iOS and Android.
37712
+ *
37713
+ * Usage: npx hot-updater keys remove [--yes]
37714
+ */
37715
+ const keysRemove = async (options = {}) => {
37716
+ const config = await loadConfig(null);
37717
+ const androidParser = new AndroidConfigParser(config.platform.android.stringResourcePaths);
37718
+ const iosParser = new IosConfigParser(config.platform.ios.infoPlistPaths);
37719
+ const [androidExists, iosExists] = await Promise.all([androidParser.exists(), iosParser.exists()]);
37720
+ if (!androidExists && !iosExists) {
37721
+ p.log.info("No native configuration files found.");
37722
+ return;
37723
+ }
37724
+ const [androidKey, iosKey] = await Promise.all([androidExists ? androidParser.get(ANDROID_KEY) : Promise.resolve({
37725
+ value: null,
37726
+ paths: []
37727
+ }), iosExists ? iosParser.get(IOS_KEY) : Promise.resolve({
37728
+ value: null,
37729
+ paths: []
37730
+ })]);
37731
+ const foundKeys = [];
37732
+ if (iosKey.value) foundKeys.push(`iOS: ${iosKey.paths.join(", ")}`);
37733
+ if (androidKey.value) foundKeys.push(`Android: ${androidKey.paths.join(", ")}`);
37734
+ if (foundKeys.length === 0) {
37735
+ p.log.info("No public keys found in native files.");
37736
+ return;
37737
+ }
37738
+ console.log("");
37739
+ p.log.step("Found public keys in:");
37740
+ for (const key of foundKeys) p.log.info(` • ${key}`);
37741
+ console.log("");
37742
+ if (!options.yes) {
37743
+ const shouldContinue = await p.confirm({
37744
+ message: "Remove public keys from these files?",
37745
+ initialValue: false
37746
+ });
37747
+ if (p.isCancel(shouldContinue) || !shouldContinue) {
37748
+ p.cancel("Operation cancelled");
37749
+ return;
37750
+ }
37751
+ }
37752
+ const results = [];
37753
+ if (iosKey.value) results.push(await removePublicKeyFromIos(config.platform.ios.infoPlistPaths));
37754
+ if (androidKey.value) results.push(await removePublicKeyFromAndroid(config.platform.android.stringResourcePaths));
37755
+ console.log("");
37756
+ 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(", ")}`);
37757
+ else if (!result.success) p.log.error(`${result.platform}: ${result.error}`);
37758
+ const successCount = results.filter((r) => r.success && r.found).length;
37759
+ console.log("");
37760
+ if (successCount > 0) {
37761
+ p.log.success("Public keys removed from native files!");
37762
+ console.log("");
37763
+ p.log.info("Next steps:");
37764
+ p.log.info(" 1. Rebuild your native apps");
37765
+ p.log.info(" 2. Release to app stores");
37766
+ p.log.info(" 3. Deploy unsigned bundles with `npx hot-updater deploy`");
37767
+ }
37768
+ };
37769
+
37269
37770
  //#endregion
37270
37771
  //#region src/commands/migrate.ts
37271
37772
  /**
@@ -37447,6 +37948,17 @@ fingerprintCommand.command("create").description("Create fingerprint").action(ha
37447
37948
  const channelCommand = program.command("channel").description("Manage channels");
37448
37949
  channelCommand.action(handleChannel);
37449
37950
  channelCommand.command("set").description("Set the channel for Android (BuildConfig) and iOS (Info.plist)").argument("<channel>", "the channel to set").action(handleSetChannel);
37951
+ const keysCommand = program.command("keys").description("Code signing key management");
37952
+ 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) => {
37953
+ const size = Number.parseInt(value, 10);
37954
+ if (size !== 2048 && size !== 4096) {
37955
+ p.log.error("Key size must be 2048 or 4096");
37956
+ process.exit(1);
37957
+ }
37958
+ return size;
37959
+ }, 4096).action(keysGenerate);
37960
+ 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);
37961
+ keysCommand.command("remove").description("Remove public keys from native configuration files").option("-y, --yes", "skip confirmation prompt").action(keysRemove);
37450
37962
  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
37963
  if (!(0, import_valid.default)(value)) {
37452
37964
  p.log.error("Invalid semver format (e.g. 1.0.0, 1.x.x)");