@webority/mobile-tools 0.1.0 → 0.2.1
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 +2 -1
- package/lib/cli.js +1 -134
- package/lib/config.js +1 -188
- package/lib/devices.js +1 -95
- package/lib/exec.js +1 -73
- package/lib/hooks.js +1 -96
- package/lib/iconArt.js +1 -74
- package/lib/icons.js +1 -162
- package/lib/ios.js +1 -78
- package/lib/metro.js +1 -82
- package/lib/output.js +1 -18
- package/lib/platform.js +1 -122
- package/lib/prebuild.js +1 -206
- package/lib/rootError.js +1 -41
- package/lib/run.js +1 -140
- package/lib/sync.js +1 -205
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,7 +30,8 @@ npx mobile-tools prebuild --same-env [--platform android|ios]
|
|
|
30
30
|
- Sets `CFBundleDisplayName` in `ios/<name>/Info.plist`.
|
|
31
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
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
|
|
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
|
+
- **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.
|
|
34
35
|
- Without `--platform`, it stamps every platform folder the app has.
|
|
35
36
|
- `--same-env` recompiles the environment last compiled, which refreshes the build stamp.
|
|
36
37
|
- Running it twice gives the same native files as running it once. A file whose content did not change is not rewritten.
|
package/lib/cli.js
CHANGED
|
@@ -1,135 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
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
|
-
});
|
|
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
CHANGED
|
@@ -1,188 +1 @@
|
|
|
1
|
-
import { existsSync, readdirSync,
|
|
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
|
-
};
|
|
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
CHANGED
|
@@ -1,95 +1 @@
|
|
|
1
|
-
import
|
|
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
|
-
};
|
|
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
CHANGED
|
@@ -1,73 +1 @@
|
|
|
1
|
-
import {
|
|
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
|
-
};
|
|
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
CHANGED
|
@@ -1,96 +1 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { capture } from './exec.js';
|
|
4
|
-
import { fail, say } from './output.js';
|
|
5
|
-
import { findOnPath } from './platform.js';
|
|
6
|
-
export const HOOK_NAMES = ['post-merge', 'post-checkout'];
|
|
7
|
-
const OWNER_LINE = '# Written by @webority/mobile-tools (hooks install).';
|
|
8
|
-
const EXIT_LINE = 'exit $status';
|
|
9
|
-
const startMarker = (appDir) => `# >>> mobile-tools ${appDir}`;
|
|
10
|
-
const endMarker = (appDir) => `# <<< mobile-tools ${appDir}`;
|
|
11
|
-
/** The block one app adds: a stale-tree check that prints its one line and never fails the git command. */
|
|
12
|
-
export const hookBlock = (appDir) => {
|
|
13
|
-
const cli = 'node_modules/@webority/mobile-tools/lib/cli.js';
|
|
14
|
-
return [
|
|
15
|
-
startMarker(appDir),
|
|
16
|
-
`if [ -f "${appDir}/${cli}" ]; then`,
|
|
17
|
-
` (cd "${appDir}" && node ${cli} sync --check) || true`,
|
|
18
|
-
'fi',
|
|
19
|
-
endMarker(appDir)
|
|
20
|
-
].join('\n');
|
|
21
|
-
};
|
|
22
|
-
export const chainedName = (hook) => `${hook}.chained`;
|
|
23
|
-
export const isOwnHook = (content) => content.includes(OWNER_LINE);
|
|
24
|
-
/**
|
|
25
|
-
* The hook file after adding or refreshing one app's block. A hook this tool did not write is
|
|
26
|
-
* never edited: the caller moves it aside and the new hook runs it first, passing its exit status
|
|
27
|
-
* through.
|
|
28
|
-
*/
|
|
29
|
-
export const composeHook = (hook, existing, appDir) => {
|
|
30
|
-
const block = hookBlock(appDir);
|
|
31
|
-
if (existing !== undefined && isOwnHook(existing)) {
|
|
32
|
-
const start = existing.indexOf(startMarker(appDir));
|
|
33
|
-
const end = existing.indexOf(endMarker(appDir));
|
|
34
|
-
if (start >= 0 && end > start) {
|
|
35
|
-
return `${existing.slice(0, start)}${block}${existing.slice(end + endMarker(appDir).length)}`;
|
|
36
|
-
}
|
|
37
|
-
const exitAt = existing.lastIndexOf(EXIT_LINE);
|
|
38
|
-
return exitAt >= 0
|
|
39
|
-
? `${existing.slice(0, exitAt)}${block}\n${existing.slice(exitAt)}`
|
|
40
|
-
: `${existing.trimEnd()}\n${block}\n`;
|
|
41
|
-
}
|
|
42
|
-
return [
|
|
43
|
-
'#!/bin/sh',
|
|
44
|
-
OWNER_LINE,
|
|
45
|
-
'status=0',
|
|
46
|
-
`chained="$(dirname "$0")/${chainedName(hook)}"`,
|
|
47
|
-
'if [ -x "$chained" ]; then "$chained" "$@" || status=$?; fi',
|
|
48
|
-
block,
|
|
49
|
-
EXIT_LINE,
|
|
50
|
-
''
|
|
51
|
-
].join('\n');
|
|
52
|
-
};
|
|
53
|
-
const git = (appRoot, args) => {
|
|
54
|
-
const exe = findOnPath('git', process.env, process.platform) ?? fail('git not found on PATH');
|
|
55
|
-
const result = capture({ file: exe, args }, appRoot);
|
|
56
|
-
if (result.status !== 0) {
|
|
57
|
-
return fail(`git ${args.join(' ')} failed: ${result.stderr.trim()}`);
|
|
58
|
-
}
|
|
59
|
-
return result.stdout.trim();
|
|
60
|
-
};
|
|
61
|
-
/** The hooks folder git runs: core.hooksPath when set (relative to the work tree top), else the repo's own. */
|
|
62
|
-
export const hooksDir = (appRoot) => {
|
|
63
|
-
const topLevel = git(appRoot, ['rev-parse', '--show-toplevel']);
|
|
64
|
-
const exe = findOnPath('git', process.env, process.platform) ?? fail('git not found on PATH');
|
|
65
|
-
const configured = capture({ file: exe, args: ['config', '--get', 'core.hooksPath'] }, appRoot);
|
|
66
|
-
const custom = configured.status === 0 ? configured.stdout.trim() : '';
|
|
67
|
-
if (custom !== '') {
|
|
68
|
-
return { dir: path.resolve(topLevel, custom), topLevel };
|
|
69
|
-
}
|
|
70
|
-
return {
|
|
71
|
-
dir: path.resolve(appRoot, git(appRoot, ['rev-parse', '--git-path', 'hooks'])),
|
|
72
|
-
topLevel
|
|
73
|
-
};
|
|
74
|
-
};
|
|
75
|
-
export const installHooks = (appRoot) => {
|
|
76
|
-
const { dir, topLevel } = hooksDir(appRoot);
|
|
77
|
-
const appDir = path.relative(topLevel, appRoot).split(path.sep).join('/') || '.';
|
|
78
|
-
mkdirSync(dir, { recursive: true });
|
|
79
|
-
for (const hook of HOOK_NAMES) {
|
|
80
|
-
const file = path.join(dir, hook);
|
|
81
|
-
let existing = existsSync(file) ? readFileSync(file, 'utf8') : undefined;
|
|
82
|
-
if (existing !== undefined && !isOwnHook(existing)) {
|
|
83
|
-
const chained = path.join(dir, chainedName(hook));
|
|
84
|
-
if (existsSync(chained)) {
|
|
85
|
-
fail(`${file} is not ours and ${chained} already exists; merge them by hand, then rerun`);
|
|
86
|
-
}
|
|
87
|
-
renameSync(file, chained);
|
|
88
|
-
chmodSync(chained, 0o755);
|
|
89
|
-
say(`hooks: kept your ${hook} hook as ${chainedName(hook)}; it still runs first`);
|
|
90
|
-
existing = undefined;
|
|
91
|
-
}
|
|
92
|
-
writeFileSync(file, composeHook(hook, existing, appDir), 'utf8');
|
|
93
|
-
chmodSync(file, 0o755);
|
|
94
|
-
say(`hooks: ${path.relative(topLevel, file).split(path.sep).join('/')} runs sync --check for ${appDir}`);
|
|
95
|
-
}
|
|
96
|
-
};
|
|
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}`)}};
|