@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/lib/hooks.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
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
|
+
};
|
package/lib/iconArt.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Production is clean; the other two carry a corner ribbon so testers can tell builds apart. */
|
|
2
|
+
export const RIBBONS = {
|
|
3
|
+
Development: { label: 'DEV', color: '#2E7D32' },
|
|
4
|
+
Staging: { label: 'STAGING', color: '#EF6C00' },
|
|
5
|
+
Production: undefined
|
|
6
|
+
};
|
|
7
|
+
const r = (n) => (Math.round(n * 100) / 100).toString();
|
|
8
|
+
/**
|
|
9
|
+
* A band across the bottom-right corner of the box, with the label along it. The band's middle
|
|
10
|
+
* sits far enough in from the corner that a circular or rounded launcher mask still shows the
|
|
11
|
+
* whole label. Text is SVG text in a generic sans-serif: no font file ships with the package.
|
|
12
|
+
*/
|
|
13
|
+
export const ribbonSvg = (canvas, box, ribbon) => {
|
|
14
|
+
const { x, y, size: s } = box;
|
|
15
|
+
const outer = 0.38 * s;
|
|
16
|
+
const inner = 0.66 * s;
|
|
17
|
+
const points = [
|
|
18
|
+
[x + outer, y + s],
|
|
19
|
+
[x + s, y + outer],
|
|
20
|
+
[x + s, y + inner],
|
|
21
|
+
[x + inner, y + s]
|
|
22
|
+
]
|
|
23
|
+
.map(([px, py]) => `${r(px)},${r(py)}`)
|
|
24
|
+
.join(' ');
|
|
25
|
+
const cx = x + 0.76 * s;
|
|
26
|
+
const cy = y + 0.76 * s;
|
|
27
|
+
const fontSize = (ribbon.label.length > 4 ? 0.105 : 0.13) * s;
|
|
28
|
+
return [
|
|
29
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${canvas}" height="${canvas}" viewBox="0 0 ${canvas} ${canvas}">`,
|
|
30
|
+
`<polygon points="${points}" fill="${ribbon.color}"/>`,
|
|
31
|
+
`<text x="${r(cx)}" y="${r(cy)}" transform="rotate(-45 ${r(cx)} ${r(cy)})" fill="#FFFFFF"`,
|
|
32
|
+
` font-family="sans-serif" font-weight="700" font-size="${r(fontSize)}" text-anchor="middle"`,
|
|
33
|
+
` dominant-baseline="central">${ribbon.label}</text>`,
|
|
34
|
+
'</svg>'
|
|
35
|
+
].join('');
|
|
36
|
+
};
|
|
37
|
+
export const circleMaskSvg = (size) => `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="#000"/></svg>`;
|
|
38
|
+
/** Density buckets and their scale from mdpi. */
|
|
39
|
+
export const DENSITIES = [
|
|
40
|
+
['mdpi', 1],
|
|
41
|
+
['hdpi', 1.5],
|
|
42
|
+
['xhdpi', 2],
|
|
43
|
+
['xxhdpi', 3],
|
|
44
|
+
['xxxhdpi', 4]
|
|
45
|
+
];
|
|
46
|
+
/** Legacy launcher icons are 48dp; adaptive layers are 108dp, of which the middle 72dp shows. */
|
|
47
|
+
export const LEGACY_DP = 48;
|
|
48
|
+
export const ADAPTIVE_DP = 108;
|
|
49
|
+
export const ADAPTIVE_VISIBLE_DP = 72;
|
|
50
|
+
export const ADAPTIVE_ICON_XML = [
|
|
51
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
52
|
+
'<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">',
|
|
53
|
+
' <background android:drawable="@color/ic_launcher_background"/>',
|
|
54
|
+
' <foreground android:drawable="@mipmap/ic_launcher_foreground"/>',
|
|
55
|
+
'</adaptive-icon>',
|
|
56
|
+
''
|
|
57
|
+
].join('\n');
|
|
58
|
+
export const backgroundColorXml = (color) => [
|
|
59
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
60
|
+
'<resources>',
|
|
61
|
+
` <color name="ic_launcher_background">${color}</color>`,
|
|
62
|
+
'</resources>',
|
|
63
|
+
''
|
|
64
|
+
].join('\n');
|
|
65
|
+
/** A single 1024 px universal icon; Xcode derives every other size from it. */
|
|
66
|
+
export const IOS_ICON_FILE = 'AppIcon-1024.png';
|
|
67
|
+
export const iosContentsJson = () => `${JSON.stringify({
|
|
68
|
+
images: [{ filename: IOS_ICON_FILE, idiom: 'universal', platform: 'ios', size: '1024x1024' }],
|
|
69
|
+
info: { author: 'xcode', version: 1 }
|
|
70
|
+
}, null, 2)}\n`;
|
|
71
|
+
export const toHex = (red, green, blue) => `#${[red, green, blue]
|
|
72
|
+
.map((v) => v.toString(16).padStart(2, '0'))
|
|
73
|
+
.join('')
|
|
74
|
+
.toUpperCase()}`;
|
package/lib/icons.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ENVIRONMENTS, flavorOf, loadToolsConfig, resolveIosProjectName } from './config.js';
|
|
4
|
+
import { ADAPTIVE_DP, ADAPTIVE_ICON_XML, ADAPTIVE_VISIBLE_DP, backgroundColorXml, circleMaskSvg, DENSITIES, IOS_ICON_FILE, iosContentsJson, LEGACY_DP, RIBBONS, ribbonSvg, toHex } from './iconArt.js';
|
|
5
|
+
import { fail, say } from './output.js';
|
|
6
|
+
const MASTER = 1024;
|
|
7
|
+
/** Fixed encoder settings: the same source always gives the same bytes. */
|
|
8
|
+
const PNG = { compressionLevel: 9, adaptiveFiltering: false, palette: false, force: true };
|
|
9
|
+
const ICON_NAMES = ['ic_launcher', 'ic_launcher_round', 'ic_launcher_foreground'];
|
|
10
|
+
/** Removes a same-named launcher icon in another format, which Android would reject as a duplicate resource. */
|
|
11
|
+
const removeOtherFormats = (dir) => {
|
|
12
|
+
if (!existsSync(dir)) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
for (const file of readdirSync(dir)) {
|
|
16
|
+
const { name, ext } = path.parse(file);
|
|
17
|
+
if (ICON_NAMES.includes(name) && ext !== '.png') {
|
|
18
|
+
rmSync(path.join(dir, file));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const write = (file, content) => {
|
|
23
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
writeFileSync(file, content);
|
|
25
|
+
};
|
|
26
|
+
/** A values file other than ours defining the same color would fail the resource merge. */
|
|
27
|
+
const assertNoColorClash = (valuesDir, ours) => {
|
|
28
|
+
if (!existsSync(valuesDir)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const file of readdirSync(valuesDir)) {
|
|
32
|
+
if (file === ours || !file.endsWith('.xml')) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (readFileSync(path.join(valuesDir, file), 'utf8').includes('name="ic_launcher_background"')) {
|
|
36
|
+
fail(`${path.join(valuesDir, file)} already defines ic_launcher_background; remove it there`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const loadMaster = async (sharp, file) => {
|
|
41
|
+
const meta = await sharp(file).metadata();
|
|
42
|
+
if (meta.format !== 'png' && meta.format !== 'svg') {
|
|
43
|
+
return fail(`icon source must be a PNG or an SVG, not ${meta.format ?? 'unknown'}: ${file}`);
|
|
44
|
+
}
|
|
45
|
+
const width = meta.width ?? 0;
|
|
46
|
+
const height = meta.height ?? 0;
|
|
47
|
+
if (width !== height) {
|
|
48
|
+
return fail(`icon source must be square, ${file} is ${width}x${height}`);
|
|
49
|
+
}
|
|
50
|
+
if (meta.format === 'png' && width < MASTER) {
|
|
51
|
+
return fail(`a PNG icon source must be at least ${MASTER}x${MASTER}, ${file} is ${width}x${height}`);
|
|
52
|
+
}
|
|
53
|
+
const options = meta.format === 'svg' ? { density: (72 * MASTER) / width } : {};
|
|
54
|
+
return sharp(file, options)
|
|
55
|
+
.resize(MASTER, MASTER, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
56
|
+
.ensureAlpha()
|
|
57
|
+
.png(PNG)
|
|
58
|
+
.toBuffer();
|
|
59
|
+
};
|
|
60
|
+
/** The adaptive background is the source's own corner colour, so a full-bleed icon reads as one piece. */
|
|
61
|
+
const cornerColor = async (sharp, master) => {
|
|
62
|
+
const { data } = await sharp(master).raw().toBuffer({ resolveWithObject: true });
|
|
63
|
+
return data[3] === 0 ? '#FFFFFF' : toHex(data[0], data[1], data[2]);
|
|
64
|
+
};
|
|
65
|
+
const withRibbon = async (sharp, image, env, box) => {
|
|
66
|
+
const ribbon = RIBBONS[env];
|
|
67
|
+
if (!ribbon) {
|
|
68
|
+
return image;
|
|
69
|
+
}
|
|
70
|
+
return sharp(image)
|
|
71
|
+
.composite([{ input: Buffer.from(ribbonSvg(MASTER, box, ribbon)) }])
|
|
72
|
+
.png(PNG)
|
|
73
|
+
.toBuffer();
|
|
74
|
+
};
|
|
75
|
+
const renderAndroid = async (sharp, appRoot, env, master, background) => {
|
|
76
|
+
const res = path.join(appRoot, 'android', 'app', 'src', flavorOf(env), 'res');
|
|
77
|
+
const full = await withRibbon(sharp, master, env, { x: 0, y: 0, size: MASTER });
|
|
78
|
+
const visible = Math.round((MASTER * ADAPTIVE_VISIBLE_DP) / ADAPTIVE_DP);
|
|
79
|
+
const inset = Math.round((MASTER - visible) / 2);
|
|
80
|
+
const inner = await sharp(master).resize(visible, visible).png(PNG).toBuffer();
|
|
81
|
+
const foregroundPlain = await sharp({
|
|
82
|
+
create: {
|
|
83
|
+
width: MASTER,
|
|
84
|
+
height: MASTER,
|
|
85
|
+
channels: 4,
|
|
86
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
.composite([{ input: inner, left: inset, top: inset }])
|
|
90
|
+
.png(PNG)
|
|
91
|
+
.toBuffer();
|
|
92
|
+
const foreground = await withRibbon(sharp, foregroundPlain, env, {
|
|
93
|
+
x: inset,
|
|
94
|
+
y: inset,
|
|
95
|
+
size: visible
|
|
96
|
+
});
|
|
97
|
+
for (const [density, scale] of DENSITIES) {
|
|
98
|
+
const dir = path.join(res, `mipmap-${density}`);
|
|
99
|
+
removeOtherFormats(dir);
|
|
100
|
+
const legacy = Math.round(LEGACY_DP * scale);
|
|
101
|
+
const adaptive = Math.round(ADAPTIVE_DP * scale);
|
|
102
|
+
write(path.join(dir, 'ic_launcher.png'), await sharp(full).resize(legacy, legacy).png(PNG).toBuffer());
|
|
103
|
+
write(path.join(dir, 'ic_launcher_round.png'), await sharp(await sharp(full).resize(legacy, legacy).png(PNG).toBuffer())
|
|
104
|
+
.composite([{ input: Buffer.from(circleMaskSvg(legacy)), blend: 'dest-in' }])
|
|
105
|
+
.png(PNG)
|
|
106
|
+
.toBuffer());
|
|
107
|
+
write(path.join(dir, 'ic_launcher_foreground.png'), await sharp(foreground).resize(adaptive, adaptive).png(PNG).toBuffer());
|
|
108
|
+
}
|
|
109
|
+
const anydpi = path.join(res, 'mipmap-anydpi-v26');
|
|
110
|
+
removeOtherFormats(anydpi);
|
|
111
|
+
write(path.join(anydpi, 'ic_launcher.xml'), ADAPTIVE_ICON_XML);
|
|
112
|
+
write(path.join(anydpi, 'ic_launcher_round.xml'), ADAPTIVE_ICON_XML);
|
|
113
|
+
const values = path.join(res, 'values');
|
|
114
|
+
assertNoColorClash(values, 'ic_launcher_background.xml');
|
|
115
|
+
write(path.join(values, 'ic_launcher_background.xml'), backgroundColorXml(background));
|
|
116
|
+
say(`icons: ${path.relative(appRoot, res)}`);
|
|
117
|
+
};
|
|
118
|
+
const renderIos = async (sharp, appRoot, projectName, env, master, background) => {
|
|
119
|
+
const catalog = path.join(appRoot, 'ios', projectName, 'Images.xcassets');
|
|
120
|
+
if (!existsSync(catalog)) {
|
|
121
|
+
fail(`${path.relative(appRoot, catalog)} not found: check "iosProjectName"`);
|
|
122
|
+
}
|
|
123
|
+
const set = path.join(catalog, `AppIcon-${env}.appiconset`);
|
|
124
|
+
// The set is generated whole: older per-size files would sit unreferenced beside the new one.
|
|
125
|
+
rmSync(set, { recursive: true, force: true });
|
|
126
|
+
const full = await withRibbon(sharp, master, env, { x: 0, y: 0, size: MASTER });
|
|
127
|
+
// The App Store rejects an icon with an alpha channel.
|
|
128
|
+
write(path.join(set, IOS_ICON_FILE), await sharp(full).flatten({ background }).png(PNG).toBuffer());
|
|
129
|
+
write(path.join(set, 'Contents.json'), iosContentsJson());
|
|
130
|
+
say(`icons: ${path.relative(appRoot, set)}`);
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Renders every environment's launcher icons from one source: Android per flavor (legacy,
|
|
134
|
+
* round and adaptive) and an iOS AppIcon-<Env> set, which prebuild then selects.
|
|
135
|
+
*/
|
|
136
|
+
export const icons = async (options) => {
|
|
137
|
+
const { appRoot } = options;
|
|
138
|
+
const config = loadToolsConfig(appRoot);
|
|
139
|
+
const source = options.source ??
|
|
140
|
+
config.iconSource ??
|
|
141
|
+
fail('no icon source: pass --source <png|svg> or set "iconSource" in mobile-tools.config.json', 2);
|
|
142
|
+
const file = path.resolve(appRoot, source);
|
|
143
|
+
if (!existsSync(file)) {
|
|
144
|
+
fail(`icon source not found: ${file}`);
|
|
145
|
+
}
|
|
146
|
+
const { default: sharp } = await import('sharp');
|
|
147
|
+
const master = await loadMaster(sharp, file);
|
|
148
|
+
const background = await cornerColor(sharp, master);
|
|
149
|
+
const projectName = resolveIosProjectName(appRoot, config);
|
|
150
|
+
const hasAndroid = existsSync(path.join(appRoot, 'android'));
|
|
151
|
+
if (hasAndroid && !config.flavors) {
|
|
152
|
+
say('icons: Android skipped, "flavors" is false: per-environment icons live in flavor source sets');
|
|
153
|
+
}
|
|
154
|
+
for (const env of ENVIRONMENTS) {
|
|
155
|
+
if (hasAndroid && config.flavors) {
|
|
156
|
+
await renderAndroid(sharp, appRoot, env, master, background);
|
|
157
|
+
}
|
|
158
|
+
if (projectName) {
|
|
159
|
+
await renderIos(sharp, appRoot, projectName, env, master, background);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
package/lib/ios.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { capture } from './exec.js';
|
|
5
|
+
import { fail } from './output.js';
|
|
6
|
+
import { XCRUN } from './platform.js';
|
|
7
|
+
/** Booted simulators from `xcrun simctl list -j devices booted`. */
|
|
8
|
+
export const parseSimctlBooted = (json) => {
|
|
9
|
+
const parsed = JSON.parse(json);
|
|
10
|
+
return Object.values(parsed.devices ?? {})
|
|
11
|
+
.flat()
|
|
12
|
+
.filter((device) => device.state === 'Booted')
|
|
13
|
+
.map((device) => ({
|
|
14
|
+
udid: device.udid,
|
|
15
|
+
name: device.name,
|
|
16
|
+
kind: 'simulator',
|
|
17
|
+
serials: [device.udid],
|
|
18
|
+
model: device.name
|
|
19
|
+
}));
|
|
20
|
+
};
|
|
21
|
+
/** Paired, reachable iOS devices from `xcrun devicectl list devices --json-output`. */
|
|
22
|
+
export const parseDevicectl = (json) => {
|
|
23
|
+
const parsed = JSON.parse(json);
|
|
24
|
+
return (parsed.result?.devices ?? [])
|
|
25
|
+
.filter((device) => device.hardwareProperties?.platform === 'iOS' &&
|
|
26
|
+
device.connectionProperties?.pairingState === 'paired' &&
|
|
27
|
+
device.connectionProperties?.tunnelState !== 'unavailable' &&
|
|
28
|
+
device.hardwareProperties?.udid)
|
|
29
|
+
.map((device) => {
|
|
30
|
+
const udid = device.hardwareProperties?.udid;
|
|
31
|
+
const name = device.deviceProperties?.name ?? udid;
|
|
32
|
+
return {
|
|
33
|
+
udid,
|
|
34
|
+
name,
|
|
35
|
+
kind: 'device',
|
|
36
|
+
serials: [udid, device.identifier ?? udid],
|
|
37
|
+
model: name
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
export const listAppleDevices = (cwd) => {
|
|
42
|
+
const sims = capture({ file: XCRUN, args: ['simctl', 'list', '-j', 'devices', 'booted'] }, cwd);
|
|
43
|
+
if (sims.status !== 0) {
|
|
44
|
+
return fail(`xcrun simctl failed: ${sims.stderr.trim()}`);
|
|
45
|
+
}
|
|
46
|
+
const devices = parseSimctlBooted(sims.stdout);
|
|
47
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'mobile-tools-'));
|
|
48
|
+
const out = path.join(dir, 'devices.json');
|
|
49
|
+
try {
|
|
50
|
+
const listed = capture({ file: XCRUN, args: ['devicectl', 'list', 'devices', '--json-output', out] }, cwd);
|
|
51
|
+
if (listed.status === 0) {
|
|
52
|
+
devices.push(...parseDevicectl(readFileSync(out, 'utf8')));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
if (devices.length === 0) {
|
|
59
|
+
return fail('no booted simulator or connected iPhone: boot one with xcrun simctl boot <name>');
|
|
60
|
+
}
|
|
61
|
+
return devices;
|
|
62
|
+
};
|
|
63
|
+
export const describeAppleDevice = (device) => `${device.name} (${device.kind}, ${device.udid})`;
|
|
64
|
+
/** xcodebuild arguments for a Debug build of one destination into ios/build. */
|
|
65
|
+
export const xcodebuildArgs = (projectName, udid) => [
|
|
66
|
+
'-workspace',
|
|
67
|
+
path.join('ios', `${projectName}.xcworkspace`),
|
|
68
|
+
'-scheme',
|
|
69
|
+
projectName,
|
|
70
|
+
'-configuration',
|
|
71
|
+
'Debug',
|
|
72
|
+
'-destination',
|
|
73
|
+
`id=${udid}`,
|
|
74
|
+
'-derivedDataPath',
|
|
75
|
+
path.join('ios', 'build'),
|
|
76
|
+
'ONLY_ACTIVE_ARCH=YES',
|
|
77
|
+
'build'
|
|
78
|
+
];
|
package/lib/metro.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { startDetached } from './exec.js';
|
|
5
|
+
import { fail, say } from './output.js';
|
|
6
|
+
export const DEFAULT_METRO_PORT = 8081;
|
|
7
|
+
const samePath = (a, b) => {
|
|
8
|
+
const norm = (p) => {
|
|
9
|
+
const resolved = path.resolve(p).replace(/[\\/]+$/, '');
|
|
10
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
11
|
+
};
|
|
12
|
+
return norm(a) === norm(b);
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Whether the Metro answering on a port serves this app. The CLI's dev server names its project
|
|
16
|
+
* root in the X-React-Native-Project-Root header; a server that does not say is treated as
|
|
17
|
+
* another project's.
|
|
18
|
+
*/
|
|
19
|
+
export const classifyStatus = (body, projectRootHeader, appRoot) => {
|
|
20
|
+
if (!body.includes('packager-status:running')) {
|
|
21
|
+
return 'other';
|
|
22
|
+
}
|
|
23
|
+
const root = projectRootHeader ?? /project-root:(.+)/.exec(body)?.[1]?.trim();
|
|
24
|
+
return root && samePath(root, appRoot) ? 'ours' : 'other';
|
|
25
|
+
};
|
|
26
|
+
const portIsFree = (port) => new Promise((resolve) => {
|
|
27
|
+
const server = createServer();
|
|
28
|
+
server.once('error', () => resolve(false));
|
|
29
|
+
server.once('listening', () => server.close(() => resolve(true)));
|
|
30
|
+
server.listen(port);
|
|
31
|
+
});
|
|
32
|
+
export const probeMetro = async (port, appRoot) => {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(`http://localhost:${port}/status`, {
|
|
35
|
+
signal: AbortSignal.timeout(1000)
|
|
36
|
+
});
|
|
37
|
+
return classifyStatus(await response.text(), response.headers.get('X-React-Native-Project-Root'), appRoot);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return (await portIsFree(port)) ? 'free' : 'other';
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const waitForMetro = async (port, appRoot, logFile) => {
|
|
44
|
+
const deadline = Date.now() + 120_000;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
if ((await probeMetro(port, appRoot)) === 'ours') {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
50
|
+
}
|
|
51
|
+
fail(`Metro did not answer on port ${port} within 2 minutes; see ${logFile}`);
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* A Metro serving this app: reuses one already running for it, otherwise starts one detached on
|
|
55
|
+
* 8081 or, when another project holds 8081, the next free port. Never prompts. Returns the port.
|
|
56
|
+
*/
|
|
57
|
+
export const ensureMetro = async (appRoot, logFile, { fixedPort }) => {
|
|
58
|
+
const lastPort = fixedPort ? DEFAULT_METRO_PORT : DEFAULT_METRO_PORT + 19;
|
|
59
|
+
for (let port = DEFAULT_METRO_PORT; port <= lastPort; port++) {
|
|
60
|
+
const state = await probeMetro(port, appRoot);
|
|
61
|
+
if (state === 'ours') {
|
|
62
|
+
say(`metro: reusing the server on port ${port}`);
|
|
63
|
+
return port;
|
|
64
|
+
}
|
|
65
|
+
if (state === 'free') {
|
|
66
|
+
let cli;
|
|
67
|
+
try {
|
|
68
|
+
cli = createRequire(path.join(appRoot, 'package.json')).resolve('react-native/cli.js');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return fail('react-native is not installed in this app; run npx mobile-tools sync');
|
|
72
|
+
}
|
|
73
|
+
startDetached({ file: process.execPath, args: [cli, 'start', '--port', String(port)] }, appRoot, logFile);
|
|
74
|
+
say(`metro: started on port ${port}, log ${logFile}`);
|
|
75
|
+
await waitForMetro(port, appRoot, logFile);
|
|
76
|
+
return port;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return fail(fixedPort
|
|
80
|
+
? 'port 8081 is held by another server (the iOS app always looks there); stop it and rerun'
|
|
81
|
+
: 'ports 8081-8100 are all held by other servers; stop one and rerun');
|
|
82
|
+
};
|
package/lib/output.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** A failure the CLI reports as one message and a non-zero exit, without a stack trace. */
|
|
2
|
+
export class CliError extends Error {
|
|
3
|
+
exitCode;
|
|
4
|
+
constructor(message, exitCode = 1) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'CliError';
|
|
7
|
+
this.exitCode = exitCode;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export const fail = (message, exitCode = 1) => {
|
|
11
|
+
throw new CliError(message, exitCode);
|
|
12
|
+
};
|
|
13
|
+
export const say = (message) => {
|
|
14
|
+
process.stdout.write(`${message}\n`);
|
|
15
|
+
};
|
|
16
|
+
export const warn = (message) => {
|
|
17
|
+
process.stderr.write(`${message}\n`);
|
|
18
|
+
};
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fail } from './output.js';
|
|
4
|
+
/** The path flavour of the host, so a Windows path is built with Windows rules on any machine. */
|
|
5
|
+
export const pathFor = (platform) => platform === 'win32' ? path.win32 : path.posix;
|
|
6
|
+
/** Windows env keys are case-insensitive: `Path` and `PATH` are one variable. */
|
|
7
|
+
const readEnv = (env, name) => {
|
|
8
|
+
const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
9
|
+
return key === undefined ? undefined : env[key];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The environment every child gets. `NoDefaultCurrentDirectoryInExePath` is set by some agent
|
|
13
|
+
* shells and stops cmd.exe finding `gradlew.bat` in the current folder, so it never reaches a
|
|
14
|
+
* child, under any casing.
|
|
15
|
+
*/
|
|
16
|
+
export const childEnv = (env) => {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [key, value] of Object.entries(env)) {
|
|
19
|
+
if (key.toLowerCase() !== 'nodefaultcurrentdirectoryinexepath') {
|
|
20
|
+
out[key] = value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
};
|
|
25
|
+
/** The absolute path of a program on PATH. On Windows only `.exe` counts: nothing here spawns a script shim. */
|
|
26
|
+
export const findOnPath = (name, env, platform, exists = existsSync) => {
|
|
27
|
+
const p = pathFor(platform);
|
|
28
|
+
const searchPath = readEnv(env, 'PATH') ?? '';
|
|
29
|
+
const file = platform === 'win32' ? `${name}.exe` : name;
|
|
30
|
+
for (const dir of searchPath.split(platform === 'win32' ? ';' : ':')) {
|
|
31
|
+
if (dir === '') {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const candidate = p.join(dir, file);
|
|
35
|
+
if (exists(candidate)) {
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
};
|
|
41
|
+
/** adb from the SDK named by ANDROID_HOME, else the first adb on PATH. */
|
|
42
|
+
export const adbPath = (env, platform, exists = existsSync) => {
|
|
43
|
+
const p = pathFor(platform);
|
|
44
|
+
const home = readEnv(env, 'ANDROID_HOME');
|
|
45
|
+
if (home) {
|
|
46
|
+
const candidate = p.join(home, 'platform-tools', platform === 'win32' ? 'adb.exe' : 'adb');
|
|
47
|
+
if (exists(candidate)) {
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return (findOnPath('adb', env, platform, exists) ??
|
|
52
|
+
fail('adb not found: set ANDROID_HOME to the Android SDK (adb lives in its platform-tools folder) or put adb on PATH.'));
|
|
53
|
+
};
|
|
54
|
+
/** Gradle arguments are fixed task names and properties; anything else would need quoting cmd.exe cannot be trusted with. */
|
|
55
|
+
const SAFE_GRADLE_ARG = /^[A-Za-z0-9_:.=-]+$/;
|
|
56
|
+
/**
|
|
57
|
+
* How to run the Gradle wrapper. POSIX runs `android/gradlew` directly. Windows runs
|
|
58
|
+
* `android/gradlew.bat`, which Node can only start through cmd.exe (it refuses to spawn a .bat
|
|
59
|
+
* without a shell), so cmd.exe is named by absolute path and every argument is checked to be a
|
|
60
|
+
* plain token first.
|
|
61
|
+
*/
|
|
62
|
+
export const gradleInvocation = (androidDir, args, platform, env) => {
|
|
63
|
+
for (const arg of args) {
|
|
64
|
+
if (!SAFE_GRADLE_ARG.test(arg)) {
|
|
65
|
+
fail(`refusing Gradle argument ${JSON.stringify(arg)}: only plain task names and -Pkey=value`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const p = pathFor(platform);
|
|
69
|
+
if (platform !== 'win32') {
|
|
70
|
+
return { file: p.join(androidDir, 'gradlew'), args };
|
|
71
|
+
}
|
|
72
|
+
const wrapper = p.join(androidDir, 'gradlew.bat');
|
|
73
|
+
if (/["%]/.test(wrapper)) {
|
|
74
|
+
fail(`the app path cannot hold " or %: ${wrapper}`);
|
|
75
|
+
}
|
|
76
|
+
const systemRoot = readEnv(env, 'SystemRoot') ?? 'C:\\Windows';
|
|
77
|
+
const cmd = readEnv(env, 'ComSpec') ?? p.join(systemRoot, 'System32', 'cmd.exe');
|
|
78
|
+
return {
|
|
79
|
+
file: cmd,
|
|
80
|
+
args: ['/d', '/s', '/c', `""${wrapper}" ${args.join(' ')}"`],
|
|
81
|
+
windowsVerbatimArguments: true
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
/** iOS commands exist only on macOS; elsewhere they stop with one line. */
|
|
85
|
+
export const requireDarwin = (platform, what) => {
|
|
86
|
+
if (platform !== 'darwin') {
|
|
87
|
+
fail(`${what} needs macOS: xcodebuild and xcrun exist only there.`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
export const XCRUN = '/usr/bin/xcrun';
|
|
91
|
+
export const XCODEBUILD = '/usr/bin/xcodebuild';
|
|
92
|
+
/**
|
|
93
|
+
* npm as `node <npm-cli.js>`: on Windows `npm` is a .cmd shim Node will not spawn without a
|
|
94
|
+
* shell. npm_execpath is set when this CLI itself runs under npm or npx.
|
|
95
|
+
*/
|
|
96
|
+
export const npmInvocation = (args, env, platform, execPath, exists = existsSync) => {
|
|
97
|
+
const p = pathFor(platform);
|
|
98
|
+
const fromEnv = readEnv(env, 'npm_execpath');
|
|
99
|
+
const candidates = [
|
|
100
|
+
fromEnv && /npm-cli\.c?js$/.test(fromEnv) ? fromEnv : undefined,
|
|
101
|
+
platform === 'win32'
|
|
102
|
+
? p.join(p.dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
103
|
+
: p.join(p.dirname(execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
104
|
+
];
|
|
105
|
+
const cli = candidates.find((c) => c !== undefined && exists(c));
|
|
106
|
+
if (!cli) {
|
|
107
|
+
return fail(`npm not found beside ${execPath}; install Node 24 with its bundled npm.`);
|
|
108
|
+
}
|
|
109
|
+
return { file: execPath, args: [cli, ...args] };
|
|
110
|
+
};
|
|
111
|
+
/** pnpm as its own executable, or as `node <pnpm.cjs>` when this CLI runs under pnpm. */
|
|
112
|
+
export const pnpmInvocation = (args, env, platform, execPath, exists = existsSync) => {
|
|
113
|
+
const fromEnv = readEnv(env, 'npm_execpath');
|
|
114
|
+
if (fromEnv && /pnpm\.c?js$/.test(fromEnv) && exists(fromEnv)) {
|
|
115
|
+
return { file: execPath, args: [fromEnv, ...args] };
|
|
116
|
+
}
|
|
117
|
+
const exe = findOnPath('pnpm', env, platform, exists);
|
|
118
|
+
if (!exe) {
|
|
119
|
+
return fail('pnpm not found on PATH (on Windows, pnpm.exe); install pnpm, or run through pnpm exec.');
|
|
120
|
+
}
|
|
121
|
+
return { file: exe, args };
|
|
122
|
+
};
|