@webority/mobile-tools 0.0.0-stage → 0.2.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/LICENSE.txt ADDED
@@ -0,0 +1,9 @@
1
+ Copyright (c) Webority Technologies. All rights reserved.
2
+
3
+ This software and its NuGet packages are the proprietary and confidential property
4
+ of Webority Technologies. They are published publicly only for the convenience of
5
+ Webority's own projects and internal consumption.
6
+
7
+ No license or right to use, copy, modify, merge, publish, distribute, sublicense,
8
+ or create derivative works is granted to any third party. Unauthorized use,
9
+ reproduction, or distribution is prohibited.
package/README.md CHANGED
@@ -1,3 +1,157 @@
1
- # Temporary Holding Version
1
+ # @webority/mobile-tools
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The dev-time CLI for Webority React Native apps. It covers four 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, and rendering per-environment launcher icons. It behaves the same on Windows (Git Bash, PowerShell, cmd) and macOS (zsh).
4
+
5
+ It is a devDependency. App code never imports it, and it adds nothing to the app bundle.
6
+
7
+ ```bash
8
+ npm install --save-dev --save-exact @webority/mobile-tools
9
+ ```
10
+
11
+ Node 24. Run every command from the app's root folder, where `appsettings.json` and `package.json` live.
12
+
13
+ ## Commands
14
+
15
+ ### `prebuild`
16
+
17
+ ```bash
18
+ npx mobile-tools prebuild --env <Development|Staging|Production> [--platform android|ios]
19
+ npx mobile-tools prebuild --same-env [--platform android|ios]
20
+ ```
21
+
22
+ - Merges `appsettings.json` with `appsettings.<Env>.json` into `appsettings.Compiled.json`.
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
+ - Fails, naming the key, when the merged settings lack `APP_NAME`, `APP_ID`, `ENVIRONMENT`, `BASE_URL`, `VERSION_DISPLAY` or `VERSION_INTERNAL`.
25
+ - 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
+ - **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.
27
+ - With flavors, `APP_ID` is the base id. Each flavor's `applicationIdSuffix` adds the suffix.
28
+ - Without flavors, `APP_ID` is the suffixed id.
29
+ - **iOS**:
30
+ - Sets `CFBundleDisplayName` in `ios/<name>/Info.plist`.
31
+ - In `project.pbxproj`, sets `PRODUCT_BUNDLE_IDENTIFIER` (suffixed), `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`. It also sets `ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-<Env>` when that icon set exists.
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
+ - Copies `<firebasePlistDir>/GoogleService-Info.<Env>.plist` to `ios/<name>/GoogleService-Info.plist`. Without that file for the environment, it removes the copy.
34
+ - Without `--platform`, it stamps every platform folder the app has.
35
+ - `--same-env` recompiles the environment last compiled, which refreshes the build stamp.
36
+ - Running it twice gives the same native files as running it once. A file whose content did not change is not rewritten.
37
+
38
+ | Environment | App id suffix | Android flavor |
39
+ |---|---|---|
40
+ | Development | `.development` | `development` |
41
+ | Staging | `.staging` | `staging` |
42
+ | Production | none | `production` |
43
+
44
+ ### `sync`
45
+
46
+ ```bash
47
+ npx mobile-tools sync --check
48
+ npx mobile-tools sync
49
+ npx mobile-tools sync --stamp
50
+ npx mobile-tools hooks install
51
+ ```
52
+
53
+ - `sync --check` hashes the lockfile (`package-lock.json` or `pnpm-lock.yaml`), the dependency fields of `package.json` and every file under `patches/`. It compares that hash with the stamp in `node_modules/.webority-install.json`.
54
+ - On macOS it also compares `ios/Podfile.lock` with `ios/Pods/Manifest.lock` when both exist.
55
+ - On a mismatch it prints the fix command and exits 1, well inside a second.
56
+ - `sync` installs from the lockfile and writes the stamp: `npm ci`, or `pnpm install --frozen-lockfile` when the app has `pnpm-lock.yaml`.
57
+ - When a package with an `android/` folder was added, removed or changed version, and the app has been built before, it runs `gradlew generateCodegenArtifactsFromSchema`. It then deletes `android/app/.cxx` and `android/app/build/generated/autolinking`.
58
+ - On macOS it runs `pod install` (through `bundle exec` when the app has a `Gemfile`) when the Pods are missing or stale.
59
+ - `sync --stamp` only records that `node_modules` matches the lockfile now. Run it from the app's `postinstall`, so a plain `npm ci` or `pnpm install` also leaves an accurate stamp.
60
+ - `hooks install` writes git `post-merge` and `post-checkout` hooks that run `sync --check` after every pull and branch switch.
61
+ - It honours `core.hooksPath`, and an app in a subfolder of the repo gets its own block.
62
+ - A hook it did not write is kept as `<hook>.chained` and still runs first, with its exit status passed through.
63
+
64
+ ### `run`
65
+
66
+ ```bash
67
+ npx mobile-tools run --env <Environment> [--platform android|ios] [--device <serial|name>]
68
+ ```
69
+
70
+ **Android** (the default):
71
+
72
+ 1. Runs `sync --check` and stops on a stale tree.
73
+ 2. Lists `adb` devices. A device that answers on several serials (MuMu shows one instance as `127.0.0.1:16416`, `127.0.0.1:7555` and `emulator-5556`) is shown once.
74
+ 3. Picks the device:
75
+ - the one named by `--device` (any of its serials, or its model name);
76
+ - otherwise the only device;
77
+ - otherwise asks, on a terminal. Without a terminal it fails with the list.
78
+ 4. Reads the device's ABI and runs `prebuild`.
79
+ 5. Builds `:app:assemble<Flavor>Debug` (or `:app:assembleDebug` without flavors) with `-PreactNativeArchitectures=<abi>`, so only one ABI compiles.
80
+ 6. Installs with `adb -s <serial> install -r` on that device only.
81
+ 7. Reuses a Metro already serving this app. Otherwise it starts one detached and prints the path of its log; when another project holds 8081, the new Metro takes the next free port.
82
+ 8. Sets `adb reverse tcp:8081` (and, for Development, the local API port). It then launches `<applicationId>/<namespace>.MainActivity`.
83
+
84
+ **iOS** (macOS only):
85
+
86
+ 1. Picks a booted simulator or a connected iPhone (`xcrun simctl`, `xcrun devicectl`).
87
+ 2. Runs `prebuild`.
88
+ 3. Builds with `xcodebuild -workspace ios/<name>.xcworkspace -scheme <name> -configuration Debug -destination id=<udid> -derivedDataPath ios/build ONLY_ACTIVE_ARCH=YES`.
89
+ 4. Installs and launches the app.
90
+ 5. Metro must be on 8081, where the iOS app looks.
91
+
92
+ The build log goes to `node_modules/.cache/mobile-tools/`. On a failure only the root error is printed, with the log path. That root error is Metro's `Unable to resolve module` when present, otherwise Gradle's first "What went wrong" block or the compiler's `error:` lines.
93
+
94
+ Every program is started by absolute path with an argument list, never through a shell string:
95
+
96
+ - `android/gradlew` on macOS. `android\gradlew.bat` on Windows, through `cmd.exe` named by path, because Node will not start a `.bat` file any other way.
97
+ - `adb` from `ANDROID_HOME/platform-tools`, falling back to `PATH`.
98
+ - `/usr/bin/xcrun` and `/usr/bin/xcodebuild`.
99
+
100
+ `NoDefaultCurrentDirectoryInExePath` is removed from every child's environment. On any other OS an iOS command stops with one line.
101
+
102
+ ### `icons`
103
+
104
+ ```bash
105
+ npx mobile-tools icons [--source <png|svg>]
106
+ ```
107
+
108
+ Renders every environment's launcher icons from one square source: an SVG, or a PNG of at least 1024 px.
109
+
110
+ - **Android**, per flavor, into `android/app/src/<flavor>/res`:
111
+ - `ic_launcher` and `ic_launcher_round` in `mipmap-mdpi` through `mipmap-xxxhdpi`;
112
+ - the adaptive `ic_launcher_foreground`, with `mipmap-anydpi-v26` XML;
113
+ - `ic_launcher_background`, taken from the source's corner colour.
114
+ - Launcher icons of the same name in another format (`.webp`) are removed, because Android rejects the duplicate.
115
+ - Skipped when `flavors` is `false`.
116
+ - **iOS**: `ios/<name>/Images.xcassets/AppIcon-<Env>.appiconset`, a single 1024 px universal icon with no alpha channel. Xcode derives the other sizes. Prebuild selects the set.
117
+ - 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.
118
+ - The same source gives the same bytes on the same machine.
119
+
120
+ ## `mobile-tools.config.json`
121
+
122
+ Optional, at the app root. Every key is optional. An unknown key or a wrong type fails, naming the key.
123
+
124
+ ```json
125
+ {
126
+ "iosProjectName": "FieldApp",
127
+ "flavors": true,
128
+ "localApiPort": 62430,
129
+ "iconSource": "assets/app-icon.svg",
130
+ "firebasePlistDir": "ios/firebase"
131
+ }
132
+ ```
133
+
134
+ | Key | Default | Meaning |
135
+ |---|---|---|
136
+ | `iosProjectName` | the single `ios/*.xcodeproj` | Xcode project, scheme and workspace name |
137
+ | `flavors` | `true` | Android product flavors `development`, `staging`, `production` in dimension `environment` |
138
+ | `localApiPort` | the port of `appsettings.Development.json`'s `BASE_URL` when it is `localhost` | port `run` reverses for a Development build |
139
+ | `iconSource` | none | source image for `icons` when `--source` is not given |
140
+ | `firebasePlistDir` | `ios/firebase` | folder holding `GoogleService-Info.<Env>.plist` |
141
+
142
+ ## Typical `package.json` scripts
143
+
144
+ ```json
145
+ {
146
+ "scripts": {
147
+ "android": "mobile-tools run --env Development",
148
+ "android:staging": "mobile-tools run --env Staging",
149
+ "ios": "mobile-tools run --env Development --platform ios",
150
+ "prebuild": "mobile-tools prebuild --same-env",
151
+ "sync": "mobile-tools sync",
152
+ "postinstall": "mobile-tools sync --stamp"
153
+ }
154
+ }
155
+ ```
156
+
157
+ `postinstall` keeps the stamp accurate after any install. After a pull, `npx mobile-tools sync` is still the one command to run: it also resets native codegen when a native package moved, which a plain install does not.
package/lib/cli.js ADDED
@@ -0,0 +1,2 @@
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,o,n=!1)=>{try{const{values:s,positionals:t}=parseArgs({args:e,options:o,allowPositionals:n,strict:!0});return{values:s,positionals:t}}catch(e){return fail(`${e.message}\n\n${USAGE}`,2)}},envOption=e=>"string"==typeof e?parseEnvironment(e):void 0,platformOption=e=>{if(void 0!==e)return"android"!==e&&"ios"!==e?fail(`--platform must be android or ios, not ${String(e)}`,2):e},main=async e=>{const[o,...n]=e,s=process.cwd();switch(o){case"prebuild":{const{values:e}=parse(n,{env:{type:"string"},"same-env":{type:"boolean"},platform:{type:"string"}});return void prebuild({appRoot:s,env:envOption(e.env),sameEnv:!0===e["same-env"],platform:platformOption(e.platform)})}case"sync":{const{values:e}=parse(n,{check:{type:"boolean"},stamp:{type:"boolean"}});if(e.check&&e.stamp&&fail("use --check or --stamp, not both",2),e.stamp)return writeStamp(s),void say("sync --stamp: recorded node_modules as matching the lockfile");if(e.check){const e=checkSync(s,process.platform);return e.length>0&&fail(e.join("\n")),void say("sync --check: node_modules matches the lockfile")}return void sync(s,process.platform)}case"hooks":{const{positionals:e}=parse(n,{},!0);return"install"===e[0]&&1===e.length||fail(`usage: mobile-tools hooks install\n\n${USAGE}`,2),void installHooks(s)}case"run":{const{values:e}=parse(n,{env:{type:"string"},platform:{type:"string"},device:{type:"string"}}),o=envOption(e.env)??fail("run needs --env <Development|Staging|Production>",2);return void await run({appRoot:s,env:o,platform:platformOption(e.platform)??"android",device:"string"==typeof e.device?e.device:void 0})}case"icons":{const{values:e}=parse(n,{source:{type:"string"}});return void await icons({appRoot:s,source:"string"==typeof e.source?e.source:void 0})}case void 0:case"help":case"--help":case"-h":return void say(USAGE);default:fail(`unknown command "${o}"\n\n${USAGE}`,2)}};main(process.argv.slice(2)).catch(e=>{e instanceof CliError&&(warn(`mobile-tools: ${e.message}`),process.exit(e.exitCode)),warn(e instanceof Error?e.stack??e.message:String(e)),process.exit(1)});
package/lib/config.js ADDED
@@ -0,0 +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=o=>ENVIRONMENTS.find(t=>t.toLowerCase()===o.toLowerCase())??fail(`unknown environment "${o}": use ${ENVIRONMENTS.join(", ")}`,2);export const envSuffix=o=>SUFFIX[o];export const flavorOf=o=>o.toLowerCase();export const applicationId=(o,t)=>`${o}${envSuffix(t)}`;export const gradleAppId=(o,t,e)=>e?o:applicationId(o,t);export const assembleTask=(o,t)=>{if(!t)return"assembleDebug";const e=flavorOf(o);return`assemble${e[0].toUpperCase()}${e.slice(1)}Debug`};export const apkDir=(o,t,e)=>e?path.join(o,"android","app","build","outputs","apk",flavorOf(t),"debug"):path.join(o,"android","app","build","outputs","apk","debug");const readJson=o=>{try{return JSON.parse(readFileSync(o,"utf8"))}catch(t){return fail(`cannot read ${o}: ${t.message}`)}},isRecord=o=>"object"==typeof o&&null!==o&&!Array.isArray(o),CONFIG_KEYS={iosProjectName:"string",flavors:"boolean",localApiPort:"port",iconSource:"string",firebasePlistDir:"string"};export const parseToolsConfig=(o,t=CONFIG_FILE)=>{if(!isRecord(o))return fail(`${t} must hold a JSON object`);for(const[e,n]of Object.entries(o)){const o=Object.hasOwn(CONFIG_KEYS,e)?CONFIG_KEYS[e]:void 0;if(!o)return fail(`${t}: unknown key "${e}" (known: ${Object.keys(CONFIG_KEYS).join(", ")})`);if(!("port"===o?Number.isInteger(n)&&n>0&&n<65536:"string"===o?"string"==typeof n&&""!==n.trim():typeof n===o)){fail(`${t}: "${e}" must be ${{port:"a port number",string:"a non-empty string",boolean:"true or false"}[o]}`)}}return{iosProjectName:o.iosProjectName,flavors:o.flavors??!0,localApiPort:o.localApiPort,iconSource:o.iconSource,firebasePlistDir:o.firebasePlistDir??path.join("ios","firebase")}};export const loadToolsConfig=o=>{const t=path.join(o,CONFIG_FILE);return parseToolsConfig(existsSync(t)?readJson(t):{})};export const resolveIosProjectName=(o,t)=>{if(t.iosProjectName)return t.iosProjectName;const e=path.join(o,"ios");if(!existsSync(e))return;const n=readdirSync(e).filter(o=>o.endsWith(".xcodeproj"));return 1!==n.length?fail(`found ${n.length} .xcodeproj folders in ios/: set "iosProjectName" in ${CONFIG_FILE}`):n[0].slice(0,-10)};export const mergeAppSettings=(o,t,e)=>{if(!isRecord(o))return fail("appsettings.json must hold a JSON object");if(!isRecord(t))return fail(`appsettings.${e}.json must hold a JSON object`);const n={...o,...t},s=REQUIRED_SETTINGS.filter(o=>void 0===n[o]||null===n[o]||""===n[o]);return s.length>0&&fail(`appsettings (${e}) is missing required key(s): ${s.join(", ")}`),n.ENVIRONMENT!==e&&fail(`appsettings.${e}.json sets ENVIRONMENT to ${JSON.stringify(n.ENVIRONMENT)}; it must be "${e}"`),void 0!==n.APP_ID_SUFFIX&&n.APP_ID_SUFFIX!==envSuffix(e)&&fail(`appsettings (${e}) sets APP_ID_SUFFIX to ${JSON.stringify(n.APP_ID_SUFFIX)}; ${e} builds always use ${JSON.stringify(envSuffix(e))}. Remove the key or correct it.`),/^[1-9][0-9]*$/.test(String(n.VERSION_INTERNAL))||fail(`VERSION_INTERNAL must be a positive whole number, not ${JSON.stringify(n.VERSION_INTERNAL)}`),/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/.test(String(n.APP_ID))||fail(`APP_ID ${JSON.stringify(n.APP_ID)} is not a reverse-domain id such as com.example.app`),n};export const loadAppSettings=(o,t)=>{const e=path.join(o,"appsettings.json"),n=path.join(o,`appsettings.${t}.json`);return existsSync(e)||fail(`no appsettings.json in ${o}: run mobile-tools from the app's root folder`),existsSync(n)||fail(`appsettings.${t}.json not found: every buildable environment declares its own file`),mergeAppSettings(readJson(e),readJson(n),t)};export const lastCompiledEnvironment=o=>{const t=path.join(o,COMPILED_FILE);if(!existsSync(t))return fail(`--same-env: no ${COMPILED_FILE} yet; pass --env <Environment> once`);const e=readJson(t);return isRecord(e)&&"string"==typeof e.ENVIRONMENT?parseEnvironment(e.ENVIRONMENT):fail(`--same-env: ${COMPILED_FILE} has no ENVIRONMENT`)};export const localApiPort=(o,t)=>{if(void 0!==t.localApiPort)return t.localApiPort;let e;try{e=new URL(o)}catch{return}return"localhost"===e.hostname||"127.0.0.1"===e.hostname?""!==e.port?Number(e.port):"https:"===e.protocol?443:80:void 0};
package/lib/devices.js ADDED
@@ -0,0 +1 @@
1
+ import{createInterface}from"node:readline/promises";import{capture}from"./exec.js";import{fail}from"./output.js";export const parseAdbDevices=e=>e.split(/\r?\n/).map(e=>e.trim()).filter(e=>""!==e&&!e.startsWith("List of devices")&&!e.startsWith("*")).map(e=>{const[t,r=""]=e.split(/\s+/);return{serial:t,state:r}});const serialRank=e=>e.startsWith("emulator-")?1:e.includes(":")?2:0;export const dedupeDevices=e=>{const t=new Map;for(const r of e){const e=""===r.serialNo?`serial:${r.serial}`:`${r.serialNo}|${r.androidId}`;t.set(e,[...t.get(e)??[],r])}return[...t.values()].map(e=>{const t=e.map(e=>e.serial).sort((e,t)=>serialRank(e)-serialRank(t)||e.localeCompare(t)),r=e.find(e=>e.serial===t[0]);return{serial:t[0],serials:t,model:r.model,abi:r.abi}}).sort((e,t)=>e.serial.localeCompare(t.serial))};const PROBE_SCRIPT="getprop ro.serialno; getprop ro.product.model; getprop ro.product.cpu.abi; settings get secure android_id";export const probeDevice=(e,t,r)=>{const s=capture({file:e,args:["-s",t,"shell",PROBE_SCRIPT]},r);if(0!==s.status)return fail(`adb could not read ${t}: ${s.stderr.trim()||s.stdout.trim()}`);const[i="",o="",a="",n=""]=s.stdout.split(/\r?\n/).map(e=>e.trim());return{serial:t,serialNo:i,model:o,abi:a,androidId:n}};export const listAndroidDevices=(e,t)=>{const r=capture({file:e,args:["devices"]},t);if(0!==r.status)return fail(`adb devices failed: ${r.stderr.trim()}`);const s=parseAdbDevices(r.stdout),i=s.filter(e=>"device"!==e.state),o=s.filter(e=>"device"===e.state);if(0===o.length){const e=i.map(e=>`${e.serial} is ${e.state}`).join("; ");return fail(`no Android device ready${e?` (${e})`:""}. Connect a phone with USB debugging, start an emulator, or adb connect host:port.`)}return dedupeDevices(o.map(r=>probeDevice(e,r.serial,t)))};export const describeDevice=e=>`${e.model||"unknown model"} (${e.serial}, ${e.abi})`;export const findDevice=(e,t)=>e.find(e=>e.serials.includes(t))??e.find(e=>e.model.toLowerCase()===t.toLowerCase());export const pickDevice=async(e,t,r,s)=>{const i=e.map((e,t)=>` ${t+1}. ${r(e)}`).join("\n");if(t)return findDevice(e,t)??fail(`no device matches --device ${t}. Connected:\n${i}`);if(1===e.length)return e[0];if(!s)return fail(`several devices are connected; pass --device <serial|name>:\n${i}`);const o=createInterface({input:process.stdin,output:process.stdout});try{const t=await o.question(`Several devices are connected:\n${i}\nRun on which? [1-${e.length}] `);return e[Number(t.trim())-1]??fail(`no device numbered ${t.trim()}`)}finally{o.close()}};
package/lib/exec.js ADDED
@@ -0,0 +1 @@
1
+ import{spawn,spawnSync}from"node:child_process";import{closeSync,mkdirSync,openSync}from"node:fs";import path from"node:path";import{fail}from"./output.js";import{childEnv}from"./platform.js";export const capture=(e,r)=>{const n=spawnSync(e.file,e.args,{cwd:r,env:childEnv(process.env),encoding:"utf8",maxBuffer:67108864,windowsHide:!0,windowsVerbatimArguments:e.windowsVerbatimArguments});return{status:n.error?-1:n.status??-1,stdout:n.stdout??"",stderr:n.error?n.error.message:n.stderr??""}};export const runInherit=(e,r,n)=>{const s=spawnSync(e.file,e.args,{cwd:r,env:childEnv(process.env),stdio:"inherit",windowsHide:!0,windowsVerbatimArguments:e.windowsVerbatimArguments});s.error&&fail(`${n} could not start: ${s.error.message}`),0!==s.status&&fail(`${n} failed (exit ${s.status??"signal"})`)};export const runToLog=(e,r,n)=>{mkdirSync(path.dirname(n),{recursive:!0});const s=openSync(n,"w");return new Promise(n=>{const o=spawn(e.file,e.args,{cwd:r,env:childEnv(process.env),stdio:["ignore",s,s],windowsHide:!0,windowsVerbatimArguments:e.windowsVerbatimArguments});o.on("error",()=>{closeSync(s),n(-1)}),o.on("close",e=>{closeSync(s),n(e??-1)})})};export const startDetached=(e,r,n)=>{mkdirSync(path.dirname(n),{recursive:!0});const s=openSync(n,"a");spawn(e.file,e.args,{cwd:r,env:childEnv(process.env),detached:!0,stdio:["ignore",s,s],windowsHide:!0}).unref(),closeSync(s)};
package/lib/hooks.js ADDED
@@ -0,0 +1 @@
1
+ import{chmodSync,existsSync,mkdirSync,readFileSync,renameSync,writeFileSync}from"node:fs";import path from"node:path";import{capture}from"./exec.js";import{fail,say}from"./output.js";import{findOnPath}from"./platform.js";export const HOOK_NAMES=["post-merge","post-checkout"];const OWNER_LINE="# Written by @webority/mobile-tools (hooks install).",EXIT_LINE="exit $status",startMarker=o=>`# >>> mobile-tools ${o}`,endMarker=o=>`# <<< mobile-tools ${o}`;export const hookBlock=o=>{const t="node_modules/@webority/mobile-tools/lib/cli.js";return[startMarker(o),`if [ -f "${o}/${t}" ]; then`,` (cd "${o}" && node ${t} sync --check) || true`,"fi",endMarker(o)].join("\n")};export const chainedName=o=>`${o}.chained`;export const isOwnHook=o=>o.includes(OWNER_LINE);export const composeHook=(o,t,e)=>{const n=hookBlock(e);if(void 0!==t&&isOwnHook(t)){const o=t.indexOf(startMarker(e)),s=t.indexOf(endMarker(e));if(o>=0&&s>o)return`${t.slice(0,o)}${n}${t.slice(s+endMarker(e).length)}`;const i=t.lastIndexOf(EXIT_LINE);return i>=0?`${t.slice(0,i)}${n}\n${t.slice(i)}`:`${t.trimEnd()}\n${n}\n`}return["#!/bin/sh",OWNER_LINE,"status=0",`chained="$(dirname "$0")/${chainedName(o)}"`,'if [ -x "$chained" ]; then "$chained" "$@" || status=$?; fi',n,EXIT_LINE,""].join("\n")};const git=(o,t)=>{const e=findOnPath("git",process.env,process.platform)??fail("git not found on PATH"),n=capture({file:e,args:t},o);return 0!==n.status?fail(`git ${t.join(" ")} failed: ${n.stderr.trim()}`):n.stdout.trim()};export const hooksDir=o=>{const t=git(o,["rev-parse","--show-toplevel"]),e=findOnPath("git",process.env,process.platform)??fail("git not found on PATH"),n=capture({file:e,args:["config","--get","core.hooksPath"]},o),s=0===n.status?n.stdout.trim():"";return""!==s?{dir:path.resolve(t,s),topLevel:t}:{dir:path.resolve(o,git(o,["rev-parse","--git-path","hooks"])),topLevel:t}};export const installHooks=o=>{const{dir:t,topLevel:e}=hooksDir(o),n=path.relative(e,o).split(path.sep).join("/")||".";mkdirSync(t,{recursive:!0});for(const o of HOOK_NAMES){const s=path.join(t,o);let i=existsSync(s)?readFileSync(s,"utf8"):void 0;if(void 0!==i&&!isOwnHook(i)){const e=path.join(t,chainedName(o));existsSync(e)&&fail(`${s} is not ours and ${e} already exists; merge them by hand, then rerun`),renameSync(s,e),chmodSync(e,493),say(`hooks: kept your ${o} hook as ${chainedName(o)}; it still runs first`),i=void 0}writeFileSync(s,composeHook(o,i,n),"utf8"),chmodSync(s,493),say(`hooks: ${path.relative(e,s).split(path.sep).join("/")} runs sync --check for ${n}`)}};
package/lib/iconArt.js ADDED
@@ -0,0 +1 @@
1
+ export const RIBBONS={Development:{label:"DEV",color:"#2E7D32"},Staging:{label:"STAGING",color:"#EF6C00"},Production:void 0};const r=o=>(Math.round(100*o)/100).toString();export const ribbonSvg=(o,n,t)=>{const{x:e,y:i,size:s}=n,a=.38*s,c=.66*s,l=[[e+a,i+s],[e+s,i+a],[e+s,i+c],[e+c,i+s]].map(([o,n])=>`${r(o)},${r(n)}`).join(" "),d=e+.76*s,p=i+.76*s,x=(t.label.length>4?.105:.13)*s;return[`<svg xmlns="http://www.w3.org/2000/svg" width="${o}" height="${o}" viewBox="0 0 ${o} ${o}">`,`<polygon points="${l}" fill="${t.color}"/>`,`<text x="${r(d)}" y="${r(p)}" transform="rotate(-45 ${r(d)} ${r(p)})" fill="#FFFFFF"`,` font-family="sans-serif" font-weight="700" font-size="${r(x)}" text-anchor="middle"`,` dominant-baseline="central">${t.label}</text>`,"</svg>"].join("")};export const circleMaskSvg=o=>`<svg xmlns="http://www.w3.org/2000/svg" width="${o}" height="${o}"><circle cx="${o/2}" cy="${o/2}" r="${o/2}" fill="#000"/></svg>`;export const DENSITIES=[["mdpi",1],["hdpi",1.5],["xhdpi",2],["xxhdpi",3],["xxxhdpi",4]];export const LEGACY_DP=48;export const ADAPTIVE_DP=108;export const ADAPTIVE_VISIBLE_DP=72;export const ADAPTIVE_ICON_XML=['<?xml version="1.0" encoding="utf-8"?>','<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">',' <background android:drawable="@color/ic_launcher_background"/>',' <foreground android:drawable="@mipmap/ic_launcher_foreground"/>',"</adaptive-icon>",""].join("\n");export const backgroundColorXml=o=>['<?xml version="1.0" encoding="utf-8"?>',"<resources>",` <color name="ic_launcher_background">${o}</color>`,"</resources>",""].join("\n");export const IOS_ICON_FILE="AppIcon-1024.png";export const iosContentsJson=()=>`${JSON.stringify({images:[{filename:IOS_ICON_FILE,idiom:"universal",platform:"ios",size:"1024x1024"}],info:{author:"xcode",version:1}},null,2)}\n`;export const toHex=(o,n,t)=>`#${[o,n,t].map(o=>o.toString(16).padStart(2,"0")).join("").toUpperCase()}`;
package/lib/icons.js ADDED
@@ -0,0 +1 @@
1
+ import{existsSync,mkdirSync,readdirSync,readFileSync,rmSync,writeFileSync}from"node:fs";import path from"node:path";import{ENVIRONMENTS,flavorOf,loadToolsConfig,resolveIosProjectName}from"./config.js";import{ADAPTIVE_DP,ADAPTIVE_ICON_XML,ADAPTIVE_VISIBLE_DP,backgroundColorXml,circleMaskSvg,DENSITIES,IOS_ICON_FILE,iosContentsJson,LEGACY_DP,RIBBONS,ribbonSvg,toHex}from"./iconArt.js";import{fail,say}from"./output.js";const MASTER=1024,PNG={compressionLevel:9,adaptiveFiltering:!1,palette:!1,force:!0},ICON_NAMES=["ic_launcher","ic_launcher_round","ic_launcher_foreground"],removeOtherFormats=o=>{if(existsSync(o))for(const r of readdirSync(o)){const{name:e,ext:n}=path.parse(r);ICON_NAMES.includes(e)&&".png"!==n&&rmSync(path.join(o,r))}},write=(o,r)=>{mkdirSync(path.dirname(o),{recursive:!0}),writeFileSync(o,r)},assertNoColorClash=(o,r)=>{if(existsSync(o))for(const e of readdirSync(o))e!==r&&e.endsWith(".xml")&&readFileSync(path.join(o,e),"utf8").includes('name="ic_launcher_background"')&&fail(`${path.join(o,e)} already defines ic_launcher_background; remove it there`)},loadMaster=async(o,r)=>{const e=await o(r).metadata();if("png"!==e.format&&"svg"!==e.format)return fail(`icon source must be a PNG or an SVG, not ${e.format??"unknown"}: ${r}`);const n=e.width??0,t=e.height??0;if(n!==t)return fail(`icon source must be square, ${r} is ${n}x${t}`);if("png"===e.format&&n<1024)return fail(`a PNG icon source must be at least 1024x1024, ${r} is ${n}x${t}`);return o(r,"svg"===e.format?{density:73728/n}:{}).resize(1024,1024,{fit:"contain",background:{r:0,g:0,b:0,alpha:0}}).ensureAlpha().png(PNG).toBuffer()},cornerColor=async(o,r)=>{const{data:e}=await o(r).raw().toBuffer({resolveWithObject:!0});return 0===e[3]?"#FFFFFF":toHex(e[0],e[1],e[2])},withRibbon=async(o,r,e,n)=>{const t=RIBBONS[e];return t?o(r).composite([{input:Buffer.from(ribbonSvg(1024,n,t))}]).png(PNG).toBuffer():r},renderAndroid=async(o,r,e,n,t)=>{const a=path.join(r,"android","app","src",flavorOf(e),"res"),i=await withRibbon(o,n,e,{x:0,y:0,size:1024}),s=Math.round(1024*ADAPTIVE_VISIBLE_DP/ADAPTIVE_DP),c=Math.round((1024-s)/2),f=await o(n).resize(s,s).png(PNG).toBuffer(),p=await o({create:{width:1024,height:1024,channels:4,background:{r:0,g:0,b:0,alpha:0}}}).composite([{input:f,left:c,top:c}]).png(PNG).toBuffer(),l=await withRibbon(o,p,e,{x:c,y:c,size:s});for(const[r,e]of DENSITIES){const n=path.join(a,`mipmap-${r}`);removeOtherFormats(n);const t=Math.round(LEGACY_DP*e),s=Math.round(ADAPTIVE_DP*e);write(path.join(n,"ic_launcher.png"),await o(i).resize(t,t).png(PNG).toBuffer()),write(path.join(n,"ic_launcher_round.png"),await o(await o(i).resize(t,t).png(PNG).toBuffer()).composite([{input:Buffer.from(circleMaskSvg(t)),blend:"dest-in"}]).png(PNG).toBuffer()),write(path.join(n,"ic_launcher_foreground.png"),await o(l).resize(s,s).png(PNG).toBuffer())}const u=path.join(a,"mipmap-anydpi-v26");removeOtherFormats(u),write(path.join(u,"ic_launcher.xml"),ADAPTIVE_ICON_XML),write(path.join(u,"ic_launcher_round.xml"),ADAPTIVE_ICON_XML);const d=path.join(a,"values");assertNoColorClash(d,"ic_launcher_background.xml"),write(path.join(d,"ic_launcher_background.xml"),backgroundColorXml(t)),say(`icons: ${path.relative(r,a)}`)},renderIos=async(o,r,e,n,t,a)=>{const i=path.join(r,"ios",e,"Images.xcassets");existsSync(i)||fail(`${path.relative(r,i)} not found: check "iosProjectName"`);const s=path.join(i,`AppIcon-${n}.appiconset`);rmSync(s,{recursive:!0,force:!0});const c=await withRibbon(o,t,n,{x:0,y:0,size:1024});write(path.join(s,IOS_ICON_FILE),await o(c).flatten({background:a}).png(PNG).toBuffer()),write(path.join(s,"Contents.json"),iosContentsJson()),say(`icons: ${path.relative(r,s)}`)};export const icons=async o=>{const{appRoot:r}=o,e=loadToolsConfig(r),n=o.source??e.iconSource??fail('no icon source: pass --source <png|svg> or set "iconSource" in mobile-tools.config.json',2),t=path.resolve(r,n);existsSync(t)||fail(`icon source not found: ${t}`);const{default:a}=await(import("sharp")),i=await loadMaster(a,t),s=await cornerColor(a,i),c=resolveIosProjectName(r,e),f=existsSync(path.join(r,"android"));f&&!e.flavors&&say('icons: Android skipped, "flavors" is false: per-environment icons live in flavor source sets');for(const o of ENVIRONMENTS)f&&e.flavors&&await renderAndroid(a,r,o,i,s),c&&await renderIos(a,r,c,o,i,s)};
package/lib/ios.js ADDED
@@ -0,0 +1 @@
1
+ import{mkdtempSync,readFileSync,rmSync}from"node:fs";import{tmpdir}from"node:os";import path from"node:path";import{capture}from"./exec.js";import{fail}from"./output.js";import{XCRUN}from"./platform.js";export const parseSimctlBooted=e=>{const t=JSON.parse(e);return Object.values(t.devices??{}).flat().filter(e=>"Booted"===e.state).map(e=>({udid:e.udid,name:e.name,kind:"simulator",serials:[e.udid],model:e.name}))};export const parseDevicectl=e=>{const t=JSON.parse(e);return(t.result?.devices??[]).filter(e=>"iOS"===e.hardwareProperties?.platform&&"paired"===e.connectionProperties?.pairingState&&"unavailable"!==e.connectionProperties?.tunnelState&&e.hardwareProperties?.udid).map(e=>{const t=e.hardwareProperties?.udid,i=e.deviceProperties?.name??t;return{udid:t,name:i,kind:"device",serials:[t,e.identifier??t],model:i}})};export const listAppleDevices=e=>{const t=capture({file:XCRUN,args:["simctl","list","-j","devices","booted"]},e);if(0!==t.status)return fail(`xcrun simctl failed: ${t.stderr.trim()}`);const i=parseSimctlBooted(t.stdout),r=mkdtempSync(path.join(tmpdir(),"mobile-tools-")),o=path.join(r,"devices.json");try{0===capture({file:XCRUN,args:["devicectl","list","devices","--json-output",o]},e).status&&i.push(...parseDevicectl(readFileSync(o,"utf8")))}finally{rmSync(r,{recursive:!0,force:!0})}return 0===i.length?fail("no booted simulator or connected iPhone: boot one with xcrun simctl boot <name>"):i};export const describeAppleDevice=e=>`${e.name} (${e.kind}, ${e.udid})`;export const xcodebuildArgs=(e,t)=>["-workspace",path.join("ios",`${e}.xcworkspace`),"-scheme",e,"-configuration","Debug","-destination",`id=${t}`,"-derivedDataPath",path.join("ios","build"),"ONLY_ACTIVE_ARCH=YES","build"];
package/lib/metro.js ADDED
@@ -0,0 +1 @@
1
+ import{createRequire}from"node:module";import{createServer}from"node:net";import path from"node:path";import{startDetached}from"./exec.js";import{fail,say}from"./output.js";export const DEFAULT_METRO_PORT=8081;const samePath=(t,e)=>{const norm=t=>{const e=path.resolve(t).replace(/[\\/]+$/,"");return"win32"===process.platform?e.toLowerCase():e};return norm(t)===norm(e)};export const classifyStatus=(t,e,r)=>{if(!t.includes("packager-status:running"))return"other";const o=e??/project-root:(.+)/.exec(t)?.[1]?.trim();return o&&samePath(o,r)?"ours":"other"};const portIsFree=t=>new Promise(e=>{const r=createServer();r.once("error",()=>e(!1)),r.once("listening",()=>r.close(()=>e(!0))),r.listen(t)});export const probeMetro=async(t,e)=>{try{const r=await fetch(`http://localhost:${t}/status`,{signal:AbortSignal.timeout(1e3)});return classifyStatus(await r.text(),r.headers.get("X-React-Native-Project-Root"),e)}catch{return await portIsFree(t)?"free":"other"}};const waitForMetro=async(t,e,r)=>{const o=Date.now()+12e4;for(;Date.now()<o;){if("ours"===await probeMetro(t,e))return;await new Promise(t=>setTimeout(t,500))}fail(`Metro did not answer on port ${t} within 2 minutes; see ${r}`)};export const ensureMetro=async(t,e,{fixedPort:r})=>{const o=r?8081:8100;for(let r=8081;r<=o;r++){const o=await probeMetro(r,t);if("ours"===o)return say(`metro: reusing the server on port ${r}`),r;if("free"===o){let o;try{o=createRequire(path.join(t,"package.json")).resolve("react-native/cli.js")}catch{return fail("react-native is not installed in this app; run npx mobile-tools sync")}return startDetached({file:process.execPath,args:[o,"start","--port",String(r)]},t,e),say(`metro: started on port ${r}, log ${e}`),await waitForMetro(r,t,e),r}}return fail(r?"port 8081 is held by another server (the iOS app always looks there); stop it and rerun":"ports 8081-8100 are all held by other servers; stop one and rerun")};
package/lib/output.js ADDED
@@ -0,0 +1 @@
1
+ export class CliError extends Error{exitCode;constructor(r,t=1){super(r),this.name="CliError",this.exitCode=t}}export const fail=(r,t=1)=>{throw new CliError(r,t)};export const say=r=>{process.stdout.write(`${r}\n`)};export const warn=r=>{process.stderr.write(`${r}\n`)};
@@ -0,0 +1 @@
1
+ import{existsSync}from"node:fs";import path from"node:path";import{fail}from"./output.js";export const pathFor=n=>"win32"===n?path.win32:path.posix;const readEnv=(n,t)=>{const o=Object.keys(n).find(n=>n.toLowerCase()===t.toLowerCase());return void 0===o?void 0:n[o]};export const childEnv=n=>{const t={};for(const[o,e]of Object.entries(n))"nodefaultcurrentdirectoryinexepath"!==o.toLowerCase()&&(t[o]=e);return t};export const findOnPath=(n,t,o,e=existsSync)=>{const i=pathFor(o),r=readEnv(t,"PATH")??"",s="win32"===o?`${n}.exe`:n;for(const n of r.split("win32"===o?";":":")){if(""===n)continue;const t=i.join(n,s);if(e(t))return t}};export const adbPath=(n,t,o=existsSync)=>{const e=pathFor(t),i=readEnv(n,"ANDROID_HOME");if(i){const n=e.join(i,"platform-tools","win32"===t?"adb.exe":"adb");if(o(n))return n}return findOnPath("adb",n,t,o)??fail("adb not found: set ANDROID_HOME to the Android SDK (adb lives in its platform-tools folder) or put adb on PATH.")};const SAFE_GRADLE_ARG=/^[A-Za-z0-9_:.=-]+$/;export const gradleInvocation=(n,t,o,e)=>{for(const n of t)SAFE_GRADLE_ARG.test(n)||fail(`refusing Gradle argument ${JSON.stringify(n)}: only plain task names and -Pkey=value`);const i=pathFor(o);if("win32"!==o)return{file:i.join(n,"gradlew"),args:t};const r=i.join(n,"gradlew.bat");/["%]/.test(r)&&fail(`the app path cannot hold " or %: ${r}`);const s=readEnv(e,"SystemRoot")??"C:\\Windows";return{file:readEnv(e,"ComSpec")??i.join(s,"System32","cmd.exe"),args:["/d","/s","/c",`""${r}" ${t.join(" ")}"`],windowsVerbatimArguments:!0}};export const requireDarwin=(n,t)=>{"darwin"!==n&&fail(`${t} needs macOS: xcodebuild and xcrun exist only there.`)};export const XCRUN="/usr/bin/xcrun";export const XCODEBUILD="/usr/bin/xcodebuild";export const npmInvocation=(n,t,o,e,i=existsSync)=>{const r=pathFor(o),s=readEnv(t,"npm_execpath"),a=[s&&/npm-cli\.c?js$/.test(s)?s:void 0,"win32"===o?r.join(r.dirname(e),"node_modules","npm","bin","npm-cli.js"):r.join(r.dirname(e),"..","lib","node_modules","npm","bin","npm-cli.js")].find(n=>void 0!==n&&i(n));return a?{file:e,args:[a,...n]}:fail(`npm not found beside ${e}; install Node 24 with its bundled npm.`)};export const pnpmInvocation=(n,t,o,e,i=existsSync)=>{const r=readEnv(t,"npm_execpath");if(r&&/pnpm\.c?js$/.test(r)&&i(r))return{file:e,args:[r,...n]};const s=findOnPath("pnpm",t,o,i);return s?{file:s,args:n}:fail("pnpm not found on PATH (on Windows, pnpm.exe); install pnpm, or run through pnpm exec.")};
@@ -0,0 +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),n=e.split(o),r=new Map(Object.entries(t)),i=n.map(e=>{const t=/^\s*([A-Za-z0-9_.-]+)\s*[=:]/.exec(e);if(t&&r.has(t[1])){const e=t[1],o=r.get(e);return r.delete(e),`${e}=${o}`}return e});if(0===r.size)return i.join(o);const s=[...r].map(([e,t])=>`${e}=${t}`);return i.length>1&&""===i[i.length-1]?i.splice(i.length-1,0,...s):1===i.length&&""===i[0]?i.splice(0,1,...s):i.push(...s),i.join(o)};const pbxValue=e=>/^[A-Za-z0-9._/-]+$/.test(e)?e:`"${e.replace(/"/g,'\\"')}"`;export const rewritePbxproj=(e,t,o={})=>{let n=e;for(const[e,r]of Object.entries(t)){const t=new RegExp(`(\\s)${e} = [^;\\n]*;`,"g");t.test(n)||fail(`${e} not found in project.pbxproj`);const i=o.quote?.includes(e)?`"${r.replace(/"/g,'\\"')}"`:pbxValue(r);n=n.replace(t,(t,o)=>`${o}${e} = ${i};`)}return n};const escapeXml=e=>e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");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,n)=>`${o}${escapeXml(t)}${n}`)};export const prebuildOutputs=e=>{const t=[COMPILED_FILE,"android/gradle.properties"];return e&&t.push(`ios/${e}/Info.plist`,`ios/${e}.xcodeproj/project.pbxproj`,`ios/${e}/GoogleService-Info.plist`),t};export const buildCommit=(e,t)=>{const o=findOnPath("git",process.env,process.platform);if(!o)return"nogit";const n=capture({file:o,args:["rev-parse","--short","HEAD"]},e);if(0!==n.status)return"nogit";const r=capture({file:o,args:["status","--porcelain","--",".",...t.map(e=>`:(exclude)${e}`)]},e);return 0!==r.status?fail(`git status failed in ${e}: ${r.stderr.trim()}`):""===r.stdout.trim()?n.stdout.trim():`${n.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 stampAndroid=(e,t,o,n)=>{const r=path.join(e,"android","gradle.properties");existsSync(r)||fail(`android/gradle.properties not found in ${e}`);const i=rewriteGradleProperties(readFileSync(r,"utf8"),{APP_NAME:String(t.APP_NAME),APP_ID:gradleAppId(t.APP_ID,o,n.flavors),VERSION_DISPLAY:String(t.VERSION_DISPLAY),VERSION_INTERNAL:String(t.VERSION_INTERNAL)});report(e,r,writeIfChanged(r,i))};export const stampIos=(e,t,o,n,r)=>{const i=path.join(e,"ios",r),s=path.join(i,"Info.plist"),p=path.join(e,"ios",`${r}.xcodeproj`,"project.pbxproj");for(const t of[s,p])existsSync(t)||fail(`${path.relative(e,t)} not found: check "iosProjectName"`);const a=`AppIcon-${o}`,l=path.join(i,"Images.xcassets"),c=existsSync(path.join(l,`${a}.appiconset`)),d=ENVIRONMENTS.filter(e=>e!==o&&existsSync(path.join(l,`AppIcon-${e}.appiconset`)));!c&&d.length>0&&fail(`ios/${r}/Images.xcassets has AppIcon-${d[0]} but no ${a}: run mobile-tools icons`),report(e,s,writeIfChanged(s,rewriteInfoPlistDisplayName(readFileSync(s,"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=a),report(e,p,writeIfChanged(p,rewritePbxproj(readFileSync(p,"utf8"),f,{quote:["PRODUCT_BUNDLE_IDENTIFIER"]}))),c||say("prebuild: no AppIcon-<Env> sets, app icon left as the project sets it");const I=path.join(i,"GoogleService-Info.plist"),u=path.join(e,n.firebasePlistDir,`GoogleService-Info.${o}.plist`);if(existsSync(u)){const t=!existsSync(I)||!readFileSync(u).equals(readFileSync(I));t&&copyFileSync(u,I),report(e,I,t)}else existsSync(I)&&(rmSync(I),say(`prebuild: removed ${path.relative(e,I)}, no ${path.relative(e,u)}`))};export const prebuild=e=>{const{appRoot:t,sameEnv:o,platform:n}=e;o&&e.env&&fail("pass --env or --same-env, not both",2);const r=o?lastCompiledEnvironment(t):e.env;if(!r)return fail("prebuild needs --env <Development|Staging|Production> or --same-env",2);const i=loadToolsConfig(t),s=resolveIosProjectName(t,i),p={...loadAppSettings(t,r),COMMIT:buildCommit(t,prebuildOutputs(s)),BUILD_TIME:(new Date).toISOString()};writeFileSync(path.join(t,COMPILED_FILE),`${JSON.stringify(p,null,2)}\n`,"utf8"),say(`prebuild: wrote ${COMPILED_FILE} (${r}, ${p.COMMIT})`);const a=existsSync(path.join(t,"android"));if(("android"===n||!n&&a)&&stampAndroid(t,p,r,i),"ios"===n||!n&&s){if(!s)return fail(`no ios/ folder in ${t}`);stampIos(t,p,r,i,s)}return p};
@@ -0,0 +1 @@
1
+ const MAX_LINES=40,blockFrom=(t,n,r)=>{const e=[];for(let o=n;o<t.length&&e.length<40&&!(o>n&&r(t[o]));o++)e.push(t[o]);for(;e.length>0&&""===e[e.length-1].trim();)e.pop();return e};export const extractRootError=t=>{const n=t.split(/\r?\n/),r=n.findIndex(t=>t.includes("Unable to resolve module"));if(r>=0)return blockFrom(n,r,t=>""===t.trim()).join("\n");const e=n.findIndex(t=>"* What went wrong:"===t.trim());if(e>=0)return blockFrom(n,e+1,t=>/^\* [A-Z]/.test(t)).join("\n");const o=[...new Set(n.filter(t=>/(^|\s)error:/.test(t)).map(t=>t.trim()))];return o.length>0?o.slice(0,10).join("\n"):n.filter(t=>""!==t.trim()).slice(-20).join("\n")};
package/lib/run.js ADDED
@@ -0,0 +1 @@
1
+ import{existsSync,readdirSync,readFileSync}from"node:fs";import path from"node:path";import{apkDir,applicationId,assembleTask,loadAppSettings,loadToolsConfig,localApiPort,resolveIosProjectName}from"./config.js";import{describeDevice,listAndroidDevices,pickDevice}from"./devices.js";import{capture,runToLog}from"./exec.js";import{describeAppleDevice,listAppleDevices,xcodebuildArgs}from"./ios.js";import{ensureMetro}from"./metro.js";import{CliError,fail,say}from"./output.js";import{adbPath,gradleInvocation,requireDarwin,XCODEBUILD,XCRUN}from"./platform.js";import{prebuild}from"./prebuild.js";import{extractRootError}from"./rootError.js";import{checkSync}from"./sync.js";export const readNamespace=e=>/^\s*namespace\s*=?\s*["']([^"']+)["']/m.exec(e)?.[1];export const ABI_PATTERN=/^(arm64-v8a|armeabi-v7a|x86_64|x86)$/;export const chooseApk=(e,i)=>{const o=e.filter(e=>e.endsWith(".apk")).sort();return o.find(e=>e.includes(i))??o.find(e=>e.includes("universal"))??o[0]};export const logDir=e=>path.join(e,"node_modules",".cache","mobile-tools");const stamp=()=>(new Date).toISOString().replace(/[:.]/g,"-"),guardSync=e=>{const i=checkSync(e,process.platform);i.length>0&&fail(i.join("\n"))},buildOrFail=async(e,i,o,r)=>{say(`${r}: building, log ${o}`);const a=Date.now();if(0!==await runToLog(e,i,o)){const e=existsSync(o)?extractRootError(readFileSync(o,"utf8")):"";throw new CliError(`${r} failed:\n${e}\nFull log: ${o}`)}say(`${r}: built in ${Math.round((Date.now()-a)/1e3)} s`)},adbOrFail=(e,i,o)=>{const r=capture({file:e,args:i},o);return 0!==r.status?fail(`adb ${i.join(" ")} failed: ${(r.stderr||r.stdout).trim()}`):r.stdout},runAndroid=async e=>{const{appRoot:i,env:o}=e,r=loadToolsConfig(i),a=path.join(i,"android"),s=path.join(a,"app","build.gradle");existsSync(s)||fail(`android/app/build.gradle not found in ${i}`);const t=readNamespace(readFileSync(s,"utf8"))??fail("android/app/build.gradle declares no namespace"),n=adbPath(process.env,process.platform),c=listAndroidDevices(n,i),l=await pickDevice(c,e.device,describeDevice,!0===process.stdin.isTTY);ABI_PATTERN.test(l.abi)||fail(`${describeDevice(l)} reports an ABI this build cannot target: "${l.abi}"`),say(`run: ${describeDevice(l)}`);const d=prebuild({appRoot:i,env:o,platform:"android"}),p=assembleTask(o,r.flavors),u=logDir(i);await buildOrFail(gradleInvocation(a,[`:app:${p}`,`-PreactNativeArchitectures=${l.abi}`],process.platform,process.env),a,path.join(u,`gradle-${o}-${stamp()}.log`),`gradle ${p}`);const m=apkDir(i,o,r.flavors),f=existsSync(m)?chooseApk(readdirSync(m),l.abi):void 0;f||fail(`the build produced no APK in ${m}`),say(`run: installing ${f} on ${l.serial}`),adbOrFail(n,["-s",l.serial,"install","-r",path.join(m,f)],i);const v=await ensureMetro(i,path.join(u,"metro.log"),{fixedPort:!1});if(adbOrFail(n,["-s",l.serial,"reverse","tcp:8081",`tcp:${v}`],i),"Development"===o){const e=localApiPort(String(loadAppSettings(i,"Development").BASE_URL),r);void 0!==e&&(adbOrFail(n,["-s",l.serial,"reverse",`tcp:${e}`,`tcp:${e}`],i),say(`run: device localhost:${e} reaches this machine's API`))}const b=applicationId(d.APP_ID,o);adbOrFail(n,["-s",l.serial,"shell","am","start","-n",`${b}/${t}.MainActivity`],i),say(`run: launched ${b} on ${l.model||l.serial}`)},runIos=async e=>{requireDarwin(process.platform,"run --platform ios");const{appRoot:i,env:o}=e,r=loadToolsConfig(i),a=resolveIosProjectName(i,r)??fail(`no ios/ folder in ${i}`),s=path.join(i,"ios",`${a}.xcworkspace`);existsSync(s)||fail(`ios/${a}.xcworkspace not found: run npx mobile-tools sync (it runs pod install)`);const t=await pickDevice(listAppleDevices(i),e.device,describeAppleDevice,!0===process.stdin.isTTY);say(`run: ${describeAppleDevice(t)}`);const n=prebuild({appRoot:i,env:o,platform:"ios"}),c=logDir(i);await buildOrFail({file:XCODEBUILD,args:xcodebuildArgs(a,t.udid)},i,path.join(c,`xcodebuild-${o}-${stamp()}.log`),"xcodebuild");const l=path.join(i,"ios","build","Build","Products","simulator"===t.kind?"Debug-iphonesimulator":"Debug-iphoneos"),d=existsSync(l)?readdirSync(l).filter(e=>e.endsWith(".app")):[],p=d.find(e=>e===`${a}.app`)??d[0];p||fail(`the build produced no .app in ${l}`);const u=path.join(l,p),m=applicationId(n.APP_ID,o);await ensureMetro(i,path.join(c,"metro.log"),{fixedPort:!0});const f="simulator"===t.kind?[["simctl","install",t.udid,u],["simctl","launch",t.udid,m]]:[["devicectl","device","install","app","--device",t.udid,u],["devicectl","device","process","launch","--device",t.udid,m]];for(const e of f){const o=capture({file:XCRUN,args:e},i);0!==o.status&&fail(`xcrun ${e.slice(0,3).join(" ")} failed: ${(o.stderr||o.stdout).trim()}`)}say(`run: launched ${m} on ${t.name}`)};export const run=async e=>{guardSync(e.appRoot),"ios"===e.platform?await runIos(e):await runAndroid(e)};
package/lib/sync.js ADDED
@@ -0,0 +1 @@
1
+ import{createHash}from"node:crypto";import{existsSync,readdirSync,readFileSync,rmSync,statSync,writeFileSync}from"node:fs";import path from"node:path";import{runInherit}from"./exec.js";import{fail,say}from"./output.js";import{findOnPath,gradleInvocation,npmInvocation,pnpmInvocation}from"./platform.js";export const STAMP_FILE=path.join("node_modules",".webority-install.json");const INSTALL_FIELDS=["dependencies","devDependencies","optionalDependencies","peerDependencies","bundleDependencies","overrides","resolutions","pnpm","workspaces"];export const lockfileOf=e=>{const n=path.join(e,"pnpm-lock.yaml"),t=path.join(e,"package-lock.json");return existsSync(n)&&existsSync(t)?fail("both pnpm-lock.yaml and package-lock.json exist: delete the one not in use"):existsSync(n)?{manager:"pnpm",file:n}:existsSync(t)?{manager:"npm",file:t}:fail(`no package-lock.json or pnpm-lock.yaml in ${e}`)};const filesUnder=(e,n="")=>readdirSync(e,{withFileTypes:!0}).flatMap(t=>{const o=""===n?t.name:`${n}/${t.name}`;return t.isDirectory()?filesUnder(path.join(e,t.name),o):[o]}).sort();export const installHash=e=>{const{file:n}=lockfileOf(e),t=JSON.parse(readFileSync(path.join(e,"package.json"),"utf8")),o=createHash("sha256");o.update(`lockfile:${path.basename(n)}\0`),o.update(readFileSync(n)),o.update("\0package.json\0"),o.update(JSON.stringify(INSTALL_FIELDS.map(e=>[e,t[e]??null])));const s=path.join(e,"patches");if(existsSync(s))for(const e of filesUnder(s))o.update(`\0patches/${e}\0`),o.update(readFileSync(path.join(s,...e.split("/"))));return o.digest("hex")};export const readStamp=e=>{const n=path.join(e,STAMP_FILE);if(existsSync(n))try{return JSON.parse(readFileSync(n,"utf8"))}catch{return}};const podFix=e=>existsSync(path.join(e,"Gemfile"))?"cd ios && bundle exec pod install":"cd ios && pod install";export const podsStale=e=>{const n=path.join(e,"ios","Podfile.lock"),t=path.join(e,"ios","Pods","Manifest.lock");return!(!existsSync(n)||!existsSync(t))&&!readFileSync(n).equals(readFileSync(t))};export const checkSync=(e,n)=>{const t=[],o=readStamp(e),{file:s}=lockfileOf(e);return o?o.hash!==installHash(e)&&t.push(`node_modules is stale: ${path.basename(s)}, package.json or patches/ changed since the last install. Fix: npx mobile-tools sync`):t.push("node_modules has no install stamp (never installed through mobile-tools). Fix: npx mobile-tools sync"),"darwin"===n&&podsStale(e)&&t.push(`ios/Pods is stale: ios/Podfile.lock differs from ios/Pods/Manifest.lock. Fix: ${podFix(e)}`),t};export const nativePackages=e=>{const n=path.join(e,"node_modules");if(!existsSync(n))return{};const t=[];for(const e of readdirSync(n,{withFileTypes:!0}))if(!e.name.startsWith(".")&&e.isDirectory())if(e.name.startsWith("@"))for(const o of readdirSync(path.join(n,e.name),{withFileTypes:!0}))o.isDirectory()&&t.push(path.join(n,e.name,o.name));else t.push(path.join(n,e.name));const o={};for(const e of t){const n=path.join(e,"android"),t=path.join(e,"package.json");if(existsSync(n)&&statSync(n).isDirectory()&&existsSync(t))try{const e=JSON.parse(readFileSync(t,"utf8"));e.name&&e.version&&(o[e.name]=e.version)}catch{}}return o};export const changedNativePackages=(e,n)=>[...new Set([...Object.keys(e),...Object.keys(n)])].filter(t=>e[t]!==n[t]).sort();export const writeStamp=e=>{const{manager:n}=lockfileOf(e),t={hash:installHash(e),manager:n,nativePackages:nativePackages(e)};return writeFileSync(path.join(e,STAMP_FILE),`${JSON.stringify(t,null,2)}\n`,"utf8"),t};export const installInvocation=(e,n)=>"pnpm"===e?pnpmInvocation(["install","--frozen-lockfile"],process.env,n,process.execPath):npmInvocation(["ci"],process.env,n,process.execPath);export const sync=(e,n)=>{const{manager:t}=lockfileOf(e),o=readStamp(e)?.nativePackages??nativePackages(e);say("sync: "+("pnpm"===t?"pnpm install --frozen-lockfile":"npm ci")),runInherit(installInvocation(t,n),e,`${t} install`);const s=writeStamp(e).nativePackages,a=changedNativePackages(o,s),i=path.join(e,"android"),c=existsSync(path.join(i,"app",".cxx"))||existsSync(path.join(i,"app","build"));if(a.length>0&&c){say(`sync: native packages changed (${a.join(", ")}): regenerating codegen`),runInherit(gradleInvocation(i,["generateCodegenArtifactsFromSchema"],n,process.env),i,"gradlew generateCodegenArtifactsFromSchema");for(const n of[path.join(i,"app",".cxx"),path.join(i,"app","build","generated","autolinking")])rmSync(n,{recursive:!0,force:!0}),say(`sync: removed ${path.relative(e,n)}`)}if("darwin"===n&&existsSync(path.join(e,"ios","Podfile"))){const t=path.join(e,"ios","Podfile.lock"),o=path.join(e,"ios","Pods","Manifest.lock");if(!existsSync(t)||!existsSync(o)||podsStale(e)){const t=existsSync(path.join(e,"Gemfile")),o=findOnPath(t?"bundle":"pod",process.env,n);o||fail(`${t?"bundle":"pod"} not found on PATH; install CocoaPods, then ${podFix(e)}`),runInherit({file:o,args:t?["exec","pod","install"]:["install"]},path.join(e,"ios"),"pod install")}}say("sync: done")};
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@webority/mobile-tools",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
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
+ "keywords": [
6
+ "react-native",
7
+ "cli",
8
+ "prebuild",
9
+ "android",
10
+ "ios",
11
+ "icons",
12
+ "webority"
13
+ ],
14
+ "homepage": "https://www.webority.com",
15
+ "license": "SEE LICENSE IN LICENSE.txt",
16
+ "author": {
17
+ "name": "Webority Technologies",
18
+ "email": "contact@webority.com",
19
+ "url": "https://www.webority.com"
20
+ },
21
+ "type": "module",
22
+ "bin": {
23
+ "mobile-tools": "lib/cli.js"
24
+ },
25
+ "exports": {
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib",
30
+ "!**/__tests__",
31
+ "!**/*.test.*",
32
+ "!**/*.spec.*",
33
+ "!**/*.map"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json && node ../../scripts/minify-lib.mjs",
37
+ "clean": "rimraf lib",
38
+ "lint": "biome lint ./src",
39
+ "lint:fix": "biome lint --write ./src",
40
+ "test": "jest",
41
+ "typecheck": "tsc --noEmit"
42
+ },
43
+ "dependencies": {
44
+ "sharp": "^0.35.5"
45
+ },
46
+ "engines": {
47
+ "node": ">=24 <25"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "registry": "https://registry.npmjs.org/"
52
+ }
53
+ }