@expo-harmony/cli 55.0.26-harmony.4 → 55.0.26-harmony.6

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/build/run/run.js CHANGED
@@ -43,7 +43,10 @@ async function runHarmonyUnlockedAsync(projectRoot, options = {}) {
43
43
  variant: options.variant || 'debug',
44
44
  };
45
45
  const steps = {};
46
- await (0, common_1.ensureGeneratedProjectAsync)(projectRoot, normalizedOptions, steps);
46
+ await (0, common_1.ensureGeneratedProjectAsync)(projectRoot, {
47
+ ...normalizedOptions,
48
+ skipGeneratedProjectCheck: true,
49
+ }, steps);
47
50
  const plan = await (0, common_1.timed)(steps, 'buildPlan', () => (0, tools_1.resolveHarmonyBuildPlanAsync)(projectRoot, { buildMode: normalizedOptions.variant }));
48
51
  const identity = resolveRunIdentity(plan, normalizedOptions);
49
52
  const toolchain = (0, tools_1.resolveHarmonyToolchain)();
@@ -80,6 +83,7 @@ async function runHarmonyUnlockedAsync(projectRoot, options = {}) {
80
83
  const buildEnv = {
81
84
  ...process.env,
82
85
  EXPO_HARMONY_NODE: process.env.EXPO_HARMONY_NODE || process.execPath,
86
+ EXPO_METRO_TARGET: 'harmony',
83
87
  HERMES_V1_ENABLED: 'true',
84
88
  ...(normalizedOptions.variant === 'release' ? { EXPO_HARMONY_BUNDLE_PREBUILT: '1' } : {}),
85
89
  ...(toolchain.sdkHome && !process.env.DEVECO_SDK_HOME
package/build/tools.js CHANGED
@@ -10,6 +10,7 @@ exports.resolveHarmonyEmulator = resolveHarmonyEmulator;
10
10
  exports.resolveHarmonyToolchain = resolveHarmonyToolchain;
11
11
  const node_fs_1 = __importDefault(require("node:fs"));
12
12
  const node_path_1 = __importDefault(require("node:path"));
13
+ const tool_command_1 = require("@expo-harmony/expo-modules-autolinking/tool-command");
13
14
  const build_descriptor_1 = require("@expo-harmony/prebuild-config/build-descriptor");
14
15
  const check_1 = require("@expo-harmony/prebuild-config/check");
15
16
  const errors_1 = require("./errors");
@@ -99,38 +100,35 @@ function resolveHarmonyToolchain() {
99
100
  const layouts = sdkHome ? devEcoLayouts(sdkHome) : [];
100
101
  let ohpm;
101
102
  if (env.HARMONY_OHPM) {
102
- ohpm = { args: [], command: env.HARMONY_OHPM, source: 'override' };
103
+ ohpm = { ...(0, tool_command_1.resolveHarmonyCommand)('ohpm', [], env), source: 'override' };
103
104
  }
104
105
  else {
105
- const devEcoOhpm = existingFile(layouts.map(layout => node_path_1.default.join(layout.toolsRoot, 'ohpm', 'bin', platform === 'win32' ? 'ohpm.bat' : 'ohpm')));
106
- ohpm = devEcoOhpm
107
- ? { args: [], command: devEcoOhpm, source: 'deveco' }
108
- : { args: [], command: platform === 'win32' ? 'ohpm.bat' : 'ohpm', source: 'path' };
106
+ const entry = layouts.map(layout => ({
107
+ node: devEcoNode(layout, platform, { ...env, HARMONY_NODE: env.HARMONY_OHPM_NODE || env.HARMONY_NODE }),
108
+ script: node_path_1.default.join(layout.toolsRoot, 'ohpm', 'bin', 'pm-cli.js'),
109
+ })).find(candidate => candidate.node && node_fs_1.default.existsSync(candidate.script));
110
+ const command = existingFile(layouts.map(layout => node_path_1.default.join(layout.toolsRoot, 'ohpm', 'bin', platform === 'win32' ? 'ohpm.bat' : 'ohpm')));
111
+ ohpm = entry
112
+ ? { args: [entry.script], command: entry.node.command, source: 'deveco' }
113
+ : command
114
+ ? { args: [], command, source: 'deveco' }
115
+ : { args: [], command: platform === 'win32' ? 'ohpm.bat' : 'ohpm', source: 'path' };
109
116
  }
110
117
  let hvigor;
111
118
  if (env.HARMONY_HVIGORW) {
112
- if (/\.(?:c|m)?js$/iu.test(env.HARMONY_HVIGORW)) {
113
- hvigor = {
114
- args: [env.HARMONY_HVIGORW],
115
- command: env.HARMONY_NODE || process.execPath,
116
- source: 'override',
117
- };
118
- }
119
- else {
120
- hvigor = { args: [], command: env.HARMONY_HVIGORW, source: 'override' };
121
- }
119
+ hvigor = { ...(0, tool_command_1.resolveHarmonyCommand)('hvigorw', [], env), source: 'override' };
122
120
  }
123
121
  else {
124
- const devEcoHvigor = layouts.map(layout => ({
125
- layout,
126
- node: devEcoNode(layout, platform, env),
122
+ const entry = layouts.map(layout => ({
123
+ node: devEcoNode(layout, platform, { ...env, HARMONY_NODE: env.HARMONY_HVIGOR_NODE || env.HARMONY_NODE }),
127
124
  script: node_path_1.default.join(layout.toolsRoot, 'hvigor', 'bin', 'hvigorw.js'),
128
125
  })).find(candidate => candidate.node && node_fs_1.default.existsSync(candidate.script));
129
- hvigor = devEcoHvigor
130
- ? { args: [devEcoHvigor.script], command: devEcoHvigor.node.command, source: 'deveco' }
126
+ hvigor = entry
127
+ ? { args: [entry.script], command: entry.node.command, source: 'deveco' }
131
128
  : { args: [], command: platform === 'win32' ? 'hvigorw.bat' : 'hvigorw', source: 'path' };
132
129
  }
133
130
  const toolsRoot = layouts.find(layout => (hvigor.args[0]?.startsWith(`${layout.toolsRoot}${node_path_1.default.sep}`)
131
+ || ohpm.args[0]?.startsWith(`${layout.toolsRoot}${node_path_1.default.sep}`)
134
132
  || ohpm.command.startsWith(`${layout.toolsRoot}${node_path_1.default.sep}`)))?.toolsRoot || null;
135
133
  let hdc;
136
134
  if (env.HARMONY_HDC) {
@@ -151,10 +149,14 @@ function resolveHarmonyToolchain() {
151
149
  function createHarmonyToolchainEnv(toolchain = resolveHarmonyToolchain()) {
152
150
  return {
153
151
  ...process.env,
154
- HARMONY_OHPM: toolchain.ohpm.command,
155
- // A script-based Hvigor invocation must retain both its script and Node executable.
152
+ HARMONY_OHPM: toolchain.ohpm.args[0] || toolchain.ohpm.command,
153
+ HARMONY_OHPM_NODE: toolchain.ohpm.args.length > 0 ? toolchain.ohpm.command : undefined,
154
+ // Script-based tools must retain both their script and Node executable.
156
155
  HARMONY_HVIGORW: toolchain.hvigor.args[0] || toolchain.hvigor.command,
157
- ...(toolchain.hvigor.args.length > 0 ? { HARMONY_NODE: toolchain.hvigor.command } : {}),
156
+ HARMONY_HVIGOR_NODE: toolchain.hvigor.args.length > 0 ? toolchain.hvigor.command : undefined,
157
+ ...(toolchain.hvigor.args.length > 0
158
+ ? { HARMONY_NODE: toolchain.hvigor.command }
159
+ : toolchain.ohpm.args.length > 0 ? { HARMONY_NODE: toolchain.ohpm.command } : {}),
158
160
  ...(toolchain.sdkHome && !process.env.DEVECO_SDK_HOME
159
161
  ? { DEVECO_SDK_HOME: toolchain.sdkHome }
160
162
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo-harmony/cli",
3
- "version": "55.0.26-harmony.4",
3
+ "version": "55.0.26-harmony.6",
4
4
  "keywords": [
5
5
  "react-native",
6
6
  "expo",
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@expo-harmony/config-plugins": "55.0.10-harmony.2",
40
- "@expo-harmony/expo-modules-autolinking": "55.0.25-harmony.2",
41
- "@expo-harmony/prebuild-config": "55.0.0-harmony.3",
40
+ "@expo-harmony/expo-modules-autolinking": "55.0.25-harmony.3",
41
+ "@expo-harmony/prebuild-config": "55.0.0-harmony.5",
42
42
  "@expo/config": "55.0.17",
43
43
  "cross-spawn": "^7.0.6",
44
44
  "json5": "2.2.3"
@@ -10,7 +10,7 @@ import { resolveHarmonyBuildPlanIfPresentAsync } from '../tools';
10
10
  interface HarmonyBuildPipelineOptions {
11
11
  io?: Pick<Console, 'error' | 'log' | 'warn'>;
12
12
  requireDeviceTools?: boolean;
13
- /** @internal The caller already completed an isolated prebuild check. */
13
+ /** @internal Reuse the native project without an isolated drift check. */
14
14
  skipGeneratedProjectCheck?: boolean;
15
15
  sync: boolean;
16
16
  variant: 'debug' | 'release';
@@ -208,6 +208,8 @@ async function doctorUnlockedAsync(projectRoot: string, options: DoctorOptions =
208
208
  tools.unshift(['hdc', toolchain.hdc, ['-v'], unavailableToolStatus]);
209
209
  }
210
210
  for (const [id, tool, versionArgs, unavailableStatus] of tools) {
211
+ const command = [tool.command, ...tool.args].map(value => JSON.stringify(value)).join(' ');
212
+
211
213
  try {
212
214
  const result = await spawnAsync(tool.command, [...tool.args, ...versionArgs], {
213
215
  capture: true,
@@ -215,11 +217,12 @@ async function doctorUnlockedAsync(projectRoot: string, options: DoctorOptions =
215
217
  operation: `doctor-${id}`,
216
218
  timeoutMs: 10_000,
217
219
  });
220
+
218
221
  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.`));
222
+ ? check(id, 'pass', `${command} is available through ${tool.source}.`)
223
+ : check(id, unavailableStatus, `${command} is unavailable or unhealthy; HAP build cannot be verified.`));
221
224
  } catch {
222
- checks.push(check(id, unavailableStatus, `${tool.command} is unavailable; generation remains available.`));
225
+ checks.push(check(id, unavailableStatus, `${command} is unavailable; generation remains available.`));
223
226
  }
224
227
  }
225
228
 
@@ -40,7 +40,28 @@ function mirrorRoot(temp, project) {
40
40
  return path.join(temp, 'filesystem', volume, ...segments);
41
41
  }
42
42
 
43
- async function linkModulesAsync(source, target) {
43
+ async function linkModuleEntryAsync(
44
+ source: string,
45
+ target: string,
46
+ entry: fs.Dirent
47
+ ) {
48
+ const directory = entry.isSymbolicLink()
49
+ ? (await fs.promises.stat(source)).isDirectory()
50
+ : entry.isDirectory();
51
+
52
+ if (process.platform === 'win32' && !directory) {
53
+ await fs.promises.copyFile(source, target);
54
+ return;
55
+ }
56
+
57
+ await fs.promises.symlink(
58
+ path.resolve(source),
59
+ target,
60
+ directory ? process.platform === 'win32' ? 'junction' : 'dir' : 'file'
61
+ );
62
+ }
63
+
64
+ async function linkModulesAsync(source: string, target: string) {
44
65
  await fs.promises.mkdir(target, { recursive: true });
45
66
 
46
67
  for (const entry of await fs.promises.readdir(source, { withFileTypes: true })) {
@@ -49,25 +70,18 @@ async function linkModulesAsync(source, target) {
49
70
 
50
71
  if (entry.name.startsWith('@') && entry.isDirectory() && !entry.isSymbolicLink()) {
51
72
  await fs.promises.mkdir(to);
73
+
52
74
  for (const child of await fs.promises.readdir(from, { withFileTypes: true })) {
53
- await fs.promises.symlink(
75
+ await linkModuleEntryAsync(
54
76
  path.join(from, child.name),
55
77
  path.join(to, child.name),
56
- process.platform === 'win32' && (child.isDirectory() || child.isSymbolicLink())
57
- ? 'junction'
58
- : child.isDirectory() ? 'dir' : 'file'
78
+ child
59
79
  );
60
80
  }
61
81
  continue;
62
82
  }
63
83
 
64
- await fs.promises.symlink(
65
- from,
66
- to,
67
- process.platform === 'win32' && (entry.isDirectory() || entry.isSymbolicLink())
68
- ? 'junction'
69
- : entry.isDirectory() ? 'dir' : 'file'
70
- );
84
+ await linkModuleEntryAsync(from, to, entry);
71
85
  }
72
86
  }
73
87
 
@@ -126,8 +140,8 @@ async function copyAsync(
126
140
  // Keep a real node_modules directory in the isolated project and link its
127
141
  // entries. Dependency scanners then retain the isolated lexical package path
128
142
  // (including scoped packages) instead of collapsing the entire node_modules
129
- // root to the source project's realpath. The packages remain read-only and
130
- // are never copied or modified by --check.
143
+ // root to the source project's realpath. Package directories stay linked;
144
+ // Windows copies loose files to avoid requiring file-symlink privileges.
131
145
  await linkModulesAsync(modules, path.join(target, 'node_modules'));
132
146
  await stageAsync(project, target, temp);
133
147
  }
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
 
@@ -76,8 +77,15 @@ function formatDiagnostics(result: Pick<ProcessResult, 'stderr' | 'stdout'>, lim
76
77
 
77
78
  function spawnAsync(command: string, args: string[], options: ProcessOptions = {}): Promise<ProcessResult> {
78
79
  return new Promise<ProcessResult>((resolve, reject) => {
80
+ if (options.signal?.aborted) {
81
+ throw new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot run ${command}: operation aborted.`, {
82
+ cause: options.signal.reason,
83
+ operation: options.operation || 'spawn',
84
+ });
85
+ }
86
+
79
87
  const piped = Boolean(options.capture || options.onStdout || options.onStderr);
80
- const outputLimit = options.outputLimit || DefaultOutputLimit;
88
+ const limit = options.outputLimit || DefaultOutputLimit;
81
89
  const child = spawn(command, args, {
82
90
  cwd: options.cwd,
83
91
  env: options.env || process.env,
@@ -86,11 +94,12 @@ function spawnAsync(command: string, args: string[], options: ProcessOptions = {
86
94
  windowsHide: true,
87
95
  });
88
96
 
89
- const stdout = new BoundedCapture(outputLimit);
90
- const stderr = new BoundedCapture(outputLimit);
97
+ const stdout = new BoundedCapture(limit);
98
+ const stderr = new BoundedCapture(limit);
91
99
  let timedOut = false;
92
100
  let settled = false;
93
- let forceKillTimer: NodeJS.Timeout | null = null;
101
+ let escalation: NodeJS.Timeout | null = null;
102
+ let stopping: Promise<void> | undefined;
94
103
 
95
104
  if (piped) {
96
105
  child.stdout.on('data', (chunk) => {
@@ -103,72 +112,96 @@ function spawnAsync(command: string, args: string[], options: ProcessOptions = {
103
112
  });
104
113
  }
105
114
 
106
- const stopChild = (signal: NodeJS.Signals = 'SIGTERM') => {
107
- child.kill(signal);
108
- if (forceKillTimer === null) {
109
- forceKillTimer = setTimeout(
110
- () => child.kill('SIGKILL'),
115
+ const stop = (signal: NodeJS.Signals = 'SIGTERM') => {
116
+ if (stopping || settled) return;
117
+
118
+ stopping = Promise.resolve().then(() => terminateProcess(child, signal));
119
+ if (process.platform === 'win32') {
120
+ void finish(null, signal);
121
+ } else {
122
+ stopping.catch((cause) => {
123
+ void finish(null, null, cause);
124
+ });
125
+ escalation = setTimeout(
126
+ () => {
127
+ stopping = terminateProcess(child, 'SIGKILL');
128
+ stopping.catch((cause) => {
129
+ void finish(null, null, cause);
130
+ });
131
+ },
111
132
  options.stopGraceMs || DefaultStopGraceMs
112
133
  );
113
- forceKillTimer.unref?.();
134
+ escalation.unref?.();
114
135
  }
115
136
  };
116
137
 
117
- const forwardSigint = () => stopChild('SIGINT');
118
- const forwardSigterm = () => stopChild('SIGTERM');
119
- process.once('SIGINT', forwardSigint);
120
- process.once('SIGTERM', forwardSigterm);
138
+ const interrupt = () => stop('SIGINT');
139
+ const terminate = () => stop('SIGTERM');
140
+ process.once('SIGINT', interrupt);
141
+ process.once('SIGTERM', terminate);
121
142
 
122
143
  const timeout = options.timeoutMs
123
144
  ? setTimeout(() => {
124
145
  timedOut = true;
125
- stopChild('SIGTERM');
146
+ stop('SIGTERM');
126
147
  }, options.timeoutMs)
127
148
  : null;
128
149
  timeout?.unref?.();
129
150
 
130
151
  const cleanup = () => {
131
152
  if (timeout) clearTimeout(timeout);
132
- if (forceKillTimer) clearTimeout(forceKillTimer);
133
- process.removeListener('SIGINT', forwardSigint);
134
- process.removeListener('SIGTERM', forwardSigterm);
153
+ if (escalation) clearTimeout(escalation);
154
+ process.removeListener('SIGINT', interrupt);
155
+ process.removeListener('SIGTERM', terminate);
135
156
  options.signal?.removeEventListener('abort', abort);
136
157
  };
137
158
 
138
- const abort = () => stopChild('SIGTERM');
139
- if (options.signal?.aborted) abort();
140
- else options.signal?.addEventListener('abort', abort, { once: true });
159
+ const finish = async (code, signal, cause?) => {
160
+ try {
161
+ await stopping;
162
+ } catch (error) {
163
+ cause = error;
164
+ }
141
165
 
142
- child.once('error', (cause) => {
143
166
  if (settled) return;
144
167
  settled = true;
145
168
  cleanup();
146
169
 
147
- reject(new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
170
+ if (cause) reject(new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot run ${command}: ${cause.message}`, {
148
171
  cause,
149
172
  operation: options.operation || 'spawn',
150
173
  }));
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({
174
+ else resolve({
160
175
  code: code === null ? 1 : code,
161
176
  signal,
162
177
  stderr: stderr.toString(),
163
178
  stdout: stdout.toString(),
164
179
  timedOut,
165
180
  });
181
+ };
182
+
183
+ child.once('error', (cause) => {
184
+ void finish(null, null, cause);
166
185
  });
186
+ child.once('close', (code, signal) => {
187
+ void finish(code, signal);
188
+ });
189
+
190
+ const abort = () => stop('SIGTERM');
191
+ options.signal?.addEventListener('abort', abort, { once: true });
192
+ if (options.signal?.aborted) abort();
167
193
  });
168
194
  }
169
195
 
170
196
  function startManagedProcess(command: string, args: string[], options: ProcessOptions = {}) {
171
- const outputLimit = options.outputLimit || DefaultOutputLimit;
197
+ if (options.signal?.aborted) {
198
+ throw new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: operation aborted.`, {
199
+ cause: options.signal.reason,
200
+ operation: options.operation || 'spawn',
201
+ });
202
+ }
203
+
204
+ const limit = options.outputLimit || DefaultOutputLimit;
172
205
  const piped = options.stdio !== 'inherit';
173
206
  const child = spawn(command, args, {
174
207
  cwd: options.cwd,
@@ -178,11 +211,14 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
178
211
  windowsHide: true,
179
212
  });
180
213
 
181
- const stdout = new BoundedCapture(outputLimit);
182
- const stderr = new BoundedCapture(outputLimit);
183
- let spawnError: HarmonyCliError | null = null;
214
+ const stdout = new BoundedCapture(limit);
215
+ const stderr = new BoundedCapture(limit);
216
+ let failure: HarmonyCliError | null = null;
184
217
  let closed = false;
185
- let stopRequested = false;
218
+ let stopped = false;
219
+ let stopping: Promise<void> | undefined;
220
+ let stopPromise: Promise<ProcessResult | undefined> | undefined;
221
+ let finish: (code: number | null, signal: NodeJS.Signals | null) => Promise<void>;
186
222
 
187
223
  if (piped) {
188
224
  child.stdout.on('data', (chunk) => {
@@ -195,34 +231,43 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
195
231
  });
196
232
  }
197
233
 
198
- const forwardSigint = () => {
199
- void stop('SIGINT');
234
+ const interrupt = () => {
235
+ void stop('SIGINT').catch(() => {});
200
236
  };
201
- const forwardSigterm = () => {
202
- void stop('SIGTERM');
237
+ const terminate = () => {
238
+ void stop('SIGTERM').catch(() => {});
203
239
  };
204
240
  const abort = () => {
205
- void stop('SIGTERM');
241
+ void stop('SIGTERM').catch(() => {});
206
242
  };
207
243
 
208
244
  const cleanup = () => {
209
- process.removeListener('SIGINT', forwardSigint);
210
- process.removeListener('SIGTERM', forwardSigterm);
245
+ process.removeListener('SIGINT', interrupt);
246
+ process.removeListener('SIGTERM', terminate);
211
247
  options.signal?.removeEventListener('abort', abort);
212
248
  };
213
249
 
214
250
  const completion = new Promise<ProcessResult>((resolve, reject) => {
215
251
  child.once('error', (cause) => {
216
- spawnError = new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
252
+ failure = new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
217
253
  cause,
218
254
  operation: options.operation || 'spawn',
219
255
  });
220
256
  });
221
- child.once('close', (code, signal) => {
257
+ finish = async (code, signal) => {
258
+ try {
259
+ await stopping;
260
+ } catch (cause) {
261
+ failure = new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot stop ${command}: ${cause.message}`, {
262
+ cause, operation: options.operation || 'spawn',
263
+ });
264
+ }
265
+
266
+ if (closed) return;
222
267
  closed = true;
223
268
  cleanup();
224
269
 
225
- if (spawnError) reject(spawnError);
270
+ if (failure) reject(failure);
226
271
  else resolve({
227
272
  code: code === null ? 1 : code,
228
273
  signal,
@@ -230,6 +275,10 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
230
275
  stdout: stdout.toString(),
231
276
  timedOut: false,
232
277
  });
278
+ };
279
+
280
+ child.once('close', (code, signal) => {
281
+ void finish(code, signal);
233
282
  });
234
283
  });
235
284
  // A readiness probe may be the first consumer. Keep early spawn failures from
@@ -237,28 +286,53 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
237
286
  completion.catch(() => {});
238
287
 
239
288
  async function stop(signal: NodeJS.Signals = 'SIGTERM', graceMs = DefaultStopGraceMs) {
289
+ if (stopPromise) return stopPromise;
240
290
  if (closed) return completion;
241
291
 
242
- stopRequested = true;
243
- child.kill(signal);
244
- let timer: NodeJS.Timeout | undefined;
292
+ stopped = true;
293
+ stopping = Promise.resolve().then(() => terminateProcess(child, signal));
294
+ stopPromise = (async () => {
295
+ if (process.platform === 'win32') {
296
+ void finish(null, signal);
297
+ return completion;
298
+ }
299
+
300
+ try {
301
+ await stopping;
302
+ } catch {
303
+ await finish(null, signal);
304
+ return completion;
305
+ }
245
306
 
246
- await Promise.race([
247
- completion.catch(() => undefined),
248
- new Promise((resolve) => {
249
- timer = setTimeout(resolve, graceMs);
250
- timer.unref?.();
251
- }),
252
- ]);
307
+ let timer: NodeJS.Timeout | undefined;
308
+
309
+ await Promise.race([
310
+ completion.catch(() => undefined),
311
+ new Promise((resolve) => {
312
+ timer = setTimeout(resolve, graceMs);
313
+ timer.unref?.();
314
+ }),
315
+ ]);
316
+
317
+ if (timer) clearTimeout(timer);
318
+ if (!closed) {
319
+ stopping = terminateProcess(child, 'SIGKILL');
320
+ try {
321
+ await stopping;
322
+ } catch {
323
+ await finish(null, signal);
324
+ }
325
+ }
253
326
 
254
- if (timer) clearTimeout(timer);
255
- if (!closed) child.kill('SIGKILL');
327
+ return completion.catch(() => undefined);
328
+ })();
329
+ stopPromise.catch(() => {});
256
330
 
257
- return completion.catch(() => undefined);
331
+ return stopPromise;
258
332
  }
259
333
 
260
- process.once('SIGINT', forwardSigint);
261
- process.once('SIGTERM', forwardSigterm);
334
+ process.once('SIGINT', interrupt);
335
+ process.once('SIGTERM', terminate);
262
336
  if (options.signal?.aborted) abort();
263
337
  else options.signal?.addEventListener('abort', abort, { once: true });
264
338
 
@@ -268,7 +342,7 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
268
342
  getStderr: () => stderr.toString(),
269
343
  getStdout: () => stdout.toString(),
270
344
  stop,
271
- wasStopped: () => stopRequested,
345
+ wasStopped: () => stopped,
272
346
  };
273
347
  }
274
348
 
package/src/run/run.ts CHANGED
@@ -118,7 +118,10 @@ async function runHarmonyUnlockedAsync(
118
118
  };
119
119
  const steps: Record<string, number> = {};
120
120
 
121
- await ensureGeneratedProjectAsync(projectRoot, normalizedOptions, steps);
121
+ await ensureGeneratedProjectAsync(projectRoot, {
122
+ ...normalizedOptions,
123
+ skipGeneratedProjectCheck: true,
124
+ }, steps);
122
125
 
123
126
  const plan = await timed(steps, 'buildPlan', () => resolveHarmonyBuildPlanAsync(
124
127
  projectRoot,
package/src/tools.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { resolveHarmonyCommand } from '@expo-harmony/expo-modules-autolinking/tool-command';
3
4
 
4
5
  import {
5
6
  resolveHarmonyBuildPath,
@@ -141,43 +142,43 @@ function resolveHarmonyToolchain(): HarmonyToolchain {
141
142
 
142
143
  let ohpm: HarmonyTool;
143
144
  if (env.HARMONY_OHPM) {
144
- ohpm = { args: [], command: env.HARMONY_OHPM, source: 'override' };
145
+ ohpm = { ...resolveHarmonyCommand('ohpm', [], env), source: 'override' };
145
146
  } else {
146
- const devEcoOhpm = existingFile(layouts.map(layout => path.join(
147
+ const entry = layouts.map(layout => ({
148
+ node: devEcoNode(layout, platform, { ...env, HARMONY_NODE: env.HARMONY_OHPM_NODE || env.HARMONY_NODE }),
149
+ script: path.join(layout.toolsRoot, 'ohpm', 'bin', 'pm-cli.js'),
150
+ })).find(candidate => candidate.node && fs.existsSync(candidate.script));
151
+ const command = existingFile(layouts.map(layout => path.join(
147
152
  layout.toolsRoot,
148
153
  'ohpm',
149
154
  'bin',
150
155
  platform === 'win32' ? 'ohpm.bat' : 'ohpm'
151
156
  )));
152
- ohpm = devEcoOhpm
153
- ? { args: [], command: devEcoOhpm, source: 'deveco' }
154
- : { args: [], command: platform === 'win32' ? 'ohpm.bat' : 'ohpm', source: 'path' };
157
+
158
+ ohpm = entry
159
+ ? { args: [entry.script], command: entry.node.command, source: 'deveco' }
160
+ : command
161
+ ? { args: [], command, source: 'deveco' }
162
+ : { args: [], command: platform === 'win32' ? 'ohpm.bat' : 'ohpm', source: 'path' };
155
163
  }
156
164
 
157
165
  let hvigor: HarmonyTool;
158
166
  if (env.HARMONY_HVIGORW) {
159
- if (/\.(?:c|m)?js$/iu.test(env.HARMONY_HVIGORW)) {
160
- hvigor = {
161
- args: [env.HARMONY_HVIGORW],
162
- command: env.HARMONY_NODE || process.execPath,
163
- source: 'override',
164
- };
165
- } else {
166
- hvigor = { args: [], command: env.HARMONY_HVIGORW, source: 'override' };
167
- }
167
+ hvigor = { ...resolveHarmonyCommand('hvigorw', [], env), source: 'override' };
168
168
  } else {
169
- const devEcoHvigor = layouts.map(layout => ({
170
- layout,
171
- node: devEcoNode(layout, platform, env),
169
+ const entry = layouts.map(layout => ({
170
+ node: devEcoNode(layout, platform, { ...env, HARMONY_NODE: env.HARMONY_HVIGOR_NODE || env.HARMONY_NODE }),
172
171
  script: path.join(layout.toolsRoot, 'hvigor', 'bin', 'hvigorw.js'),
173
172
  })).find(candidate => candidate.node && fs.existsSync(candidate.script));
174
- hvigor = devEcoHvigor
175
- ? { args: [devEcoHvigor.script], command: devEcoHvigor.node.command, source: 'deveco' }
173
+
174
+ hvigor = entry
175
+ ? { args: [entry.script], command: entry.node.command, source: 'deveco' }
176
176
  : { args: [], command: platform === 'win32' ? 'hvigorw.bat' : 'hvigorw', source: 'path' };
177
177
  }
178
178
 
179
179
  const toolsRoot = layouts.find(layout => (
180
180
  hvigor.args[0]?.startsWith(`${layout.toolsRoot}${path.sep}`)
181
+ || ohpm.args[0]?.startsWith(`${layout.toolsRoot}${path.sep}`)
181
182
  || ohpm.command.startsWith(`${layout.toolsRoot}${path.sep}`)
182
183
  ))?.toolsRoot || null;
183
184
 
@@ -201,10 +202,14 @@ function resolveHarmonyToolchain(): HarmonyToolchain {
201
202
  function createHarmonyToolchainEnv(toolchain = resolveHarmonyToolchain()): NodeJS.ProcessEnv {
202
203
  return {
203
204
  ...process.env,
204
- HARMONY_OHPM: toolchain.ohpm.command,
205
- // A script-based Hvigor invocation must retain both its script and Node executable.
205
+ HARMONY_OHPM: toolchain.ohpm.args[0] || toolchain.ohpm.command,
206
+ HARMONY_OHPM_NODE: toolchain.ohpm.args.length > 0 ? toolchain.ohpm.command : undefined,
207
+ // Script-based tools must retain both their script and Node executable.
206
208
  HARMONY_HVIGORW: toolchain.hvigor.args[0] || toolchain.hvigor.command,
207
- ...(toolchain.hvigor.args.length > 0 ? { HARMONY_NODE: toolchain.hvigor.command } : {}),
209
+ HARMONY_HVIGOR_NODE: toolchain.hvigor.args.length > 0 ? toolchain.hvigor.command : undefined,
210
+ ...(toolchain.hvigor.args.length > 0
211
+ ? { HARMONY_NODE: toolchain.hvigor.command }
212
+ : toolchain.ohpm.args.length > 0 ? { HARMONY_NODE: toolchain.ohpm.command } : {}),
208
213
  ...(toolchain.sdkHome && !process.env.DEVECO_SDK_HOME
209
214
  ? { DEVECO_SDK_HOME: toolchain.sdkHome }
210
215
  : {}),