@expo-harmony/cli 55.0.26-harmony.1 → 55.0.26-harmony.10

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 (185) hide show
  1. package/README.md +87 -38
  2. package/build/args.d.ts +19 -0
  3. package/build/args.js +27 -0
  4. package/build/bin/expo-harmony.d.ts +2 -0
  5. package/build/bin/expo-harmony.js +1 -1
  6. package/build/buildHap/build.d.ts +18 -0
  7. package/build/buildHap/build.js +38 -0
  8. package/build/buildHap/index.d.ts +2 -0
  9. package/build/buildHap/index.js +19 -0
  10. package/build/buildHap/options.d.ts +7 -0
  11. package/build/buildHap/options.js +30 -0
  12. package/build/cli.d.ts +3 -0
  13. package/build/cli.js +40 -0
  14. package/build/command.d.ts +9 -0
  15. package/build/command.js +20 -0
  16. package/build/development/manifest.d.ts +1 -0
  17. package/build/development/manifest.js +100 -0
  18. package/build/development/protocol.d.ts +2 -0
  19. package/build/development/protocol.js +26 -0
  20. package/build/development/session.d.ts +5 -0
  21. package/build/development/session.js +66 -0
  22. package/build/doctor/doctor.d.ts +20 -0
  23. package/build/doctor/doctor.js +226 -0
  24. package/build/doctor/index.d.ts +2 -0
  25. package/build/doctor/index.js +16 -0
  26. package/build/doctor/options.d.ts +5 -0
  27. package/build/doctor/options.js +15 -0
  28. package/build/entry.d.ts +2 -0
  29. package/build/entry.js +47 -0
  30. package/build/errors.d.ts +12 -0
  31. package/build/errors.js +16 -0
  32. package/build/expo.d.ts +19 -0
  33. package/build/expo.js +53 -0
  34. package/build/exportEmbed/export.d.ts +18 -0
  35. package/build/exportEmbed/export.js +124 -0
  36. package/build/exportEmbed/index.d.ts +2 -0
  37. package/build/exportEmbed/index.js +19 -0
  38. package/build/exportEmbed/manifest.d.ts +44 -0
  39. package/build/exportEmbed/manifest.js +171 -0
  40. package/build/exportEmbed/options.d.ts +7 -0
  41. package/build/exportEmbed/options.js +22 -0
  42. package/build/file.d.ts +10 -0
  43. package/build/file.js +110 -0
  44. package/build/help.d.ts +1 -0
  45. package/build/help.js +44 -0
  46. package/build/index.d.ts +14 -0
  47. package/build/index.js +6 -5
  48. package/build/internal/development.d.ts +1 -0
  49. package/build/internal/development.js +8 -0
  50. package/build/internal/export.d.ts +1 -0
  51. package/build/internal/export.js +80 -0
  52. package/build/internal/prebuild.d.ts +3 -0
  53. package/build/internal/prebuild.js +9 -0
  54. package/build/log.d.ts +4 -0
  55. package/build/log.js +6 -0
  56. package/build/modules/index.d.ts +2 -0
  57. package/build/modules/index.js +16 -0
  58. package/build/modules/modules.d.ts +42 -0
  59. package/build/modules/modules.js +84 -0
  60. package/build/modules/options.d.ts +10 -0
  61. package/build/modules/options.js +45 -0
  62. package/build/native/bare.d.ts +4 -0
  63. package/build/native/bare.js +130 -0
  64. package/build/native/build.d.ts +15 -0
  65. package/build/native/build.js +69 -0
  66. package/build/native/cache.d.ts +11 -0
  67. package/build/native/cache.js +73 -0
  68. package/build/native/hvigor.d.ts +3 -0
  69. package/build/native/hvigor.js +83 -0
  70. package/build/native/install.d.ts +5 -0
  71. package/build/native/install.js +13 -0
  72. package/build/native/prepareProject.d.ts +11 -0
  73. package/build/native/prepareProject.js +68 -0
  74. package/build/native/project.d.ts +8 -0
  75. package/build/native/project.js +58 -0
  76. package/build/native/toolchain.d.ts +16 -0
  77. package/build/native/toolchain.js +172 -0
  78. package/build/native/types.d.ts +47 -0
  79. package/build/native/types.js +2 -0
  80. package/build/path.d.ts +4 -0
  81. package/build/path.js +27 -0
  82. package/build/prebuild/check.d.ts +10 -0
  83. package/build/prebuild/check.js +151 -0
  84. package/build/prebuild/clean.d.ts +2 -0
  85. package/build/prebuild/clean.js +50 -0
  86. package/build/prebuild/index.d.ts +2 -0
  87. package/build/prebuild/index.js +51 -0
  88. package/build/prebuild/options.d.ts +10 -0
  89. package/build/prebuild/options.js +50 -0
  90. package/build/prebuild/prebuild.d.ts +6 -0
  91. package/build/prebuild/prebuild.js +70 -0
  92. package/build/prebuild/template.d.ts +8 -0
  93. package/build/prebuild/template.js +86 -0
  94. package/build/process.d.ts +41 -0
  95. package/build/process.js +324 -0
  96. package/build/profile.d.ts +1 -0
  97. package/build/profile.js +12 -0
  98. package/build/project.d.ts +2 -0
  99. package/build/project.js +23 -0
  100. package/build/projectLock.d.ts +2 -0
  101. package/build/projectLock.js +185 -0
  102. package/build/run/devices.d.ts +29 -0
  103. package/build/run/devices.js +216 -0
  104. package/build/run/emulators.d.ts +13 -0
  105. package/build/run/emulators.js +82 -0
  106. package/build/run/index.d.ts +2 -0
  107. package/build/run/index.js +25 -0
  108. package/build/run/metro.d.ts +21 -0
  109. package/build/run/metro.js +162 -0
  110. package/build/run/options.d.ts +14 -0
  111. package/build/run/options.js +62 -0
  112. package/build/run/run.d.ts +46 -0
  113. package/build/run/run.js +135 -0
  114. package/build/runtime/config.d.ts +35 -0
  115. package/build/runtime/config.js +118 -0
  116. package/build/runtime/contract.d.ts +5 -0
  117. package/build/runtime/contract.js +108 -0
  118. package/build/runtime/fingerprint.d.ts +1 -0
  119. package/build/runtime/fingerprint.js +44 -0
  120. package/build/runtime/index.d.ts +2 -0
  121. package/build/runtime/index.js +50 -0
  122. package/build/start/index.d.ts +2 -0
  123. package/build/start/index.js +38 -0
  124. package/build/start/options.d.ts +9 -0
  125. package/build/start/options.js +38 -0
  126. package/build/updates/export.d.ts +14 -0
  127. package/build/updates/export.js +46 -0
  128. package/build/updates/index.d.ts +2 -0
  129. package/build/updates/index.js +96 -0
  130. package/build/upstream.d.ts +9 -0
  131. package/build/upstream.js +17 -0
  132. package/package.json +21 -5
  133. package/src/bin/expo-harmony.ts +2 -2
  134. package/src/buildHap/build.ts +13 -105
  135. package/src/buildHap/index.ts +19 -0
  136. package/src/cli.ts +27 -225
  137. package/src/command.ts +24 -0
  138. package/src/development/manifest.ts +106 -0
  139. package/src/development/protocol.ts +37 -0
  140. package/src/development/session.ts +83 -0
  141. package/src/doctor/doctor.ts +67 -62
  142. package/src/doctor/index.ts +16 -0
  143. package/src/exportEmbed/export.ts +73 -72
  144. package/src/exportEmbed/index.ts +17 -0
  145. package/src/file.ts +20 -11
  146. package/src/help.ts +41 -0
  147. package/src/index.ts +5 -3
  148. package/src/internal/development.ts +8 -0
  149. package/src/internal/export.ts +82 -0
  150. package/src/internal/prebuild.ts +3 -0
  151. package/src/log.ts +5 -0
  152. package/src/modules/index.ts +16 -0
  153. package/src/native/bare.ts +145 -0
  154. package/src/native/build.ts +83 -0
  155. package/src/{run → native}/cache.ts +10 -9
  156. package/src/native/hvigor.ts +88 -0
  157. package/src/native/install.ts +17 -0
  158. package/src/native/prepareProject.ts +99 -0
  159. package/src/native/project.ts +88 -0
  160. package/src/{tools.ts → native/toolchain.ts} +38 -140
  161. package/src/native/types.ts +44 -0
  162. package/src/prebuild/check.ts +56 -31
  163. package/src/prebuild/clean.ts +3 -3
  164. package/src/prebuild/index.ts +56 -0
  165. package/src/prebuild/prebuild.ts +11 -1
  166. package/src/prebuild/template.ts +2 -2
  167. package/src/process.ts +175 -64
  168. package/src/profile.ts +13 -0
  169. package/src/projectLock.ts +9 -1
  170. package/src/run/devices.ts +16 -11
  171. package/src/run/emulators.ts +4 -6
  172. package/src/run/index.ts +25 -0
  173. package/src/run/metro.ts +10 -1
  174. package/src/run/options.ts +2 -0
  175. package/src/run/run.ts +63 -129
  176. package/src/runtime/config.ts +150 -0
  177. package/src/runtime/contract.ts +165 -0
  178. package/src/runtime/fingerprint.ts +39 -0
  179. package/src/runtime/index.ts +54 -0
  180. package/src/start/index.ts +37 -0
  181. package/src/start/options.ts +12 -0
  182. package/src/updates/export.ts +60 -0
  183. package/src/updates/index.ts +89 -0
  184. package/src/buildHap/common.ts +0 -145
  185. package/src/run/install.ts +0 -41
