@webority/mobile-tools 0.109.5 → 0.109.7
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 +20 -5
- package/lib/build.js +1 -1
- package/lib/buildLog.js +1 -1
- package/lib/cli.js +1 -1
- package/lib/config.js +1 -1
- package/lib/metroConfig.js +1 -0
- package/lib/prebuild.js +1 -1
- package/lib/run.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @webority/mobile-tools
|
|
2
2
|
|
|
3
|
-
The dev-time CLI for Webority React Native apps. It covers these jobs: compiling the environment config into the native projects, catching a stale `node_modules` before a 20-minute build, building and launching on one device (installing a cached build when nothing native changed), building release APKs, App Bundles and iOS simulator apps, listing devices, checking the machine and the app tree, restarting Metro,
|
|
3
|
+
The dev-time CLI for Webority React Native apps. It covers these jobs: compiling the environment config into the native projects, catching a stale `node_modules` before a 20-minute build, building and launching on one device (installing a cached build when nothing native changed), building release APKs, App Bundles and iOS simulator apps, listing devices, checking the machine and the app tree, restarting Metro, rendering per-environment launcher icons, and sharing one Metro config. It behaves the same on Windows (Git Bash, PowerShell, cmd) and macOS (zsh).
|
|
4
4
|
|
|
5
|
-
It is a devDependency. App code never imports it, and it adds nothing to the app bundle.
|
|
5
|
+
It is a devDependency. App code never imports it; only metro.config.js does (see Metro config), and it adds nothing to the app bundle.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install --save-dev --save-exact @webority/mobile-tools
|
|
@@ -22,6 +22,7 @@ npx mobile-tools prebuild --same-env [--platform android|ios]
|
|
|
22
22
|
- Merges `appsettings.json` with `appsettings.<Env>.json` into `appsettings.Compiled.json`.
|
|
23
23
|
- Stamps `COMMIT` (the short git sha, with `-dirty` when the app folder has changes other than the files prebuild writes) and `BUILD_TIME` (UTC, ISO 8601).
|
|
24
24
|
- Fails, naming the key, when the merged settings lack `APP_NAME`, `APP_ID`, `ENVIRONMENT`, `BASE_URL`, `VERSION_DISPLAY` or `VERSION_INTERNAL`.
|
|
25
|
+
- Fails, naming every key, when a key listed in `requiredSettings` (`mobile-tools.config.json`) is absent or null in the merged settings. An empty string passes.
|
|
25
26
|
- Fails when `ENVIRONMENT` differs from the `--env` given, when `VERSION_INTERNAL` is not a positive whole number, or when an `APP_ID_SUFFIX` disagrees with the environment.
|
|
26
27
|
- Checks the app settings rules and warns about each break: a Staging or Production `BASE_URL` that is not https or points at this machine; `API_TIMEOUT`, `UPLOAD_TIMEOUT`, `MAX_RETRY_ATTEMPTS`, `LOGGING_ENABLED`, `PUSH_ENABLED`, `LOG_LEVEL` and `MIN_SUPPORTED_INTERNAL_VERSION` with the wrong type; a `MIN_SUPPORTED_INTERNAL_VERSION` above `VERSION_INTERNAL`; and a missing `PUSH_ENABLED` in an app that depends on `@react-native-firebase/messaging`. Set `MOBILE_TOOLS_STRICT_SETTINGS=1` to stop the prebuild on any of them.
|
|
27
28
|
- **Android** (`android/gradle.properties`): sets `APP_NAME`, `APP_ID`, `VERSION_DISPLAY` and `VERSION_INTERNAL`. Every other line, comment and line ending stays as it was.
|
|
@@ -111,7 +112,7 @@ Every program is started by absolute path with an argument list, never through a
|
|
|
111
112
|
### `build`
|
|
112
113
|
|
|
113
114
|
```bash
|
|
114
|
-
npx mobile-tools build --env <Environment> --artifact apk|aab|ios-sim [--debug] [--json]
|
|
115
|
+
npx mobile-tools build --env <Environment> --artifact apk|aab|ios-sim [--debug] [--json] [-- <gradle args>]
|
|
115
116
|
```
|
|
116
117
|
|
|
117
118
|
1. Stops on a stale tree, as `run` does.
|
|
@@ -125,7 +126,9 @@ Notes:
|
|
|
125
126
|
- `apk` and `aab` are Android release builds of every ABI unless `--debug` is given.
|
|
126
127
|
- `aab` is release only.
|
|
127
128
|
- `ios-sim` is macOS only and unsigned.
|
|
128
|
-
- Store `ipa` builds are not this command's job.
|
|
129
|
+
- Store `ipa` builds are not this command's job: iOS store builds run on the build server through xOS Tools.
|
|
130
|
+
- Arguments after `--` go to Gradle unchanged for apk and aab (for example `-- --no-daemon --max-workers=3`); each must be a plain token, and ios-sim refuses them.
|
|
131
|
+
- With CI=true, a failed build also prints the whole log to stderr, so a CI job log carries it.
|
|
129
132
|
|
|
130
133
|
Human output, one line per artifact, then the mapping when there is one:
|
|
131
134
|
|
|
@@ -332,7 +335,8 @@ Optional, at the app root. Every key is optional. An unknown key or a wrong type
|
|
|
332
335
|
"flavors": true,
|
|
333
336
|
"localApiPort": 62430,
|
|
334
337
|
"iconSource": "assets/app-icon.svg",
|
|
335
|
-
"firebasePlistDir": "ios/firebase"
|
|
338
|
+
"firebasePlistDir": "ios/firebase",
|
|
339
|
+
"requiredSettings": ["LOG_LEVEL", "OTA_UPDATE_BASE_URL"]
|
|
336
340
|
}
|
|
337
341
|
```
|
|
338
342
|
|
|
@@ -343,6 +347,17 @@ Optional, at the app root. Every key is optional. An unknown key or a wrong type
|
|
|
343
347
|
| `localApiPort` | the port of `appsettings.Development.json`'s `BASE_URL` when it is `localhost` | port `run` reverses for a Development build |
|
|
344
348
|
| `iconSource` | none | source image for `icons` when `--source` is not given |
|
|
345
349
|
| `firebasePlistDir` | `ios/firebase` | folder holding `GoogleService-Info.<Env>.plist` |
|
|
350
|
+
| `requiredSettings` | `[]` | setting keys the app reads beyond the six prebuild always requires; each must be present and not null, else prebuild fails. Upper case names, each once, none of the six. |
|
|
351
|
+
|
|
352
|
+
## Metro config
|
|
353
|
+
|
|
354
|
+
`metro.config.js` at the app root is one line:
|
|
355
|
+
|
|
356
|
+
```js
|
|
357
|
+
module.exports = require("@webority/mobile-tools/metro-config").createMetroConfig(__dirname);
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
It returns Expo's `getDefaultConfig` for the app, with `console` calls dropped from minified bundles. It needs `expo` installed in the app and Node 24, because the module is an ES module loaded with `require`. An app adds its own changes by assigning to the returned object before it exports it.
|
|
346
361
|
|
|
347
362
|
## Typical `package.json` scripts
|
|
348
363
|
|
package/lib/build.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,readdirSync}from"node:fs";import path from"node:path";import{buildOrFail,logDir,logStamp}from"./buildLog.js";import{gradleOutputDir,gradleTask,loadToolsConfig,mappingFile,resolveIosProjectName}from"./config.js";import{fail,isJsonOutput,say,sayJson}from"./output.js";import{gradleInvocation,requireDarwin,XCODEBUILD}from"./platform.js";import{prebuild}from"./prebuild.js";import{requireSynced}from"./sync.js";const buildAndroid=async(i,o,a)=>{const{appRoot:r,env:t}=i;requireSynced(r);const n=loadToolsConfig(r),s=path.join(r,"android");existsSync(path.join(s,"app","build.gradle"))||fail("appConfigInvalid",`android/app/build.gradle not found in ${r}`),prebuild({appRoot:r,env:t,platform:"android"});const e=gradleTask(t,n.flavors,o,a),l=path.join(logDir(r),`gradle-${t}-${logStamp()}.log`);await buildOrFail(gradleInvocation(s,[`:app:${e}
|
|
1
|
+
import{existsSync,readdirSync}from"node:fs";import path from"node:path";import{buildOrFail,logDir,logStamp}from"./buildLog.js";import{gradleOutputDir,gradleTask,loadToolsConfig,mappingFile,resolveIosProjectName}from"./config.js";import{fail,isJsonOutput,say,sayJson}from"./output.js";import{gradleInvocation,requireDarwin,XCODEBUILD}from"./platform.js";import{prebuild}from"./prebuild.js";import{requireSynced}from"./sync.js";const buildAndroid=async(i,o,a)=>{const{appRoot:r,env:t}=i;requireSynced(r);const n=loadToolsConfig(r),s=path.join(r,"android");existsSync(path.join(s,"app","build.gradle"))||fail("appConfigInvalid",`android/app/build.gradle not found in ${r}`),prebuild({appRoot:r,env:t,platform:"android"});const e=gradleTask(t,n.flavors,o,a),l=path.join(logDir(r),`gradle-${t}-${logStamp()}.log`);await buildOrFail(gradleInvocation(s,[`:app:${e}`,...i.gradleArgs],process.platform,process.env),s,l,`gradle ${e}`);const p=gradleOutputDir(r,t,n.flavors,o,a),d=existsSync(p)?readdirSync(p,{withFileTypes:!0}).filter(i=>i.isFile()&&i.name.endsWith(`.${o}`)).map(i=>path.resolve(p,i.name)).sort():[];0===d.length&&fail("artifactMissing",`the build produced no ${o} in ${p}`);const u=mappingFile(r,t,n.flavors);return{artifacts:d,mapping:"release"===a&&existsSync(u)?u:null,log:l}},buildIos=async(i,o)=>{requireDarwin(process.platform,"build --artifact ios-sim"),i.gradleArgs.length>0&&fail("badInput","Gradle arguments after -- apply to apk and aab builds only");const{appRoot:a,env:r}=i;requireSynced(a);const t=loadToolsConfig(a),n=resolveIosProjectName(a,t)??fail("appConfigInvalid",`no ios/ folder in ${a}`),s=path.join(a,"ios",`${n}.xcworkspace`);existsSync(s)||fail("appConfigInvalid",`ios/${n}.xcworkspace not found: run npx mobile-tools sync (it runs pod install)`),prebuild({appRoot:a,env:r,platform:"ios"});const e="release"===o?"Release":"Debug",l=path.join(logDir(a),`xcodebuild-${r}-${logStamp()}.log`);await buildOrFail({file:XCODEBUILD,args:["-workspace",path.join("ios",`${n}.xcworkspace`),"-scheme",n,"-configuration",e,"-sdk","iphonesimulator","-destination","generic/platform=iOS Simulator","-derivedDataPath",path.join("ios","build"),"CODE_SIGNING_ALLOWED=NO","build"]},a,l,"xcodebuild");const p=path.join(a,"ios","build","Build","Products",`${e}-iphonesimulator`),d=path.join(p,`${n}.app`);return existsSync(d)||fail("artifactMissing",`the build produced no ${n}.app in ${p}`),{artifacts:[d],mapping:null,log:l}};export const build=async i=>{const o=i.debug?"debug":"release",a="ios-sim"===i.artifact?await buildIos(i,o):await buildAndroid(i,i.artifact,o);if(isJsonOutput())sayJson({artifacts:a.artifacts,mapping:a.mapping,log:a.log});else{for(const i of a.artifacts)say(`build: ${i}`);null!==a.mapping&&say(`build: mapping ${a.mapping}`)}};
|
package/lib/buildLog.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,readFileSync}from"node:fs";import path from"node:path";import{runToLog}from"./exec.js";import{fail,say}from"./output.js";import{extractRootError}from"./rootError.js";export const logDir=o=>path.join(o,"node_modules",".cache","mobile-tools");export const logStamp=()=>(new Date).toISOString().replace(/[:.]/g,"-");export const buildOrFail=async(o,t,
|
|
1
|
+
import{existsSync,readFileSync}from"node:fs";import path from"node:path";import{runToLog}from"./exec.js";import{fail,say,warn}from"./output.js";import{extractRootError}from"./rootError.js";export const logDir=o=>path.join(o,"node_modules",".cache","mobile-tools");export const logStamp=()=>(new Date).toISOString().replace(/[:.]/g,"-");export const buildOrFail=async(o,r,t,e)=>{say(`${e}: building, log ${t}`);const n=Date.now();if(0!==await runToLog(o,r,t)){const o=existsSync(t)?readFileSync(t,"utf8"):"";"true"===process.env.CI&&(warn(`${e}: full log follows`),warn(o),warn(`${e}: end of log`)),fail("buildFailed",`${e} failed:\n${""===o?"":extractRootError(o)}\nFull log: ${t}`)}say(`${e}: built in ${Math.round((Date.now()-n)/1e3)} s`)};
|
package/lib/cli.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{parseArgs}from"node:util";import{build}from"./build.js";import{cache}from"./cache.js";import{parseEnvironment}from"./config.js";import{devices}from"./devices.js";import{doctor}from"./doctor.js";import{installHooks}from"./hooks.js";import{icons}from"./icons.js";import{restartMetro}from"./metro.js";import{CliError,fail,reportFailure,say,setJsonOutput,warn}from"./output.js";import{prebuild}from"./prebuild.js";import{run}from"./run.js";import{checkSync,sync,writeStamp}from"./sync.js";const USAGE="mobile-tools <command> [options] (run from the app's root folder)\n\n prebuild --env <Development|Staging|Production> [--platform android|ios]\n prebuild --same-env [--platform android|ios]\n Compile appsettings into appsettings.Compiled.json and stamp the native projects.\n\n sync [--check | --stamp]\n --check: exit 1 with the fix command when node_modules (or ios/Pods) is stale.\n --stamp: record that node_modules matches the lockfile now (run it from postinstall).\n Without either: install from the lockfile, stamp, and reset native codegen when needed.\n\n hooks install\n Add git post-merge and post-checkout hooks that run sync --check.\n\n run --env <Environment> [--platform android|ios] [--device <serial|name>] [--rebuild]\n Build for one device's ABI (or install the cached build with the same native fingerprint), install it there, reuse or start Metro, and launch. --rebuild skips the cache.\n\n build --env <Environment> --artifact apk|aab|ios-sim [--debug] [--json]\n Prebuild, then build a release APK or App Bundle (Android, every ABI) or an iOS simulator app (macOS only).\n --debug builds the debug variant (apk or ios-sim only).\n\n devices [--platform android|ios] [--json]\n Connect local MuMu emulators, then list every ready device once.\n\n doctor [--platform android|ios] [--fix] [--json]\n Check the toolchain and the app tree; exit 1 while a problem remains. --fix removes a stale autolinking cache.\n\n cache list|clear [--json]\n List or remove this app's cached builds.\n\n metro restart\n Stop the Metro mobile-tools started for this app and start it again on the same port.\n\n icons [--source <png|svg>]\n Render per-environment launcher icons for Android flavors and iOS AppIcon-<Env> sets.",parse=(e,o,t=!1)=>{try{const{values:n,positionals:a}=parseArgs({args:e,options:o,allowPositionals:t,strict:!0});return{values:n,positionals:a}}catch(e){return fail("badInput",`${e.message}\n\n${USAGE}`)}},envOption=e=>"string"==typeof e?parseEnvironment(e):void 0,platformOption=e=>{if(void 0!==e)return"android"!==e&&"ios"!==e?fail("badInput",`--platform must be android or ios, not ${String(e)}`):e},JSON_COMMANDS=new Set(["build","cache","devices","doctor"]),artifactOption=e=>"apk"===e||"aab"===e||"ios-sim"===e?e:fail("badInput","build needs --artifact apk, aab or ios-sim"),main=async e=>{const[o,...t]=e,n=process.cwd();switch(setJsonOutput(JSON_COMMANDS.has(o??"")&&
|
|
2
|
+
import{parseArgs}from"node:util";import{build}from"./build.js";import{cache}from"./cache.js";import{parseEnvironment}from"./config.js";import{devices}from"./devices.js";import{doctor}from"./doctor.js";import{installHooks}from"./hooks.js";import{icons}from"./icons.js";import{restartMetro}from"./metro.js";import{CliError,fail,reportFailure,say,setJsonOutput,warn}from"./output.js";import{prebuild}from"./prebuild.js";import{run}from"./run.js";import{checkSync,sync,writeStamp}from"./sync.js";const USAGE="mobile-tools <command> [options] (run from the app's root folder)\n\n prebuild --env <Development|Staging|Production> [--platform android|ios]\n prebuild --same-env [--platform android|ios]\n Compile appsettings into appsettings.Compiled.json and stamp the native projects.\n\n sync [--check | --stamp]\n --check: exit 1 with the fix command when node_modules (or ios/Pods) is stale.\n --stamp: record that node_modules matches the lockfile now (run it from postinstall).\n Without either: install from the lockfile, stamp, and reset native codegen when needed.\n\n hooks install\n Add git post-merge and post-checkout hooks that run sync --check.\n\n run --env <Environment> [--platform android|ios] [--device <serial|name>] [--rebuild]\n Build for one device's ABI (or install the cached build with the same native fingerprint), install it there, reuse or start Metro, and launch. --rebuild skips the cache.\n\n build --env <Environment> --artifact apk|aab|ios-sim [--debug] [--json] [-- <gradle args>]\n Prebuild, then build a release APK or App Bundle (Android, every ABI) or an iOS simulator app (macOS only).\n --debug builds the debug variant (apk or ios-sim only).\n Arguments after -- go to Gradle unchanged (apk and aab only), for example -- --no-daemon --max-workers=3.\n\n devices [--platform android|ios] [--json]\n Connect local MuMu emulators, then list every ready device once.\n\n doctor [--platform android|ios] [--fix] [--json]\n Check the toolchain and the app tree; exit 1 while a problem remains. --fix removes a stale autolinking cache.\n\n cache list|clear [--json]\n List or remove this app's cached builds.\n\n metro restart\n Stop the Metro mobile-tools started for this app and start it again on the same port.\n\n icons [--source <png|svg>]\n Render per-environment launcher icons for Android flavors and iOS AppIcon-<Env> sets.",parse=(e,o,t=!1)=>{try{const{values:n,positionals:a}=parseArgs({args:e,options:o,allowPositionals:t,strict:!0});return{values:n,positionals:a}}catch(e){return fail("badInput",`${e.message}\n\n${USAGE}`)}},envOption=e=>"string"==typeof e?parseEnvironment(e):void 0,platformOption=e=>{if(void 0!==e)return"android"!==e&&"ios"!==e?fail("badInput",`--platform must be android or ios, not ${String(e)}`):e},JSON_COMMANDS=new Set(["build","cache","devices","doctor"]),artifactOption=e=>"apk"===e||"aab"===e||"ios-sim"===e?e:fail("badInput","build needs --artifact apk, aab or ios-sim"),main=async e=>{const[o,...t]=e,n=process.cwd(),a=t.indexOf("--"),s=a<0?t:t.slice(0,a);switch(setJsonOutput(JSON_COMMANDS.has(o??"")&&s.includes("--json")),o){case"prebuild":{const{values:e}=parse(t,{env:{type:"string"},"same-env":{type:"boolean"},platform:{type:"string"}});return void prebuild({appRoot:n,env:envOption(e.env),sameEnv:!0===e["same-env"],platform:platformOption(e.platform)})}case"sync":{const{values:e}=parse(t,{check:{type:"boolean"},stamp:{type:"boolean"}});if(e.check&&e.stamp&&fail("badInput","use --check or --stamp, not both"),e.stamp)return writeStamp(n),void say("sync --stamp: recorded node_modules as matching the lockfile");if(e.check){const e=checkSync(n,process.platform);return e.length>0&&fail("installStale",e.join("\n")),void say("sync --check: node_modules matches the lockfile")}return void sync(n,process.platform)}case"hooks":{const{positionals:e}=parse(t,{},!0);return"install"===e[0]&&1===e.length||fail("badInput",`usage: mobile-tools hooks install\n\n${USAGE}`),void installHooks(n)}case"run":{const{values:e}=parse(t,{env:{type:"string"},platform:{type:"string"},device:{type:"string"},rebuild:{type:"boolean"}}),o=envOption(e.env)??fail("badInput","run needs --env <Development|Staging|Production>");return void await run({appRoot:n,env:o,platform:platformOption(e.platform)??"android",device:"string"==typeof e.device?e.device:void 0,rebuild:!0===e.rebuild})}case"build":{const e=t.indexOf("--"),o=e<0?t:t.slice(0,e),a=e<0?[]:t.slice(e+1),{values:s}=parse(o,{env:{type:"string"},artifact:{type:"string"},debug:{type:"boolean"},json:{type:"boolean"}}),r=envOption(s.env)??fail("badInput","build needs --env <Development|Staging|Production>"),i=artifactOption(s.artifact);return!0===s.debug&&"aab"===i&&fail("badInput","--debug builds an apk or an ios-sim app; an App Bundle is release only"),"ios-sim"===i&&a.length>0&&fail("badInput","Gradle arguments after -- apply to apk and aab builds only"),void await build({appRoot:n,env:r,artifact:i,debug:!0===s.debug,gradleArgs:a})}case"devices":{const{values:e}=parse(t,{platform:{type:"string"},json:{type:"boolean"}});return void await devices({appRoot:n,platform:platformOption(e.platform)??"android"})}case"doctor":{const{values:e}=parse(t,{platform:{type:"string"},fix:{type:"boolean"},json:{type:"boolean"}});return void await doctor({appRoot:n,platform:platformOption(e.platform),fix:!0===e.fix})}case"cache":{const{positionals:e}=parse(t,{json:{type:"boolean"}},!0),o=e[0];return(1!==e.length||"list"!==o&&"clear"!==o)&&fail("badInput",`usage: mobile-tools cache list|clear\n\n${USAGE}`),void await cache({appRoot:n,action:o})}case"metro":{const{positionals:e}=parse(t,{},!0);return"restart"===e[0]&&1===e.length||fail("badInput",`usage: mobile-tools metro restart\n\n${USAGE}`),void await restartMetro(n)}case"icons":{const{values:e}=parse(t,{source:{type:"string"}});return void await icons({appRoot:n,source:"string"==typeof e.source?e.source:void 0})}case void 0:case"help":case"--help":case"-h":return void say(USAGE);default:fail("badInput",`unknown command "${o}"\n\n${USAGE}`)}};main(process.argv.slice(2)).catch(e=>{e instanceof CliError&&(reportFailure(e),process.exit(e.exitCode)),warn(e instanceof Error?e.stack??e.message:String(e)),process.exit(1)});
|
package/lib/config.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,readdirSync,readFileSync}from"node:fs";import path from"node:path";import{fail}from"./output.js";export const ENVIRONMENTS=["Development","Staging","Production"];const SUFFIX={Development:".development",Staging:".staging",Production:""};export const CONFIG_FILE="mobile-tools.config.json";export const COMPILED_FILE="appsettings.Compiled.json";export const REQUIRED_SETTINGS=["APP_NAME","APP_ID","ENVIRONMENT","BASE_URL","VERSION_DISPLAY","VERSION_INTERNAL"];export const parseEnvironment=
|
|
1
|
+
import{existsSync,readdirSync,readFileSync}from"node:fs";import path from"node:path";import{fail}from"./output.js";export const ENVIRONMENTS=["Development","Staging","Production"];const SUFFIX={Development:".development",Staging:".staging",Production:""};export const CONFIG_FILE="mobile-tools.config.json";export const COMPILED_FILE="appsettings.Compiled.json";export const REQUIRED_SETTINGS=["APP_NAME","APP_ID","ENVIRONMENT","BASE_URL","VERSION_DISPLAY","VERSION_INTERNAL"];export const parseEnvironment=t=>ENVIRONMENTS.find(e=>e.toLowerCase()===t.toLowerCase())??fail("badInput",`unknown environment "${t}": use ${ENVIRONMENTS.join(", ")}`);export const envSuffix=t=>SUFFIX[t];export const flavorOf=t=>t.toLowerCase();export const applicationId=(t,e)=>`${t}${envSuffix(e)}`;export const gradleAppId=(t,e,n)=>n?t:applicationId(t,e);const capitalize=t=>`${t[0].toUpperCase()}${t.slice(1)}`;export const variantName=(t,e,n)=>e?`${flavorOf(t)}${capitalize(n)}`:n;export const gradleTask=(t,e,n,i)=>`${"apk"===n?"assemble":"bundle"}${capitalize(variantName(t,e,i))}`;export const gradleOutputDir=(t,e,n,i,o)=>{const s=path.join(t,"android","app","build","outputs");return"aab"===i?path.join(s,"bundle",variantName(e,n,o)):n?path.join(s,"apk",flavorOf(e),o):path.join(s,"apk",o)};export const mappingFile=(t,e,n)=>path.join(t,"android","app","build","outputs","mapping",variantName(e,n,"release"),"mapping.txt");const readJson=t=>{try{return JSON.parse(readFileSync(t,"utf8"))}catch(e){return fail("appConfigInvalid",`cannot read ${t}: ${e.message}`)}},isRecord=t=>"object"==typeof t&&null!==t&&!Array.isArray(t),CONFIG_KEYS={iosProjectName:"string",flavors:"boolean",localApiPort:"port",iconSource:"string",firebasePlistDir:"string",requiredSettings:"settingNames"},SETTING_NAME_PATTERN=/^[A-Z][A-Z0-9_]*$/,checkSettingNames=(t,e)=>{Array.isArray(t)&&!t.some(t=>"string"!=typeof t)||fail("appConfigInvalid",`${e}: "requiredSettings" must be an array of setting names such as ["LOG_LEVEL"]`);const n=new Set;for(const i of t)SETTING_NAME_PATTERN.test(i)||fail("appConfigInvalid",`${e}: "requiredSettings" holds ${JSON.stringify(i)}; a setting name is upper case letters, digits and _`),n.has(i)&&fail("appConfigInvalid",`${e}: "requiredSettings" lists ${i} twice`),REQUIRED_SETTINGS.includes(i)&&fail("appConfigInvalid",`${e}: "requiredSettings" lists ${i}, which prebuild always requires; remove it`),n.add(i)};export const parseToolsConfig=(t,e=CONFIG_FILE)=>{if(!isRecord(t))return fail("appConfigInvalid",`${e} must hold a JSON object`);for(const[n,i]of Object.entries(t)){const t=Object.hasOwn(CONFIG_KEYS,n)?CONFIG_KEYS[n]:void 0;if(!t)return fail("appConfigInvalid",`${e}: unknown key "${n}" (known: ${Object.keys(CONFIG_KEYS).join(", ")})`);if("settingNames"===t){checkSettingNames(i,e);continue}if(!("port"===t?Number.isInteger(i)&&i>0&&i<65536:"string"===t?"string"==typeof i&&""!==i.trim():typeof i===t)){fail("appConfigInvalid",`${e}: "${n}" must be ${{port:"a port number",string:"a non-empty string",boolean:"true or false"}[t]}`)}}return{iosProjectName:t.iosProjectName,flavors:t.flavors??!0,localApiPort:t.localApiPort,iconSource:t.iconSource,firebasePlistDir:t.firebasePlistDir??path.join("ios","firebase"),requiredSettings:t.requiredSettings??[]}};export const loadToolsConfig=t=>{const e=path.join(t,CONFIG_FILE);return parseToolsConfig(existsSync(e)?readJson(e):{})};export const resolveIosProjectName=(t,e)=>{if(e.iosProjectName)return e.iosProjectName;const n=path.join(t,"ios");if(!existsSync(n))return;const i=readdirSync(n).filter(t=>t.endsWith(".xcodeproj"));return 1!==i.length?fail("appConfigInvalid",`found ${i.length} .xcodeproj folders in ios/: set "iosProjectName" in ${CONFIG_FILE}`):i[0].slice(0,-10)};const APP_ID_PATTERN=/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;export const mergeAppSettings=(t,e,n,i)=>{if(!isRecord(t))return fail("appConfigInvalid","appsettings.json must hold a JSON object");if(!isRecord(e))return fail("appConfigInvalid",`appsettings.${n}.json must hold a JSON object`);const o={...t,...e},s=REQUIRED_SETTINGS.filter(t=>void 0===o[t]||null===o[t]||""===o[t]);s.length>0&&fail("appConfigInvalid",`appsettings (${n}) is missing required key(s): ${s.join(", ")}`);const a=i.filter(t=>void 0===o[t]||null===o[t]);return a.length>0&&fail("appConfigInvalid",`appsettings (${n}) is missing setting(s) mobile-tools.config.json requires: ${a.join(", ")}`),o.ENVIRONMENT!==n&&fail("appConfigInvalid",`appsettings.${n}.json sets ENVIRONMENT to ${JSON.stringify(o.ENVIRONMENT)}; it must be "${n}"`),void 0!==o.APP_ID_SUFFIX&&o.APP_ID_SUFFIX!==envSuffix(n)&&fail("appConfigInvalid",`appsettings (${n}) sets APP_ID_SUFFIX to ${JSON.stringify(o.APP_ID_SUFFIX)}; ${n} builds always use ${JSON.stringify(envSuffix(n))}. Remove the key or correct it.`),/^[1-9][0-9]*$/.test(String(o.VERSION_INTERNAL))||fail("appConfigInvalid",`VERSION_INTERNAL must be a positive whole number, not ${JSON.stringify(o.VERSION_INTERNAL)}`),APP_ID_PATTERN.test(String(o.APP_ID))||fail("appConfigInvalid",`APP_ID ${JSON.stringify(o.APP_ID)} is not a reverse-domain id such as com.example.app`),o};export const loadAppSettings=(t,e,n)=>{const i=path.join(t,"appsettings.json"),o=path.join(t,`appsettings.${e}.json`);return existsSync(i)||fail("appConfigInvalid",`no appsettings.json in ${t}: run mobile-tools from the app's root folder`),existsSync(o)||fail("appConfigInvalid",`appsettings.${e}.json not found: every buildable environment declares its own file`),mergeAppSettings(readJson(i),readJson(o),e,n)};export const baseAppId=t=>{const e=path.join(t,"appsettings.json");if(!existsSync(e))return fail("appConfigInvalid",`no appsettings.json in ${t}: run mobile-tools from the app's root folder`);const n=readJson(e),i=isRecord(n)?n.APP_ID:void 0;return"string"==typeof i&&APP_ID_PATTERN.test(i)?i:fail("appConfigInvalid",`APP_ID ${JSON.stringify(i)} in appsettings.json is not a reverse-domain id such as com.example.app`)};export const lastCompiledEnvironment=t=>{const e=path.join(t,COMPILED_FILE);if(!existsSync(e))return fail("appConfigInvalid",`--same-env: no ${COMPILED_FILE} yet; pass --env <Environment> once`);const n=readJson(e);return isRecord(n)&&"string"==typeof n.ENVIRONMENT?parseEnvironment(n.ENVIRONMENT):fail("appConfigInvalid",`--same-env: ${COMPILED_FILE} has no ENVIRONMENT`)};export const localApiPort=(t,e)=>{if(void 0!==e.localApiPort)return e.localApiPort;let n;try{n=new URL(t)}catch{return}return"localhost"===n.hostname||"127.0.0.1"===n.hostname?""!==n.port?Number(n.port):"https:"===n.protocol?443:80:void 0};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createRequire}from"node:module";import path from"node:path";export const createMetroConfig=o=>{const e=createRequire(path.join(o,"package.json"));let r;try{r=e("expo/metro-config")}catch{throw new Error(`@webority/mobile-tools/metro-config: expo is not installed in ${o}; run npx mobile-tools sync`)}const t=r.getDefaultConfig(o);return t.transformer.minifierConfig={...t.transformer.minifierConfig,compress:{...t.transformer.minifierConfig?.compress,drop_console:!0}},t};
|
package/lib/prebuild.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{copyFileSync,existsSync,readFileSync,rmSync,writeFileSync}from"node:fs";import path from"node:path";import{checkAppSettings}from"./appSettingsRules.js";import{applicationId,COMPILED_FILE,ENVIRONMENTS,gradleAppId,lastCompiledEnvironment,loadAppSettings,loadToolsConfig,resolveIosProjectName}from"./config.js";import{capture}from"./exec.js";import{otaNativeSettings,rewriteAndroidManifestOta,rewriteExpoPlistOta}from"./otaNative.js";import{fail,say}from"./output.js";import{findOnPath}from"./platform.js";const eolOf=e=>e.includes("\r\n")?"\r\n":"\n";export const rewriteGradleProperties=(e,t)=>{const n=eolOf(e),o=e.split(n),i=new Map(Object.entries(t)),s=o.map(e=>{const t=/^\s*([A-Za-z0-9_.-]+)\s*[=:]/.exec(e);if(t&&i.has(t[1])){const e=t[1],n=i.get(e);return i.delete(e),`${e}=${n}`}return e});if(0===i.size)return s.join(n);const r=[...i].map(([e,t])=>`${e}=${t}`);return s.length>1&&""===s[s.length-1]?s.splice(s.length-1,0,...r):1===s.length&&""===s[0]?s.splice(0,1,...r):s.push(...r),s.join(n)};const pbxValue=e=>/^[A-Za-z0-9._/-]+$/.test(e)?e:`"${e.replace(/"/g,'\\"')}"`;export const rewritePbxproj=(e,t,n={})=>{let o=e;for(const[e,i]of Object.entries(t)){const t=new RegExp(`(\\s)${e} = [^;\\n]*;`,"g");t.test(o)||fail("appConfigInvalid",`${e} not found in project.pbxproj`);const s=n.quote?.includes(e)?`"${i.replace(/"/g,'\\"')}"`:pbxValue(i);o=o.replace(t,(t,n)=>`${n}${e} = ${s};`)}return o};const escapeXml=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");export const rewriteInfoPlistDisplayName=(e,t)=>{const n=/(<key>CFBundleDisplayName<\/key>\s*<string>)[^<]*(<\/string>)/;return n.test(e)||fail("appConfigInvalid","CFBundleDisplayName not found in Info.plist"),e.replace(n,(e,n,o)=>`${n}${escapeXml(t)}${o}`)};const pbxObject=(e,t)=>{const n=new RegExp(`\\n\\s*${t}(?: /\\*[^\\n]*?\\*/)? = \\{`).exec(e);if(!n)return;let o=0;for(let t=n.index+n[0].length-1;t<e.length;t++)if(e.startsWith("/*",t)){if(t=e.indexOf("*/",t+2)+1,0===t)return}else if('"'===e[t])for(t++;t<e.length&&'"'!==e[t];t++)"\\"===e[t]&&t++;else if("{"===e[t])o++;else if("}"===e[t]&&0===--o)return e.slice(n.index,t+1)},unquote=e=>e.startsWith('"')&&e.endsWith('"')?e.slice(1,-1).replace(/\\"/g,'"'):e,shipsInfoPlist=e=>/^com\.apple\.product-type\.(application|.*extension)/.test(e);export const shippedInfoPlists=e=>{const t=new Map,n=/\n\s*(\w+)(?: \/\*[^\n]*?\*\/)? = \{\s*isa = PBXNativeTarget;/g;for(const[,o]of e.matchAll(n)){const n=pbxObject(e,o)??"",i=unquote(/\sproductType = ([^;\n]*);/.exec(n)?.[1]??"");if(!shipsInfoPlist(i))continue;const s=unquote(/\sname = ([^;\n]*);/.exec(n)?.[1]??o),r=/\sbuildConfigurationList = (\w+)/.exec(n)?.[1],a=r&&pbxObject(e,r)||"",p=/buildConfigurations = \(([^)]*)\)/.exec(a)?.[1]??"";for(const[n]of p.matchAll(/\w+(?= \/\*|,)/g)){const o=/\sINFOPLIST_FILE = ([^;\n]*);/.exec(pbxObject(e,n)??"")?.[1];o&&!t.has(unquote(o))&&t.set(unquote(o),s)}}return[...t].map(([e,t])=>({target:t,file:e}))};const BUILD_SETTING_KEYS=[["CFBundleIdentifier","PRODUCT_BUNDLE_IDENTIFIER"],["CFBundleShortVersionString","MARKETING_VERSION"],["CFBundleVersion","CURRENT_PROJECT_VERSION"]];export const fixedInfoPlistValues=e=>BUILD_SETTING_KEYS.flatMap(([t,n])=>{const o=new RegExp(`<key>${t}</key>\\s*(<[^>]*>)?([^<]*)`).exec(e);if(!o)return[];const i="<string>"===o[1]?o[2].trim():o[1]??o[2].trim();return i===`$(${n})`||i===`\${${n}}`?[]:[{key:t,value:i,setting:n}]});export const checkInfoPlistReferences=(e,t)=>{const n=path.join(e,"ios"),o=readFileSync(path.join(n,`${t}.xcodeproj`,"project.pbxproj"),"utf8"),i=[{target:t,file:`${t}/Info.plist`},...shippedInfoPlists(o)],s=[],r=new Set;for(const{target:t,file:o}of i){const i=o.replace(/^(\$\(SRCROOT\)|\$\{SRCROOT\}|\$\(PROJECT_DIR\))\//,"");if(/\$[({]/.test(i)){s.push(`target ${t}: INFOPLIST_FILE ${o} uses a build setting prebuild cannot resolve`);continue}const a=path.resolve(n,i);if(r.has(a))continue;r.add(a);const p=path.relative(e,a).replace(/\\/g,"/");if(existsSync(a))for(const{key:e,value:t,setting:n}of fixedInfoPlistValues(readFileSync(a,"utf8")))s.push(`${p}: ${e} is "${t}", must be $(${n})`);else s.push(`${p} not found (INFOPLIST_FILE of target ${t})`)}s.length>0&&fail("appConfigInvalid",`Info.plist values that ignore what prebuild writes into project.pbxproj:\n ${s.join("\n ")}`)};const ANDROID_MANIFEST="android/app/src/main/AndroidManifest.xml",expoPlistPath=e=>`ios/${e}/Supporting/Expo.plist`;export const prebuildOutputs=(e,t=!1)=>{const n=[COMPILED_FILE,"android/gradle.properties"];return t&&n.push(ANDROID_MANIFEST),e&&(n.push(`ios/${e}/Info.plist`,`ios/${e}.xcodeproj/project.pbxproj`,`ios/${e}/GoogleService-Info.plist`),t&&n.push(expoPlistPath(e))),n};export const buildCommit=(e,t)=>{const n=findOnPath("git",process.env,process.platform);if(!n)return"nogit";const o=capture({file:n,args:["rev-parse","--short","HEAD"]},e);if(0!==o.status)return"nogit";const i=capture({file:n,args:["status","--porcelain","--",".",...t.map(e=>`:(exclude)${e}`)]},e);return 0!==i.status?fail("toolFailed",`git status failed in ${e}: ${i.stderr.trim()}`):""===i.stdout.trim()?o.stdout.trim():`${o.stdout.trim()}-dirty`};const writeIfChanged=(e,t)=>(!existsSync(e)||readFileSync(e,"utf8")!==t)&&(writeFileSync(e,t,"utf8"),!0),report=(e,t,n)=>{say(`prebuild: ${n?"wrote":"unchanged"} ${path.relative(e,t)}`)};export const usesFirebase=e=>{const t=path.join(e,"package.json");if(!existsSync(t))return!1;const n=JSON.parse(readFileSync(t,"utf8"));return Boolean(n.dependencies?.["@react-native-firebase/app"])};export const checkNdkHook=e=>{const t=path.join(e,"android","build.gradle");if(!existsSync(t))return;const n=readFileSync(t,"utf8");/withId\(\s*["']com\.android\.library["']\s*\)/.test(n)&&/\.ndkVersion\s*=\s*rootProject\.ext\.ndkVersion/.test(n)||fail("appConfigInvalid",'android/build.gradle does not hand the app NDK to library modules; add after apply plugin "com.facebook.react.rootproject": subprojects { project -> project.plugins.withId("com.android.library") { project.android.ndkVersion = rootProject.ext.ndkVersion } }')};export const stampAndroid=(e,t,n,o)=>{const i=path.join(e,"android","gradle.properties");existsSync(i)||fail("appConfigInvalid",`android/gradle.properties not found in ${e}`),usesFirebase(e)&&!existsSync(path.join(e,"android","app","google-services.json"))&&fail("appConfigInvalid","android/app/google-services.json is missing; the app uses @react-native-firebase, whose Gradle plugin needs it"),checkNdkHook(e);const s=rewriteGradleProperties(readFileSync(i,"utf8"),{APP_NAME:String(t.APP_NAME),APP_ID:gradleAppId(t.APP_ID,n,o.flavors),VERSION_DISPLAY:String(t.VERSION_DISPLAY),VERSION_INTERNAL:String(t.VERSION_INTERNAL)});report(e,i,writeIfChanged(i,s));const r=otaNativeSettings(e,t,n,"android");if(r){const t=path.join(e,ANDROID_MANIFEST);existsSync(t)||fail("appConfigInvalid",`${ANDROID_MANIFEST} not found: expo-updates reads its settings from it`),report(e,t,writeIfChanged(t,rewriteAndroidManifestOta(readFileSync(t,"utf8"),r)))}};export const stampIos=(e,t,n,o,i)=>{const s=path.join(e,"ios",i),r=path.join(s,"Info.plist"),a=path.join(e,"ios",`${i}.xcodeproj`,"project.pbxproj");for(const t of[r,a])existsSync(t)||fail("appConfigInvalid",`${path.relative(e,t)} not found: check "iosProjectName"`);checkInfoPlistReferences(e,i);const p=`AppIcon-${n}`,c=path.join(s,"Images.xcassets"),l=existsSync(path.join(c,`${p}.appiconset`)),d=ENVIRONMENTS.filter(e=>e!==n&&existsSync(path.join(c,`AppIcon-${e}.appiconset`)));!l&&d.length>0&&fail("appConfigInvalid",`ios/${i}/Images.xcassets has AppIcon-${d[0]} but no ${p}: run mobile-tools icons`),report(e,r,writeIfChanged(r,rewriteInfoPlistDisplayName(readFileSync(r,"utf8"),String(t.APP_NAME))));const f={PRODUCT_BUNDLE_IDENTIFIER:applicationId(t.APP_ID,n),MARKETING_VERSION:String(t.VERSION_DISPLAY),CURRENT_PROJECT_VERSION:String(t.VERSION_INTERNAL)};l&&(f.ASSETCATALOG_COMPILER_APPICON_NAME=p),report(e,a,writeIfChanged(a,rewritePbxproj(readFileSync(a,"utf8"),f,{quote:["PRODUCT_BUNDLE_IDENTIFIER"]}))),l||say("prebuild: no AppIcon-<Env> sets, app icon left as the project sets it");const u=path.join(s,"GoogleService-Info.plist"),I=path.join(e,o.firebasePlistDir,`GoogleService-Info.${n}.plist`);if(!existsSync(I)&&usesFirebase(e)&&fail("appConfigInvalid",`${path.relative(e,I)} is missing; the app uses @react-native-firebase, and an iOS build without it reports no crashes`),existsSync(I)){const t=!existsSync(u)||!readFileSync(I).equals(readFileSync(u));t&©FileSync(I,u),report(e,u,t)}else existsSync(u)&&(rmSync(u),say(`prebuild: removed ${path.relative(e,u)}, no ${path.relative(e,I)}`));const g=otaNativeSettings(e,t,n,"ios");if(g){const t=path.join(e,expoPlistPath(i));existsSync(t)||fail("appConfigInvalid",`${expoPlistPath(i)} not found: add it to the Xcode project for expo-updates`),report(e,t,writeIfChanged(t,rewriteExpoPlistOta(readFileSync(t,"utf8"),g)))}};export const prebuild=e=>{const{appRoot:t,sameEnv:n,platform:o}=e;n&&e.env&&fail("badInput","pass --env or --same-env, not both");const i=n?lastCompiledEnvironment(t):e.env;if(!i)return fail("badInput","prebuild needs --env <Development|Staging|Production> or --same-env");const s=loadToolsConfig(t),r=resolveIosProjectName(t,s),a=loadAppSettings(t,i);checkAppSettings(t,a,i);const p={...a,COMMIT:buildCommit(t,prebuildOutputs(r,Boolean(a.OTA_UPDATE_BASE_URL))),BUILD_TIME:(new Date).toISOString()};writeFileSync(path.join(t,COMPILED_FILE),`${JSON.stringify(p,null,2)}\n`,"utf8"),say(`prebuild: wrote ${COMPILED_FILE} (${i}, ${p.COMMIT})`);const c=existsSync(path.join(t,"android"));if(("android"===o||!o&&c)&&stampAndroid(t,p,i,s),"ios"===o||!o&&r){if(!r)return fail("appConfigInvalid",`no ios/ folder in ${t}`);stampIos(t,p,i,s,r)}return"Production"!==i&&say(`prebuild: tracked native files now carry ${i} values; run prebuild --env Production before committing them`),p};
|
|
1
|
+
import{copyFileSync,existsSync,readFileSync,rmSync,writeFileSync}from"node:fs";import path from"node:path";import{checkAppSettings}from"./appSettingsRules.js";import{applicationId,COMPILED_FILE,ENVIRONMENTS,gradleAppId,lastCompiledEnvironment,loadAppSettings,loadToolsConfig,resolveIosProjectName}from"./config.js";import{capture}from"./exec.js";import{otaNativeSettings,rewriteAndroidManifestOta,rewriteExpoPlistOta}from"./otaNative.js";import{fail,say}from"./output.js";import{findOnPath}from"./platform.js";const eolOf=e=>e.includes("\r\n")?"\r\n":"\n";export const rewriteGradleProperties=(e,t)=>{const n=eolOf(e),o=e.split(n),i=new Map(Object.entries(t)),s=o.map(e=>{const t=/^\s*([A-Za-z0-9_.-]+)\s*[=:]/.exec(e);if(t&&i.has(t[1])){const e=t[1],n=i.get(e);return i.delete(e),`${e}=${n}`}return e});if(0===i.size)return s.join(n);const r=[...i].map(([e,t])=>`${e}=${t}`);return s.length>1&&""===s[s.length-1]?s.splice(s.length-1,0,...r):1===s.length&&""===s[0]?s.splice(0,1,...r):s.push(...r),s.join(n)};const pbxValue=e=>/^[A-Za-z0-9._/-]+$/.test(e)?e:`"${e.replace(/"/g,'\\"')}"`;export const rewritePbxproj=(e,t,n={})=>{let o=e;for(const[e,i]of Object.entries(t)){const t=new RegExp(`(\\s)${e} = [^;\\n]*;`,"g");t.test(o)||fail("appConfigInvalid",`${e} not found in project.pbxproj`);const s=n.quote?.includes(e)?`"${i.replace(/"/g,'\\"')}"`:pbxValue(i);o=o.replace(t,(t,n)=>`${n}${e} = ${s};`)}return o};const escapeXml=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");export const rewriteInfoPlistDisplayName=(e,t)=>{const n=/(<key>CFBundleDisplayName<\/key>\s*<string>)[^<]*(<\/string>)/;return n.test(e)||fail("appConfigInvalid","CFBundleDisplayName not found in Info.plist"),e.replace(n,(e,n,o)=>`${n}${escapeXml(t)}${o}`)};const pbxObject=(e,t)=>{const n=new RegExp(`\\n\\s*${t}(?: /\\*[^\\n]*?\\*/)? = \\{`).exec(e);if(!n)return;let o=0;for(let t=n.index+n[0].length-1;t<e.length;t++)if(e.startsWith("/*",t)){if(t=e.indexOf("*/",t+2)+1,0===t)return}else if('"'===e[t])for(t++;t<e.length&&'"'!==e[t];t++)"\\"===e[t]&&t++;else if("{"===e[t])o++;else if("}"===e[t]&&0===--o)return e.slice(n.index,t+1)},unquote=e=>e.startsWith('"')&&e.endsWith('"')?e.slice(1,-1).replace(/\\"/g,'"'):e,shipsInfoPlist=e=>/^com\.apple\.product-type\.(application|.*extension)/.test(e);export const shippedInfoPlists=e=>{const t=new Map,n=/\n\s*(\w+)(?: \/\*[^\n]*?\*\/)? = \{\s*isa = PBXNativeTarget;/g;for(const[,o]of e.matchAll(n)){const n=pbxObject(e,o)??"",i=unquote(/\sproductType = ([^;\n]*);/.exec(n)?.[1]??"");if(!shipsInfoPlist(i))continue;const s=unquote(/\sname = ([^;\n]*);/.exec(n)?.[1]??o),r=/\sbuildConfigurationList = (\w+)/.exec(n)?.[1],a=r&&pbxObject(e,r)||"",p=/buildConfigurations = \(([^)]*)\)/.exec(a)?.[1]??"";for(const[n]of p.matchAll(/\w+(?= \/\*|,)/g)){const o=/\sINFOPLIST_FILE = ([^;\n]*);/.exec(pbxObject(e,n)??"")?.[1];o&&!t.has(unquote(o))&&t.set(unquote(o),s)}}return[...t].map(([e,t])=>({target:t,file:e}))};const BUILD_SETTING_KEYS=[["CFBundleIdentifier","PRODUCT_BUNDLE_IDENTIFIER"],["CFBundleShortVersionString","MARKETING_VERSION"],["CFBundleVersion","CURRENT_PROJECT_VERSION"]];export const fixedInfoPlistValues=e=>BUILD_SETTING_KEYS.flatMap(([t,n])=>{const o=new RegExp(`<key>${t}</key>\\s*(<[^>]*>)?([^<]*)`).exec(e);if(!o)return[];const i="<string>"===o[1]?o[2].trim():o[1]??o[2].trim();return i===`$(${n})`||i===`\${${n}}`?[]:[{key:t,value:i,setting:n}]});export const checkInfoPlistReferences=(e,t)=>{const n=path.join(e,"ios"),o=readFileSync(path.join(n,`${t}.xcodeproj`,"project.pbxproj"),"utf8"),i=[{target:t,file:`${t}/Info.plist`},...shippedInfoPlists(o)],s=[],r=new Set;for(const{target:t,file:o}of i){const i=o.replace(/^(\$\(SRCROOT\)|\$\{SRCROOT\}|\$\(PROJECT_DIR\))\//,"");if(/\$[({]/.test(i)){s.push(`target ${t}: INFOPLIST_FILE ${o} uses a build setting prebuild cannot resolve`);continue}const a=path.resolve(n,i);if(r.has(a))continue;r.add(a);const p=path.relative(e,a).replace(/\\/g,"/");if(existsSync(a))for(const{key:e,value:t,setting:n}of fixedInfoPlistValues(readFileSync(a,"utf8")))s.push(`${p}: ${e} is "${t}", must be $(${n})`);else s.push(`${p} not found (INFOPLIST_FILE of target ${t})`)}s.length>0&&fail("appConfigInvalid",`Info.plist values that ignore what prebuild writes into project.pbxproj:\n ${s.join("\n ")}`)};const ANDROID_MANIFEST="android/app/src/main/AndroidManifest.xml",expoPlistPath=e=>`ios/${e}/Supporting/Expo.plist`;export const prebuildOutputs=(e,t=!1)=>{const n=[COMPILED_FILE,"android/gradle.properties"];return t&&n.push(ANDROID_MANIFEST),e&&(n.push(`ios/${e}/Info.plist`,`ios/${e}.xcodeproj/project.pbxproj`,`ios/${e}/GoogleService-Info.plist`),t&&n.push(expoPlistPath(e))),n};export const buildCommit=(e,t)=>{const n=findOnPath("git",process.env,process.platform);if(!n)return"nogit";const o=capture({file:n,args:["rev-parse","--short","HEAD"]},e);if(0!==o.status)return"nogit";const i=capture({file:n,args:["status","--porcelain","--",".",...t.map(e=>`:(exclude)${e}`)]},e);return 0!==i.status?fail("toolFailed",`git status failed in ${e}: ${i.stderr.trim()}`):""===i.stdout.trim()?o.stdout.trim():`${o.stdout.trim()}-dirty`};const writeIfChanged=(e,t)=>(!existsSync(e)||readFileSync(e,"utf8")!==t)&&(writeFileSync(e,t,"utf8"),!0),report=(e,t,n)=>{say(`prebuild: ${n?"wrote":"unchanged"} ${path.relative(e,t)}`)};export const usesFirebase=e=>{const t=path.join(e,"package.json");if(!existsSync(t))return!1;const n=JSON.parse(readFileSync(t,"utf8"));return Boolean(n.dependencies?.["@react-native-firebase/app"])};export const checkNdkHook=e=>{const t=path.join(e,"android","build.gradle");if(!existsSync(t))return;const n=readFileSync(t,"utf8");/withId\(\s*["']com\.android\.library["']\s*\)/.test(n)&&/\.ndkVersion\s*=\s*rootProject\.ext\.ndkVersion/.test(n)||fail("appConfigInvalid",'android/build.gradle does not hand the app NDK to library modules; add after apply plugin "com.facebook.react.rootproject": subprojects { project -> project.plugins.withId("com.android.library") { project.android.ndkVersion = rootProject.ext.ndkVersion } }')};export const stampAndroid=(e,t,n,o)=>{const i=path.join(e,"android","gradle.properties");existsSync(i)||fail("appConfigInvalid",`android/gradle.properties not found in ${e}`),usesFirebase(e)&&!existsSync(path.join(e,"android","app","google-services.json"))&&fail("appConfigInvalid","android/app/google-services.json is missing; the app uses @react-native-firebase, whose Gradle plugin needs it"),checkNdkHook(e);const s=rewriteGradleProperties(readFileSync(i,"utf8"),{APP_NAME:String(t.APP_NAME),APP_ID:gradleAppId(t.APP_ID,n,o.flavors),VERSION_DISPLAY:String(t.VERSION_DISPLAY),VERSION_INTERNAL:String(t.VERSION_INTERNAL)});report(e,i,writeIfChanged(i,s));const r=otaNativeSettings(e,t,n,"android");if(r){const t=path.join(e,ANDROID_MANIFEST);existsSync(t)||fail("appConfigInvalid",`${ANDROID_MANIFEST} not found: expo-updates reads its settings from it`),report(e,t,writeIfChanged(t,rewriteAndroidManifestOta(readFileSync(t,"utf8"),r)))}};export const stampIos=(e,t,n,o,i)=>{const s=path.join(e,"ios",i),r=path.join(s,"Info.plist"),a=path.join(e,"ios",`${i}.xcodeproj`,"project.pbxproj");for(const t of[r,a])existsSync(t)||fail("appConfigInvalid",`${path.relative(e,t)} not found: check "iosProjectName"`);checkInfoPlistReferences(e,i);const p=`AppIcon-${n}`,c=path.join(s,"Images.xcassets"),l=existsSync(path.join(c,`${p}.appiconset`)),d=ENVIRONMENTS.filter(e=>e!==n&&existsSync(path.join(c,`AppIcon-${e}.appiconset`)));!l&&d.length>0&&fail("appConfigInvalid",`ios/${i}/Images.xcassets has AppIcon-${d[0]} but no ${p}: run mobile-tools icons`),report(e,r,writeIfChanged(r,rewriteInfoPlistDisplayName(readFileSync(r,"utf8"),String(t.APP_NAME))));const f={PRODUCT_BUNDLE_IDENTIFIER:applicationId(t.APP_ID,n),MARKETING_VERSION:String(t.VERSION_DISPLAY),CURRENT_PROJECT_VERSION:String(t.VERSION_INTERNAL)};l&&(f.ASSETCATALOG_COMPILER_APPICON_NAME=p),report(e,a,writeIfChanged(a,rewritePbxproj(readFileSync(a,"utf8"),f,{quote:["PRODUCT_BUNDLE_IDENTIFIER"]}))),l||say("prebuild: no AppIcon-<Env> sets, app icon left as the project sets it");const u=path.join(s,"GoogleService-Info.plist"),I=path.join(e,o.firebasePlistDir,`GoogleService-Info.${n}.plist`);if(!existsSync(I)&&usesFirebase(e)&&fail("appConfigInvalid",`${path.relative(e,I)} is missing; the app uses @react-native-firebase, and an iOS build without it reports no crashes`),existsSync(I)){const t=!existsSync(u)||!readFileSync(I).equals(readFileSync(u));t&©FileSync(I,u),report(e,u,t)}else existsSync(u)&&(rmSync(u),say(`prebuild: removed ${path.relative(e,u)}, no ${path.relative(e,I)}`));const g=otaNativeSettings(e,t,n,"ios");if(g){const t=path.join(e,expoPlistPath(i));existsSync(t)||fail("appConfigInvalid",`${expoPlistPath(i)} not found: add it to the Xcode project for expo-updates`),report(e,t,writeIfChanged(t,rewriteExpoPlistOta(readFileSync(t,"utf8"),g)))}};export const prebuild=e=>{const{appRoot:t,sameEnv:n,platform:o}=e;n&&e.env&&fail("badInput","pass --env or --same-env, not both");const i=n?lastCompiledEnvironment(t):e.env;if(!i)return fail("badInput","prebuild needs --env <Development|Staging|Production> or --same-env");const s=loadToolsConfig(t),r=resolveIosProjectName(t,s),a=loadAppSettings(t,i,s.requiredSettings);checkAppSettings(t,a,i);const p={...a,COMMIT:buildCommit(t,prebuildOutputs(r,Boolean(a.OTA_UPDATE_BASE_URL))),BUILD_TIME:(new Date).toISOString()};writeFileSync(path.join(t,COMPILED_FILE),`${JSON.stringify(p,null,2)}\n`,"utf8"),say(`prebuild: wrote ${COMPILED_FILE} (${i}, ${p.COMMIT})`);const c=existsSync(path.join(t,"android"));if(("android"===o||!o&&c)&&stampAndroid(t,p,i,s),"ios"===o||!o&&r){if(!r)return fail("appConfigInvalid",`no ios/ folder in ${t}`);stampIos(t,p,i,s,r)}return"Production"!==i&&say(`prebuild: tracked native files now carry ${i} values; run prebuild --env Production before committing them`),p};
|
package/lib/run.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,readdirSync,readFileSync}from"node:fs";import path from"node:path";import{buildOrFail,logDir,logStamp}from"./buildLog.js";import{findCachedBuild,nativeFingerprint,storeCachedBuild}from"./cache.js";import{applicationId,baseAppId,gradleOutputDir,gradleTask,
|
|
1
|
+
import{existsSync,readdirSync,readFileSync}from"node:fs";import path from"node:path";import{buildOrFail,logDir,logStamp}from"./buildLog.js";import{findCachedBuild,nativeFingerprint,storeCachedBuild}from"./cache.js";import{applicationId,baseAppId,gradleOutputDir,gradleTask,loadToolsConfig,localApiPort,resolveIosProjectName,variantName}from"./config.js";import{connectLocalEmulators,describeDevice,listAndroidDevices,pickDevice}from"./devices.js";import{capture}from"./exec.js";import{describeAppleDevice,listAppleDevices,xcodebuildArgs}from"./ios.js";import{ensureMetro}from"./metro.js";import{fail,say}from"./output.js";import{adbPath,gradleInvocation,requireDarwin,XCODEBUILD,XCRUN}from"./platform.js";import{prebuild}from"./prebuild.js";import{requireSynced}from"./sync.js";export const readNamespace=i=>/^\s*namespace\s*=?\s*["']([^"']+)["']/m.exec(i)?.[1];export const ABI_PATTERN=/^(arm64-v8a|armeabi-v7a|x86_64|x86)$/;export const chooseApk=(i,e)=>{const a=i.filter(i=>i.endsWith(".apk")).sort();return a.find(i=>i.includes(e))??a.find(i=>i.includes("universal"))??a[0]};const adbOrFail=(i,e,a)=>{const o=capture({file:i,args:e},a);return 0!==o.status?fail("toolFailed",`adb ${e.join(" ")} failed: ${(o.stderr||o.stdout).trim()}`):o.stdout};export const installFailure=i=>/Failure \[[^\]]*\]/.exec(i)?.[0];const installOrFail=(i,e,a,o)=>{const r=adbOrFail(i,["-s",e,"install","-r",a],o),t=installFailure(r);void 0!==t&&fail("toolFailed",`adb install on ${e} failed: ${t}`)},elapsedSeconds=i=>((Date.now()-i)/1e3).toFixed(1),cachedBuild=(i,e,a)=>{const o=i.fingerprint.slice(0,12),r=e?void 0:findCachedBuild(i);if(r)return say(`cache: hit ${o} (fingerprint ${a} s), installing the cached build`),r;say(e?"cache: skipped (--rebuild), building":`cache: miss ${o} (fingerprint ${a} s), building`)},DEVICE_METRO_PORT=8081,runAndroid=async i=>{const{appRoot:e,env:a}=i,o=loadToolsConfig(e),r=path.join(e,"android"),t=path.join(r,"app","build.gradle");existsSync(t)||fail("appConfigInvalid",`android/app/build.gradle not found in ${e}`);const s=readNamespace(readFileSync(t,"utf8"))??fail("appConfigInvalid","android/app/build.gradle declares no namespace"),n=adbPath(process.env,process.platform);await connectLocalEmulators(n,e);const l=listAndroidDevices(n,e),d=await pickDevice(l,i.device,describeDevice,!0===process.stdin.isTTY);ABI_PATTERN.test(d.abi)||fail("deviceNotFound",`${describeDevice(d)} reports an ABI this build cannot target: "${d.abi}"`),say(`run: ${describeDevice(d)}`);const c=prebuild({appRoot:e,env:a,platform:"android"}),p=Date.now(),u=await nativeFingerprint(e,"android"),f={appId:baseAppId(e),platform:"android",variant:variantName(a,o.flavors,"debug"),abi:d.abi,fingerprint:u};let m=cachedBuild(f,i.rebuild,elapsedSeconds(p));if(void 0===m){const i=gradleTask(a,o.flavors,"apk","debug"),t=logDir(e);await buildOrFail(gradleInvocation(r,[`:app:${i}`,`-PreactNativeArchitectures=${d.abi}`],process.platform,process.env),r,path.join(t,`gradle-${a}-${logStamp()}.log`),`gradle ${i}`);const s=gradleOutputDir(e,a,o.flavors,"apk","debug"),n=existsSync(s)?chooseApk(readdirSync(s),d.abi):void 0;n||fail("artifactMissing",`the build produced no APK in ${s}`),m=path.join(s,n),storeCachedBuild(f,m),say(`cache: stored ${u.slice(0,12)}`)}say(`run: installing ${path.basename(m)} on ${d.serial}`),installOrFail(n,d.serial,m,e);const v=await ensureMetro(e,path.join(logDir(e),"metro.log"),{fixedPort:!1});if(adbOrFail(n,["-s",d.serial,"reverse","tcp:8081",`tcp:${v}`],e),"Development"===a){const i=localApiPort(String(c.BASE_URL),o);void 0!==i&&(adbOrFail(n,["-s",d.serial,"reverse",`tcp:${i}`,`tcp:${i}`],e),say(`run: device localhost:${i} reaches this machine's API`))}const b=applicationId(c.APP_ID,a);adbOrFail(n,["-s",d.serial,"shell","am","start","-n",`${b}/${s}.MainActivity`],e),say(`run: launched ${b} on ${d.model||d.serial}`)},runIos=async i=>{requireDarwin(process.platform,"run --platform ios");const{appRoot:e,env:a}=i,o=loadToolsConfig(e),r=resolveIosProjectName(e,o)??fail("appConfigInvalid",`no ios/ folder in ${e}`),t=path.join(e,"ios",`${r}.xcworkspace`);existsSync(t)||fail("appConfigInvalid",`ios/${r}.xcworkspace not found: run npx mobile-tools sync (it runs pod install)`);const s=await pickDevice(listAppleDevices(e),i.device,describeAppleDevice,!0===process.stdin.isTTY);say(`run: ${describeAppleDevice(s)}`);const n=prebuild({appRoot:e,env:a,platform:"ios"}),l=logDir(e);let d,c;if("simulator"===s.kind){const a=Date.now(),o=await nativeFingerprint(e,"ios");d={appId:baseAppId(e),platform:"ios",variant:"Debug-iphonesimulator",abi:"simulator",fingerprint:o},c=cachedBuild(d,i.rebuild,elapsedSeconds(a))}else say("cache: not used for a physical iPhone");if(void 0===c){await buildOrFail({file:XCODEBUILD,args:xcodebuildArgs(r,s.udid)},e,path.join(l,`xcodebuild-${a}-${logStamp()}.log`),"xcodebuild");const i=path.join(e,"ios","build","Build","Products","simulator"===s.kind?"Debug-iphonesimulator":"Debug-iphoneos"),o=existsSync(i)?readdirSync(i).filter(i=>i.endsWith(".app")):[],t=o.find(i=>i===`${r}.app`)??o[0];t||fail("artifactMissing",`the build produced no .app in ${i}`),c=path.join(i,t),d&&(storeCachedBuild(d,c),say(`cache: stored ${d.fingerprint.slice(0,12)}`))}const p=applicationId(n.APP_ID,a);await ensureMetro(e,path.join(l,"metro.log"),{fixedPort:!0});const u="simulator"===s.kind?[["simctl","install",s.udid,c],["simctl","launch",s.udid,p]]:[["devicectl","device","install","app","--device",s.udid,c],["devicectl","device","process","launch","--device",s.udid,p]];for(const i of u){const a=capture({file:XCRUN,args:i},e);0!==a.status&&fail("toolFailed",`xcrun ${i.slice(0,3).join(" ")} failed: ${(a.stderr||a.stdout).trim()}`)}say(`run: launched ${p} on ${s.name}`)};export const run=async i=>{requireSynced(i.appRoot),"ios"===i.platform?await runIos(i):await runAndroid(i)};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webority/mobile-tools",
|
|
3
|
-
"version": "0.109.
|
|
3
|
+
"version": "0.109.7",
|
|
4
4
|
"description": "Dev-time CLI for Webority React Native apps: one prebuild, a dependency guard, a device-aware run and per-environment launcher icons, the same on Windows and macOS.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"mobile-tools": "lib/cli.js"
|
|
24
24
|
},
|
|
25
25
|
"exports": {
|
|
26
|
-
"./package.json": "./package.json"
|
|
26
|
+
"./package.json": "./package.json",
|
|
27
|
+
"./metro-config": "./lib/metroConfig.js"
|
|
27
28
|
},
|
|
28
29
|
"files": [
|
|
29
30
|
"lib",
|