@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/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 };