@@ -1,11 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
- import { HarmonyPlatformDirectory } from '@expo-harmony/prebuild-config/build-descriptor';
4
+ import { HarmonyPlatformDirectory } from '@expo-harmony/prebuild-config/internal';
5
5
 
6
6
  import { HarmonyCliError } from '../errors';
7
7
  import { isInside } from '../path';
8
- import { resolveHarmonyBuildPlanIfPresentAsync } from '../tools';
8
+ import { resolveHarmonyBuildPlanIfPresentAsync } from '../native/project';
9
9
 
10
10
  async function assertSafeCleanTarget(projectRoot) {
11
11
  const root = await fs.promises.realpath(projectRoot);
@@ -24,7 +24,7 @@ async function assertSafeCleanTarget(projectRoot) {
24
24
  );
25
25
  }
26
26
 
27
- if (!plan) {
27
+ if (!plan || plan.workflow !== 'cng') {
28
28
  throw new HarmonyCliError(
29
29
  'ERR_HARMONY_CLEAN_TARGET',
30
30
  `Refusing to clean ${target} because its CNG manifest is missing. Please delete it manually or run prebuild without --clean.`,
@@ -0,0 +1,56 @@
1
+ import { parseCommandArgs, type Command } from '../command';
2
+ import { HarmonyCliError } from '../errors';
3
+ import { withHarmonyProjectLockAsync } from '../projectLock';
4
+ import { parsePrebuildArgs } from './options';
5
+
6
+ export const command: Command = async (argv, io) => {
7
+ const args = parseCommandArgs(args => parsePrebuildArgs(args, { allowProject: true }), argv, io);
8
+ if (!args) return 0;
9
+
10
+ const { projectRoot: root, options } = args;
11
+ const { check, clean, passthrough } = options;
12
+
13
+ if (check && passthrough.length) {
14
+ throw new HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', '--check cannot be combined with mutating prebuild options.');
15
+ }
16
+
17
+ return withHarmonyProjectLockAsync(
18
+ root,
19
+ check ? 'prebuild-check' : 'prebuild',
20
+ async () => {
21
+ if (clean) {
22
+ const { assertSafeCleanTarget } = await import('./clean.js');
23
+ await assertSafeCleanTarget(root);
24
+ }
25
+
26
+ const { doctorAsync, formatDoctor } = await import('../doctor/doctor.js');
27
+ const doctor = await doctorAsync(root, {
28
+ requireBuildTools: false,
29
+ validateGeneratedProject: !clean,
30
+ validateModules: false,
31
+ });
32
+ if (!doctor.ok) {
33
+ io.error(formatDoctor(doctor));
34
+ throw new HarmonyCliError('ERR_HARMONY_DOCTOR_FAILED', 'Harmony doctor found blocking errors.', {
35
+ operation: 'doctor',
36
+ });
37
+ }
38
+
39
+ for (const item of doctor.checks.filter(item => item.status === 'warn')) io.warn(`! ${item.message}`);
40
+
41
+ if (check) {
42
+ const { checkAsync } = await import('./check.js');
43
+ const result = await checkAsync(root);
44
+
45
+ if (result.clean) io.log('Harmony CNG output is up to date.');
46
+ else for (const change of result.changes) io.log(`${change.type}: ${change.path}`);
47
+
48
+ return result.clean ? 0 : 2;
49
+ }
50
+
51
+ const { prebuildParsedAsync } = await import('./prebuild.js');
52
+ await prebuildParsedAsync(root, passthrough);
53
+
54
+ return 0;
55
+ });
56
+ };
@@ -1,10 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
 
3
3
  import { HarmonyCliError } from '../errors';
4
+ import { isBareHarmonyProject } from '../native/bare';
4
5
  import { formatDiagnostics, spawnAsync } from '../process';
5
6
  import { withHarmonyProjectLockAsync } from '../projectLock';
6
7
  import { resolveExpoCli } from '../expo';
7
- import { createHarmonyToolchainEnv, resolveHarmonyBuildPlanAsync } from '../tools';
8
+ import { createHarmonyToolchainEnv } from '../native/toolchain';
9
+ import { resolveHarmonyBuildPlanAsync } from '../native/project';
8
10
  import { assertSafeCleanTarget } from './clean';
9
11
  import { packAsync } from './template';
10
12
 
@@ -18,6 +20,9 @@ async function prebuildParsedUnlockedAsync(
18
20
  passthrough: string[],
19
21
  options: PrebuildOptions = {}
20
22
  ) {
23
+ if (isBareHarmonyProject(projectRoot)) {
24
+ throw new HarmonyCliError('ERR_HARMONY_BARE_PREBUILD', 'This Harmony project is manually maintained. Build it directly; prebuild cannot overwrite a bare project.', { operation: 'prebuild' });
25
+ }
21
26
  if (passthrough.includes('--clean')) await assertSafeCleanTarget(projectRoot);
22
27
 
23
28
  const expo = resolveExpoCli(projectRoot);
@@ -44,6 +49,7 @@ async function prebuildParsedUnlockedAsync(
44
49
 
45
50
  if (result.code !== 0) {
46
51
  const diagnostics = options.capture ? formatDiagnostics(result) : '';
52
+
47
53
  throw new HarmonyCliError('ERR_HARMONY_PREBUILD_FAILED', `Expo prebuild exited with code ${result.code}.${diagnostics ? `\n${diagnostics}` : ''}`, {
48
54
  exitCode: result.code,
49
55
  operation: 'expo-prebuild',
@@ -54,6 +60,10 @@ async function prebuildParsedUnlockedAsync(
54
60
  const plan = await resolveHarmonyBuildPlanAsync(projectRoot, {
55
61
  buildMode: options.buildType,
56
62
  });
63
+ if (plan.workflow !== 'cng') {
64
+ throw new HarmonyCliError('ERR_HARMONY_TEMPLATE_INVALID', 'Prebuild did not produce a CNG project.', { operation: 'prebuild' });
65
+ }
66
+
57
67
  await fs.promises.access(plan.projectFiles.templateMarker, fs.constants.R_OK);
58
68
  } catch (cause) {
59
69
  throw new HarmonyCliError('ERR_HARMONY_TEMPLATE_INVALID', 'Expo prebuild exited successfully but the Harmony marker or CNG manifest is invalid.', { cause, operation: 'verify-prebuild' });
@@ -19,11 +19,11 @@ function resolve(project: string) {
19
19
  let api: Api;
20
20
 
21
21
  try {
22
- api = load('@expo-harmony/prebuild-config/template');
22
+ api = load('@expo-harmony/prebuild-config/internal/template');
23
23
  } catch (cause) {
24
24
  throw new HarmonyCliError(
25
25
  'ERR_HARMONY_TEMPLATE_INVALID',
26
- 'Cannot load @expo-harmony/prebuild-config/template from the project. Update the project-local prebuild config to a compatible version.',
26
+ 'Cannot load @expo-harmony/prebuild-config/internal/template from the project. Update the project-local prebuild config to a compatible version.',
27
27
  { cause, operation: 'resolve-template' }
28
28
  );
29
29
  }
package/src/process.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import spawn from 'cross-spawn';
2
+ import { terminateProcess } from '@expo-harmony/expo-modules-autolinking/tool-command';
2
3
 
3
4
  import { HarmonyCliError } from './errors';
4
5
 
@@ -27,6 +28,16 @@ export interface ProcessResult {
27
28
  timedOut: boolean;
28
29
  }
29
30
 
31
+ interface CheckedProcessOptions {
32
+ code: string;
33
+ cwd: string;
34
+ env?: NodeJS.ProcessEnv;
35
+ message: string;
36
+ operation: string;
37
+ outputLimit?: number;
38
+ timeoutMs?: number;
39
+ }
40
+
30
41
  class BoundedCapture {
31
42
  private buffers: Uint8Array[] = [];
32
43
  private head = 0;
@@ -76,8 +87,15 @@ function formatDiagnostics(result: Pick<ProcessResult, 'stderr' | 'stdout'>, lim
76
87
 
77
88
  function spawnAsync(command: string, args: string[], options: ProcessOptions = {}): Promise<ProcessResult> {
78
89
  return new Promise<ProcessResult>((resolve, reject) => {
90
+ if (options.signal?.aborted) {
91
+ throw new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot run ${command}: operation aborted.`, {
92
+ cause: options.signal.reason,
93
+ operation: options.operation || 'spawn',
94
+ });
95
+ }
96
+
79
97
  const piped = Boolean(options.capture || options.onStdout || options.onStderr);
80
- const outputLimit = options.outputLimit || DefaultOutputLimit;
98
+ const limit = options.outputLimit || DefaultOutputLimit;
81
99
  const child = spawn(command, args, {
82
100
  cwd: options.cwd,
83
101
  env: options.env || process.env,
@@ -86,11 +104,12 @@ function spawnAsync(command: string, args: string[], options: ProcessOptions = {
86
104
  windowsHide: true,
87
105
  });
88
106
 
89
- const stdout = new BoundedCapture(outputLimit);
90
- const stderr = new BoundedCapture(outputLimit);
107
+ const stdout = new BoundedCapture(limit);
108
+ const stderr = new BoundedCapture(limit);
91
109
  let timedOut = false;
92
110
  let settled = false;
93
- let forceKillTimer: NodeJS.Timeout | null = null;
111
+ let escalation: NodeJS.Timeout | null = null;
112
+ let stopping: Promise<void> | undefined;
94
113
 
95
114
  if (piped) {
96
115
  child.stdout.on('data', (chunk) => {
@@ -103,72 +122,96 @@ function spawnAsync(command: string, args: string[], options: ProcessOptions = {
103
122
  });
104
123
  }
105
124
 
106
- const stopChild = (signal: NodeJS.Signals = 'SIGTERM') => {
107
- child.kill(signal);
108
- if (forceKillTimer === null) {
109
- forceKillTimer = setTimeout(
110
- () => child.kill('SIGKILL'),
125
+ const stop = (signal: NodeJS.Signals = 'SIGTERM') => {
126
+ if (stopping || settled) return;
127
+
128
+ stopping = Promise.resolve().then(() => terminateProcess(child, signal));
129
+ if (process.platform === 'win32') {
130
+ void finish(null, signal);
131
+ } else {
132
+ stopping.catch((cause) => {
133
+ void finish(null, null, cause);
134
+ });
135
+ escalation = setTimeout(
136
+ () => {
137
+ stopping = terminateProcess(child, 'SIGKILL');
138
+ stopping.catch((cause) => {
139
+ void finish(null, null, cause);
140
+ });
141
+ },
111
142
  options.stopGraceMs || DefaultStopGraceMs
112
143
  );
113
- forceKillTimer.unref?.();
144
+ escalation.unref?.();
114
145
  }
115
146
  };
116
147
 
117
- const forwardSigint = () => stopChild('SIGINT');
118
- const forwardSigterm = () => stopChild('SIGTERM');
119
- process.once('SIGINT', forwardSigint);
120
- process.once('SIGTERM', forwardSigterm);
148
+ const interrupt = () => stop('SIGINT');
149
+ const terminate = () => stop('SIGTERM');
150
+ process.once('SIGINT', interrupt);
151
+ process.once('SIGTERM', terminate);
121
152
 
122
153
  const timeout = options.timeoutMs
123
154
  ? setTimeout(() => {
124
155
  timedOut = true;
125
- stopChild('SIGTERM');
156
+ stop('SIGTERM');
126
157
  }, options.timeoutMs)
127
158
  : null;
128
159
  timeout?.unref?.();
129
160
 
130
161
  const cleanup = () => {
131
162
  if (timeout) clearTimeout(timeout);
132
- if (forceKillTimer) clearTimeout(forceKillTimer);
133
- process.removeListener('SIGINT', forwardSigint);
134
- process.removeListener('SIGTERM', forwardSigterm);
163
+ if (escalation) clearTimeout(escalation);
164
+ process.removeListener('SIGINT', interrupt);
165
+ process.removeListener('SIGTERM', terminate);
135
166
  options.signal?.removeEventListener('abort', abort);
136
167
  };
137
168
 
138
- const abort = () => stopChild('SIGTERM');
139
- if (options.signal?.aborted) abort();
140
- else options.signal?.addEventListener('abort', abort, { once: true });
169
+ const finish = async (code, signal, cause?) => {
170
+ try {
171
+ await stopping;
172
+ } catch (error) {
173
+ cause = error;
174
+ }
141
175
 
142
- child.once('error', (cause) => {
143
176
  if (settled) return;
144
177
  settled = true;
145
178
  cleanup();
146
179
 
147
- reject(new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
180
+ if (cause) reject(new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot run ${command}: ${cause.message}`, {
148
181
  cause,
149
182
  operation: options.operation || 'spawn',
150
183
  }));
151
- });
152
- // `close` runs after stdout/stderr have closed, so captured diagnostics are
153
- // complete. `exit` can fire while pipe data is still pending.
154
- child.once('close', (code, signal) => {
155
- if (settled) return;
156
- settled = true;
157
- cleanup();
158
-
159
- resolve({
184
+ else resolve({
160
185
  code: code === null ? 1 : code,
161
186
  signal,
162
187
  stderr: stderr.toString(),
163
188
  stdout: stdout.toString(),
164
189
  timedOut,
165
190
  });
191
+ };
192
+
193
+ child.once('error', (cause) => {
194
+ void finish(null, null, cause);
195
+ });
196
+ child.once('close', (code, signal) => {
197
+ void finish(code, signal);
166
198
  });
199
+
200
+ const abort = () => stop('SIGTERM');
201
+ options.signal?.addEventListener('abort', abort, { once: true });
202
+ if (options.signal?.aborted) abort();
167
203
  });
168
204
  }
169
205
 
170
206
  function startManagedProcess(command: string, args: string[], options: ProcessOptions = {}) {
171
- const outputLimit = options.outputLimit || DefaultOutputLimit;
207
+ if (options.signal?.aborted) {
208
+ throw new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: operation aborted.`, {
209
+ cause: options.signal.reason,
210
+ operation: options.operation || 'spawn',
211
+ });
212
+ }
213
+
214
+ const limit = options.outputLimit || DefaultOutputLimit;
172
215
  const piped = options.stdio !== 'inherit';
173
216
  const child = spawn(command, args, {
174
217
  cwd: options.cwd,
@@ -178,11 +221,14 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
178
221
  windowsHide: true,
179
222
  });
180
223
 
181
- const stdout = new BoundedCapture(outputLimit);
182
- const stderr = new BoundedCapture(outputLimit);
183
- let spawnError: HarmonyCliError | null = null;
224
+ const stdout = new BoundedCapture(limit);
225
+ const stderr = new BoundedCapture(limit);
226
+ let failure: HarmonyCliError | null = null;
184
227
  let closed = false;
185
- let stopRequested = false;
228
+ let stopped = false;
229
+ let stopping: Promise<void> | undefined;
230
+ let stopPromise: Promise<ProcessResult | undefined> | undefined;
231
+ let finish: (code: number | null, signal: NodeJS.Signals | null) => Promise<void>;
186
232
 
187
233
  if (piped) {
188
234
  child.stdout.on('data', (chunk) => {
@@ -195,34 +241,43 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
195
241
  });
196
242
  }
197
243
 
198
- const forwardSigint = () => {
199
- void stop('SIGINT');
244
+ const interrupt = () => {
245
+ void stop('SIGINT').catch(() => {});
200
246
  };
201
- const forwardSigterm = () => {
202
- void stop('SIGTERM');
247
+ const terminate = () => {
248
+ void stop('SIGTERM').catch(() => {});
203
249
  };
204
250
  const abort = () => {
205
- void stop('SIGTERM');
251
+ void stop('SIGTERM').catch(() => {});
206
252
  };
207
253
 
208
254
  const cleanup = () => {
209
- process.removeListener('SIGINT', forwardSigint);
210
- process.removeListener('SIGTERM', forwardSigterm);
255
+ process.removeListener('SIGINT', interrupt);
256
+ process.removeListener('SIGTERM', terminate);
211
257
  options.signal?.removeEventListener('abort', abort);
212
258
  };
213
259
 
214
260
  const completion = new Promise<ProcessResult>((resolve, reject) => {
215
261
  child.once('error', (cause) => {
216
- spawnError = new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
262
+ failure = new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
217
263
  cause,
218
264
  operation: options.operation || 'spawn',
219
265
  });
220
266
  });
221
- child.once('close', (code, signal) => {
267
+ finish = async (code, signal) => {
268
+ try {
269
+ await stopping;
270
+ } catch (cause) {
271
+ failure = new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot stop ${command}: ${cause.message}`, {
272
+ cause, operation: options.operation || 'spawn',
273
+ });
274
+ }
275
+
276
+ if (closed) return;
222
277
  closed = true;
223
278
  cleanup();
224
279
 
225
- if (spawnError) reject(spawnError);
280
+ if (failure) reject(failure);
226
281
  else resolve({
227
282
  code: code === null ? 1 : code,
228
283
  signal,
@@ -230,6 +285,10 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
230
285
  stdout: stdout.toString(),
231
286
  timedOut: false,
232
287
  });
288
+ };
289
+
290
+ child.once('close', (code, signal) => {
291
+ void finish(code, signal);
233
292
  });
234
293
  });
235
294
  // A readiness probe may be the first consumer. Keep early spawn failures from
@@ -237,28 +296,53 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
237
296
  completion.catch(() => {});
238
297
 
239
298
  async function stop(signal: NodeJS.Signals = 'SIGTERM', graceMs = DefaultStopGraceMs) {
299
+ if (stopPromise) return stopPromise;
240
300
  if (closed) return completion;
241
301
 
242
- stopRequested = true;
243
- child.kill(signal);
244
- let timer: NodeJS.Timeout | undefined;
302
+ stopped = true;
303
+ stopping = Promise.resolve().then(() => terminateProcess(child, signal));
304
+ stopPromise = (async () => {
305
+ if (process.platform === 'win32') {
306
+ void finish(null, signal);
307
+ return completion;
308
+ }
245
309
 
246
- await Promise.race([
247
- completion.catch(() => undefined),
248
- new Promise((resolve) => {
249
- timer = setTimeout(resolve, graceMs);
250
- timer.unref?.();
251
- }),
252
- ]);
310
+ try {
311
+ await stopping;
312
+ } catch {
313
+ await finish(null, signal);
314
+ return completion;
315
+ }
316
+
317
+ let timer: NodeJS.Timeout | undefined;
318
+
319
+ await Promise.race([
320
+ completion.catch(() => undefined),
321
+ new Promise((resolve) => {
322
+ timer = setTimeout(resolve, graceMs);
323
+ timer.unref?.();
324
+ }),
325
+ ]);
326
+
327
+ if (timer) clearTimeout(timer);
328
+ if (!closed) {
329
+ stopping = terminateProcess(child, 'SIGKILL');
330
+ try {
331
+ await stopping;
332
+ } catch {
333
+ await finish(null, signal);
334
+ }
335
+ }
253
336
 
254
- if (timer) clearTimeout(timer);
255
- if (!closed) child.kill('SIGKILL');
337
+ return completion.catch(() => undefined);
338
+ })();
339
+ stopPromise.catch(() => {});
256
340
 
257
- return completion.catch(() => undefined);
341
+ return stopPromise;
258
342
  }
259
343
 
260
- process.once('SIGINT', forwardSigint);
261
- process.once('SIGTERM', forwardSigterm);
344
+ process.once('SIGINT', interrupt);
345
+ process.once('SIGTERM', terminate);
262
346
  if (options.signal?.aborted) abort();
263
347
  else options.signal?.addEventListener('abort', abort, { once: true });
264
348
 
@@ -268,8 +352,35 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
268
352
  getStderr: () => stderr.toString(),
269
353
  getStdout: () => stdout.toString(),
270
354
  stop,
271
- wasStopped: () => stopRequested,
355
+ wasStopped: () => stopped,
272
356
  };
273
357
  }
274
358
 
275
- export { formatDiagnostics, spawnAsync, startManagedProcess };
359
+ async function runCheckedAsync(
360
+ command: string,
361
+ args: string[],
362
+ options: CheckedProcessOptions
363
+ ) {
364
+ const result = await spawnAsync(command, args, {
365
+ capture: true,
366
+ cwd: options.cwd,
367
+ env: options.env,
368
+ operation: options.operation,
369
+ outputLimit: options.outputLimit || 2 * 1024 * 1024,
370
+ timeoutMs: options.timeoutMs,
371
+ });
372
+
373
+ if (result.code !== 0 || result.timedOut) {
374
+ const diagnostics = formatDiagnostics(result);
375
+
376
+ throw new HarmonyCliError(
377
+ options.code,
378
+ `${options.message} exited with code ${result.code}${result.timedOut ? ' after timing out' : ''}.${diagnostics ? `\n${diagnostics}` : ''}`,
379
+ { exitCode: result.code || 1, operation: options.operation }
380
+ );
381
+ }
382
+
383
+ return result;
384
+ }
385
+
386
+ export { formatDiagnostics, runCheckedAsync, spawnAsync, startManagedProcess };
package/src/profile.ts ADDED
@@ -0,0 +1,13 @@
1
+ export async function timedAsync<T>(
2
+ steps: Record<string, number>,
3
+ name: string,
4
+ operation: () => Promise<T> | T
5
+ ): Promise<T> {
6
+ const started = performance.now();
7
+
8
+ try {
9
+ return await operation();
10
+ } finally {
11
+ steps[name] = Math.max(0, Math.round(performance.now() - started));
12
+ }
13
+ }
@@ -162,6 +162,7 @@ async function acquireHarmonyProjectLockAsync(
162
162
  await handle.sync();
163
163
  } catch (cause) {
164
164
  await releaseOwnedLockAsync(handle, lockPath, owned);
165
+
165
166
  throw new HarmonyCliError(
166
167
  'ERR_HARMONY_PROJECT_LOCK',
167
168
  'Cannot initialize the Harmony native operation lock.',
@@ -183,7 +184,14 @@ async function acquireHarmonyProjectLockAsync(
183
184
  },
184
185
  };
185
186
  } catch (cause) {
186
- if (cause instanceof HarmonyCliError) throw cause;
187
+ if (cause instanceof HarmonyCliError) {
188
+ throw new HarmonyCliError(cause.code, cause.message, {
189
+ cause,
190
+ exitCode: cause.exitCode,
191
+ operation: cause.operation,
192
+ });
193
+ }
194
+
187
195
  if (cause?.code !== 'EEXIST') {
188
196
  throw new HarmonyCliError(
189
197
  'ERR_HARMONY_PROJECT_LOCK',
@@ -2,7 +2,7 @@ import { setTimeout as delay } from 'node:timers/promises';
2
2
 
3
3
  import { listEmulatorsAsync, startEmulator } from './emulators';
4
4
  import { HarmonyCliError } from '../errors';
5
- import type { HarmonyTool } from '../tools';
5
+ import { type HarmonyTool } from '../native/toolchain';
6
6
  import { formatDiagnostics, spawnAsync, type ProcessResult } from '../process';
7
7
 
8
8
  interface Device {
@@ -44,8 +44,6 @@ function parseHdcTargets(output: string): Device[] {
44
44
  }
45
45
 
46
46
  return {
47
- // Only the first HDC column is a selectable target name. The remaining
48
- // verbose columns describe transport, state, location and connect tool.
49
47
  aliases: [fields[0]],
50
48
  connectTool: fields[4] || null,
51
49
  id: fields[0],
@@ -59,10 +57,7 @@ function parseHdcTargets(output: string): Device[] {
59
57
  function hasCommandFailure(result: ProcessResult): boolean {
60
58
  const output = `${result.stdout || ''}\n${result.stderr || ''}`;
61
59
  return result.code !== 0 || result.timedOut
62
- // HDC occasionally reports transport and package-manager failures on
63
- // stdout while still returning exit code 0 (for example,
64
- // "Connect server failed"). Treat its documented failure vocabulary as
65
- // authoritative regardless of where it appears on the line.
60
+ // HDC can print failures to stdout while returning exit code 0.
66
61
  || /\[(?:Fail|Error)\]|\b(?:Failure|failed)\b|(?:失败|错误)/iu.test(output);
67
62
  }
68
63
 
@@ -146,7 +141,6 @@ async function selectDeviceAsync(
146
141
  }
147
142
 
148
143
  const instances = await listEmulatorsAsync(options.emulator, { cwd: options.cwd });
149
- // Prefer an instance that is already booting when HDC is not connected yet.
150
144
  const running = instances.filter(instance => instance.running);
151
145
  const candidates = requested
152
146
  ? instances.filter(instance => instance.name === requested)
@@ -242,13 +236,24 @@ async function configureMetroPortAsync(
242
236
  const deviceEndpoint = `tcp:${options.devicePort || 8081}`;
243
237
  const hostEndpoint = `tcp:${port}`;
244
238
 
245
- await runHdcAsync(hdc, ['-t', device.id, 'fport', 'rm', deviceEndpoint, hostEndpoint], {
246
- allowFailure: true,
239
+ const forwards = await runHdcAsync(hdc, ['-t', device.id, 'fport', 'ls'], {
247
240
  cwd: options.cwd,
248
- operation: 'remove-metro-port',
241
+ operation: 'list-metro-ports',
249
242
  timeoutMs: 15_000,
250
243
  });
251
244
 
245
+ for (const line of forwards.stdout.split(/\r?\n/u)) {
246
+ const [target, remote, local, direction] = line.trim().split(/\s+/u);
247
+ if (target !== device.id || remote !== deviceEndpoint || direction !== '[Reverse]') continue;
248
+ if (local === hostEndpoint) return;
249
+
250
+ await runHdcAsync(hdc, ['-t', device.id, 'fport', 'rm', remote, local], {
251
+ cwd: options.cwd,
252
+ operation: 'remove-metro-port',
253
+ timeoutMs: 15_000,
254
+ });
255
+ }
256
+
252
257
  await runHdcAsync(hdc, ['-t', device.id, 'rport', deviceEndpoint, hostEndpoint], {
253
258
  code: 'ERR_HARMONY_METRO_FORWARD',
254
259
  cwd: options.cwd,
@@ -4,7 +4,7 @@ import spawn from 'cross-spawn';
4
4
 
5
5
  import { HarmonyCliError } from '../errors';
6
6
  import { formatDiagnostics, spawnAsync } from '../process';
7
- import type { HarmonyTool } from '../tools';
7
+ import { type HarmonyTool } from '../native/toolchain';
8
8
 
9
9
  interface HarmonyEmulator {
10
10
  name: string;
@@ -67,10 +67,9 @@ function startEmulator(tool: HarmonyTool, name: string, logFile: string, cwd?: s
67
67
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
68
68
  const log = fs.openSync(logFile, 'w', 0o600);
69
69
  let failure: string | null = null;
70
+
70
71
  try {
71
- // Emulator may remain alive for the whole GUI session. Give it its own
72
- // process group and file-backed output so it survives the CLI/Metro exit.
73
- // Default instance/image paths match the ones used by -list -details.
72
+ // A detached process with file output lets the emulator survive CLI exit.
74
73
  const child = spawn(tool.command, [...tool.args, '-start', name], {
75
74
  cwd,
76
75
  detached: true,
@@ -82,8 +81,7 @@ function startEmulator(tool: HarmonyTool, name: string, logFile: string, cwd?: s
82
81
  failure = cause.message;
83
82
  });
84
83
  child.once('exit', (code, signal) => {
85
- // Some versions use a short-lived launcher. A zero exit alone does not
86
- // prove readiness; the caller still checks the guest's name and boot state.
84
+ // Some launchers exit before the guest boots; HDC still has to confirm readiness.
87
85
  if (code !== 0) failure = signal ? `terminated by ${signal}` : `exited with code ${code}`;
88
86
  });
89
87
  child.unref();
@@ -0,0 +1,25 @@
1
+ import { parseCommandArgs, type Command } from '../command';
2
+ import { parseRunArgs } from './options';
3
+
4
+ export const command: Command = async (argv, io) => {
5
+ const args = parseCommandArgs(parseRunArgs, argv, io);
6
+ if (!args) return 0;
7
+
8
+ const { projectRoot: root, options } = args;
9
+ const { runHarmonySessionAsync } = await import('./run.js');
10
+
11
+ const session = await runHarmonySessionAsync(root, {
12
+ ...options,
13
+ interactiveBundler: true,
14
+ io,
15
+ });
16
+ const { result } = session;
17
+
18
+ io.log(`Launched ${result.bundleName} on ${result.device.id} (${result.variant}).`);
19
+ if (session.metro.owner === 'started') {
20
+ io.log('\n› Logs for your project will appear below. Press Ctrl+C to exit.');
21
+ await session.metro.waitAsync();
22
+ }
23
+
24
+ return 0;
25
+ };