@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.
@@ -0,0 +1,206 @@
1
+ import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { applicationId, COMPILED_FILE, ENVIRONMENTS, gradleAppId, lastCompiledEnvironment, loadAppSettings, loadToolsConfig, resolveIosProjectName } from './config.js';
4
+ import { capture } from './exec.js';
5
+ import { fail, say } from './output.js';
6
+ import { findOnPath } from './platform.js';
7
+ const eolOf = (text) => (text.includes('\r\n') ? '\r\n' : '\n');
8
+ /**
9
+ * Sets each key in a .properties text, keeping every other line, comment, the order, the line
10
+ * endings and the presence or absence of a final newline. Missing keys are appended.
11
+ */
12
+ export const rewriteGradleProperties = (text, values) => {
13
+ const eol = eolOf(text);
14
+ const lines = text.split(eol);
15
+ const pending = new Map(Object.entries(values));
16
+ const out = lines.map((line) => {
17
+ const match = /^\s*([A-Za-z0-9_.-]+)\s*[=:]/.exec(line);
18
+ if (match && pending.has(match[1])) {
19
+ const key = match[1];
20
+ const value = pending.get(key);
21
+ pending.delete(key);
22
+ return `${key}=${value}`;
23
+ }
24
+ return line;
25
+ });
26
+ if (pending.size === 0) {
27
+ return out.join(eol);
28
+ }
29
+ const added = [...pending].map(([key, value]) => `${key}=${value}`);
30
+ const endsWithNewline = out.length > 1 && out[out.length - 1] === '';
31
+ if (endsWithNewline) {
32
+ out.splice(out.length - 1, 0, ...added);
33
+ }
34
+ else if (out.length === 1 && out[0] === '') {
35
+ out.splice(0, 1, ...added);
36
+ }
37
+ else {
38
+ out.push(...added);
39
+ }
40
+ return out.join(eol);
41
+ };
42
+ /** Xcode leaves a value bare when it is one plain token and quotes it otherwise. */
43
+ const pbxValue = (value) => /^[A-Za-z0-9._/-]+$/.test(value) ? value : `"${value.replace(/"/g, '\\"')}"`;
44
+ /**
45
+ * Sets every occurrence of each build setting in a project.pbxproj (one per build
46
+ * configuration). A setting the project does not carry fails: writing it in would need a guess
47
+ * at which configurations it belongs to.
48
+ */
49
+ export const rewritePbxproj = (text, settings, options = {}) => {
50
+ let out = text;
51
+ for (const [setting, value] of Object.entries(settings)) {
52
+ const pattern = new RegExp(`(\\s)${setting} = [^;\\n]*;`, 'g');
53
+ if (!pattern.test(out)) {
54
+ fail(`${setting} not found in project.pbxproj`);
55
+ }
56
+ const rendered = options.quote?.includes(setting)
57
+ ? `"${value.replace(/"/g, '\\"')}"`
58
+ : pbxValue(value);
59
+ out = out.replace(pattern, (_match, lead) => `${lead}${setting} = ${rendered};`);
60
+ }
61
+ return out;
62
+ };
63
+ const escapeXml = (value) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
64
+ /** Sets CFBundleDisplayName in an Info.plist text; a plist without the key fails. */
65
+ export const rewriteInfoPlistDisplayName = (text, displayName) => {
66
+ const pattern = /(<key>CFBundleDisplayName<\/key>\s*<string>)[^<]*(<\/string>)/;
67
+ if (!pattern.test(text)) {
68
+ fail('CFBundleDisplayName not found in Info.plist');
69
+ }
70
+ return text.replace(pattern, (_m, open, close) => `${open}${escapeXml(displayName)}${close}`);
71
+ };
72
+ /** Paths, relative to the app root, that prebuild writes and the dirty check must ignore. */
73
+ export const prebuildOutputs = (iosProjectName) => {
74
+ const outputs = [COMPILED_FILE, 'android/gradle.properties'];
75
+ if (iosProjectName) {
76
+ outputs.push(`ios/${iosProjectName}/Info.plist`, `ios/${iosProjectName}.xcodeproj/project.pbxproj`, `ios/${iosProjectName}/GoogleService-Info.plist`);
77
+ }
78
+ return outputs;
79
+ };
80
+ /**
81
+ * The short HEAD sha, with -dirty when the app folder has uncommitted changes other than the
82
+ * files prebuild itself writes (or every build after an environment switch would read dirty).
83
+ * "nogit" when the folder is not a git checkout.
84
+ */
85
+ export const buildCommit = (appRoot, excludes) => {
86
+ const git = findOnPath('git', process.env, process.platform);
87
+ if (!git) {
88
+ return 'nogit';
89
+ }
90
+ const sha = capture({ file: git, args: ['rev-parse', '--short', 'HEAD'] }, appRoot);
91
+ if (sha.status !== 0) {
92
+ return 'nogit';
93
+ }
94
+ const status = capture({
95
+ file: git,
96
+ args: ['status', '--porcelain', '--', '.', ...excludes.map((p) => `:(exclude)${p}`)]
97
+ }, appRoot);
98
+ if (status.status !== 0) {
99
+ return fail(`git status failed in ${appRoot}: ${status.stderr.trim()}`);
100
+ }
101
+ return status.stdout.trim() === '' ? sha.stdout.trim() : `${sha.stdout.trim()}-dirty`;
102
+ };
103
+ /** Writes a file only when its content changes, so an unchanged native file keeps its mtime. */
104
+ const writeIfChanged = (file, content) => {
105
+ if (existsSync(file) && readFileSync(file, 'utf8') === content) {
106
+ return false;
107
+ }
108
+ writeFileSync(file, content, 'utf8');
109
+ return true;
110
+ };
111
+ const report = (appRoot, file, changed) => {
112
+ say(`prebuild: ${changed ? 'wrote' : 'unchanged'} ${path.relative(appRoot, file)}`);
113
+ };
114
+ export const stampAndroid = (appRoot, settings, env, config) => {
115
+ const file = path.join(appRoot, 'android', 'gradle.properties');
116
+ if (!existsSync(file)) {
117
+ fail(`android/gradle.properties not found in ${appRoot}`);
118
+ }
119
+ const next = rewriteGradleProperties(readFileSync(file, 'utf8'), {
120
+ APP_NAME: String(settings.APP_NAME),
121
+ APP_ID: gradleAppId(settings.APP_ID, env, config.flavors),
122
+ VERSION_DISPLAY: String(settings.VERSION_DISPLAY),
123
+ VERSION_INTERNAL: String(settings.VERSION_INTERNAL)
124
+ });
125
+ report(appRoot, file, writeIfChanged(file, next));
126
+ };
127
+ export const stampIos = (appRoot, settings, env, config, projectName) => {
128
+ const projectDir = path.join(appRoot, 'ios', projectName);
129
+ const infoPlist = path.join(projectDir, 'Info.plist');
130
+ const pbxproj = path.join(appRoot, 'ios', `${projectName}.xcodeproj`, 'project.pbxproj');
131
+ for (const file of [infoPlist, pbxproj]) {
132
+ if (!existsSync(file)) {
133
+ fail(`${path.relative(appRoot, file)} not found: check "iosProjectName"`);
134
+ }
135
+ }
136
+ const iconSet = `AppIcon-${env}`;
137
+ const catalog = path.join(projectDir, 'Images.xcassets');
138
+ const hasIconSet = existsSync(path.join(catalog, `${iconSet}.appiconset`));
139
+ const otherSets = ENVIRONMENTS.filter((other) => other !== env && existsSync(path.join(catalog, `AppIcon-${other}.appiconset`)));
140
+ if (!hasIconSet && otherSets.length > 0) {
141
+ // Leaving the setting would ship another environment's icon.
142
+ fail(`ios/${projectName}/Images.xcassets has AppIcon-${otherSets[0]} but no ${iconSet}: run mobile-tools icons`);
143
+ }
144
+ report(appRoot, infoPlist, writeIfChanged(infoPlist, rewriteInfoPlistDisplayName(readFileSync(infoPlist, 'utf8'), String(settings.APP_NAME))));
145
+ const settingsToWrite = {
146
+ PRODUCT_BUNDLE_IDENTIFIER: applicationId(settings.APP_ID, env),
147
+ MARKETING_VERSION: String(settings.VERSION_DISPLAY),
148
+ CURRENT_PROJECT_VERSION: String(settings.VERSION_INTERNAL)
149
+ };
150
+ if (hasIconSet) {
151
+ settingsToWrite.ASSETCATALOG_COMPILER_APPICON_NAME = iconSet;
152
+ }
153
+ report(appRoot, pbxproj, writeIfChanged(pbxproj, rewritePbxproj(readFileSync(pbxproj, 'utf8'), settingsToWrite, {
154
+ quote: ['PRODUCT_BUNDLE_IDENTIFIER']
155
+ })));
156
+ if (!hasIconSet) {
157
+ say(`prebuild: no AppIcon-<Env> sets, app icon left as the project sets it`);
158
+ }
159
+ const firebaseTarget = path.join(projectDir, 'GoogleService-Info.plist');
160
+ const firebaseSource = path.join(appRoot, config.firebasePlistDir, `GoogleService-Info.${env}.plist`);
161
+ if (existsSync(firebaseSource)) {
162
+ const changed = !existsSync(firebaseTarget) ||
163
+ !readFileSync(firebaseSource).equals(readFileSync(firebaseTarget));
164
+ if (changed) {
165
+ copyFileSync(firebaseSource, firebaseTarget);
166
+ }
167
+ report(appRoot, firebaseTarget, changed);
168
+ }
169
+ else if (existsSync(firebaseTarget)) {
170
+ // A build must never ship another environment's Firebase identity.
171
+ rmSync(firebaseTarget);
172
+ say(`prebuild: removed ${path.relative(appRoot, firebaseTarget)}, no ${path.relative(appRoot, firebaseSource)}`);
173
+ }
174
+ };
175
+ /** Compiles appsettings and stamps the native layers. Returns the settings it compiled. */
176
+ export const prebuild = (options) => {
177
+ const { appRoot, sameEnv, platform } = options;
178
+ if (sameEnv && options.env) {
179
+ fail('pass --env or --same-env, not both', 2);
180
+ }
181
+ const env = sameEnv ? lastCompiledEnvironment(appRoot) : options.env;
182
+ if (!env) {
183
+ return fail('prebuild needs --env <Development|Staging|Production> or --same-env', 2);
184
+ }
185
+ const config = loadToolsConfig(appRoot);
186
+ const projectName = resolveIosProjectName(appRoot, config);
187
+ const settings = loadAppSettings(appRoot, env);
188
+ const compiled = {
189
+ ...settings,
190
+ COMMIT: buildCommit(appRoot, prebuildOutputs(projectName)),
191
+ BUILD_TIME: new Date().toISOString()
192
+ };
193
+ writeFileSync(path.join(appRoot, COMPILED_FILE), `${JSON.stringify(compiled, null, 2)}\n`, 'utf8');
194
+ say(`prebuild: wrote ${COMPILED_FILE} (${env}, ${compiled.COMMIT})`);
195
+ const hasAndroid = existsSync(path.join(appRoot, 'android'));
196
+ if (platform === 'android' || (!platform && hasAndroid)) {
197
+ stampAndroid(appRoot, compiled, env, config);
198
+ }
199
+ if (platform === 'ios' || (!platform && projectName)) {
200
+ if (!projectName) {
201
+ return fail(`no ios/ folder in ${appRoot}`);
202
+ }
203
+ stampIos(appRoot, compiled, env, config, projectName);
204
+ }
205
+ return compiled;
206
+ };
@@ -0,0 +1,41 @@
1
+ const MAX_LINES = 40;
2
+ const blockFrom = (lines, start, isEnd) => {
3
+ const block = [];
4
+ for (let i = start; i < lines.length && block.length < MAX_LINES; i++) {
5
+ if (i > start && isEnd(lines[i])) {
6
+ break;
7
+ }
8
+ block.push(lines[i]);
9
+ }
10
+ while (block.length > 0 && block[block.length - 1].trim() === '') {
11
+ block.pop();
12
+ }
13
+ return block;
14
+ };
15
+ /**
16
+ * The one part of a build log worth printing. Metro's "Unable to resolve module" comes first
17
+ * when present: Gradle reports that failure only as a generic non-zero exit of the bundling
18
+ * command, at the very end of the build. Then Gradle's first "What went wrong" block, then
19
+ * compiler-style "error:" lines (xcodebuild), then the log's tail.
20
+ */
21
+ export const extractRootError = (log) => {
22
+ const lines = log.split(/\r?\n/);
23
+ const metro = lines.findIndex((line) => line.includes('Unable to resolve module'));
24
+ if (metro >= 0) {
25
+ return blockFrom(lines, metro, (line) => line.trim() === '').join('\n');
26
+ }
27
+ const gradle = lines.findIndex((line) => line.trim() === '* What went wrong:');
28
+ if (gradle >= 0) {
29
+ return blockFrom(lines, gradle + 1, (line) => /^\* [A-Z]/.test(line)).join('\n');
30
+ }
31
+ const errors = [
32
+ ...new Set(lines.filter((line) => /(^|\s)error:/.test(line)).map((l) => l.trim()))
33
+ ];
34
+ if (errors.length > 0) {
35
+ return errors.slice(0, 10).join('\n');
36
+ }
37
+ return lines
38
+ .filter((line) => line.trim() !== '')
39
+ .slice(-20)
40
+ .join('\n');
41
+ };
package/lib/run.js ADDED
@@ -0,0 +1,140 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { apkDir, applicationId, assembleTask, loadAppSettings, loadToolsConfig, localApiPort, resolveIosProjectName } from './config.js';
4
+ import { describeDevice, listAndroidDevices, pickDevice } from './devices.js';
5
+ import { capture, runToLog } from './exec.js';
6
+ import { describeAppleDevice, listAppleDevices, xcodebuildArgs } from './ios.js';
7
+ import { ensureMetro } from './metro.js';
8
+ import { CliError, fail, say } from './output.js';
9
+ import { adbPath, gradleInvocation, requireDarwin, XCODEBUILD, XCRUN } from './platform.js';
10
+ import { prebuild } from './prebuild.js';
11
+ import { extractRootError } from './rootError.js';
12
+ import { checkSync } from './sync.js';
13
+ /** The Android namespace from build.gradle (`namespace "x"` or `namespace = 'x'`); MainActivity lives in it. */
14
+ export const readNamespace = (buildGradle) => /^\s*namespace\s*=?\s*["']([^"']+)["']/m.exec(buildGradle)?.[1];
15
+ /** The ABI token Gradle receives; anything else from a device is refused rather than passed on. */
16
+ export const ABI_PATTERN = /^(arm64-v8a|armeabi-v7a|x86_64|x86)$/;
17
+ /** The APK a debug build produced, preferring one built for the device's ABI. */
18
+ export const chooseApk = (files, abi) => {
19
+ const apks = files.filter((file) => file.endsWith('.apk')).sort();
20
+ return (apks.find((file) => file.includes(abi)) ??
21
+ apks.find((file) => file.includes('universal')) ??
22
+ apks[0]);
23
+ };
24
+ /** Where run writes its build and Metro logs: inside node_modules/.cache, which no repo tracks. */
25
+ export const logDir = (appRoot) => path.join(appRoot, 'node_modules', '.cache', 'mobile-tools');
26
+ const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
27
+ const guardSync = (appRoot) => {
28
+ const problems = checkSync(appRoot, process.platform);
29
+ if (problems.length > 0) {
30
+ fail(problems.join('\n'));
31
+ }
32
+ };
33
+ const buildOrFail = async (invocation, cwd, logFile, what) => {
34
+ say(`${what}: building, log ${logFile}`);
35
+ const started = Date.now();
36
+ const status = await runToLog(invocation, cwd, logFile);
37
+ if (status !== 0) {
38
+ const root = existsSync(logFile) ? extractRootError(readFileSync(logFile, 'utf8')) : '';
39
+ throw new CliError(`${what} failed:\n${root}\nFull log: ${logFile}`);
40
+ }
41
+ say(`${what}: built in ${Math.round((Date.now() - started) / 1000)} s`);
42
+ };
43
+ const adbOrFail = (adb, args, cwd) => {
44
+ const result = capture({ file: adb, args }, cwd);
45
+ if (result.status !== 0) {
46
+ return fail(`adb ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`);
47
+ }
48
+ return result.stdout;
49
+ };
50
+ const runAndroid = async (options) => {
51
+ const { appRoot, env } = options;
52
+ const config = loadToolsConfig(appRoot);
53
+ const androidDir = path.join(appRoot, 'android');
54
+ const buildGradle = path.join(androidDir, 'app', 'build.gradle');
55
+ if (!existsSync(buildGradle)) {
56
+ fail(`android/app/build.gradle not found in ${appRoot}`);
57
+ }
58
+ const namespace = readNamespace(readFileSync(buildGradle, 'utf8')) ??
59
+ fail('android/app/build.gradle declares no namespace');
60
+ const adb = adbPath(process.env, process.platform);
61
+ const devices = listAndroidDevices(adb, appRoot);
62
+ const device = await pickDevice(devices, options.device, describeDevice, process.stdin.isTTY === true);
63
+ if (!ABI_PATTERN.test(device.abi)) {
64
+ fail(`${describeDevice(device)} reports an ABI this build cannot target: "${device.abi}"`);
65
+ }
66
+ say(`run: ${describeDevice(device)}`);
67
+ const settings = prebuild({ appRoot, env, platform: 'android' });
68
+ const task = assembleTask(env, config.flavors);
69
+ const logs = logDir(appRoot);
70
+ await buildOrFail(gradleInvocation(androidDir, [`:app:${task}`, `-PreactNativeArchitectures=${device.abi}`], process.platform, process.env), androidDir, path.join(logs, `gradle-${env}-${stamp()}.log`), `gradle ${task}`);
71
+ const dir = apkDir(appRoot, env, config.flavors);
72
+ const apk = existsSync(dir) ? chooseApk(readdirSync(dir), device.abi) : undefined;
73
+ if (!apk) {
74
+ fail(`the build produced no APK in ${dir}`);
75
+ }
76
+ say(`run: installing ${apk} on ${device.serial}`);
77
+ adbOrFail(adb, ['-s', device.serial, 'install', '-r', path.join(dir, apk)], appRoot);
78
+ const port = await ensureMetro(appRoot, path.join(logs, 'metro.log'), { fixedPort: false });
79
+ adbOrFail(adb, ['-s', device.serial, 'reverse', 'tcp:8081', `tcp:${port}`], appRoot);
80
+ if (env === 'Development') {
81
+ const apiPort = localApiPort(String(loadAppSettings(appRoot, 'Development').BASE_URL), config);
82
+ if (apiPort !== undefined) {
83
+ adbOrFail(adb, ['-s', device.serial, 'reverse', `tcp:${apiPort}`, `tcp:${apiPort}`], appRoot);
84
+ say(`run: device localhost:${apiPort} reaches this machine's API`);
85
+ }
86
+ }
87
+ const appId = applicationId(settings.APP_ID, env);
88
+ adbOrFail(adb, ['-s', device.serial, 'shell', 'am', 'start', '-n', `${appId}/${namespace}.MainActivity`], appRoot);
89
+ say(`run: launched ${appId} on ${device.model || device.serial}`);
90
+ };
91
+ const runIos = async (options) => {
92
+ requireDarwin(process.platform, 'run --platform ios');
93
+ const { appRoot, env } = options;
94
+ const config = loadToolsConfig(appRoot);
95
+ const projectName = resolveIosProjectName(appRoot, config) ?? fail(`no ios/ folder in ${appRoot}`);
96
+ const workspace = path.join(appRoot, 'ios', `${projectName}.xcworkspace`);
97
+ if (!existsSync(workspace)) {
98
+ fail(`ios/${projectName}.xcworkspace not found: run npx mobile-tools sync (it runs pod install)`);
99
+ }
100
+ const device = await pickDevice(listAppleDevices(appRoot), options.device, describeAppleDevice, process.stdin.isTTY === true);
101
+ say(`run: ${describeAppleDevice(device)}`);
102
+ const settings = prebuild({ appRoot, env, platform: 'ios' });
103
+ const logs = logDir(appRoot);
104
+ await buildOrFail({ file: XCODEBUILD, args: xcodebuildArgs(projectName, device.udid) }, appRoot, path.join(logs, `xcodebuild-${env}-${stamp()}.log`), 'xcodebuild');
105
+ const products = path.join(appRoot, 'ios', 'build', 'Build', 'Products', device.kind === 'simulator' ? 'Debug-iphonesimulator' : 'Debug-iphoneos');
106
+ const apps = existsSync(products) ? readdirSync(products).filter((n) => n.endsWith('.app')) : [];
107
+ const app = apps.find((name) => name === `${projectName}.app`) ?? apps[0];
108
+ if (!app) {
109
+ fail(`the build produced no .app in ${products}`);
110
+ }
111
+ const appPath = path.join(products, app);
112
+ const bundleId = applicationId(settings.APP_ID, env);
113
+ await ensureMetro(appRoot, path.join(logs, 'metro.log'), { fixedPort: true });
114
+ const steps = device.kind === 'simulator'
115
+ ? [
116
+ ['simctl', 'install', device.udid, appPath],
117
+ ['simctl', 'launch', device.udid, bundleId]
118
+ ]
119
+ : [
120
+ ['devicectl', 'device', 'install', 'app', '--device', device.udid, appPath],
121
+ ['devicectl', 'device', 'process', 'launch', '--device', device.udid, bundleId]
122
+ ];
123
+ for (const args of steps) {
124
+ const result = capture({ file: XCRUN, args }, appRoot);
125
+ if (result.status !== 0) {
126
+ fail(`xcrun ${args.slice(0, 3).join(' ')} failed: ${(result.stderr || result.stdout).trim()}`);
127
+ }
128
+ }
129
+ say(`run: launched ${bundleId} on ${device.name}`);
130
+ };
131
+ /** Guard, pick a device, prebuild, build for that device only, install, Metro, launch. */
132
+ export const run = async (options) => {
133
+ guardSync(options.appRoot);
134
+ if (options.platform === 'ios') {
135
+ await runIos(options);
136
+ }
137
+ else {
138
+ await runAndroid(options);
139
+ }
140
+ };
package/lib/sync.js ADDED
@@ -0,0 +1,205 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { runInherit } from './exec.js';
5
+ import { fail, say } from './output.js';
6
+ import { findOnPath, gradleInvocation, npmInvocation, pnpmInvocation } from './platform.js';
7
+ export const STAMP_FILE = path.join('node_modules', '.webority-install.json');
8
+ /** package.json fields that change what an install puts in node_modules. */
9
+ const INSTALL_FIELDS = [
10
+ 'dependencies',
11
+ 'devDependencies',
12
+ 'optionalDependencies',
13
+ 'peerDependencies',
14
+ 'bundleDependencies',
15
+ 'overrides',
16
+ 'resolutions',
17
+ 'pnpm',
18
+ 'workspaces'
19
+ ];
20
+ export const lockfileOf = (appRoot) => {
21
+ const pnpm = path.join(appRoot, 'pnpm-lock.yaml');
22
+ const npm = path.join(appRoot, 'package-lock.json');
23
+ if (existsSync(pnpm) && existsSync(npm)) {
24
+ return fail('both pnpm-lock.yaml and package-lock.json exist: delete the one not in use');
25
+ }
26
+ if (existsSync(pnpm)) {
27
+ return { manager: 'pnpm', file: pnpm };
28
+ }
29
+ if (existsSync(npm)) {
30
+ return { manager: 'npm', file: npm };
31
+ }
32
+ return fail(`no package-lock.json or pnpm-lock.yaml in ${appRoot}`);
33
+ };
34
+ /** Every file under a folder, as forward-slash paths relative to it, sorted. */
35
+ const filesUnder = (dir, prefix = '') => readdirSync(dir, { withFileTypes: true })
36
+ .flatMap((entry) => {
37
+ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
38
+ return entry.isDirectory() ? filesUnder(path.join(dir, entry.name), relative) : [relative];
39
+ })
40
+ .sort();
41
+ /** One hash over the lockfile, the install-relevant package.json fields and patches/. */
42
+ export const installHash = (appRoot) => {
43
+ const { file } = lockfileOf(appRoot);
44
+ const pkg = JSON.parse(readFileSync(path.join(appRoot, 'package.json'), 'utf8'));
45
+ const hash = createHash('sha256');
46
+ hash.update(`lockfile:${path.basename(file)}\0`);
47
+ hash.update(readFileSync(file));
48
+ hash.update('\0package.json\0');
49
+ hash.update(JSON.stringify(INSTALL_FIELDS.map((field) => [field, pkg[field] ?? null])));
50
+ const patches = path.join(appRoot, 'patches');
51
+ if (existsSync(patches)) {
52
+ for (const relative of filesUnder(patches)) {
53
+ hash.update(`\0patches/${relative}\0`);
54
+ hash.update(readFileSync(path.join(patches, ...relative.split('/'))));
55
+ }
56
+ }
57
+ return hash.digest('hex');
58
+ };
59
+ export const readStamp = (appRoot) => {
60
+ const file = path.join(appRoot, STAMP_FILE);
61
+ if (!existsSync(file)) {
62
+ return undefined;
63
+ }
64
+ try {
65
+ return JSON.parse(readFileSync(file, 'utf8'));
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ };
71
+ /** The fix command for an iOS Pods tree: through Bundler when the app pins CocoaPods in a Gemfile. */
72
+ const podFix = (appRoot) => existsSync(path.join(appRoot, 'Gemfile'))
73
+ ? 'cd ios && bundle exec pod install'
74
+ : 'cd ios && pod install';
75
+ export const podsStale = (appRoot) => {
76
+ const podfileLock = path.join(appRoot, 'ios', 'Podfile.lock');
77
+ const manifest = path.join(appRoot, 'ios', 'Pods', 'Manifest.lock');
78
+ if (!existsSync(podfileLock) || !existsSync(manifest)) {
79
+ return false;
80
+ }
81
+ return !readFileSync(podfileLock).equals(readFileSync(manifest));
82
+ };
83
+ /**
84
+ * What is stale, as messages that each end with the command that fixes it. Empty when the tree
85
+ * matches its lockfiles. Reads files only, so it answers well inside a second.
86
+ */
87
+ export const checkSync = (appRoot, platform) => {
88
+ const problems = [];
89
+ const stamp = readStamp(appRoot);
90
+ const { file } = lockfileOf(appRoot);
91
+ if (!stamp) {
92
+ problems.push(`node_modules has no install stamp (never installed through mobile-tools). Fix: npx mobile-tools sync`);
93
+ }
94
+ else if (stamp.hash !== installHash(appRoot)) {
95
+ problems.push(`node_modules is stale: ${path.basename(file)}, package.json or patches/ changed since the last install. Fix: npx mobile-tools sync`);
96
+ }
97
+ if (platform === 'darwin' && podsStale(appRoot)) {
98
+ problems.push(`ios/Pods is stale: ios/Podfile.lock differs from ios/Pods/Manifest.lock. Fix: ${podFix(appRoot)}`);
99
+ }
100
+ return problems;
101
+ };
102
+ /** Top-level packages that ship an android/ folder, name to version. Autolinking reads only these. */
103
+ export const nativePackages = (appRoot) => {
104
+ const modules = path.join(appRoot, 'node_modules');
105
+ if (!existsSync(modules)) {
106
+ return {};
107
+ }
108
+ const dirs = [];
109
+ for (const entry of readdirSync(modules, { withFileTypes: true })) {
110
+ if (entry.name.startsWith('.') || !entry.isDirectory()) {
111
+ continue;
112
+ }
113
+ if (entry.name.startsWith('@')) {
114
+ for (const scoped of readdirSync(path.join(modules, entry.name), { withFileTypes: true })) {
115
+ if (scoped.isDirectory()) {
116
+ dirs.push(path.join(modules, entry.name, scoped.name));
117
+ }
118
+ }
119
+ }
120
+ else {
121
+ dirs.push(path.join(modules, entry.name));
122
+ }
123
+ }
124
+ const found = {};
125
+ for (const dir of dirs) {
126
+ const android = path.join(dir, 'android');
127
+ const manifest = path.join(dir, 'package.json');
128
+ if (!existsSync(android) || !statSync(android).isDirectory() || !existsSync(manifest)) {
129
+ continue;
130
+ }
131
+ try {
132
+ const pkg = JSON.parse(readFileSync(manifest, 'utf8'));
133
+ if (pkg.name && pkg.version) {
134
+ found[pkg.name] = pkg.version;
135
+ }
136
+ }
137
+ catch {
138
+ // A package with an unreadable package.json is not one autolinking can link either.
139
+ }
140
+ }
141
+ return found;
142
+ };
143
+ /** Native packages added, removed or moved between two snapshots, sorted. */
144
+ export const changedNativePackages = (before, after) => [...new Set([...Object.keys(before), ...Object.keys(after)])]
145
+ .filter((name) => before[name] !== after[name])
146
+ .sort();
147
+ /**
148
+ * Records that node_modules matches the lockfile now. `sync` calls it after its install; an app
149
+ * also calls it from `postinstall` (`mobile-tools sync --stamp`), so a plain `npm ci` or
150
+ * `pnpm install` leaves an accurate stamp and `sync --check` never asks for a needless reinstall.
151
+ */
152
+ export const writeStamp = (appRoot) => {
153
+ const { manager } = lockfileOf(appRoot);
154
+ const stamp = {
155
+ hash: installHash(appRoot),
156
+ manager,
157
+ nativePackages: nativePackages(appRoot)
158
+ };
159
+ writeFileSync(path.join(appRoot, STAMP_FILE), `${JSON.stringify(stamp, null, 2)}\n`, 'utf8');
160
+ return stamp;
161
+ };
162
+ export const installInvocation = (manager, platform) => manager === 'pnpm'
163
+ ? pnpmInvocation(['install', '--frozen-lockfile'], process.env, platform, process.execPath)
164
+ : npmInvocation(['ci'], process.env, platform, process.execPath);
165
+ /**
166
+ * Installs from the lockfile, stamps node_modules, and when a native package moved, regenerates
167
+ * library codegen and drops the CMake caches that still point at the old output. On macOS it
168
+ * also brings ios/Pods in line with Podfile.lock.
169
+ */
170
+ export const sync = (appRoot, platform) => {
171
+ const { manager } = lockfileOf(appRoot);
172
+ const before = readStamp(appRoot)?.nativePackages ?? nativePackages(appRoot);
173
+ say(`sync: ${manager === 'pnpm' ? 'pnpm install --frozen-lockfile' : 'npm ci'}`);
174
+ runInherit(installInvocation(manager, platform), appRoot, `${manager} install`);
175
+ const after = writeStamp(appRoot).nativePackages;
176
+ const moved = changedNativePackages(before, after);
177
+ const androidDir = path.join(appRoot, 'android');
178
+ // With no .cxx and no app build yet there is no stale native state: the first build runs codegen itself.
179
+ const builtBefore = existsSync(path.join(androidDir, 'app', '.cxx')) ||
180
+ existsSync(path.join(androidDir, 'app', 'build'));
181
+ if (moved.length > 0 && builtBefore) {
182
+ say(`sync: native packages changed (${moved.join(', ')}): regenerating codegen`);
183
+ runInherit(gradleInvocation(androidDir, ['generateCodegenArtifactsFromSchema'], platform, process.env), androidDir, 'gradlew generateCodegenArtifactsFromSchema');
184
+ for (const stale of [
185
+ path.join(androidDir, 'app', '.cxx'),
186
+ path.join(androidDir, 'app', 'build', 'generated', 'autolinking')
187
+ ]) {
188
+ rmSync(stale, { recursive: true, force: true });
189
+ say(`sync: removed ${path.relative(appRoot, stale)}`);
190
+ }
191
+ }
192
+ if (platform === 'darwin' && existsSync(path.join(appRoot, 'ios', 'Podfile'))) {
193
+ const podfileLock = path.join(appRoot, 'ios', 'Podfile.lock');
194
+ const manifest = path.join(appRoot, 'ios', 'Pods', 'Manifest.lock');
195
+ if (!existsSync(podfileLock) || !existsSync(manifest) || podsStale(appRoot)) {
196
+ const useBundler = existsSync(path.join(appRoot, 'Gemfile'));
197
+ const tool = findOnPath(useBundler ? 'bundle' : 'pod', process.env, platform);
198
+ if (!tool) {
199
+ fail(`${useBundler ? 'bundle' : 'pod'} not found on PATH; install CocoaPods, then ${podFix(appRoot)}`);
200
+ }
201
+ runInherit({ file: tool, args: useBundler ? ['exec', 'pod', 'install'] : ['install'] }, path.join(appRoot, 'ios'), 'pod install');
202
+ }
203
+ }
204
+ say('sync: done');
205
+ };
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.1.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",
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
+ }