@expo-harmony/cli 55.0.26-harmony.8 → 55.0.26-harmony.9

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.
Files changed (115) hide show
  1. package/README.md +74 -46
  2. package/build/bin/expo-harmony.js +1 -1
  3. package/build/buildHap/build.d.ts +3 -10
  4. package/build/buildHap/build.js +11 -69
  5. package/build/buildHap/index.d.ts +2 -0
  6. package/build/buildHap/index.js +19 -0
  7. package/build/cli.d.ts +3 -3
  8. package/build/cli.js +21 -187
  9. package/build/command.d.ts +9 -0
  10. package/build/command.js +20 -0
  11. package/build/doctor/doctor.d.ts +1 -1
  12. package/build/doctor/doctor.js +64 -56
  13. package/build/doctor/index.d.ts +2 -0
  14. package/build/doctor/index.js +16 -0
  15. package/build/exportEmbed/export.d.ts +2 -2
  16. package/build/exportEmbed/export.js +53 -49
  17. package/build/exportEmbed/index.d.ts +2 -0
  18. package/build/exportEmbed/index.js +19 -0
  19. package/build/file.d.ts +2 -1
  20. package/build/file.js +18 -8
  21. package/build/help.d.ts +1 -0
  22. package/build/help.js +36 -0
  23. package/build/index.d.ts +5 -3
  24. package/build/index.js +6 -5
  25. package/build/internal/prebuild.d.ts +3 -0
  26. package/build/internal/prebuild.js +9 -0
  27. package/build/log.d.ts +4 -0
  28. package/build/log.js +6 -0
  29. package/build/modules/index.d.ts +2 -0
  30. package/build/modules/index.js +16 -0
  31. package/build/native/bare.d.ts +4 -0
  32. package/build/native/bare.js +130 -0
  33. package/build/native/build.d.ts +15 -0
  34. package/build/native/build.js +67 -0
  35. package/build/{run → native}/cache.d.ts +2 -2
  36. package/build/{run → native}/cache.js +8 -8
  37. package/build/native/hvigor.d.ts +2 -0
  38. package/build/native/hvigor.js +77 -0
  39. package/build/native/install.d.ts +5 -0
  40. package/build/native/install.js +13 -0
  41. package/build/native/prepareProject.d.ts +11 -0
  42. package/build/native/prepareProject.js +68 -0
  43. package/build/native/project.d.ts +8 -0
  44. package/build/native/project.js +58 -0
  45. package/build/native/toolchain.d.ts +16 -0
  46. package/build/{tools.js → native/toolchain.js} +1 -57
  47. package/build/native/types.d.ts +47 -0
  48. package/build/native/types.js +2 -0
  49. package/build/prebuild/check.d.ts +7 -3
  50. package/build/prebuild/check.js +26 -20
  51. package/build/prebuild/clean.js +5 -5
  52. package/build/prebuild/index.d.ts +2 -0
  53. package/build/prebuild/index.js +51 -0
  54. package/build/prebuild/prebuild.js +11 -3
  55. package/build/prebuild/template.js +2 -2
  56. package/build/process.d.ts +11 -1
  57. package/build/process.js +16 -0
  58. package/build/profile.d.ts +1 -0
  59. package/build/profile.js +12 -0
  60. package/build/projectLock.js +7 -2
  61. package/build/run/devices.d.ts +1 -1
  62. package/build/run/devices.js +15 -10
  63. package/build/run/emulators.d.ts +1 -1
  64. package/build/run/emulators.js +2 -5
  65. package/build/run/index.d.ts +2 -0
  66. package/build/run/index.js +25 -0
  67. package/build/run/run.d.ts +4 -10
  68. package/build/run/run.js +45 -90
  69. package/build/start/index.d.ts +2 -0
  70. package/build/start/index.js +33 -0
  71. package/package.json +14 -4
  72. package/src/bin/expo-harmony.ts +2 -2
  73. package/src/buildHap/build.ts +13 -105
  74. package/src/buildHap/index.ts +19 -0
  75. package/src/cli.ts +24 -227
  76. package/src/command.ts +24 -0
  77. package/src/doctor/doctor.ts +63 -61
  78. package/src/doctor/index.ts +16 -0
  79. package/src/exportEmbed/export.ts +64 -55
  80. package/src/exportEmbed/index.ts +17 -0
  81. package/src/file.ts +20 -11
  82. package/src/help.ts +33 -0
  83. package/src/index.ts +5 -3
  84. package/src/internal/prebuild.ts +3 -0
  85. package/src/log.ts +5 -0
  86. package/src/modules/index.ts +16 -0
  87. package/src/native/bare.ts +145 -0
  88. package/src/native/build.ts +81 -0
  89. package/src/{run → native}/cache.ts +10 -9
  90. package/src/native/hvigor.ts +81 -0
  91. package/src/native/install.ts +17 -0
  92. package/src/native/prepareProject.ts +99 -0
  93. package/src/native/project.ts +88 -0
  94. package/src/{tools.ts → native/toolchain.ts} +11 -118
  95. package/src/native/types.ts +44 -0
  96. package/src/prebuild/check.ts +30 -19
  97. package/src/prebuild/clean.ts +3 -3
  98. package/src/prebuild/index.ts +56 -0
  99. package/src/prebuild/prebuild.ts +11 -1
  100. package/src/prebuild/template.ts +2 -2
  101. package/src/process.ts +38 -1
  102. package/src/profile.ts +13 -0
  103. package/src/projectLock.ts +9 -1
  104. package/src/run/devices.ts +16 -11
  105. package/src/run/emulators.ts +4 -6
  106. package/src/run/index.ts +25 -0
  107. package/src/run/run.ts +56 -129
  108. package/src/start/index.ts +31 -0
  109. package/build/buildHap/common.d.ts +0 -24
  110. package/build/buildHap/common.js +0 -92
  111. package/build/run/install.d.ts +0 -7
  112. package/build/run/install.js +0 -26
  113. package/build/tools.d.ts +0 -39
  114. package/src/buildHap/common.ts +0 -145
  115. package/src/run/install.ts +0 -41
