@expo-harmony/cli 55.0.26-harmony.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 +150 -0
- package/build/bin/expo-harmony.js +5 -0
- package/build/index.js +22 -0
- package/package.json +67 -0
- package/src/args.ts +42 -0
- package/src/bin/expo-harmony.ts +5 -0
- package/src/buildHap/build.ts +164 -0
- package/src/buildHap/common.ts +145 -0
- package/src/buildHap/options.ts +35 -0
- package/src/cli.ts +242 -0
- package/src/doctor/doctor.ts +267 -0
- package/src/doctor/options.ts +17 -0
- package/src/entry.ts +47 -0
- package/src/errors.ts +22 -0
- package/src/expo.ts +89 -0
- package/src/exportEmbed/export.ts +177 -0
- package/src/exportEmbed/manifest.ts +222 -0
- package/src/exportEmbed/options.ts +25 -0
- package/src/file.ts +110 -0
- package/src/index.ts +12 -0
- package/src/modules/modules.ts +154 -0
- package/src/modules/options.ts +70 -0
- package/src/path.ts +27 -0
- package/src/prebuild/check.ts +189 -0
- package/src/prebuild/clean.ts +67 -0
- package/src/prebuild/options.ts +69 -0
- package/src/prebuild/prebuild.ts +80 -0
- package/src/prebuild/template.ts +119 -0
- package/src/process.ts +275 -0
- package/src/project.ts +21 -0
- package/src/projectLock.ts +229 -0
- package/src/run/cache.ts +105 -0
- package/src/run/devices.ts +302 -0
- package/src/run/emulators.ts +107 -0
- package/src/run/install.ts +41 -0
- package/src/run/metro.ts +237 -0
- package/src/run/options.ts +71 -0
- package/src/run/run.ts +302 -0
- package/src/start/options.ts +36 -0
- package/src/tools.ts +316 -0
- package/src/upstream.ts +18 -0
- package/tsconfig.json +20 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { parseBuildArgs } from './buildHap/options';
|
|
4
|
+
import { parseDoctorArgs } from './doctor/options';
|
|
5
|
+
import { HarmonyCliError } from './errors';
|
|
6
|
+
import { parseExportEmbedArgs } from './exportEmbed/options';
|
|
7
|
+
import { parseModulesArgs } from './modules/options';
|
|
8
|
+
import { parsePrebuildArgs } from './prebuild/options';
|
|
9
|
+
import { resolveProject } from './project';
|
|
10
|
+
import { withHarmonyProjectLockAsync } from './projectLock';
|
|
11
|
+
import { parseRunArgs } from './run/options';
|
|
12
|
+
import { parseStartArgs } from './start/options';
|
|
13
|
+
|
|
14
|
+
const Help = `Usage: expo-harmony <command> [project] [options]
|
|
15
|
+
|
|
16
|
+
Commands:
|
|
17
|
+
start Start Expo Metro for Harmony development
|
|
18
|
+
prebuild Generate the Harmony native project with Expo CNG
|
|
19
|
+
prebuild --clean Safely recreate the managed Harmony directory
|
|
20
|
+
prebuild --check Compare generated desired state without project writes
|
|
21
|
+
build Build a HAP without selecting or contacting a device
|
|
22
|
+
doctor Validate config, versions, Metro, RNOH, SDK, and signing
|
|
23
|
+
modules list List native module candidates and their discovery source
|
|
24
|
+
modules inspect Resolve Harmony metadata (--package narrows the result)
|
|
25
|
+
modules verify Validate module config, registration conflicts and safe paths
|
|
26
|
+
export:embed Export validated Hermes bytecode, assets, and a source map
|
|
27
|
+
run Build, install, and launch the Harmony app
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--no-install Skip dependency install (prebuild) or HAP install (run)
|
|
31
|
+
--npm|--yarn|--pnpm|--bun
|
|
32
|
+
Select the package manager used by prebuild dependency install
|
|
33
|
+
--skip-dependency-update <packages>
|
|
34
|
+
Preserve comma-separated dependency versions
|
|
35
|
+
--device <id-or-name> Select an HDC target or start a local emulator by name
|
|
36
|
+
--variant <mode> Build debug or release (default: debug)
|
|
37
|
+
--no-bundler Use an already-running Expo Metro server
|
|
38
|
+
--app-id <bundleName>
|
|
39
|
+
Launch another installed app (requires --no-install when different)
|
|
40
|
+
--port <number> Metro and device reverse port (default: 8081)
|
|
41
|
+
--sync Re-run prebuild before building
|
|
42
|
+
--check Validate an existing export without writing
|
|
43
|
+
--reset-cache Reset Metro while exporting or starting
|
|
44
|
+
-c, --clear Alias for --reset-cache (start)
|
|
45
|
+
-h, --help Show this help
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
type Invocation = { command: 'help' }
|
|
49
|
+
| { command: 'build'; parsed: ReturnType<typeof parseBuildArgs>; projectRoot: string }
|
|
50
|
+
| { command: 'doctor'; parsed: ReturnType<typeof parseDoctorArgs>; projectRoot: string }
|
|
51
|
+
| { command: 'export:embed'; parsed: ReturnType<typeof parseExportEmbedArgs>; projectRoot: string }
|
|
52
|
+
| { command: 'modules'; parsed: ReturnType<typeof parseModulesArgs>; projectRoot: string }
|
|
53
|
+
| { command: 'prebuild'; parsed: ReturnType<typeof parsePrebuildArgs>; projectRoot: string }
|
|
54
|
+
| { command: 'run'; parsed: ReturnType<typeof parseRunArgs>; projectRoot: string }
|
|
55
|
+
| { command: 'start'; parsed: ReturnType<typeof parseStartArgs>; projectRoot: string };
|
|
56
|
+
|
|
57
|
+
function parseInvocation(argv: string[]): Invocation {
|
|
58
|
+
if (argv.length === 0 || (argv.length === 1 && ['--help', '-h'].includes(argv[0]))) {
|
|
59
|
+
return { command: 'help' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const command = argv[0] as Exclude<Invocation['command'], 'help'>;
|
|
63
|
+
if (!['build', 'prebuild', 'doctor', 'export:embed', 'modules', 'run', 'start'].includes(command)) {
|
|
64
|
+
throw new HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', `Unknown command: ${command}`, {
|
|
65
|
+
operation: 'parse-arguments',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parsed = command === 'build'
|
|
70
|
+
? parseBuildArgs(argv.slice(1))
|
|
71
|
+
: command === 'prebuild'
|
|
72
|
+
? parsePrebuildArgs(argv.slice(1), { allowProject: true })
|
|
73
|
+
: command === 'doctor'
|
|
74
|
+
? parseDoctorArgs(argv.slice(1))
|
|
75
|
+
: command === 'export:embed'
|
|
76
|
+
? parseExportEmbedArgs(argv.slice(1))
|
|
77
|
+
: command === 'modules'
|
|
78
|
+
? parseModulesArgs(argv.slice(1))
|
|
79
|
+
: command === 'start'
|
|
80
|
+
? parseStartArgs(argv.slice(1))
|
|
81
|
+
: parseRunArgs(argv.slice(1));
|
|
82
|
+
if (parsed.help) return { command: 'help' };
|
|
83
|
+
|
|
84
|
+
const projectRoot = resolveProject(parsed.project ? path.resolve(parsed.project) : process.cwd());
|
|
85
|
+
|
|
86
|
+
return { command, parsed, projectRoot } as Invocation;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runAsync(
|
|
90
|
+
argv: string[] = process.argv.slice(2),
|
|
91
|
+
io: Pick<Console, 'error' | 'log' | 'warn'> = console
|
|
92
|
+
): Promise<number> {
|
|
93
|
+
const invocation = parseInvocation(argv);
|
|
94
|
+
if (invocation.command === 'help') {
|
|
95
|
+
io.log(Help);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (invocation.command === 'start') {
|
|
100
|
+
const { startExpoMetroAsync } = await import('./run/metro.js');
|
|
101
|
+
const metro = await startExpoMetroAsync(invocation.projectRoot, {
|
|
102
|
+
...invocation.parsed,
|
|
103
|
+
interactive: true,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
if (metro.owner === 'existing') {
|
|
108
|
+
io.log(`Expo Metro is already running on port ${metro.port}.`);
|
|
109
|
+
if (invocation.parsed.resetCache) {
|
|
110
|
+
io.warn('Stop the existing Metro server and run this command again to reset its cache.');
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
io.log('\n› Logs for your project will appear below. Press Ctrl+C to exit.');
|
|
114
|
+
await metro.waitAsync();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return 0;
|
|
118
|
+
} finally {
|
|
119
|
+
await metro.stop();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (invocation.command === 'doctor') {
|
|
124
|
+
const { doctorAsync, formatDoctor } = await import('./doctor/doctor.js');
|
|
125
|
+
const result = await doctorAsync(invocation.projectRoot, { requireBuildTools: true });
|
|
126
|
+
|
|
127
|
+
io.log(formatDoctor(result));
|
|
128
|
+
|
|
129
|
+
return result.ok ? 0 : 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (invocation.command === 'build') {
|
|
133
|
+
const { buildHarmonyAsync } = await import('./buildHap/build.js');
|
|
134
|
+
const result = await buildHarmonyAsync(invocation.projectRoot, {
|
|
135
|
+
...invocation.parsed,
|
|
136
|
+
io,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
io.log(`Built ${result.variant} HAP at ${result.hapPath}; no device actions were performed.`);
|
|
140
|
+
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (invocation.command === 'export:embed') {
|
|
145
|
+
const { exportEmbedAsync } = await import('./exportEmbed/export.js');
|
|
146
|
+
const result = await exportEmbedAsync(invocation.projectRoot, invocation.parsed);
|
|
147
|
+
|
|
148
|
+
if (invocation.parsed.check) io.log('Harmony export bundle, assets, and source map are valid.');
|
|
149
|
+
else io.log(`Exported Hermes bytecode ${result.bundle.path} with ${result.assets.length} asset file(s).`);
|
|
150
|
+
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (invocation.command === 'modules') {
|
|
155
|
+
const { formatModulesResult, runModulesCommandAsync } = await import('./modules/modules.js');
|
|
156
|
+
const result = await runModulesCommandAsync(invocation.projectRoot, invocation.parsed);
|
|
157
|
+
|
|
158
|
+
io.log(formatModulesResult(result));
|
|
159
|
+
return result.ok ? 0 : 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (invocation.command === 'run') {
|
|
163
|
+
const { runHarmonySessionAsync } = await import('./run/run.js');
|
|
164
|
+
|
|
165
|
+
const session = await runHarmonySessionAsync(invocation.projectRoot, {
|
|
166
|
+
...invocation.parsed,
|
|
167
|
+
interactiveBundler: true,
|
|
168
|
+
io,
|
|
169
|
+
});
|
|
170
|
+
const { result } = session;
|
|
171
|
+
|
|
172
|
+
io.log(`Launched ${result.bundleName} on ${result.device.id} (${result.variant}).`);
|
|
173
|
+
if (session.metro.owner === 'started') {
|
|
174
|
+
io.log('\n› Logs for your project will appear below. Press Ctrl+C to exit.');
|
|
175
|
+
await session.metro.waitAsync();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { check, clean, passthrough } = invocation.parsed;
|
|
182
|
+
|
|
183
|
+
if (check && passthrough.length) {
|
|
184
|
+
throw new HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', '--check cannot be combined with mutating prebuild options.');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return withHarmonyProjectLockAsync(
|
|
188
|
+
invocation.projectRoot,
|
|
189
|
+
check ? 'prebuild-check' : 'prebuild',
|
|
190
|
+
async () => {
|
|
191
|
+
if (clean) {
|
|
192
|
+
const { assertSafeCleanTarget } = await import('./prebuild/clean.js');
|
|
193
|
+
await assertSafeCleanTarget(invocation.projectRoot);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { doctorAsync, formatDoctor } = await import('./doctor/doctor.js');
|
|
197
|
+
const doctor = await doctorAsync(invocation.projectRoot, {
|
|
198
|
+
requireBuildTools: false,
|
|
199
|
+
validateGeneratedProject: !clean,
|
|
200
|
+
validateModules: false,
|
|
201
|
+
});
|
|
202
|
+
if (!doctor.ok) {
|
|
203
|
+
io.error(formatDoctor(doctor));
|
|
204
|
+
throw new HarmonyCliError('ERR_HARMONY_DOCTOR_FAILED', 'Harmony doctor found blocking errors.', {
|
|
205
|
+
operation: 'doctor',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const item of doctor.checks.filter(item => item.status === 'warn')) io.warn(`! ${item.message}`);
|
|
210
|
+
|
|
211
|
+
if (check) {
|
|
212
|
+
const { checkAsync } = await import('./prebuild/check.js');
|
|
213
|
+
const result = await checkAsync(invocation.projectRoot);
|
|
214
|
+
|
|
215
|
+
if (result.clean) io.log('Harmony CNG output is up to date.');
|
|
216
|
+
else for (const change of result.changes) io.log(`${change.type}: ${change.path}`);
|
|
217
|
+
|
|
218
|
+
return result.clean ? 0 : 2;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { prebuildParsedAsync } = await import('./prebuild/prebuild.js');
|
|
222
|
+
await prebuildParsedAsync(invocation.projectRoot, passthrough);
|
|
223
|
+
|
|
224
|
+
return 0;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function main(argv = process.argv.slice(2)) {
|
|
229
|
+
try {
|
|
230
|
+
const code = await runAsync(argv);
|
|
231
|
+
|
|
232
|
+
process.exitCode = code;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const code = error.code || 'ERR_HARMONY_UNKNOWN';
|
|
235
|
+
|
|
236
|
+
console.error(`[${code}] ${error.message}`);
|
|
237
|
+
|
|
238
|
+
process.exitCode = error.exitCode || 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export { main, runAsync };
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { getConfig } from '@expo/config';
|
|
6
|
+
import { normalizeHarmonyConfig } from '@expo-harmony/config-plugins';
|
|
7
|
+
import { verifyModulesAsync } from '@expo-harmony/expo-modules-autolinking';
|
|
8
|
+
import { isRnohAutolinkingDisabled } from '@expo-harmony/prebuild-config/native-project';
|
|
9
|
+
import { validateHarmonySigningConfigFile } from '@expo-harmony/prebuild-config/signing';
|
|
10
|
+
|
|
11
|
+
import { spawnAsync } from '../process';
|
|
12
|
+
import { withHarmonyProjectLockAsync } from '../projectLock';
|
|
13
|
+
import {
|
|
14
|
+
resolveHarmonyBuildPlanIfPresentAsync,
|
|
15
|
+
resolveHarmonyToolchain,
|
|
16
|
+
type HarmonyTool,
|
|
17
|
+
} from '../tools';
|
|
18
|
+
import { RequiredProjectPackages } from '../upstream';
|
|
19
|
+
|
|
20
|
+
export interface DoctorCheck {
|
|
21
|
+
details?: unknown;
|
|
22
|
+
id: string;
|
|
23
|
+
message: string;
|
|
24
|
+
status: 'error' | 'pass' | 'warn';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DoctorResult {
|
|
28
|
+
checks: DoctorCheck[];
|
|
29
|
+
ok: boolean;
|
|
30
|
+
projectRoot: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DoctorOptions {
|
|
34
|
+
requireBuildTools?: boolean;
|
|
35
|
+
requireDeviceTools?: boolean;
|
|
36
|
+
validateGeneratedProject?: boolean;
|
|
37
|
+
validateModules?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasPlugin(plugins, packageName) {
|
|
41
|
+
return (plugins || []).some(plugin => (Array.isArray(plugin) ? plugin[0] : plugin) === packageName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function check(id: string, status: DoctorCheck['status'], message: string, details?: unknown): DoctorCheck {
|
|
45
|
+
return { id, status, message, ...(details ? { details } : {}) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function canResolvePackage(projectRoot, packageName) {
|
|
49
|
+
const projectRequire = createRequire(path.join(projectRoot, 'package.json'));
|
|
50
|
+
try {
|
|
51
|
+
projectRequire.resolve(`${packageName}/package.json`);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
try {
|
|
55
|
+
projectRequire.resolve(packageName);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function validateMetroConfigAsync(projectRoot) {
|
|
64
|
+
const projectRequire = createRequire(path.join(projectRoot, 'package.json'));
|
|
65
|
+
let metroConfig;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
metroConfig = projectRequire('metro-config');
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
throw new Error('Cannot load the project-local metro-config package.', { cause });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof metroConfig.resolveConfig !== 'function') {
|
|
74
|
+
const version = (() => {
|
|
75
|
+
try {
|
|
76
|
+
return projectRequire('metro-config/package.json').version;
|
|
77
|
+
} catch {
|
|
78
|
+
return 'unknown';
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
throw new Error(`metro-config ${version} does not expose resolveConfig().`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hadMetroTarget = Object.hasOwn(process.env, 'EXPO_METRO_TARGET');
|
|
86
|
+
const previousMetroTarget = process.env.EXPO_METRO_TARGET;
|
|
87
|
+
let resolved;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
process.env.EXPO_METRO_TARGET = 'harmony';
|
|
91
|
+
resolved = await metroConfig.resolveConfig(undefined, projectRoot);
|
|
92
|
+
} finally {
|
|
93
|
+
if (hadMetroTarget) process.env.EXPO_METRO_TARGET = previousMetroTarget;
|
|
94
|
+
else delete process.env.EXPO_METRO_TARGET;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const config = resolved?.config;
|
|
98
|
+
const platforms = config?.resolver?.platforms;
|
|
99
|
+
const conditions = config?.resolver?.unstable_conditionsByPlatform?.harmony;
|
|
100
|
+
|
|
101
|
+
if (!Array.isArray(platforms) || !platforms.includes('harmony')
|
|
102
|
+
|| !Array.isArray(conditions) || !conditions.includes('harmony')
|
|
103
|
+
|| typeof config?.resolver?.resolveRequest !== 'function') {
|
|
104
|
+
throw new Error('The resolved Metro config must register the harmony platform, Harmony conditions, and a resolver.');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function doctorUnlockedAsync(projectRoot: string, options: DoctorOptions = {}): Promise<DoctorResult> {
|
|
109
|
+
const checks: DoctorCheck[] = [];
|
|
110
|
+
const unavailableToolStatus = options.requireBuildTools ? 'error' : 'warn';
|
|
111
|
+
let config;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
config = getConfig(projectRoot, {
|
|
115
|
+
isModdedConfig: true,
|
|
116
|
+
skipSDKVersionRequirement: true,
|
|
117
|
+
}).exp;
|
|
118
|
+
normalizeHarmonyConfig(config);
|
|
119
|
+
checks.push(check('app-config', 'pass', 'Harmony app config is valid.'));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
checks.push(check('app-config', 'error', error.message, { code: error.code || 'ERR_HARMONY_CONFIG_INVALID' }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (config) {
|
|
125
|
+
checks.push(hasPlugin(config.plugins, '@expo-harmony/prebuild-config')
|
|
126
|
+
? check('config-plugin', 'pass', '@expo-harmony/prebuild-config is registered.')
|
|
127
|
+
: check('config-plugin', 'error', 'Add @expo-harmony/prebuild-config to expo.plugins.'));
|
|
128
|
+
const harmony = (config as typeof config & {
|
|
129
|
+
harmony?: { signingConfigFile?: string };
|
|
130
|
+
}).harmony;
|
|
131
|
+
|
|
132
|
+
if (harmony?.signingConfigFile) {
|
|
133
|
+
try {
|
|
134
|
+
const signing = await validateHarmonySigningConfigFile(projectRoot, harmony.signingConfigFile);
|
|
135
|
+
checks.push(check('signing', 'pass', `Harmony signing config ${signing.name} is valid.`));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
checks.push(check('signing', 'error', error.message, { code: error.code || 'ERR_HARMONY_SIGNING_INVALID' }));
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
checks.push(check('signing', 'warn', 'No external signing config is set; unsigned generation remains available.'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await validateMetroConfigAsync(projectRoot);
|
|
146
|
+
checks.push(check('metro', 'pass', 'The resolved Metro config enables Harmony.'));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
checks.push(check('metro', 'error', `Cannot load a Harmony-enabled Metro config: ${error.message}`, { code: error.code || 'ERR_HARMONY_METRO_CONFIG' }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const packageName of RequiredProjectPackages) {
|
|
152
|
+
if (canResolvePackage(projectRoot, packageName)) {
|
|
153
|
+
checks.push(check(`package:${packageName}`, 'pass', `${packageName} is resolvable.`));
|
|
154
|
+
} else {
|
|
155
|
+
checks.push(check(`package:${packageName}`, 'error', `${packageName} is not resolvable from the app.`));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (options.validateModules !== false) {
|
|
160
|
+
try {
|
|
161
|
+
const result = await verifyModulesAsync({ platform: 'harmony', projectRoot });
|
|
162
|
+
const errors = result.diagnostics.filter(item => item.severity === 'error');
|
|
163
|
+
const warnings = result.diagnostics.filter(item => item.severity === 'warning');
|
|
164
|
+
|
|
165
|
+
if (errors.length > 0) {
|
|
166
|
+
checks.push(check(
|
|
167
|
+
'expo-modules',
|
|
168
|
+
'error',
|
|
169
|
+
`Harmony Expo Modules verification found ${errors.length} error(s). Run 'expo-harmony modules verify' for details.`,
|
|
170
|
+
{ diagnostics: result.diagnostics }
|
|
171
|
+
));
|
|
172
|
+
} else if (warnings.length > 0) {
|
|
173
|
+
checks.push(check(
|
|
174
|
+
'expo-modules',
|
|
175
|
+
'warn',
|
|
176
|
+
`Verified ${result.modules.length} Harmony native module(s) with ${warnings.length} warning(s).`,
|
|
177
|
+
{ diagnostics: warnings }
|
|
178
|
+
));
|
|
179
|
+
} else {
|
|
180
|
+
checks.push(check('expo-modules', 'pass', `Verified ${result.modules.length} Harmony native module(s).`));
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
checks.push(check(
|
|
184
|
+
'expo-modules',
|
|
185
|
+
'error',
|
|
186
|
+
`Harmony Expo Modules verification failed: ${error.message}`,
|
|
187
|
+
{ code: error.code || 'ERR_HARMONY_AUTOLINKING_FAILED' }
|
|
188
|
+
));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const toolchain = resolveHarmonyToolchain();
|
|
193
|
+
let sdkCheck;
|
|
194
|
+
|
|
195
|
+
if (toolchain.sdkHome) {
|
|
196
|
+
sdkCheck = check('harmony-sdk', 'pass', `Complete Harmony SDK root was resolved at ${toolchain.sdkHome}.`);
|
|
197
|
+
} else {
|
|
198
|
+
sdkCheck = check('harmony-sdk', unavailableToolStatus, 'No complete Harmony SDK root with HMS and OpenHarmony components was found; generation works but HAP build cannot be verified.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
checks.push(sdkCheck);
|
|
202
|
+
|
|
203
|
+
const tools: Array<[string, HarmonyTool, string[], DoctorCheck['status']]> = [
|
|
204
|
+
['ohpm', toolchain.ohpm, ['--version'], unavailableToolStatus],
|
|
205
|
+
['hvigor-command', toolchain.hvigor, ['--version'], unavailableToolStatus],
|
|
206
|
+
];
|
|
207
|
+
if (options.requireDeviceTools !== false) {
|
|
208
|
+
tools.unshift(['hdc', toolchain.hdc, ['-v'], unavailableToolStatus]);
|
|
209
|
+
}
|
|
210
|
+
for (const [id, tool, versionArgs, unavailableStatus] of tools) {
|
|
211
|
+
try {
|
|
212
|
+
const result = await spawnAsync(tool.command, [...tool.args, ...versionArgs], {
|
|
213
|
+
capture: true,
|
|
214
|
+
cwd: projectRoot,
|
|
215
|
+
operation: `doctor-${id}`,
|
|
216
|
+
timeoutMs: 10_000,
|
|
217
|
+
});
|
|
218
|
+
checks.push(result.code === 0 && !result.timedOut
|
|
219
|
+
? check(id, 'pass', `${tool.command} is available through ${tool.source}.`)
|
|
220
|
+
: check(id, unavailableStatus, `${tool.command} is unavailable or unhealthy; HAP build cannot be verified.`));
|
|
221
|
+
} catch {
|
|
222
|
+
checks.push(check(id, unavailableStatus, `${tool.command} is unavailable; generation remains available.`));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (options.validateGeneratedProject !== false) {
|
|
227
|
+
try {
|
|
228
|
+
const plan = await resolveHarmonyBuildPlanIfPresentAsync(projectRoot);
|
|
229
|
+
if (plan && fs.existsSync(plan.harmonyRoot)) {
|
|
230
|
+
if (!fs.existsSync(plan.projectFiles.rootHvigor)
|
|
231
|
+
|| !fs.existsSync(plan.projectFiles.moduleHvigor)) {
|
|
232
|
+
checks.push(check('hvigor', 'error', 'Generated root and module Hvigor files are required.'));
|
|
233
|
+
} else {
|
|
234
|
+
const content = await fs.promises.readFile(plan.projectFiles.moduleHvigor, 'utf8');
|
|
235
|
+
checks.push(isRnohAutolinkingDisabled(content)
|
|
236
|
+
? check('hvigor-autolinking', 'pass', 'RNOH duplicate autolinking is disabled.')
|
|
237
|
+
: check('hvigor-autolinking', 'error', 'The generated module Hvigor file must disable RNOH autolinking.'));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
checks.push(check('harmony-project', 'error', error.message, { code: error.code || 'ERR_HARMONY_TEMPLATE_INVALID' }));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
checks,
|
|
247
|
+
ok: checks.every(item => item.status !== 'error'),
|
|
248
|
+
projectRoot,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function doctorAsync(projectRoot: string, options: DoctorOptions = {}): Promise<DoctorResult> {
|
|
253
|
+
return withHarmonyProjectLockAsync(
|
|
254
|
+
projectRoot,
|
|
255
|
+
'doctor',
|
|
256
|
+
() => doctorUnlockedAsync(projectRoot, options)
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function formatDoctor(result: DoctorResult): string {
|
|
261
|
+
return result.checks.map((item) => {
|
|
262
|
+
const symbol = item.status === 'pass' ? '✓' : item.status === 'warn' ? '!' : '✗';
|
|
263
|
+
return `${symbol} [${item.status}] ${item.message}`;
|
|
264
|
+
}).join('\n');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export { doctorAsync, formatDoctor };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CommonOptions, parseArgs } from '../args';
|
|
2
|
+
import { HarmonyCliError } from '../errors';
|
|
3
|
+
|
|
4
|
+
function parseDoctorArgs(argv: string[]) {
|
|
5
|
+
const { positionals, values } = parseArgs(CommonOptions, argv);
|
|
6
|
+
|
|
7
|
+
if (positionals.length > 1) {
|
|
8
|
+
throw new HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', `Unexpected doctor positional argument: ${positionals[1]}`, { operation: 'parse-arguments' });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
help: Boolean(values.help),
|
|
13
|
+
project: positionals[0],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { parseDoctorArgs };
|
package/src/entry.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { resolveEntryPoint } from '@expo/config/paths';
|
|
6
|
+
|
|
7
|
+
import { HarmonyCliError } from './errors';
|
|
8
|
+
|
|
9
|
+
function isFile(file) {
|
|
10
|
+
try {
|
|
11
|
+
return fs.statSync(file).isFile();
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveHarmonyEntryPoint(projectRoot) {
|
|
18
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
21
|
+
let entry = resolveEntryPoint(projectRoot, { pkg, platform: 'harmony' });
|
|
22
|
+
|
|
23
|
+
if (!isFile(entry) && typeof pkg.main === 'string' && pkg.main) {
|
|
24
|
+
const logicalPackageRoot = entry;
|
|
25
|
+
const resolved = createRequire(packageJsonPath).resolve(pkg.main);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const relative = path.relative(fs.realpathSync(logicalPackageRoot), fs.realpathSync(resolved));
|
|
29
|
+
entry = relative !== '..'
|
|
30
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
31
|
+
&& !path.isAbsolute(relative)
|
|
32
|
+
? path.join(logicalPackageRoot, relative)
|
|
33
|
+
: resolved;
|
|
34
|
+
} catch {
|
|
35
|
+
entry = resolved;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!isFile(entry)) throw new Error('Resolved entry is not a file.');
|
|
40
|
+
|
|
41
|
+
return entry;
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
throw new HarmonyCliError('ERR_HARMONY_EXPORT_ENTRY', 'Cannot resolve the Expo entry point for the Harmony bundle.', { cause, operation: 'resolve-entry-point' });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { resolveHarmonyEntryPoint };
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
interface HarmonyCliErrorOptions {
|
|
2
|
+
cause?: unknown;
|
|
3
|
+
exitCode?: number;
|
|
4
|
+
operation?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
class HarmonyCliError extends Error {
|
|
8
|
+
code: string;
|
|
9
|
+
exitCode: number;
|
|
10
|
+
operation: string;
|
|
11
|
+
|
|
12
|
+
constructor(code: string, message: string, options: HarmonyCliErrorOptions = {}) {
|
|
13
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
14
|
+
|
|
15
|
+
this.name = 'HarmonyCliError';
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.exitCode = options.exitCode || 1;
|
|
18
|
+
this.operation = options.operation || 'cli';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { HarmonyCliError };
|
package/src/expo.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { HarmonyCliError } from './errors';
|
|
6
|
+
|
|
7
|
+
interface ExpoHermesBundleOptions {
|
|
8
|
+
code: string;
|
|
9
|
+
filename: string;
|
|
10
|
+
map: string | null;
|
|
11
|
+
minify?: boolean;
|
|
12
|
+
projectRoot: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ExpoHermesBundleOutput {
|
|
16
|
+
hbc: Uint8Array;
|
|
17
|
+
sourcemap: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ExpoHermesBuilder = (
|
|
21
|
+
options: ExpoHermesBundleOptions
|
|
22
|
+
) => Promise<ExpoHermesBundleOutput>;
|
|
23
|
+
|
|
24
|
+
function resolveExpoCli(projectRoot) {
|
|
25
|
+
const projectRequire = createRequire(path.join(projectRoot, 'package.json'));
|
|
26
|
+
let packageJsonPath;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
packageJsonPath = projectRequire.resolve('expo/package.json');
|
|
30
|
+
} catch (cause) {
|
|
31
|
+
throw new HarmonyCliError('ERR_HARMONY_EXPO_CLI_NOT_FOUND', 'Cannot resolve the project-local expo package.', { cause, operation: 'resolve-expo-cli' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
35
|
+
const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.expo;
|
|
36
|
+
|
|
37
|
+
if (typeof bin !== 'string' || !bin) {
|
|
38
|
+
throw new HarmonyCliError('ERR_HARMONY_EXPO_CLI_NOT_FOUND', 'The project-local expo package has no expo binary.', { operation: 'resolve-expo-cli' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cliPath = path.resolve(path.dirname(packageJsonPath), bin);
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(cliPath)) {
|
|
44
|
+
throw new HarmonyCliError('ERR_HARMONY_EXPO_CLI_NOT_FOUND', `Expo CLI binary does not exist: ${cliPath}`, { operation: 'resolve-expo-cli' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { cliPath, packageJsonPath };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveExpoHermesBuilder(projectRoot: string): ExpoHermesBuilder {
|
|
51
|
+
const projectRequire = createRequire(path.join(projectRoot, 'package.json'));
|
|
52
|
+
let packageJsonPath;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
packageJsonPath = projectRequire.resolve('@expo/metro-config/package.json');
|
|
56
|
+
} catch (cause) {
|
|
57
|
+
throw new HarmonyCliError(
|
|
58
|
+
'ERR_HARMONY_EXPORT_HERMES',
|
|
59
|
+
'Cannot resolve the project-local @expo/metro-config package required for Hermes export.',
|
|
60
|
+
{ cause, operation: 'resolve-expo-hermes' }
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const modulePath = path.join(path.dirname(packageJsonPath), 'build', 'serializer', 'exportHermes.js');
|
|
65
|
+
let moduleExports;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
moduleExports = projectRequire(modulePath);
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
throw new HarmonyCliError(
|
|
71
|
+
'ERR_HARMONY_EXPORT_HERMES',
|
|
72
|
+
`Cannot load the Expo Hermes exporter: ${modulePath}`,
|
|
73
|
+
{ cause, operation: 'resolve-expo-hermes' }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof moduleExports?.buildHermesBundleAsync !== 'function') {
|
|
78
|
+
throw new HarmonyCliError(
|
|
79
|
+
'ERR_HARMONY_EXPORT_HERMES',
|
|
80
|
+
'The project-local @expo/metro-config package does not expose buildHermesBundleAsync().',
|
|
81
|
+
{ operation: 'resolve-expo-hermes' }
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return moduleExports.buildHermesBundleAsync as ExpoHermesBuilder;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { resolveExpoCli, resolveExpoHermesBuilder };
|
|
89
|
+
export type { ExpoHermesBuilder };
|