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-
|
|
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.
|
|
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.
|
|
59
|
-
"@hot-updater/console": "0.
|
|
60
|
-
"@hot-updater/core": "0.
|
|
61
|
-
"@hot-updater/plugin-core": "0.
|
|
62
|
-
"@hot-updater/server": "0.
|
|
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.
|
|
93
|
-
"@hot-updater/server": "0.
|
|
94
|
-
"@hot-updater/firebase": "0.
|
|
95
|
-
"@hot-updater/cloudflare": "0.
|
|
96
|
-
"@hot-updater/supabase": "0.
|
|
97
|
-
"@hot-updater/test-utils": "0.
|
|
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": "*",
|