@@ -3,11 +3,11 @@ import { createRequire } from 'node:module';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
- import { doctorAsync, type DoctorResult } from '../doctor/doctor';
6
+ import { doctorAsync } from '../doctor/doctor';
7
7
  import { resolveHarmonyEntryPoint } from '../entry';
8
8
  import { HarmonyCliError } from '../errors';
9
9
  import { withHarmonyProjectLockAsync } from '../projectLock';
10
- import { resolveHarmonyBuildPlanAsync } from '../tools';
10
+ import { resolveHarmonyBuildPlanAsync } from '../native/project';
11
11
  import { formatDiagnostics, spawnAsync } from '../process';
12
12
  import { resolveExpoCli, resolveExpoHermesBuilder } from '../expo';
13
13
  import {
@@ -32,65 +32,65 @@ export interface ExportTemporary {
32
32
  }
33
33
 
34
34
  function createExpoExportEmbedArgs(
35
- projectRoot: string,
36
- entryFile: string,
37
- temporary: ExportTemporary,
35
+ root: string,
36
+ entry: string,
37
+ temp: ExportTemporary,
38
38
  options: ExportOptions = {}
39
39
  ) {
40
40
  return [
41
41
  'export:embed',
42
42
  '--platform', 'harmony',
43
- '--entry-file', entryFile,
44
- '--bundle-output', temporary.javascript,
45
- '--assets-dest', temporary.assets,
43
+ '--entry-file', entry,
44
+ '--bundle-output', temp.javascript,
45
+ '--assets-dest', temp.assets,
46
46
  '--dev', 'false',
47
47
  '--minify', 'false',
48
- '--sourcemap-output', temporary.metroSourceMap,
48
+ '--sourcemap-output', temp.metroSourceMap,
49
49
  '--sourcemap-sources-root', '.',
50
50
  '--unstable-transform-profile', 'hermes-stable',
51
51
  ...(options.resetCache ? ['--reset-cache=true'] : []),
52
- projectRoot,
52
+ root,
53
53
  ];
54
54
  }
55
55
 
56
- function assertDoctor(result: DoctorResult) {
57
- if (result.ok) return;
58
-
59
- const failing = result.checks.filter(check => check.status === 'error').map(check => check.id);
60
- throw new HarmonyCliError('ERR_HARMONY_EXPORT_DOCTOR', `Harmony doctor found blocking checks: ${failing.join(', ') || 'unknown'}.`, { operation: 'doctor' });
61
- }
62
-
63
56
  async function exportEmbedUnlockedAsync(
64
- projectRoot: string,
57
+ root: string,
65
58
  options: ExportOptions = {}
66
59
  ): Promise<HarmonyExportManifest> {
67
- if (!options.skipDoctor) assertDoctor(await doctorAsync(projectRoot));
60
+ if (!options.skipDoctor) {
61
+ const doctor = await doctorAsync(root);
62
+ if (!doctor.ok) {
63
+ const checks = doctor.checks.filter(check => check.status === 'error').map(check => check.id);
64
+
65
+ throw new HarmonyCliError('ERR_HARMONY_EXPORT_DOCTOR', `Harmony doctor found blocking checks: ${checks.join(', ') || 'unknown'}.`, { operation: 'doctor' });
66
+ }
67
+ }
68
68
 
69
- const plan = await resolveHarmonyBuildPlanAsync(projectRoot, { buildMode: 'release' });
69
+ const plan = await resolveHarmonyBuildPlanAsync(root, { buildMode: 'release' });
70
70
  const paths = exportPaths(plan);
71
71
 
72
72
  if (options.check) return await validatePublishedExportAsync(paths);
73
73
 
74
- const entryFile = resolveHarmonyEntryPoint(projectRoot);
75
- const temporaryRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'expo-harmony-export-'));
76
- const temporary: ExportTemporary = {
77
- assets: path.join(temporaryRoot, 'assets'),
78
- bundle: path.join(temporaryRoot, 'hermes_bundle.hbc'),
79
- javascript: path.join(temporaryRoot, 'index.js'),
80
- metroSourceMap: path.join(temporaryRoot, 'index.js.map'),
81
- sourceMap: path.join(temporaryRoot, 'hermes_bundle.hbc.map'),
74
+ const entry = resolveHarmonyEntryPoint(root);
75
+ const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'expo-harmony-export-'));
76
+ const temp: ExportTemporary = {
77
+ assets: path.join(directory, 'assets'),
78
+ bundle: path.join(directory, 'hermes_bundle.hbc'),
79
+ javascript: path.join(directory, 'index.js'),
80
+ metroSourceMap: path.join(directory, 'index.js.map'),
81
+ sourceMap: path.join(directory, 'hermes_bundle.hbc.map'),
82
82
  };
