@webority/mobile-tools 0.0.0-stage → 0.1.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 +9 -0
- package/README.md +156 -2
- package/lib/cli.js +135 -0
- package/lib/config.js +188 -0
- package/lib/devices.js +95 -0
- package/lib/exec.js +73 -0
- package/lib/hooks.js +96 -0
- package/lib/iconArt.js +74 -0
- package/lib/icons.js +162 -0
- package/lib/ios.js +78 -0
- package/lib/metro.js +82 -0
- package/lib/output.js +18 -0
- package/lib/platform.js +122 -0
- package/lib/prebuild.js +206 -0
- package/lib/rootError.js +41 -0
- package/lib/run.js +140 -0
- package/lib/sync.js +205 -0
- package/package.json +51 -4
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
|
-
#
|
|
1
|
+
# @webority/mobile-tools
|
|
2
2
|
|
|
3
|
-
|
|
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,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { parseEnvironment } from './config.js';
|
|
4
|
+
import { installHooks } from './hooks.js';
|
|
5
|
+
import { icons } from './icons.js';
|
|
6
|
+
import { CliError, fail, say, warn } from './output.js';
|
|
7
|
+
import { prebuild } from './prebuild.js';
|
|
8
|
+
import { run } from './run.js';
|
|
9
|
+
import { checkSync, sync, writeStamp } from './sync.js';
|
|
10
|
+
const USAGE = `mobile-tools <command> [options] (run from the app's root folder)
|
|
11
|
+
|
|
12
|
+
prebuild --env <Development|Staging|Production> [--platform android|ios]
|
|
13
|
+
prebuild --same-env [--platform android|ios]
|
|
14
|
+
Compile appsettings into appsettings.Compiled.json and stamp the native projects.
|
|
15
|
+
|
|
16
|
+
sync [--check | --stamp]
|
|
17
|
+
--check: exit 1 with the fix command when node_modules (or ios/Pods) is stale.
|
|
18
|
+
--stamp: record that node_modules matches the lockfile now (run it from postinstall).
|
|
19
|
+
Without either: install from the lockfile, stamp, and reset native codegen when needed.
|
|
20
|
+
|
|
21
|
+
hooks install
|
|
22
|
+
Add git post-merge and post-checkout hooks that run sync --check.
|
|
23
|
+
|
|
24
|
+
run --env <Environment> [--platform android|ios] [--device <serial|name>]
|
|
25
|
+
Build for one device's ABI, install it there, reuse or start Metro, and launch.
|
|
26
|
+
|
|
27
|
+
icons [--source <png|svg>]
|
|
28
|
+
Render per-environment launcher icons for Android flavors and iOS AppIcon-<Env> sets.`;
|
|
29
|
+
const parse = (args, options, allowPositionals = false) => {
|
|
30
|
+
try {
|
|
31
|
+
const { values, positionals } = parseArgs({ args, options, allowPositionals, strict: true });
|
|
32
|
+
return { values: values, positionals };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
return fail(`${error.message}\n\n${USAGE}`, 2);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const envOption = (value) => typeof value === 'string' ? parseEnvironment(value) : undefined;
|
|
39
|
+
const platformOption = (value) => {
|
|
40
|
+
if (value === undefined) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (value !== 'android' && value !== 'ios') {
|
|
44
|
+
return fail(`--platform must be android or ios, not ${String(value)}`, 2);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
};
|
|
48
|
+
const main = async (argv) => {
|
|
49
|
+
const [command, ...rest] = argv;
|
|
50
|
+
const appRoot = process.cwd();
|
|
51
|
+
switch (command) {
|
|
52
|
+
case 'prebuild': {
|
|
53
|
+
const { values } = parse(rest, {
|
|
54
|
+
env: { type: 'string' },
|
|
55
|
+
'same-env': { type: 'boolean' },
|
|
56
|
+
platform: { type: 'string' }
|
|
57
|
+
});
|
|
58
|
+
prebuild({
|
|
59
|
+
appRoot,
|
|
60
|
+
env: envOption(values.env),
|
|
61
|
+
sameEnv: values['same-env'] === true,
|
|
62
|
+
platform: platformOption(values.platform)
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
case 'sync': {
|
|
67
|
+
const { values } = parse(rest, { check: { type: 'boolean' }, stamp: { type: 'boolean' } });
|
|
68
|
+
if (values.check && values.stamp) {
|
|
69
|
+
fail('use --check or --stamp, not both', 2);
|
|
70
|
+
}
|
|
71
|
+
if (values.stamp) {
|
|
72
|
+
writeStamp(appRoot);
|
|
73
|
+
say('sync --stamp: recorded node_modules as matching the lockfile');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (values.check) {
|
|
77
|
+
const problems = checkSync(appRoot, process.platform);
|
|
78
|
+
if (problems.length > 0) {
|
|
79
|
+
fail(problems.join('\n'));
|
|
80
|
+
}
|
|
81
|
+
say('sync --check: node_modules matches the lockfile');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
sync(appRoot, process.platform);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case 'hooks': {
|
|
88
|
+
const { positionals } = parse(rest, {}, true);
|
|
89
|
+
if (positionals[0] !== 'install' || positionals.length !== 1) {
|
|
90
|
+
fail(`usage: mobile-tools hooks install\n\n${USAGE}`, 2);
|
|
91
|
+
}
|
|
92
|
+
installHooks(appRoot);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
case 'run': {
|
|
96
|
+
const { values } = parse(rest, {
|
|
97
|
+
env: { type: 'string' },
|
|
98
|
+
platform: { type: 'string' },
|
|
99
|
+
device: { type: 'string' }
|
|
100
|
+
});
|
|
101
|
+
const env = envOption(values.env) ?? fail('run needs --env <Development|Staging|Production>', 2);
|
|
102
|
+
await run({
|
|
103
|
+
appRoot,
|
|
104
|
+
env,
|
|
105
|
+
platform: platformOption(values.platform) ?? 'android',
|
|
106
|
+
device: typeof values.device === 'string' ? values.device : undefined
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
case 'icons': {
|
|
111
|
+
const { values } = parse(rest, { source: { type: 'string' } });
|
|
112
|
+
await icons({
|
|
113
|
+
appRoot,
|
|
114
|
+
source: typeof values.source === 'string' ? values.source : undefined
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case undefined:
|
|
119
|
+
case 'help':
|
|
120
|
+
case '--help':
|
|
121
|
+
case '-h':
|
|
122
|
+
say(USAGE);
|
|
123
|
+
return;
|
|
124
|
+
default:
|
|
125
|
+
fail(`unknown command "${command}"\n\n${USAGE}`, 2);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
129
|
+
if (error instanceof CliError) {
|
|
130
|
+
warn(`mobile-tools: ${error.message}`);
|
|
131
|
+
process.exit(error.exitCode);
|
|
132
|
+
}
|
|
133
|
+
warn(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
134
|
+
process.exit(1);
|
|
135
|
+
});
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fail } from './output.js';
|
|
4
|
+
export const ENVIRONMENTS = ['Development', 'Staging', 'Production'];
|
|
5
|
+
/** The only app id suffixes the fleet allows; Production carries none. */
|
|
6
|
+
const SUFFIX = {
|
|
7
|
+
Development: '.development',
|
|
8
|
+
Staging: '.staging',
|
|
9
|
+
Production: ''
|
|
10
|
+
};
|
|
11
|
+
export const CONFIG_FILE = 'mobile-tools.config.json';
|
|
12
|
+
export const COMPILED_FILE = 'appsettings.Compiled.json';
|
|
13
|
+
/** The keys every app's merged appsettings must carry before a build. */
|
|
14
|
+
export const REQUIRED_SETTINGS = [
|
|
15
|
+
'APP_NAME',
|
|
16
|
+
'APP_ID',
|
|
17
|
+
'ENVIRONMENT',
|
|
18
|
+
'BASE_URL',
|
|
19
|
+
'VERSION_DISPLAY',
|
|
20
|
+
'VERSION_INTERNAL'
|
|
21
|
+
];
|
|
22
|
+
export const parseEnvironment = (value) => {
|
|
23
|
+
const match = ENVIRONMENTS.find((env) => env.toLowerCase() === value.toLowerCase());
|
|
24
|
+
return match ?? fail(`unknown environment "${value}": use ${ENVIRONMENTS.join(', ')}`, 2);
|
|
25
|
+
};
|
|
26
|
+
export const envSuffix = (env) => SUFFIX[env];
|
|
27
|
+
/** Android flavor names are the environment in lower case: development, staging, production. */
|
|
28
|
+
export const flavorOf = (env) => env.toLowerCase();
|
|
29
|
+
/** The id the app installs under: the base id plus the environment's suffix. */
|
|
30
|
+
export const applicationId = (baseId, env) => `${baseId}${envSuffix(env)}`;
|
|
31
|
+
/**
|
|
32
|
+
* The APP_ID written to gradle.properties. With flavors the flavor's applicationIdSuffix adds
|
|
33
|
+
* the suffix, so Gradle gets the base; without flavors nothing else would add it.
|
|
34
|
+
*/
|
|
35
|
+
export const gradleAppId = (baseId, env, flavors) => flavors ? baseId : applicationId(baseId, env);
|
|
36
|
+
/** The debug assemble task for an environment. */
|
|
37
|
+
export const assembleTask = (env, flavors) => {
|
|
38
|
+
if (!flavors) {
|
|
39
|
+
return 'assembleDebug';
|
|
40
|
+
}
|
|
41
|
+
const flavor = flavorOf(env);
|
|
42
|
+
return `assemble${flavor[0].toUpperCase()}${flavor.slice(1)}Debug`;
|
|
43
|
+
};
|
|
44
|
+
/** Where Gradle puts that task's APKs. */
|
|
45
|
+
export const apkDir = (appRoot, env, flavors) => flavors
|
|
46
|
+
? path.join(appRoot, 'android', 'app', 'build', 'outputs', 'apk', flavorOf(env), 'debug')
|
|
47
|
+
: path.join(appRoot, 'android', 'app', 'build', 'outputs', 'apk', 'debug');
|
|
48
|
+
const readJson = (file) => {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return fail(`cannot read ${file}: ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
+
const CONFIG_KEYS = {
|
|
58
|
+
iosProjectName: 'string',
|
|
59
|
+
flavors: 'boolean',
|
|
60
|
+
localApiPort: 'port',
|
|
61
|
+
iconSource: 'string',
|
|
62
|
+
firebasePlistDir: 'string'
|
|
63
|
+
};
|
|
64
|
+
/** Validates mobile-tools.config.json; every unknown key or wrong type fails, naming the key. */
|
|
65
|
+
export const parseToolsConfig = (raw, source = CONFIG_FILE) => {
|
|
66
|
+
if (!isRecord(raw)) {
|
|
67
|
+
return fail(`${source} must hold a JSON object`);
|
|
68
|
+
}
|
|
69
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
70
|
+
const kind = Object.hasOwn(CONFIG_KEYS, key) ? CONFIG_KEYS[key] : undefined;
|
|
71
|
+
if (!kind) {
|
|
72
|
+
return fail(`${source}: unknown key "${key}" (known: ${Object.keys(CONFIG_KEYS).join(', ')})`);
|
|
73
|
+
}
|
|
74
|
+
const ok = kind === 'port'
|
|
75
|
+
? Number.isInteger(value) && value > 0 && value < 65536
|
|
76
|
+
: kind === 'string'
|
|
77
|
+
? typeof value === 'string' && value.trim() !== ''
|
|
78
|
+
: typeof value === kind;
|
|
79
|
+
if (!ok) {
|
|
80
|
+
const expected = {
|
|
81
|
+
port: 'a port number',
|
|
82
|
+
string: 'a non-empty string',
|
|
83
|
+
boolean: 'true or false'
|
|
84
|
+
};
|
|
85
|
+
fail(`${source}: "${key}" must be ${expected[kind]}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
iosProjectName: raw.iosProjectName,
|
|
90
|
+
flavors: raw.flavors ?? true,
|
|
91
|
+
localApiPort: raw.localApiPort,
|
|
92
|
+
iconSource: raw.iconSource,
|
|
93
|
+
firebasePlistDir: raw.firebasePlistDir ?? path.join('ios', 'firebase')
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
export const loadToolsConfig = (appRoot) => {
|
|
97
|
+
const file = path.join(appRoot, CONFIG_FILE);
|
|
98
|
+
return parseToolsConfig(existsSync(file) ? readJson(file) : {});
|
|
99
|
+
};
|
|
100
|
+
/** The configured iOS project name, or the one `*.xcodeproj` under ios/; undefined when the app has no ios/. */
|
|
101
|
+
export const resolveIosProjectName = (appRoot, config) => {
|
|
102
|
+
if (config.iosProjectName) {
|
|
103
|
+
return config.iosProjectName;
|
|
104
|
+
}
|
|
105
|
+
const iosDir = path.join(appRoot, 'ios');
|
|
106
|
+
if (!existsSync(iosDir)) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const projects = readdirSync(iosDir).filter((name) => name.endsWith('.xcodeproj'));
|
|
110
|
+
if (projects.length !== 1) {
|
|
111
|
+
return fail(`found ${projects.length} .xcodeproj folders in ios/: set "iosProjectName" in ${CONFIG_FILE}`);
|
|
112
|
+
}
|
|
113
|
+
return projects[0].slice(0, -'.xcodeproj'.length);
|
|
114
|
+
};
|
|
115
|
+
/** Base settings overlaid by one environment's file, then validated. Pure apart from the inputs. */
|
|
116
|
+
export const mergeAppSettings = (base, overlay, env) => {
|
|
117
|
+
if (!isRecord(base)) {
|
|
118
|
+
return fail('appsettings.json must hold a JSON object');
|
|
119
|
+
}
|
|
120
|
+
if (!isRecord(overlay)) {
|
|
121
|
+
return fail(`appsettings.${env}.json must hold a JSON object`);
|
|
122
|
+
}
|
|
123
|
+
const merged = { ...base, ...overlay };
|
|
124
|
+
const missing = REQUIRED_SETTINGS.filter((key) => merged[key] === undefined || merged[key] === null || merged[key] === '');
|
|
125
|
+
if (missing.length > 0) {
|
|
126
|
+
fail(`appsettings (${env}) is missing required key(s): ${missing.join(', ')}`);
|
|
127
|
+
}
|
|
128
|
+
if (merged.ENVIRONMENT !== env) {
|
|
129
|
+
fail(`appsettings.${env}.json sets ENVIRONMENT to ${JSON.stringify(merged.ENVIRONMENT)}; it must be "${env}"`);
|
|
130
|
+
}
|
|
131
|
+
if (merged.APP_ID_SUFFIX !== undefined && merged.APP_ID_SUFFIX !== envSuffix(env)) {
|
|
132
|
+
fail(`appsettings (${env}) sets APP_ID_SUFFIX to ${JSON.stringify(merged.APP_ID_SUFFIX)}; ` +
|
|
133
|
+
`${env} builds always use ${JSON.stringify(envSuffix(env))}. Remove the key or correct it.`);
|
|
134
|
+
}
|
|
135
|
+
if (!/^[1-9][0-9]*$/.test(String(merged.VERSION_INTERNAL))) {
|
|
136
|
+
fail(`VERSION_INTERNAL must be a positive whole number, not ${JSON.stringify(merged.VERSION_INTERNAL)}`);
|
|
137
|
+
}
|
|
138
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/.test(String(merged.APP_ID))) {
|
|
139
|
+
fail(`APP_ID ${JSON.stringify(merged.APP_ID)} is not a reverse-domain id such as com.example.app`);
|
|
140
|
+
}
|
|
141
|
+
return merged;
|
|
142
|
+
};
|
|
143
|
+
export const loadAppSettings = (appRoot, env) => {
|
|
144
|
+
const baseFile = path.join(appRoot, 'appsettings.json');
|
|
145
|
+
const envFile = path.join(appRoot, `appsettings.${env}.json`);
|
|
146
|
+
if (!existsSync(baseFile)) {
|
|
147
|
+
fail(`no appsettings.json in ${appRoot}: run mobile-tools from the app's root folder`);
|
|
148
|
+
}
|
|
149
|
+
if (!existsSync(envFile)) {
|
|
150
|
+
fail(`appsettings.${env}.json not found: every buildable environment declares its own file`);
|
|
151
|
+
}
|
|
152
|
+
return mergeAppSettings(readJson(baseFile), readJson(envFile), env);
|
|
153
|
+
};
|
|
154
|
+
/** The ENVIRONMENT of the last compile, for --same-env. */
|
|
155
|
+
export const lastCompiledEnvironment = (appRoot) => {
|
|
156
|
+
const file = path.join(appRoot, COMPILED_FILE);
|
|
157
|
+
if (!existsSync(file)) {
|
|
158
|
+
return fail(`--same-env: no ${COMPILED_FILE} yet; pass --env <Environment> once`);
|
|
159
|
+
}
|
|
160
|
+
const compiled = readJson(file);
|
|
161
|
+
if (!isRecord(compiled) || typeof compiled.ENVIRONMENT !== 'string') {
|
|
162
|
+
return fail(`--same-env: ${COMPILED_FILE} has no ENVIRONMENT`);
|
|
163
|
+
}
|
|
164
|
+
return parseEnvironment(compiled.ENVIRONMENT);
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* The local API port `run` reverses for Development: the configured port, else the port of a
|
|
168
|
+
* localhost BASE_URL, else none (the API is remote and needs no tunnel).
|
|
169
|
+
*/
|
|
170
|
+
export const localApiPort = (devBaseUrl, config) => {
|
|
171
|
+
if (config.localApiPort !== undefined) {
|
|
172
|
+
return config.localApiPort;
|
|
173
|
+
}
|
|
174
|
+
let url;
|
|
175
|
+
try {
|
|
176
|
+
url = new URL(devBaseUrl);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
if (url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
if (url.port !== '') {
|
|
185
|
+
return Number(url.port);
|
|
186
|
+
}
|
|
187
|
+
return url.protocol === 'https:' ? 443 : 80;
|
|
188
|
+
};
|
package/lib/devices.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { capture } from './exec.js';
|
|
3
|
+
import { fail } from './output.js';
|
|
4
|
+
/** Parses `adb devices`. */
|
|
5
|
+
export const parseAdbDevices = (text) => text
|
|
6
|
+
.split(/\r?\n/)
|
|
7
|
+
.map((line) => line.trim())
|
|
8
|
+
.filter((line) => line !== '' && !line.startsWith('List of devices') && !line.startsWith('*'))
|
|
9
|
+
.map((line) => {
|
|
10
|
+
const [serial, state = ''] = line.split(/\s+/);
|
|
11
|
+
return { serial, state };
|
|
12
|
+
});
|
|
13
|
+
/** A USB serial first, then emulator-NNNN, then host:port. */
|
|
14
|
+
const serialRank = (serial) => {
|
|
15
|
+
if (serial.startsWith('emulator-')) {
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
return serial.includes(':') ? 2 : 0;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Collapses serials that reach one device. Some emulators (MuMu) answer on several serials at
|
|
22
|
+
* once (127.0.0.1:16416, 127.0.0.1:7555, emulator-5556); they report the same ro.serialno and
|
|
23
|
+
* android_id. Two separate emulators can share ro.serialno, so android_id is part of the key.
|
|
24
|
+
*/
|
|
25
|
+
export const dedupeDevices = (probes) => {
|
|
26
|
+
const groups = new Map();
|
|
27
|
+
for (const probe of probes) {
|
|
28
|
+
const key = probe.serialNo === '' ? `serial:${probe.serial}` : `${probe.serialNo}|${probe.androidId}`;
|
|
29
|
+
groups.set(key, [...(groups.get(key) ?? []), probe]);
|
|
30
|
+
}
|
|
31
|
+
return [...groups.values()]
|
|
32
|
+
.map((group) => {
|
|
33
|
+
const serials = group
|
|
34
|
+
.map((probe) => probe.serial)
|
|
35
|
+
.sort((a, b) => serialRank(a) - serialRank(b) || a.localeCompare(b));
|
|
36
|
+
const chosen = group.find((probe) => probe.serial === serials[0]);
|
|
37
|
+
return { serial: serials[0], serials, model: chosen.model, abi: chosen.abi };
|
|
38
|
+
})
|
|
39
|
+
.sort((a, b) => a.serial.localeCompare(b.serial));
|
|
40
|
+
};
|
|
41
|
+
const PROBE_SCRIPT = 'getprop ro.serialno; getprop ro.product.model; getprop ro.product.cpu.abi; settings get secure android_id';
|
|
42
|
+
export const probeDevice = (adb, serial, cwd) => {
|
|
43
|
+
const result = capture({ file: adb, args: ['-s', serial, 'shell', PROBE_SCRIPT] }, cwd);
|
|
44
|
+
if (result.status !== 0) {
|
|
45
|
+
return fail(`adb could not read ${serial}: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
46
|
+
}
|
|
47
|
+
const [serialNo = '', model = '', abi = '', androidId = ''] = result.stdout
|
|
48
|
+
.split(/\r?\n/)
|
|
49
|
+
.map((line) => line.trim());
|
|
50
|
+
return { serial, serialNo, model, abi, androidId };
|
|
51
|
+
};
|
|
52
|
+
export const listAndroidDevices = (adb, cwd) => {
|
|
53
|
+
const result = capture({ file: adb, args: ['devices'] }, cwd);
|
|
54
|
+
if (result.status !== 0) {
|
|
55
|
+
return fail(`adb devices failed: ${result.stderr.trim()}`);
|
|
56
|
+
}
|
|
57
|
+
const entries = parseAdbDevices(result.stdout);
|
|
58
|
+
const unusable = entries.filter((entry) => entry.state !== 'device');
|
|
59
|
+
const ready = entries.filter((entry) => entry.state === 'device');
|
|
60
|
+
if (ready.length === 0) {
|
|
61
|
+
const detail = unusable.map((entry) => `${entry.serial} is ${entry.state}`).join('; ');
|
|
62
|
+
return fail(`no Android device ready${detail ? ` (${detail})` : ''}. Connect a phone with USB debugging, start an emulator, or adb connect host:port.`);
|
|
63
|
+
}
|
|
64
|
+
return dedupeDevices(ready.map((entry) => probeDevice(adb, entry.serial, cwd)));
|
|
65
|
+
};
|
|
66
|
+
export const describeDevice = (device) => `${device.model || 'unknown model'} (${device.serial}, ${device.abi})`;
|
|
67
|
+
/** Matches --device against any serial of a device or its model name (case-insensitive). */
|
|
68
|
+
export const findDevice = (devices, requested) => devices.find((device) => device.serials.includes(requested)) ??
|
|
69
|
+
devices.find((device) => device.model.toLowerCase() === requested.toLowerCase());
|
|
70
|
+
/**
|
|
71
|
+
* The device to use: the --device match, the only device, or the user's pick on a terminal.
|
|
72
|
+
* Without a terminal several devices are an error listing them, never a guess.
|
|
73
|
+
*/
|
|
74
|
+
export const pickDevice = async (devices, requested, describe, interactive) => {
|
|
75
|
+
const list = devices.map((device, i) => ` ${i + 1}. ${describe(device)}`).join('\n');
|
|
76
|
+
if (requested) {
|
|
77
|
+
return (findDevice(devices, requested) ??
|
|
78
|
+
fail(`no device matches --device ${requested}. Connected:\n${list}`));
|
|
79
|
+
}
|
|
80
|
+
if (devices.length === 1) {
|
|
81
|
+
return devices[0];
|
|
82
|
+
}
|
|
83
|
+
if (!interactive) {
|
|
84
|
+
return fail(`several devices are connected; pass --device <serial|name>:\n${list}`);
|
|
85
|
+
}
|
|
86
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
87
|
+
try {
|
|
88
|
+
const answer = await rl.question(`Several devices are connected:\n${list}\nRun on which? [1-${devices.length}] `);
|
|
89
|
+
const index = Number(answer.trim()) - 1;
|
|
90
|
+
return devices[index] ?? fail(`no device numbered ${answer.trim()}`);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
rl.close();
|
|
94
|
+
}
|
|
95
|
+
};
|
package/lib/exec.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fail } from './output.js';
|
|
5
|
+
import { childEnv } from './platform.js';
|
|
6
|
+
/** Run to completion and capture the output. A program that cannot start reports status -1. */
|
|
7
|
+
export const capture = (inv, cwd) => {
|
|
8
|
+
const result = spawnSync(inv.file, inv.args, {
|
|
9
|
+
cwd,
|
|
10
|
+
env: childEnv(process.env),
|
|
11
|
+
encoding: 'utf8',
|
|
12
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
13
|
+
windowsHide: true,
|
|
14
|
+
windowsVerbatimArguments: inv.windowsVerbatimArguments
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
status: result.error ? -1 : (result.status ?? -1),
|
|
18
|
+
stdout: result.stdout ?? '',
|
|
19
|
+
stderr: result.error ? result.error.message : (result.stderr ?? '')
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/** Run with the terminal attached (installs, pod install, codegen) and fail on a non-zero exit. */
|
|
23
|
+
export const runInherit = (inv, cwd, what) => {
|
|
24
|
+
const result = spawnSync(inv.file, inv.args, {
|
|
25
|
+
cwd,
|
|
26
|
+
env: childEnv(process.env),
|
|
27
|
+
stdio: 'inherit',
|
|
28
|
+
windowsHide: true,
|
|
29
|
+
windowsVerbatimArguments: inv.windowsVerbatimArguments
|
|
30
|
+
});
|
|
31
|
+
if (result.error) {
|
|
32
|
+
fail(`${what} could not start: ${result.error.message}`);
|
|
33
|
+
}
|
|
34
|
+
if (result.status !== 0) {
|
|
35
|
+
fail(`${what} failed (exit ${result.status ?? 'signal'})`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** Run with stdout and stderr written to a log file; resolves to the exit status. */
|
|
39
|
+
export const runToLog = (inv, cwd, logFile) => {
|
|
40
|
+
mkdirSync(path.dirname(logFile), { recursive: true });
|
|
41
|
+
const fd = openSync(logFile, 'w');
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const child = spawn(inv.file, inv.args, {
|
|
44
|
+
cwd,
|
|
45
|
+
env: childEnv(process.env),
|
|
46
|
+
stdio: ['ignore', fd, fd],
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
windowsVerbatimArguments: inv.windowsVerbatimArguments
|
|
49
|
+
});
|
|
50
|
+
child.on('error', () => {
|
|
51
|
+
closeSync(fd);
|
|
52
|
+
resolve(-1);
|
|
53
|
+
});
|
|
54
|
+
child.on('close', (code) => {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
resolve(code ?? -1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
/** Start a long-lived process detached from this one, its output in a log file. */
|
|
61
|
+
export const startDetached = (inv, cwd, logFile) => {
|
|
62
|
+
mkdirSync(path.dirname(logFile), { recursive: true });
|
|
63
|
+
const fd = openSync(logFile, 'a');
|
|
64
|
+
const child = spawn(inv.file, inv.args, {
|
|
65
|
+
cwd,
|
|
66
|
+
env: childEnv(process.env),
|
|
67
|
+
detached: true,
|
|
68
|
+
stdio: ['ignore', fd, fd],
|
|
69
|
+
windowsHide: true
|
|
70
|
+
});
|
|
71
|
+
child.unref();
|
|
72
|
+
closeSync(fd);
|
|
73
|
+
};
|