@fireota/cli 0.4.0 → 0.6.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/README.md +121 -117
- package/bin/fireota.mjs +536 -431
- package/lib/ci.mjs +53 -53
- package/lib/init.mjs +334 -334
- package/lib/project.mjs +78 -78
- package/lib/signing.mjs +48 -48
- package/package.json +40 -40
package/lib/project.mjs
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
/** Reads facts about the React Native project the CLI runs in. */
|
|
2
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
/** android/app/build.gradle(.kts): versionName "1.3.3" / versionName = "1.3.3" */
|
|
6
|
-
export function androidVersionName(root = ".") {
|
|
7
|
-
for (const file of ["android/app/build.gradle", "android/app/build.gradle.kts"]) {
|
|
8
|
-
const path = join(root, file);
|
|
9
|
-
if (!existsSync(path)) continue;
|
|
10
|
-
const match = /versionName\s*=?\s*["']([^"']+)["']/.exec(readFileSync(path, "utf8"));
|
|
11
|
-
if (match) return match[1];
|
|
12
|
-
}
|
|
13
|
-
return undefined;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** ios/<App>.xcodeproj/project.pbxproj MARKETING_VERSION, or a literal CFBundleShortVersionString. */
|
|
17
|
-
export function iosMarketingVersion(root = ".") {
|
|
18
|
-
const ios = join(root, "ios");
|
|
19
|
-
if (!existsSync(ios)) return undefined;
|
|
20
|
-
for (const entry of readdirSync(ios)) {
|
|
21
|
-
if (!entry.endsWith(".xcodeproj")) continue;
|
|
22
|
-
const pbx = join(ios, entry, "project.pbxproj");
|
|
23
|
-
if (!existsSync(pbx)) continue;
|
|
24
|
-
const match = /MARKETING_VERSION\s*=\s*"?([0-9][^";]*)"?;/.exec(readFileSync(pbx, "utf8"));
|
|
25
|
-
if (match) return match[1].trim();
|
|
26
|
-
}
|
|
27
|
-
for (const entry of readdirSync(ios)) {
|
|
28
|
-
const plist = join(ios, entry, "Info.plist");
|
|
29
|
-
if (!existsSync(plist)) continue;
|
|
30
|
-
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<$]+)<\/string>/.exec(readFileSync(plist, "utf8"));
|
|
31
|
-
if (match) return match[1].trim();
|
|
32
|
-
}
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** True for Expo projects (the `expo` package is a dependency). */
|
|
37
|
-
export function isExpoProject(root = ".") {
|
|
38
|
-
const path = join(root, "package.json");
|
|
39
|
-
if (!existsSync(path)) return false;
|
|
40
|
-
const pkg = JSON.parse(readFileSync(path, "utf8"));
|
|
41
|
-
return Boolean(pkg.dependencies?.expo ?? pkg.devDependencies?.expo);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** The `expo` object of app.json, or null (no app.json, or a dynamic app.config.js / .ts). */
|
|
45
|
-
export function readExpoAppJson(root = ".") {
|
|
46
|
-
const path = join(root, "app.json");
|
|
47
|
-
if (!existsSync(path)) return null;
|
|
48
|
-
const json = JSON.parse(readFileSync(path, "utf8"));
|
|
49
|
-
return json.expo ?? json;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Expo writes `expo.version` into versionName / CFBundleShortVersionString at prebuild. */
|
|
53
|
-
export function expoVersion(root = ".") {
|
|
54
|
-
const version = readExpoAppJson(root)?.version;
|
|
55
|
-
return typeof version === "string" && version ? version : undefined;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The app's store version for a platform — FireOTA's default runtime version. */
|
|
59
|
-
export function storeVersion(platform, root = ".") {
|
|
60
|
-
const native = platform === "ios" ? iosMarketingVersion(root) : androidVersionName(root);
|
|
61
|
-
// Expo projects without android/ or ios/ yet (Continuous Native Generation): use app.json.
|
|
62
|
-
return native ?? (isExpoProject(root) ? expoVersion(root) : undefined);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Recursively finds the first file matching a name test below a directory. */
|
|
66
|
-
export function findFile(dir, test, depth = 6) {
|
|
67
|
-
if (!existsSync(dir) || depth < 0) return undefined;
|
|
68
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
69
|
-
if (entry.name === "build" || entry.name === "Pods" || entry.name.startsWith(".")) continue;
|
|
70
|
-
const path = join(dir, entry.name);
|
|
71
|
-
if (entry.isFile() && test(entry.name)) return path;
|
|
72
|
-
if (entry.isDirectory()) {
|
|
73
|
-
const found = findFile(path, test, depth - 1);
|
|
74
|
-
if (found) return found;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return undefined;
|
|
78
|
-
}
|
|
1
|
+
/** Reads facts about the React Native project the CLI runs in. */
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** android/app/build.gradle(.kts): versionName "1.3.3" / versionName = "1.3.3" */
|
|
6
|
+
export function androidVersionName(root = ".") {
|
|
7
|
+
for (const file of ["android/app/build.gradle", "android/app/build.gradle.kts"]) {
|
|
8
|
+
const path = join(root, file);
|
|
9
|
+
if (!existsSync(path)) continue;
|
|
10
|
+
const match = /versionName\s*=?\s*["']([^"']+)["']/.exec(readFileSync(path, "utf8"));
|
|
11
|
+
if (match) return match[1];
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** ios/<App>.xcodeproj/project.pbxproj MARKETING_VERSION, or a literal CFBundleShortVersionString. */
|
|
17
|
+
export function iosMarketingVersion(root = ".") {
|
|
18
|
+
const ios = join(root, "ios");
|
|
19
|
+
if (!existsSync(ios)) return undefined;
|
|
20
|
+
for (const entry of readdirSync(ios)) {
|
|
21
|
+
if (!entry.endsWith(".xcodeproj")) continue;
|
|
22
|
+
const pbx = join(ios, entry, "project.pbxproj");
|
|
23
|
+
if (!existsSync(pbx)) continue;
|
|
24
|
+
const match = /MARKETING_VERSION\s*=\s*"?([0-9][^";]*)"?;/.exec(readFileSync(pbx, "utf8"));
|
|
25
|
+
if (match) return match[1].trim();
|
|
26
|
+
}
|
|
27
|
+
for (const entry of readdirSync(ios)) {
|
|
28
|
+
const plist = join(ios, entry, "Info.plist");
|
|
29
|
+
if (!existsSync(plist)) continue;
|
|
30
|
+
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<$]+)<\/string>/.exec(readFileSync(plist, "utf8"));
|
|
31
|
+
if (match) return match[1].trim();
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True for Expo projects (the `expo` package is a dependency). */
|
|
37
|
+
export function isExpoProject(root = ".") {
|
|
38
|
+
const path = join(root, "package.json");
|
|
39
|
+
if (!existsSync(path)) return false;
|
|
40
|
+
const pkg = JSON.parse(readFileSync(path, "utf8"));
|
|
41
|
+
return Boolean(pkg.dependencies?.expo ?? pkg.devDependencies?.expo);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `expo` object of app.json, or null (no app.json, or a dynamic app.config.js / .ts). */
|
|
45
|
+
export function readExpoAppJson(root = ".") {
|
|
46
|
+
const path = join(root, "app.json");
|
|
47
|
+
if (!existsSync(path)) return null;
|
|
48
|
+
const json = JSON.parse(readFileSync(path, "utf8"));
|
|
49
|
+
return json.expo ?? json;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Expo writes `expo.version` into versionName / CFBundleShortVersionString at prebuild. */
|
|
53
|
+
export function expoVersion(root = ".") {
|
|
54
|
+
const version = readExpoAppJson(root)?.version;
|
|
55
|
+
return typeof version === "string" && version ? version : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The app's store version for a platform — FireOTA's default runtime version. */
|
|
59
|
+
export function storeVersion(platform, root = ".") {
|
|
60
|
+
const native = platform === "ios" ? iosMarketingVersion(root) : androidVersionName(root);
|
|
61
|
+
// Expo projects without android/ or ios/ yet (Continuous Native Generation): use app.json.
|
|
62
|
+
return native ?? (isExpoProject(root) ? expoVersion(root) : undefined);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Recursively finds the first file matching a name test below a directory. */
|
|
66
|
+
export function findFile(dir, test, depth = 6) {
|
|
67
|
+
if (!existsSync(dir) || depth < 0) return undefined;
|
|
68
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
69
|
+
if (entry.name === "build" || entry.name === "Pods" || entry.name.startsWith(".")) continue;
|
|
70
|
+
const path = join(dir, entry.name);
|
|
71
|
+
if (entry.isFile() && test(entry.name)) return path;
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
const found = findFile(path, test, depth - 1);
|
|
74
|
+
if (found) return found;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
package/lib/signing.mjs
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Code signing (optional, per app). The private key stays on this machine or in your CI's
|
|
3
|
-
* secret store; FireOTA and the app only ever see the public key.
|
|
4
|
-
*
|
|
5
|
-
* Signed message — identical in the server and the SDK:
|
|
6
|
-
* fireota-update-v1\n<runtimeVersion>\n<platform>\n<sha256 of the .fota package, hex>
|
|
7
|
-
*/
|
|
8
|
-
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { join, resolve } from "node:path";
|
|
11
|
-
|
|
12
|
-
export function signatureMessage({ runtimeVersion, platform, sha256 }) {
|
|
13
|
-
return `fireota-update-v1\n${runtimeVersion}\n${platform}\n${sha256.toLowerCase()}`;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** --private-key <file>, or FIREOTA_SIGNING_KEY (PEM text) / FIREOTA_SIGNING_KEY_PATH in CI. */
|
|
17
|
-
export function loadPrivateKey(flagPath) {
|
|
18
|
-
const path = flagPath ?? process.env.FIREOTA_SIGNING_KEY_PATH;
|
|
19
|
-
const pem = path ? readFileSync(resolve(String(path)), "utf8") : process.env.FIREOTA_SIGNING_KEY;
|
|
20
|
-
if (!pem) return null;
|
|
21
|
-
// CI secret stores often turn newlines into literal "\n".
|
|
22
|
-
const key = createPrivateKey(pem.includes("\\n") ? pem.replace(/\\n/g, "\n") : pem);
|
|
23
|
-
if (key.asymmetricKeyType !== "rsa") throw new Error("The signing key must be an RSA private key.");
|
|
24
|
-
return key;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function signUpdate(privateKey, input) {
|
|
28
|
-
return sign("sha256", Buffer.from(signatureMessage(input), "utf8"), privateKey).toString("base64");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function generateKeys(outDir) {
|
|
32
|
-
const dir = resolve(outDir ?? "fireota-keys");
|
|
33
|
-
const privatePath = join(dir, "fireota-private.pem");
|
|
34
|
-
const publicPath = join(dir, "fireota-public.pem");
|
|
35
|
-
if (existsSync(privatePath) || existsSync(publicPath)) {
|
|
36
|
-
throw new Error(`${dir} already contains FireOTA keys. Refusing to overwrite them.`);
|
|
37
|
-
}
|
|
38
|
-
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
|
39
|
-
modulusLength: 2048,
|
|
40
|
-
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
41
|
-
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
42
|
-
});
|
|
43
|
-
mkdirSync(dir, { recursive: true });
|
|
44
|
-
writeFileSync(privatePath, privateKey, { mode: 0o600 });
|
|
45
|
-
writeFileSync(publicPath, publicKey);
|
|
46
|
-
writeFileSync(join(dir, ".gitignore"), "fireota-private.pem\n");
|
|
47
|
-
return { dir, privatePath, publicPath, publicKey };
|
|
48
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Code signing (optional, per app). The private key stays on this machine or in your CI's
|
|
3
|
+
* secret store; FireOTA and the app only ever see the public key.
|
|
4
|
+
*
|
|
5
|
+
* Signed message — identical in the server and the SDK:
|
|
6
|
+
* fireota-update-v1\n<runtimeVersion>\n<platform>\n<sha256 of the .fota package, hex>
|
|
7
|
+
*/
|
|
8
|
+
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
export function signatureMessage({ runtimeVersion, platform, sha256 }) {
|
|
13
|
+
return `fireota-update-v1\n${runtimeVersion}\n${platform}\n${sha256.toLowerCase()}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** --private-key <file>, or FIREOTA_SIGNING_KEY (PEM text) / FIREOTA_SIGNING_KEY_PATH in CI. */
|
|
17
|
+
export function loadPrivateKey(flagPath) {
|
|
18
|
+
const path = flagPath ?? process.env.FIREOTA_SIGNING_KEY_PATH;
|
|
19
|
+
const pem = path ? readFileSync(resolve(String(path)), "utf8") : process.env.FIREOTA_SIGNING_KEY;
|
|
20
|
+
if (!pem) return null;
|
|
21
|
+
// CI secret stores often turn newlines into literal "\n".
|
|
22
|
+
const key = createPrivateKey(pem.includes("\\n") ? pem.replace(/\\n/g, "\n") : pem);
|
|
23
|
+
if (key.asymmetricKeyType !== "rsa") throw new Error("The signing key must be an RSA private key.");
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function signUpdate(privateKey, input) {
|
|
28
|
+
return sign("sha256", Buffer.from(signatureMessage(input), "utf8"), privateKey).toString("base64");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function generateKeys(outDir) {
|
|
32
|
+
const dir = resolve(outDir ?? "fireota-keys");
|
|
33
|
+
const privatePath = join(dir, "fireota-private.pem");
|
|
34
|
+
const publicPath = join(dir, "fireota-public.pem");
|
|
35
|
+
if (existsSync(privatePath) || existsSync(publicPath)) {
|
|
36
|
+
throw new Error(`${dir} already contains FireOTA keys. Refusing to overwrite them.`);
|
|
37
|
+
}
|
|
38
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
|
39
|
+
modulusLength: 2048,
|
|
40
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
41
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
42
|
+
});
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
writeFileSync(privatePath, privateKey, { mode: 0o600 });
|
|
45
|
+
writeFileSync(publicPath, publicKey);
|
|
46
|
+
writeFileSync(join(dir, ".gitignore"), "fireota-private.pem\n");
|
|
47
|
+
return { dir, privatePath, publicPath, publicKey };
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@fireota/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Build FireOTA update packages and publish React Native OTA releases from a terminal or CI.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"fireota": "bin/fireota.mjs"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"bin",
|
|
12
|
-
"lib",
|
|
13
|
-
"README.md",
|
|
14
|
-
"LICENSE"
|
|
15
|
-
],
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">=18.17"
|
|
18
|
-
},
|
|
19
|
-
"keywords": [
|
|
20
|
-
"react-native",
|
|
21
|
-
"ota",
|
|
22
|
-
"over-the-air",
|
|
23
|
-
"fireota",
|
|
24
|
-
"cli",
|
|
25
|
-
"ci"
|
|
26
|
-
],
|
|
27
|
-
"publishConfig": {
|
|
28
|
-
"access": "public"
|
|
29
|
-
},
|
|
30
|
-
"author": "FireOTA",
|
|
31
|
-
"repository": {
|
|
32
|
-
"type": "git",
|
|
33
|
-
"url": "git+https://github.com/Sujay-Dynamisch/FireOTA.git",
|
|
34
|
-
"directory": "packages/cli"
|
|
35
|
-
},
|
|
36
|
-
"homepage": "https://github.com/Sujay-Dynamisch/FireOTA/tree/main/packages/cli#readme",
|
|
37
|
-
"bugs": {
|
|
38
|
-
"url": "https://github.com/Sujay-Dynamisch/FireOTA/issues"
|
|
39
|
-
}
|
|
40
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@fireota/cli",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Build FireOTA update packages and publish React Native OTA releases from a terminal or CI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"fireota": "bin/fireota.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"lib",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.17"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"react-native",
|
|
21
|
+
"ota",
|
|
22
|
+
"over-the-air",
|
|
23
|
+
"fireota",
|
|
24
|
+
"cli",
|
|
25
|
+
"ci"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"author": "FireOTA",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/Sujay-Dynamisch/FireOTA.git",
|
|
34
|
+
"directory": "packages/cli"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/Sujay-Dynamisch/FireOTA/tree/main/packages/cli#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/Sujay-Dynamisch/FireOTA/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|