83
83
 
84
84
  try {
85
- await fs.promises.mkdir(temporary.assets, { recursive: true });
85
+ await fs.promises.mkdir(temp.assets, { recursive: true });
86
86
 
87
- const expo = resolveExpoCli(projectRoot);
87
+ const expo = resolveExpoCli(root);
88
88
  const result = await spawnAsync(process.execPath, [
89
89
  expo.cliPath,
90
- ...createExpoExportEmbedArgs(projectRoot, entryFile, temporary, options),
90
+ ...createExpoExportEmbedArgs(root, entry, temp, options),
91
91
  ], {
92
92
  capture: true,
93
- cwd: projectRoot,
93
+ cwd: root,
94
94
  env: {
95
95
  ...process.env,
96
96
  EXPO_METRO_TARGET: 'harmony',
@@ -104,6 +104,7 @@ async function exportEmbedUnlockedAsync(
104
104
 
105
105
  if (result.code !== 0 || result.timedOut) {
106
106
  const diagnostics = formatDiagnostics(result);
107
+
107
108
  throw new HarmonyCliError(
108
109
  'ERR_HARMONY_EXPORT_FAILED',
109
110
  `Expo export:embed exited with code ${result.code}${result.timedOut ? ' after timing out' : ''}.${diagnostics ? `\n${diagnostics}` : ''}`,
@@ -115,24 +116,27 @@ async function exportEmbedUnlockedAsync(
115
116
  // then explicitly hand its JS and source map to Expo's own Hermes exporter.
116
117
  try {
117
118
  const [code, map] = await Promise.all([
118
- fs.promises.readFile(temporary.javascript, 'utf8'),
119
- fs.promises.readFile(temporary.metroSourceMap, 'utf8'),
119
+ fs.promises.readFile(temp.javascript, 'utf8'),
120
+ fs.promises.readFile(temp.metroSourceMap, 'utf8'),
120
121
  ]);
121
- const buildHermesBundleAsync = resolveExpoHermesBuilder(projectRoot);
122
+ const buildHermesBundleAsync = resolveExpoHermesBuilder(root);
123
+
122
124
  // Expo resolves hermes-compiler from react-native. For Harmony, resolve it
123
125
  // from RNOH instead of the app's Android/iOS React Native installation.
124
- const projectRequire = createRequire(path.join(projectRoot, 'package.json'));
125
- const harmonyRuntime = path.dirname(projectRequire.resolve('@react-native-oh/react-native-harmony/package.json'));
126
- const compilerModules = path.join(temporaryRoot, 'node_modules');
127
- await fs.promises.mkdir(compilerModules);
128
- await fs.promises.symlink(harmonyRuntime, path.join(compilerModules, 'react-native'),
126
+ const require = createRequire(path.join(root, 'package.json'));
127
+ const runtime = path.dirname(require.resolve('@react-native-oh/react-native-harmony/package.json'));
128
+ const modules = path.join(directory, 'node_modules');
129
+
130
+ await fs.promises.mkdir(modules);
131
+ await fs.promises.symlink(runtime, path.join(modules, 'react-native'),
129
132
  process.platform === 'win32' ? 'junction' : 'dir');
133
+
130
134
  const output = await buildHermesBundleAsync({
131
135
  code,
132
- filename: entryFile,
136
+ filename: entry,
133
137
  map,
134
138
  minify: true,
135
- projectRoot: temporaryRoot,
139
+ projectRoot: directory,
136
140
  });
137
141
 
138
142
  if (!(output?.hbc instanceof Uint8Array) || typeof output.sourcemap !== 'string') {
@@ -140,11 +144,18 @@ async function exportEmbedUnlockedAsync(
140
144
  }
141
145
 
142
146
  await Promise.all([
143
- fs.promises.writeFile(temporary.bundle, output.hbc),
144
- fs.promises.writeFile(temporary.sourceMap, output.sourcemap),
147
+ fs.promises.writeFile(temp.bundle, output.hbc),
148
+ fs.promises.writeFile(temp.sourceMap, output.sourcemap),
145
149
  ]);
146
150
  } catch (cause) {
147
- if (cause instanceof HarmonyCliError) throw cause;
151
+ if (cause instanceof HarmonyCliError) {
152
+ throw new HarmonyCliError(cause.code, cause.message, {
153
+ cause,
154
+ exitCode: cause.exitCode,
155
+ operation: cause.operation,
156
+ });
157
+ }
158
+
148
159
  throw new HarmonyCliError(
149
160
  'ERR_HARMONY_EXPORT_HERMES',
150
161
  'Expo failed to compile the Harmony bundle to Hermes bytecode.',
@@ -152,26 +163,24 @@ async function exportEmbedUnlockedAsync(
152
163
  );
153
164
  }
154
165
 
155
- const bytecode = await assertHermesBundle(temporary.bundle);
156
- await assertSourceMap(temporary.sourceMap);
166
+ const bytecode = await assertHermesBundle(temp.bundle);
167
+ await assertSourceMap(temp.sourceMap);
157
168
 
158
- return await publishExportAsync(projectRoot, paths, temporary, entryFile, bytecode);
169
+ return await publishExportAsync(root, paths, temp, entry, bytecode);
159
170
  } finally {
160
- await fs.promises.rm(temporaryRoot, { force: true, recursive: true });
171
+ await fs.promises.rm(directory, { force: true, recursive: true });
161
172
  }
162
173
  }
163
174
 
164
175
  async function exportEmbedAsync(
165
- projectRoot: string,
176
+ root: string,
166
177
  options: ExportOptions = {}
167
178
  ): Promise<HarmonyExportManifest> {
168
179
  return withHarmonyProjectLockAsync(
169
- projectRoot,
180
+ root,
170
181
  'export:embed',
171
- () => exportEmbedUnlockedAsync(projectRoot, options)
182
+ () => exportEmbedUnlockedAsync(root, options)
172
183
  );
173
184
  }
174
185
 
175
- export {
176
- exportEmbedAsync,
177
- };
186
+ export { exportEmbedAsync };
@@ -0,0 +1,17 @@
1
+ import { parseCommandArgs, type Command } from '../command';
2
+ import { parseExportEmbedArgs } from './options';
3
+
4
+ export const command: Command = async (argv, io) => {
5
+ const args = parseCommandArgs(parseExportEmbedArgs, argv, io);
6
+ if (!args) return 0;
7
+
8
+ const { projectRoot: root, options } = args;
9
+ const { exportEmbedAsync } = await import('./export.js');
10
+
11
+ const result = await exportEmbedAsync(root, options);
12
+
13
+ if (options.check) io.log('Harmony export bundle, assets, and source map are valid.');
14
+ else io.log(`Exported Hermes bytecode ${result.bundle.path} with ${result.assets.length} asset file(s).`);
15
+
16
+ return 0;
17
+ };
package/src/file.ts CHANGED
@@ -1,4 +1,4 @@
1
- import nodeCrypto from 'node:crypto';
1
+ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
@@ -6,7 +6,7 @@ import { isInside } from './path';
6
6
 
7
7
  async function sha256File(file: string): Promise<string> {
8
8
  return await new Promise<string>((resolve, reject) => {
9
- const hash = nodeCrypto.createHash('sha256');
9
+ const hash = crypto.createHash('sha256');
10
10
  const input = fs.createReadStream(file);
11
11
 
12
12
  input.on('error', reject);
@@ -70,26 +70,26 @@ async function ensureSafeParent(root: string, destination: string): Promise<void
70
70
  async function atomicCopy(source: string, destination: string, root: string): Promise<void> {
71
71
  await ensureSafeParent(root, destination);
72
72
 
73
- const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.expo-${nodeCrypto.randomUUID()}.tmp`);
73
+ const temp = path.join(path.dirname(destination), `.${path.basename(destination)}.expo-${crypto.randomUUID()}.tmp`);
74
74
 
75
75
  try {
76
- await fs.promises.copyFile(source, temporary, fs.constants.COPYFILE_EXCL);
77
- await fs.promises.rename(temporary, destination);
76
+ await fs.promises.copyFile(source, temp, fs.constants.COPYFILE_EXCL);
77
+ await fs.promises.rename(temp, destination);
78
78
  } finally {
79
- await fs.promises.rm(temporary, { force: true });
79
+ await fs.promises.rm(temp, { force: true });
80
80
  }
81
81
  }
82
82
 
83
83
  async function atomicWriteJson(value: unknown, destination: string, root: string): Promise<void> {
84
84
  await ensureSafeParent(root, destination);
85
85
 
86
- const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.expo-${nodeCrypto.randomUUID()}.tmp`);
86
+ const temp = path.join(path.dirname(destination), `.${path.basename(destination)}.expo-${crypto.randomUUID()}.tmp`);
87
87
 
88
88
  try {
89
- await fs.promises.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
90
- await fs.promises.rename(temporary, destination);
89
+ await fs.promises.writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
90
+ await fs.promises.rename(temp, destination);
91
91
  } finally {
92
- await fs.promises.rm(temporary, { force: true });
92
+ await fs.promises.rm(temp, { force: true });
93
93
  }
94
94
  }
95
95
 
@@ -107,4 +107,13 @@ async function removeEmptyParents(file: string, stop: string): Promise<void> {
107
107
  }
108
108
  }
109
109
 
110
- export { atomicCopy, atomicWriteJson, describeFile, listFiles, removeEmptyParents };
110
+ function isNonEmptyRegularFile(file: string): boolean {
111
+ try {
112
+ const stat = fs.lstatSync(file);
113
+ return !stat.isSymbolicLink() && stat.isFile() && stat.size > 0;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ export { atomicCopy, atomicWriteJson, describeFile, isNonEmptyRegularFile, listFiles, removeEmptyParents };
package/src/help.ts ADDED
@@ -0,0 +1,33 @@
1
+ export const Help = `Usage: expo-harmony <command> [project] [options]
2
+
3
+ Commands:
4
+ start Start Expo Metro for Harmony development
5
+ prebuild Generate the Harmony native project with Expo CNG
6
+ prebuild --clean Safely recreate the managed Harmony directory
7
+ prebuild --check Compare generated desired state without project writes
8
+ build Build a HAP without selecting or contacting a device
9
+ doctor Validate config, versions, Metro, RNOH, SDK, and signing
10
+ modules list List native module candidates and their discovery source
11
+ modules inspect Resolve Harmony metadata (--package narrows the result)
12
+ modules verify Validate module config, registration conflicts and safe paths
13
+ export:embed Export validated Hermes bytecode, assets, and a source map
14
+ run Build, install, and launch the Harmony app
15
+
16
+ Options:
17
+ --no-install Skip dependency install (prebuild) or HAP install (run)
18
+ --npm|--yarn|--pnpm|--bun
19
+ Select the package manager used by prebuild dependency install
20
+ --skip-dependency-update <packages>
21
+ Preserve comma-separated dependency versions
22
+ --device <id-or-name> Select an HDC target or start a local emulator by name
23
+ --variant <mode> Build debug or release (default: debug)
24
+ --no-bundler Use an already-running Expo Metro server
25
+ --app-id <bundleName>
26
+ Launch another installed app (requires --no-install when different)
27
+ --port <number> Metro and device reverse port (default: 8081)
28
+ --sync Re-run prebuild before building
29
+ --check Validate an existing export without writing
30
+ --reset-cache Reset Metro while exporting or starting
31
+ -c, --clear Alias for --reset-cache (start)
32
+ -h, --help Show this help
33
+ `;
package/src/index.ts CHANGED
@@ -2,11 +2,13 @@ export { runAsync } from './cli';
2
2
  export { buildHarmonyAsync } from './buildHap/build';
3
3
  export type { HarmonyBuildOptions, HarmonyBuildResult } from './buildHap/build';
4
4
  export { exportEmbedAsync } from './exportEmbed/export';
5
- export { resolveHarmonyBuildPlanAsync, resolveHarmonyToolchain } from './tools';
6
- export { installHarmonyDependenciesAsync } from './run/install';
5
+ export { resolveHarmonyBuildPlanAsync } from './native/project';
6
+ export type { HarmonyBuildPlan, HarmonyBuildMode } from './native/types';
7
+ export { resolveHarmonyToolchain } from './native/toolchain';
8
+ export { installHarmonyDependenciesAsync } from './native/install';
7
9
  export { formatModulesResult, runModulesCommandAsync } from './modules/modules';
8
10
  export type { ModulesCommandResult } from './modules/modules';
9
- export { commitHarmonyNativeBuildCacheAsync, prepareHarmonyNativeBuildCacheAsync } from './run/cache';
11
+ export { commitHarmonyNativeBuildCacheAsync, prepareHarmonyNativeBuildCacheAsync } from './native/cache';
10
12
  export { runHarmonyAsync } from './run/run';
11
13
  export type { HarmonyExportManifest } from './exportEmbed/manifest';
12
14
  export type { HarmonyRunOptions, HarmonyRunResult } from './run/run';
@@ -0,0 +1,3 @@
1
+ export { withGeneratedProjectAsync } from '../prebuild/check';
2
+ export { assertSafeCleanTarget } from '../prebuild/clean';
3
+ export { withHarmonyProjectLockAsync } from '../projectLock';
package/src/log.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type Log = Pick<Console, 'error' | 'log' | 'warn'>;
2
+
3
+ export function progress(options: { io?: Log }, message: string): void {
4
+ options.io?.log(`› ${message}`);
5
+ }
@@ -0,0 +1,16 @@
1
+ import { parseCommandArgs, type Command } from '../command';
2
+ import { parseModulesArgs } from './options';
3
+
4
+ export const command: Command = async (argv, io) => {
5
+ const args = parseCommandArgs(parseModulesArgs, argv, io);
6
+ if (!args) return 0;
7
+
8
+ const { projectRoot: root, options } = args;
9
+ const { formatModulesResult, runModulesCommandAsync } = await import('./modules.js');
10
+
11
+ const result = await runModulesCommandAsync(root, options);
12
+
13
+ io.log(formatModulesResult(result));
14
+
15
+ return result.ok ? 0 : 1;
16
+ };
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import JSON5 from 'json5';
4
+
5
+ import { HarmonyCliError } from '../errors';
6
+ import type { BareHarmonyBuildPlan } from './types';
7
+
8
+ function isBareHarmonyProject(root: string): boolean {
9
+ return fs.existsSync(path.join(root, 'harmony'))
10
+ && !fs.existsSync(path.join(root, 'harmony/.expo-harmony-template'))
11
+ && !fs.existsSync(path.join(root, '.expo/harmony/cng-manifest.json'));
12
+ }
13
+
14
+ function record(value: unknown, field: string): Record<string, unknown> {
15
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
16
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', `Native ${field} must be an object.`, { operation: 'resolve-build' });
17
+ }
18
+
19
+ return value as Record<string, unknown>;
20
+ }
21
+
22
+ function records(value: unknown, field: string): Record<string, unknown>[] {
23
+ if (!Array.isArray(value)) {
24
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', `Native ${field} must be an array.`, { operation: 'resolve-build' });
25
+ }
26
+
27
+ return value.map(item => record(item, field));
28
+ }
29
+
30
+ function readJson(file: string): Record<string, unknown> {
31
+ try {
32
+ return record(JSON5.parse(fs.readFileSync(file, 'utf8')), file);
33
+ } catch (cause) {
34
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', `Cannot read native project file ${file}: ${cause.message}`, { cause, operation: 'resolve-build' });
35
+ }
36
+ }
37
+
38
+ function identifier(value: unknown, field: string): string {
39
+ if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_.]*$/.test(value)) {
40
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', `The native project must specify a valid ${field}.`, { operation: 'resolve-build' });
41
+ }
42
+
43
+ return value;
44
+ }
45
+
46
+ function selectDefault(items: Record<string, unknown>[], field: string): Record<string, unknown> {
47
+ if (items.length === 0) {
48
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', `No ${field} is defined in harmony/build-profile.json5.`, { operation: 'resolve-build' });
49
+ }
50
+
51
+ const defaults = items.filter(item => item.name === 'default');
52
+
53
+ if (defaults.length === 1) return defaults[0];
54
+ if (items.length === 1) return items[0];
55
+
56
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', `Multiple ${field} are defined; name the intended default "default" in harmony/build-profile.json5.`, { operation: 'resolve-build' });
57
+ }
58
+
59
+ function resolveBareHarmonyBuildPlan(root: string, mode: 'debug' | 'release'): BareHarmonyBuildPlan {
60
+ const harmony = path.join(root, 'harmony');
61
+ const entry = path.join(harmony, 'entry');
62
+
63
+ for (const directory of [harmony, entry]) {
64
+ if (!fs.existsSync(directory)
65
+ || !fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) {
66
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', `Bare Harmony projects require a regular directory at ${directory}.`, { operation: 'resolve-build' });
67
+ }
68
+ }
69
+
70
+ const profile = readJson(path.join(harmony, 'build-profile.json5'));
71
+ const app = record(readJson(path.join(harmony, 'AppScope/app.json5')).app, 'app');
72
+ const module = record(readJson(path.join(entry, 'src/main/module.json5')).module, 'module');
73
+
74
+ const name = identifier(module.name, 'module.name');
75
+ const registration = records(profile.modules, 'modules').find(item => item.name === name);
76
+ if (!registration || typeof registration.srcPath !== 'string'
77
+ || path.resolve(harmony, registration.srcPath) !== entry || module.type !== 'entry') {
78
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', 'Bare Harmony autolinking requires an entry module at harmony/entry, registered in build-profile.json5.', { operation: 'resolve-build' });
79
+ }
80
+
81
+ const config = record(profile.app, 'build-profile.app');
82
+ const selected = selectDefault(records(config.products, 'products'), 'products');
83
+ const product = identifier(selected.name, 'product name');
84
+ const targets = records(registration.targets, 'module targets').filter((item) => {
85
+ if (item.applyToProducts === undefined) return true;
86
+ if (!Array.isArray(item.applyToProducts) || item.applyToProducts.some(value => typeof value !== 'string')) {
87
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', 'Native target.applyToProducts must be an array of product names.', { operation: 'resolve-build' });
88
+ }
89
+
90
+ return item.applyToProducts.includes(product);
91
+ });
92
+ const target = identifier(selectDefault(targets, 'module targets').name, 'target name');
93
+
94
+ const ability = identifier(module.mainElement, 'module.mainElement');
95
+ if (!records(module.abilities, 'abilities').some(item => item.name === ability)) {
96
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', `Ability ${ability} is missing from module.json5.`, { operation: 'resolve-build' });
97
+ }
98
+
99
+ const bundle = identifier(app.bundleName, 'app.bundleName');
100
+ const signed = Boolean(selected.signingConfig);
101
+ if (signed && !records(config.signingConfigs, 'signingConfigs').some(item => item.name === selected.signingConfig)) {
102
+ throw new HarmonyCliError('ERR_HARMONY_NATIVE_PROJECT', `Signing config ${selected.signingConfig} is referenced by the product but is not defined.`, { operation: 'resolve-build' });
103
+ }
104
+
105
+ const rawfile = path.join(entry, 'src/main/resources/rawfile');
106
+ const metadata = path.join(root, '.expo/harmony/export');
107
+
108
+ return {
109
+ workflow: 'bare',
110
+ abilityName: ability,
111
+ buildMode: mode,
112
+ bundleName: bundle,
113
+ harmonyRoot: harmony,
114
+ moduleName: name,
115
+ moduleRoot: entry,
116
+ productName: product,
117
+ targetName: target,
118
+ expectedHap: path.join(entry, `build/${target}/outputs/${target}/${name}-${target}-${signed ? 'signed' : 'unsigned'}.hap`),
119
+ hvigorArgs: ['--mode', 'module', '-p', `module=${name}@${target}`, '-p', `product=${product}`, '-p', `buildMode=${mode}`, '--no-daemon', 'assembleHap'],
120
+ exportPaths: {
121
+ bundle: path.join(rawfile, 'hermes_bundle.hbc'),
122
+ manifest: path.join(root, '.expo/harmony/export-manifest.json'),
123
+ metadataRoot: metadata,
124
+ rawfileRoot: rawfile,
125
+ sourceMap: path.join(metadata, 'hermes_bundle.hbc.map'),
126
+ },
127
+ nativeCache: {
128
+ invalidationRoots: [path.join(entry, '.cxx'), path.join(entry, 'build')],
129
+ stateFile: path.join(entry, '.cxx/.expo-harmony-native-dependencies.json'),
130
+ },
131
+ nativeInputs: {
132
+ lockfile: path.join(harmony, 'oh-package-lock.json5'),
133
+ manifest: path.join(harmony, 'oh-package.json5'),
134
+ },
135
+ projectFiles: {
136
+ hvigorConfig: path.join(harmony, 'hvigor/hvigor-config.json5'),
137
+ moduleHvigor: path.join(entry, 'hvigorfile.ts'),
138
+ moduleJson: path.join(entry, 'src/main/module.json5'),
139
+ projectBuildProfile: path.join(harmony, 'build-profile.json5'),
140
+ rootHvigor: path.join(harmony, 'hvigorfile.ts'),
141
+ },
142
+ };
143
+ }
144
+
145
+ export { isBareHarmonyProject, resolveBareHarmonyBuildPlan };
@@ -0,0 +1,81 @@
1
+ import path from 'node:path';
2
+
3
+ import { commitHarmonyNativeBuildCacheAsync, prepareHarmonyNativeBuildCacheAsync } from './cache';
4
+ import { installHarmonyDependenciesAsync } from './install';
5
+ import type { HarmonyToolchain } from './toolchain';
6
+ import type { HarmonyBuildPlan } from './types';
7
+ import { HarmonyCliError } from '../errors';
8
+ import { exportEmbedAsync } from '../exportEmbed/export';
9
+ import { isNonEmptyRegularFile } from '../file';
10
+ import { progress, type Log } from '../log';
11
+ import { toPosixPath } from '../path';
12
+ import { runCheckedAsync } from '../process';
13
+ import { timedAsync } from '../profile';
14
+
15
+ export interface NativeBuildResult {
16
+ export: null | { assetCount: number; bundleSha256: string; sourceMapSha256: string };
17
+ hapPath: string;
18
+ }
19
+
20
+ export async function buildNativeAsync(
21
+ root: string,
22
+ plan: HarmonyBuildPlan,
23
+ tools: HarmonyToolchain,
24
+ options: { io?: Log; resetCache?: boolean },
25
+ steps: Record<string, number>
26
+ ): Promise<NativeBuildResult> {
27
+ let exported: NativeBuildResult['export'] = null;
28
+ if (plan.buildMode === 'release') {
29
+ progress(options, 'Exporting the release Hermes bundle');
30
+ const manifest = await timedAsync(steps, 'export', () => exportEmbedAsync(root, {
31
+ resetCache: options.resetCache,
32
+ skipDoctor: true,
33
+ }));
34
+
35
+ exported = {
36
+ assetCount: manifest.assets.length,
37
+ bundleSha256: manifest.bundle.sha256,
38
+ sourceMapSha256: manifest.sourceMap.sha256,
39
+ };
40
+ } else {
41
+ steps.export = 0;
42
+ }
43
+
44
+ progress(options, 'Installing Harmony project dependencies');
45
+ await timedAsync(steps, 'ohpm', () => installHarmonyDependenciesAsync(plan, tools));
46
+
47
+ progress(options, 'Checking Harmony native dependency cache');
48
+ const cache = await timedAsync(steps, 'nativeCache', () => prepareHarmonyNativeBuildCacheAsync(root, plan));
49
+ if (cache.changed) progress(options, 'Invalidated stale Harmony native build objects');
50
+
51
+ progress(options, `Building the ${plan.buildMode} HAP`);
52
+ await timedAsync(steps, 'build', () => runCheckedAsync(tools.hvigor.command, [
53
+ ...tools.hvigor.args,
54
+ ...plan.hvigorArgs,
55
+ ], {
56
+ code: 'ERR_HARMONY_BUILD_FAILED',
57
+ cwd: plan.harmonyRoot,
58
+ env: {
59
+ ...process.env,
60
+ EXPO_HARMONY_NODE: process.env.EXPO_HARMONY_NODE || process.execPath,
61
+ EXPO_METRO_TARGET: 'harmony',
62
+ HERMES_V1_ENABLED: 'true',
63
+ ...(plan.workflow === 'bare' ? { EXPO_HARMONY_NATIVE_PREPARED: '1' } : {}),
64
+ ...(plan.buildMode === 'release' ? { EXPO_HARMONY_BUNDLE_PREBUILT: '1' } : {}),
65
+ ...(tools.sdkHome && !process.env.DEVECO_SDK_HOME
66
+ ? { DEVECO_SDK_HOME: tools.sdkHome }
67
+ : {}),
68
+ },
69
+ message: 'Hvigor build',
70
+ operation: 'hvigor-build',
71
+ timeoutMs: 15 * 60_000,
72
+ }));
73
+
74
+ if (!isNonEmptyRegularFile(plan.expectedHap)) {
75
+ throw new HarmonyCliError('ERR_HARMONY_HAP_MISSING', 'Hvigor completed without producing the expected non-empty regular HAP.', { operation: 'verify-hap' });
76
+ }
77
+
78
+ await timedAsync(steps, 'nativeCacheCommit', () => commitHarmonyNativeBuildCacheAsync(cache));
79
+
80
+ return { export: exported, hapPath: toPosixPath(path.relative(root, plan.expectedHap)) };
81
+ }