@webority/mobile-tools 0.4.0 → 0.5.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 +25 -1
- package/lib/cli.js +1 -1
- package/lib/ota.js +1 -0
- package/lib/otaNative.js +1 -0
- package/lib/prebuild.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @webority/mobile-tools
|
|
2
2
|
|
|
3
|
-
The dev-time CLI for Webority React Native apps. It covers
|
|
3
|
+
The dev-time CLI for Webority React Native apps. It covers five 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, rendering per-environment launcher icons, and publishing over-the-air updates. It behaves the same on Windows (Git Bash, PowerShell, cmd) and macOS (zsh).
|
|
4
4
|
|
|
5
5
|
It is a devDependency. App code never imports it, and it adds nothing to the app bundle.
|
|
6
6
|
|
|
@@ -32,6 +32,10 @@ npx mobile-tools prebuild --same-env [--platform android|ios]
|
|
|
32
32
|
- When another environment's icon set exists but this one's does not, prebuild fails, so a build never ships the wrong icon.
|
|
33
33
|
- Copies `<firebasePlistDir>/GoogleService-Info.<Env>.plist` to `ios/<name>/GoogleService-Info.plist`. Without that file for the environment it fails when the app depends on `@react-native-firebase/app` (the Crashlytics build phase needs it), and otherwise removes the copy.
|
|
34
34
|
- **Firebase on Android**: an app that depends on `@react-native-firebase/app` fails without `android/app/google-services.json`, whose absence would otherwise stop the Gradle build later with a less direct error.
|
|
35
|
+
- **Over-the-air updates**, only when the settings carry `OTA_UPDATE_BASE_URL` and the platform's component id (`OTA_ANDROID_COMPONENT_ID`, `OTA_IOS_COMPONENT_ID`). An app without them is untouched.
|
|
36
|
+
- Writes the expo-updates settings into `android/app/src/main/AndroidManifest.xml` (`expo.modules.updates.*` meta-data) and `ios/<name>/Supporting/Expo.plist` (`EXUpdates*` keys): enabled, the manifest URL `<OTA_UPDATE_BASE_URL>/ota/manifest/<OTA_PROJECT_ID>/<component id>/<environment>`, the runtime version `VERSION_INTERNAL`, check on launch `ALWAYS`, launch wait 0, and the code-signing certificate with metadata `keyid` `main`, `alg` `rsa-v1_5-sha256`.
|
|
37
|
+
- `OTA_CODE_SIGNING_CERT` names the update server's PEM certificate, relative to the app folder. It is required, because the server signs every manifest and an app without the certificate cannot verify one. A missing `OTA_PROJECT_ID` also fails.
|
|
38
|
+
- A missing `AndroidManifest.xml` or `Expo.plist` fails rather than being created: Xcode bundles only a plist the project lists.
|
|
35
39
|
- Without `--platform`, it stamps every platform folder the app has.
|
|
36
40
|
- `--same-env` recompiles the environment last compiled, which refreshes the build stamp.
|
|
37
41
|
- Running it twice gives the same native files as running it once. A file whose content did not change is not rewritten.
|
|
@@ -118,6 +122,26 @@ Renders every environment's launcher icons from one square source: an SVG, or a
|
|
|
118
122
|
- Production is clean. Staging carries an orange `STAGING` ribbon across the bottom-right corner, and Development a green `DEV` one. The ribbon is SVG text in a generic sans-serif, with no font file.
|
|
119
123
|
- The same source gives the same bytes on the same machine.
|
|
120
124
|
|
|
125
|
+
### `ota deploy`
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npx mobile-tools ota deploy --env <Staging|Production> [--platform android|ios] [--rollout <0-100>] [--notes <text>] [--critical]
|
|
129
|
+
npx mobile-tools ota deploy --complete
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Publishes a JavaScript-only change over the air to the phones already on the current store build. A native change, a permission or an SDK bump goes through the stores instead.
|
|
133
|
+
|
|
134
|
+
1. Reads every id from the app's settings for the environment, never from the command line:
|
|
135
|
+
- `OTA_ANDROID_COMPONENT_ID` and `OTA_IOS_COMPONENT_ID`, the update server's component per platform;
|
|
136
|
+
- `APP_ID`, the identity the server and the app check the update against;
|
|
137
|
+
- `VERSION_INTERNAL`, the runtime version. An update reaches only phones whose build is exactly this number.
|
|
138
|
+
2. **Production only:** asks the stores for the live build, through `webority-play release tracks get` and `webority-appstore status`. A platform whose live build is not `VERSION_INTERNAL` is skipped, because the update would reach nobody (for example, while the new build is still in review). `--force` publishes anyway. A lookup that fails is reported and the platform kept. `--no-store-check` skips the lookup, and `--store-profile <name>` picks the store CLIs' credential profile.
|
|
139
|
+
3. Per platform: runs `prebuild` (which also writes the expo-updates settings, above), then `expo export --platform <platform>` into `node_modules/.cache/mobile-tools/ota/`, then `weborityos ota deploy` with `--app-identifier` and `--yes`.
|
|
140
|
+
4. The first deploy uploads nothing. It raises an approval card per platform and the ids are recorded in `node_modules/.cache/mobile-tools/ota/approvals.json`. Once a person approves, `ota deploy --complete` uploads every recorded bundle. `--approval-id <id>` with `--platform` does the same for one platform in one run.
|
|
141
|
+
5. Prebuilds the environment that was compiled before, so a Development checkout is left as it was.
|
|
142
|
+
|
|
143
|
+
`--dry-run` prints the commands after the store check, and changes nothing. `weborityos` must be on `PATH` and signed in (`weborityos login`). Development is refused: it runs from Metro.
|
|
144
|
+
|
|
121
145
|
## `mobile-tools.config.json`
|
|
122
146
|
|
|
123
147
|
Optional, at the app root. Every key is optional. An unknown key or a wrong type fails, naming the key.
|
package/lib/cli.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{parseArgs}from"node:util";import{parseEnvironment}from"./config.js";import{installHooks}from"./hooks.js";import{icons}from"./icons.js";import{CliError,fail,say,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>]\n Build for one device's ABI, install it there, reuse or start Metro, and launch.\n\n icons [--source <png|svg>]\n Render per-environment launcher icons for Android flavors and iOS AppIcon-<Env> sets.",parse=(e,
|
|
2
|
+
import{parseArgs}from"node:util";import{parseEnvironment}from"./config.js";import{installHooks}from"./hooks.js";import{icons}from"./icons.js";import{otaDeploy}from"./ota.js";import{CliError,fail,say,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>]\n Build for one device's ABI, install it there, reuse or start Metro, and launch.\n\n icons [--source <png|svg>]\n Render per-environment launcher icons for Android flavors and iOS AppIcon-<Env> sets.\n\n ota deploy --env <Staging|Production> [--platform android|ios] [--rollout <0-100>]\n [--notes <text>] [--critical] [--approval-id <id>] [--force] [--no-store-check]\n [--store-profile <name>] [--dry-run]\n ota deploy --complete\n Prebuild, expo export and weborityos ota deploy per platform. Production skips a platform\n whose live store build is not VERSION_INTERNAL. The first run raises an approval card per\n platform; --complete uploads the bundles once approved.",parse=(o,e,t=!1)=>{try{const{values:n,positionals:r}=parseArgs({args:o,options:e,allowPositionals:t,strict:!0});return{values:n,positionals:r}}catch(o){return fail(`${o.message}\n\n${USAGE}`,2)}},envOption=o=>"string"==typeof o?parseEnvironment(o):void 0,platformOption=o=>{if(void 0!==o)return"android"!==o&&"ios"!==o?fail(`--platform must be android or ios, not ${String(o)}`,2):o},main=async o=>{const[e,...t]=o,n=process.cwd();switch(e){case"prebuild":{const{values:o}=parse(t,{env:{type:"string"},"same-env":{type:"boolean"},platform:{type:"string"}});return void prebuild({appRoot:n,env:envOption(o.env),sameEnv:!0===o["same-env"],platform:platformOption(o.platform)})}case"sync":{const{values:o}=parse(t,{check:{type:"boolean"},stamp:{type:"boolean"}});if(o.check&&o.stamp&&fail("use --check or --stamp, not both",2),o.stamp)return writeStamp(n),void say("sync --stamp: recorded node_modules as matching the lockfile");if(o.check){const o=checkSync(n,process.platform);return o.length>0&&fail(o.join("\n")),void say("sync --check: node_modules matches the lockfile")}return void sync(n,process.platform)}case"hooks":{const{positionals:o}=parse(t,{},!0);return"install"===o[0]&&1===o.length||fail(`usage: mobile-tools hooks install\n\n${USAGE}`,2),void installHooks(n)}case"run":{const{values:o}=parse(t,{env:{type:"string"},platform:{type:"string"},device:{type:"string"}}),e=envOption(o.env)??fail("run needs --env <Development|Staging|Production>",2);return void await run({appRoot:n,env:e,platform:platformOption(o.platform)??"android",device:"string"==typeof o.device?o.device:void 0})}case"icons":{const{values:o}=parse(t,{source:{type:"string"}});return void await icons({appRoot:n,source:"string"==typeof o.source?o.source:void 0})}case"ota":{const{values:o,positionals:e}=parse(t,{env:{type:"string"},platform:{type:"string"},rollout:{type:"string"},notes:{type:"string"},critical:{type:"boolean"},"approval-id":{type:"string"},complete:{type:"boolean"},force:{type:"boolean"},"no-store-check":{type:"boolean"},"store-profile":{type:"string"},"dry-run":{type:"boolean"}},!0);"deploy"===e[0]&&1===e.length||fail(`usage: mobile-tools ota deploy --env <Staging|Production>\n\n${USAGE}`,2);const text=o=>"string"==typeof o?o:void 0;return void otaDeploy({appRoot:n,env:envOption(o.env),platform:platformOption(o.platform),rollout:text(o.rollout),notes:text(o.notes),critical:!0===o.critical,approvalId:text(o["approval-id"]),complete:!0===o.complete,force:!0===o.force,storeCheck:!0!==o["no-store-check"],storeProfile:text(o["store-profile"]),dryRun:!0===o["dry-run"]})}case void 0:case"help":case"--help":case"-h":return void say(USAGE);default:fail(`unknown command "${e}"\n\n${USAGE}`,2)}};main(process.argv.slice(2)).catch(o=>{o instanceof CliError&&(warn(`mobile-tools: ${o.message}`),process.exit(o.exitCode)),warn(o instanceof Error?o.stack??o.message:String(o)),process.exit(1)});
|
package/lib/ota.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{existsSync,mkdirSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{createRequire}from"node:module";import path from"node:path";import{COMPILED_FILE,lastCompiledEnvironment,loadAppSettings,loadToolsConfig,resolveIosProjectName}from"./config.js";import{capture,runInherit}from"./exec.js";import{componentIdFor}from"./otaNative.js";import{fail,say,warn}from"./output.js";import{findOnPath}from"./platform.js";import{prebuild}from"./prebuild.js";import{logDir}from"./run.js";export const otaDir=o=>path.join(logDir(o),"ota");const manifestFile=o=>path.join(otaDir(o),"approvals.json"),readManifest=o=>{const t=manifestFile(o);return existsSync(t)?JSON.parse(readFileSync(t,"utf8")):{entries:[]}},writeManifest=(o,t)=>{mkdirSync(otaDir(o),{recursive:!0}),writeFileSync(manifestFile(o),`${JSON.stringify(t,null,2)}\n`,"utf8")};export const upsertEntry=(o,t)=>{const e=o.entries.findIndex(o=>o.componentId===t.componentId&&o.environment===t.environment);e>=0?o.entries[e]=t:o.entries.push(t)};export const parseApprovalId=o=>/approvalId\W+(\d+)/i.exec(o)?.[1]??null;export const deployArgs=(o,t,e)=>{const r=["ota","deploy",o.outDir,"--component",o.componentId,"--environment",o.environment,"--runtime-version",o.runtimeVersion,"--app-identifier",o.appIdentifier];return void 0!==t.rollout&&r.push("--rollout",t.rollout),t.notes&&r.push("--notes",t.notes),t.critical&&r.push("--critical"),e&&r.push("--approval-id",e),r.push("--yes"),r};export const liveAndroidBuild=o=>{const t=o?.releases,e=(t??[]).filter(o=>"completed"===o.status||"inProgress"===o.status).flatMap(o=>o.versionCodes??[]).map(Number).filter(Number.isFinite);return e.length>0?Math.max(...e):null};export const liveIosBuild=o=>{const t=o?.platforms?.IOS?.live?.build?.buildNumber;return void 0===t?null:Number(t)};const storeCli=o=>findOnPath(o,process.env,process.platform)??fail(`${o} not found on PATH: install the Webority store CLIs, or pass --no-store-check`),runJson=(o,t,e)=>{const r=capture(o,t);if(0!==r.status)return fail(`${e} failed: ${(r.stderr||r.stdout).trim().split("\n")[0]}`);try{return JSON.parse(r.stdout)}catch{return fail(`${e} printed no JSON`)}},liveStoreBuild=(o,t,e)=>{const r=e.storeProfile?["--profile",e.storeProfile]:[];return"android"===o?liveAndroidBuild(runJson({file:storeCli("webority-play"),args:[...r,"release","tracks","get","--package-name",t,"--track","production"]},e.appRoot,"webority-play release tracks get")):liveIosBuild(runJson({file:storeCli("webority-appstore"),args:[...r,"status","--app",t,"--platform","IOS","--builds","1"]},e.appRoot,"webority-appstore status"))},reachablePlatforms=(o,t,e,r)=>{const n=[];for(const i of o){let o;try{o=liveStoreBuild(i,t.APP_ID,r)}catch(o){warn(`ota: ${i}: could not read the live store build (${o.message}); keeping it`),n.push(i);continue}if(o===Number(e)){say(`ota: ${i}: live store build ${o} matches runtime ${e}`),n.push(i);continue}const a=`live store build ${o??"none"} is not runtime ${e}, so the update would reach nobody`;r.force?(say(`ota: ${i}: ${a}; publishing anyway (--force)`),n.push(i)):say(`ota: ${i}: skipped, ${a}`)}return n},weborityos=()=>findOnPath("weborityos",process.env,process.platform)??fail("weborityos not found on PATH: install the Webority OS CLI and run weborityos login"),expoCli=o=>{try{return createRequire(path.join(o,"package.json")).resolve("expo/bin/cli")}catch{return fail("expo is not installed in this app; run npx mobile-tools sync")}},runDeploy=(o,t,e)=>{const r=capture({file:weborityos(),args:deployArgs(o,t,e)},t.appRoot),n=`${r.stdout}${r.stderr}`;""!==n.trim()&&say(n.trimEnd());const i=e?null:parseApprovalId(n);return i?{...o,status:"pending-approval",approvalId:i}:0!==r.status?fail(`weborityos ota deploy failed for ${o.platform} (exit ${r.status})`):{...o,status:"published",approvalId:e}},completeFromManifest=o=>{const t=readManifest(o.appRoot),e=t.entries.filter(o=>"pending-approval"===o.status);if(0===e.length)return void say("ota: nothing awaits upload; no bundle is pending approval");const r=[];for(const n of e)if(existsSync(n.outDir)){say(`ota: ${n.platform} ${n.environment}: uploading under approval ${n.approvalId}`);try{upsertEntry(t,runDeploy(n,o,n.approvalId))}catch(o){r.push(`${n.platform} ${n.environment}: ${o.message}`)}}else r.push(`${n.platform} ${n.environment}: the bundle is gone, deploy again`);writeManifest(o.appRoot,t),r.length>0&&fail(`ota: still pending:\n ${r.join("\n ")}`),say("ota: every approved bundle is published")},platformsOf=(o,t)=>{if(t)return[t];const e=[];return existsSync(path.join(o,"android"))&&e.push("android"),resolveIosProjectName(o,loadToolsConfig(o))&&e.push("ios"),e.length>0?e:fail(`no android/ or ios/ folder in ${o}`)};export const otaDeploy=o=>{const{appRoot:t}=o;if(o.complete)return void completeFromManifest(o);const e=o.env??fail("ota deploy needs --env <Staging|Production>",2);"Development"===e&&fail("ota deploy publishes to Staging or Production; a Development build runs from Metro",2),void 0===o.rollout||/^(100|[1-9]?[0-9])$/.test(o.rollout)||fail(`--rollout must be a whole number from 0 to 100, not ${o.rollout}`,2),void 0!==o.approvalId&&void 0===o.platform&&fail("--approval-id belongs to one platform: pass --platform with it",2);const r=loadAppSettings(t,e),n=String(r.VERSION_INTERNAL);let i=platformsOf(t,o.platform);const a=new Map(i.map(o=>[o,componentIdFor(r,o)]));if(o.storeCheck&&"Production"===e?i=reachablePlatforms(i,r,n,o):"Production"===e&&say("ota: live store check skipped (--no-store-check)"),0===i.length)return void say("ota: nothing left to publish");const s=i.map(o=>({platform:o,componentId:a.get(o),appIdentifier:r.APP_ID,runtimeVersion:n,environment:e,outDir:path.join(otaDir(t),`${o}-${e.toLowerCase()}`)}));if(o.dryRun){for(const t of s)say(`ota (dry run): ${t.platform}`),say(` mobile-tools prebuild --env ${e} --platform ${t.platform}`),say(` expo export --platform ${t.platform} --output-dir ${t.outDir}`),say(` weborityos ${deployArgs(t,o,o.approvalId??null).join(" ")}`);return}weborityos();const p=expoCli(t),l=existsSync(path.join(t,COMPILED_FILE))?lastCompiledEnvironment(t):void 0,u=readManifest(t),d=[];try{for(const r of s){say(`ota: ${r.platform} to ${e}`);try{prebuild({appRoot:t,env:e,platform:r.platform}),rmSync(r.outDir,{recursive:!0,force:!0}),runInherit({file:process.execPath,args:[p,"export","--platform",r.platform,"--output-dir",r.outDir]},t,"expo export");const n=runDeploy(r,o,o.approvalId??null);upsertEntry(u,n),writeManifest(t,u)}catch(o){d.push(`${r.platform}: ${o.message}`)}}}finally{l&&l!==e&&prebuild({appRoot:t,env:l})}const f=u.entries.filter(o=>"pending-approval"===o.status&&o.environment===e);for(const o of f)say(`ota: ${o.platform} ${e}: exported, awaiting approval ${o.approvalId}; nothing uploaded`);f.length>0&&say("ota: once approved, run: npx mobile-tools ota deploy --complete"),d.length>0&&fail(`ota: failed:\n ${d.join("\n ")}`)};
|
package/lib/otaNative.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{existsSync,readFileSync}from"node:fs";import path from"node:path";import{fail}from"./output.js";export const COMPONENT_KEY={android:"OTA_ANDROID_COMPONENT_ID",ios:"OTA_IOS_COMPONENT_ID"};const CODE_SIGNING_KEY_ID="main",CODE_SIGNING_ALG="rsa-v1_5-sha256",WHOLE_NUMBER=/^[1-9][0-9]*$/,isSet=e=>null!=e&&""!==e;export const componentIdFor=(e,t)=>{const n=COMPONENT_KEY[t],i=e[n];return isSet(i)?WHOLE_NUMBER.test(String(i))?String(i):fail(`${n} must be a positive whole number, not ${JSON.stringify(i)}`):fail(`appsettings has no ${n}: the ${t} app has no OTA component to publish to`)};export const otaNativeSettings=(e,t,n,i)=>{const s=t.OTA_UPDATE_BASE_URL;if(!isSet(s)||!isSet(t[COMPONENT_KEY[i]]))return;const a=componentIdFor(t,i),o=t.OTA_PROJECT_ID;if(!isSet(o)||!WHOLE_NUMBER.test(String(o)))return fail(`OTA_PROJECT_ID must be the update server's project id (a positive whole number), not ${JSON.stringify(o)}`);const r=t.OTA_CODE_SIGNING_CERT;if("string"!=typeof r||""===r.trim())return fail("OTA_CODE_SIGNING_CERT must name the PEM certificate file, relative to the app folder");const p=path.join(e,r);if(!existsSync(p))return fail(`OTA_CODE_SIGNING_CERT ${r} not found in ${e}`);const c=readFileSync(p,"utf8").replace(/\r\n/g,"\n").trim();return c.startsWith("-----BEGIN CERTIFICATE-----")?{updateUrl:`${String(s).replace(/\/+$/,"")}/ota/manifest/${o}/${a}/${n}`,runtimeVersion:String(t.VERSION_INTERNAL),certificate:c}:fail(`${r} is not a PEM certificate`)};const escapeXml=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),escapeRegExp=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),eolOf=e=>e.includes("\r\n")?"\r\n":"\n";export const androidOtaMetaData=e=>[["expo.modules.updates.ENABLED","true"],["expo.modules.updates.EXPO_UPDATE_URL",e.updateUrl],["expo.modules.updates.EXPO_RUNTIME_VERSION",e.runtimeVersion],["expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH","ALWAYS"],["expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS","0"],["expo.modules.updates.CODE_SIGNING_CERTIFICATE",e.certificate],["expo.modules.updates.CODE_SIGNING_METADATA",JSON.stringify({keyid:"main",alg:CODE_SIGNING_ALG})]];const childIndent=(e,t)=>{const n=e.split(/\r?\n/).filter(e=>""!==e.trim()),i=/^[ \t]*/.exec(n[n.length-1]??"")?.[0]??"";return i.length>t.length?i:`${t} `};export const rewriteAndroidManifestOta=(e,t)=>{const n=eolOf(e);let i=e;const s=[];for(const[e,n]of androidOtaMetaData(t)){const t=escapeXml(n).replace(/\n/g," "),a=new RegExp(`(<meta-data\\s+android:name="${escapeRegExp(e)}"\\s+android:value=")[^"]*(")`);a.test(i)?i=i.replace(a,(e,n,i)=>`${n}${t}${i}`):s.push([e,t])}if(0===s.length)return i;const a=/^([ \t]*)<\/application>/m.exec(i);if(!a)return fail("AndroidManifest.xml has no </application> to add the expo-updates settings to");const o=childIndent(i.slice(0,a.index),a[1]),r=s.map(([e,t])=>`${o}<meta-data android:name="${e}" android:value="${t}" />`).join(n);return`${i.slice(0,a.index)}${r}${n}${i.slice(a.index)}`};export const iosOtaPlistKeys=e=>[["EXUpdatesEnabled",!0],["EXUpdatesURL",e.updateUrl],["EXUpdatesRuntimeVersion",e.runtimeVersion],["EXUpdatesCheckOnLaunch","ALWAYS"],["EXUpdatesLaunchWaitMs",0],["EXUpdatesCodeSigningCertificate",e.certificate],["EXUpdatesCodeSigningMetadata",{keyid:"main",alg:CODE_SIGNING_ALG}]];const plistValue=(e,t,n)=>{if("boolean"==typeof e)return e?"<true/>":"<false/>";if("number"==typeof e)return`<integer>${e}</integer>`;if("string"==typeof e)return`<string>${escapeXml(e).replace(/\n/g,n)}</string>`;const i=`${t}${t.includes("\t")?"\t":" "}`,s=Object.entries(e).map(([e,t])=>`${i}<key>${escapeXml(e)}</key>${n}${i}<string>${escapeXml(t)}</string>`).join(n);return`<dict>${n}${s}${n}${t}</dict>`},PLIST_VALUE=String.raw`(?:<(?:true|false)\/>|<string>[\s\S]*?<\/string>|<integer>[^<]*<\/integer>|<dict>[\s\S]*?<\/dict>|<dict\/>)`;export const rewriteExpoPlistOta=(e,t)=>{const n=eolOf(e);let i=e;const s=[];for(const[e,a]of iosOtaPlistKeys(t)){const t=new RegExp(`([ \\t]*)(<key>${escapeRegExp(e)}<\\/key>\\s*)${PLIST_VALUE}`).exec(i);if(t){const e=t[1];i=`${i.slice(0,t.index)}${e}${t[2]}${plistValue(a,e,n)}${i.slice(t.index+t[0].length)}`}else s.push([e,a])}if(0===s.length)return i;const a=/^([ \t]*)<\/dict>\s*<\/plist>/m.exec(i);if(!a)return fail("Expo.plist has no root </dict> to add the expo-updates settings to");const o=childIndent(i.slice(0,a.index),a[1]),r=s.map(([e,t])=>`${o}<key>${escapeXml(e)}</key>${n}${o}${plistValue(t,o,n)}`).join(n);return`${i.slice(0,a.index)}${r}${n}${i.slice(a.index)}`};
|
package/lib/prebuild.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{copyFileSync,existsSync,readFileSync,rmSync,writeFileSync}from"node:fs";import path from"node:path";import{applicationId,COMPILED_FILE,ENVIRONMENTS,gradleAppId,lastCompiledEnvironment,loadAppSettings,loadToolsConfig,resolveIosProjectName}from"./config.js";import{capture}from"./exec.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 o=eolOf(e),
|
|
1
|
+
import{copyFileSync,existsSync,readFileSync,rmSync,writeFileSync}from"node:fs";import path from"node:path";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 o=eolOf(e),i=e.split(o),s=new Map(Object.entries(t)),n=i.map(e=>{const t=/^\s*([A-Za-z0-9_.-]+)\s*[=:]/.exec(e);if(t&&s.has(t[1])){const e=t[1],o=s.get(e);return s.delete(e),`${e}=${o}`}return e});if(0===s.size)return n.join(o);const r=[...s].map(([e,t])=>`${e}=${t}`);return n.length>1&&""===n[n.length-1]?n.splice(n.length-1,0,...r):1===n.length&&""===n[0]?n.splice(0,1,...r):n.push(...r),n.join(o)};const pbxValue=e=>/^[A-Za-z0-9._/-]+$/.test(e)?e:`"${e.replace(/"/g,'\\"')}"`;export const rewritePbxproj=(e,t,o={})=>{let i=e;for(const[e,s]of Object.entries(t)){const t=new RegExp(`(\\s)${e} = [^;\\n]*;`,"g");t.test(i)||fail(`${e} not found in project.pbxproj`);const n=o.quote?.includes(e)?`"${s.replace(/"/g,'\\"')}"`:pbxValue(s);i=i.replace(t,(t,o)=>`${o}${e} = ${n};`)}return i};const escapeXml=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");export const rewriteInfoPlistDisplayName=(e,t)=>{const o=/(<key>CFBundleDisplayName<\/key>\s*<string>)[^<]*(<\/string>)/;return o.test(e)||fail("CFBundleDisplayName not found in Info.plist"),e.replace(o,(e,o,i)=>`${o}${escapeXml(t)}${i}`)};const ANDROID_MANIFEST="android/app/src/main/AndroidManifest.xml",expoPlistPath=e=>`ios/${e}/Supporting/Expo.plist`;export const prebuildOutputs=(e,t=!1)=>{const o=[COMPILED_FILE,"android/gradle.properties"];return t&&o.push(ANDROID_MANIFEST),e&&(o.push(`ios/${e}/Info.plist`,`ios/${e}.xcodeproj/project.pbxproj`,`ios/${e}/GoogleService-Info.plist`),t&&o.push(expoPlistPath(e))),o};export const buildCommit=(e,t)=>{const o=findOnPath("git",process.env,process.platform);if(!o)return"nogit";const i=capture({file:o,args:["rev-parse","--short","HEAD"]},e);if(0!==i.status)return"nogit";const s=capture({file:o,args:["status","--porcelain","--",".",...t.map(e=>`:(exclude)${e}`)]},e);return 0!==s.status?fail(`git status failed in ${e}: ${s.stderr.trim()}`):""===s.stdout.trim()?i.stdout.trim():`${i.stdout.trim()}-dirty`};const writeIfChanged=(e,t)=>(!existsSync(e)||readFileSync(e,"utf8")!==t)&&(writeFileSync(e,t,"utf8"),!0),report=(e,t,o)=>{say(`prebuild: ${o?"wrote":"unchanged"} ${path.relative(e,t)}`)};export const usesFirebase=e=>{const t=path.join(e,"package.json");if(!existsSync(t))return!1;const o=JSON.parse(readFileSync(t,"utf8"));return Boolean(o.dependencies?.["@react-native-firebase/app"])};export const stampAndroid=(e,t,o,i)=>{const s=path.join(e,"android","gradle.properties");existsSync(s)||fail(`android/gradle.properties not found in ${e}`),usesFirebase(e)&&!existsSync(path.join(e,"android","app","google-services.json"))&&fail("android/app/google-services.json is missing; the app uses @react-native-firebase, whose Gradle plugin needs it");const n=rewriteGradleProperties(readFileSync(s,"utf8"),{APP_NAME:String(t.APP_NAME),APP_ID:gradleAppId(t.APP_ID,o,i.flavors),VERSION_DISPLAY:String(t.VERSION_DISPLAY),VERSION_INTERNAL:String(t.VERSION_INTERNAL)});report(e,s,writeIfChanged(s,n));const r=otaNativeSettings(e,t,o,"android");if(r){const t=path.join(e,ANDROID_MANIFEST);existsSync(t)||fail(`${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,o,i,s)=>{const n=path.join(e,"ios",s),r=path.join(n,"Info.plist"),a=path.join(e,"ios",`${s}.xcodeproj`,"project.pbxproj");for(const t of[r,a])existsSync(t)||fail(`${path.relative(e,t)} not found: check "iosProjectName"`);const p=`AppIcon-${o}`,l=path.join(n,"Images.xcassets"),c=existsSync(path.join(l,`${p}.appiconset`)),d=ENVIRONMENTS.filter(e=>e!==o&&existsSync(path.join(l,`AppIcon-${e}.appiconset`)));!c&&d.length>0&&fail(`ios/${s}/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,o),MARKETING_VERSION:String(t.VERSION_DISPLAY),CURRENT_PROJECT_VERSION:String(t.VERSION_INTERNAL)};c&&(f.ASSETCATALOG_COMPILER_APPICON_NAME=p),report(e,a,writeIfChanged(a,rewritePbxproj(readFileSync(a,"utf8"),f,{quote:["PRODUCT_BUNDLE_IDENTIFIER"]}))),c||say("prebuild: no AppIcon-<Env> sets, app icon left as the project sets it");const u=path.join(n,"GoogleService-Info.plist"),I=path.join(e,i.firebasePlistDir,`GoogleService-Info.${o}.plist`);if(!existsSync(I)&&usesFirebase(e)&&fail(`${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 S=otaNativeSettings(e,t,o,"ios");if(S){const t=path.join(e,expoPlistPath(s));existsSync(t)||fail(`${expoPlistPath(s)} not found: add it to the Xcode project for expo-updates`),report(e,t,writeIfChanged(t,rewriteExpoPlistOta(readFileSync(t,"utf8"),S)))}};export const prebuild=e=>{const{appRoot:t,sameEnv:o,platform:i}=e;o&&e.env&&fail("pass --env or --same-env, not both",2);const s=o?lastCompiledEnvironment(t):e.env;if(!s)return fail("prebuild needs --env <Development|Staging|Production> or --same-env",2);const n=loadToolsConfig(t),r=resolveIosProjectName(t,n),a=loadAppSettings(t,s),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} (${s}, ${p.COMMIT})`);const l=existsSync(path.join(t,"android"));if(("android"===i||!i&&l)&&stampAndroid(t,p,s,n),"ios"===i||!i&&r){if(!r)return fail(`no ios/ folder in ${t}`);stampIos(t,p,s,n,r)}return p};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webority/mobile-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Dev-time CLI for Webority React Native apps: one prebuild, a dependency guard, a device-aware run
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Dev-time CLI for Webority React Native apps: one prebuild, a dependency guard, a device-aware run, per-environment launcher icons and over-the-air deploys, the same on Windows and macOS.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
7
7
|
"cli",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"!**/*.map"
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
|
+
"prepack": "node ../../scripts/package-guard-pack.mjs --output-dirs lib --allow-file ../../.webority/package-allow.json",
|
|
36
37
|
"build": "tsc -p tsconfig.build.json && node ../../scripts/minify-lib.mjs",
|
|
37
38
|
"clean": "rimraf lib",
|
|
38
39
|
"lint": "biome lint ./src",
|