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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ npx expo-harmony prebuild --check
68
68
 
69
69
  `prebuild` 固定使用 HarmonyOS 平台和 `@expo-harmony/template` 模板,因此不像官方接受 `--platform` 和 `--template`。依赖安装相关选项会透传给 Expo CLI:`--no-install`、`--npm`/`--yarn`/`--pnpm`/`--bun`(最多选择一个)和 `--skip-dependency-update <packages>`。执行前会先运行一次 doctor(此阶段不要求构建工具就绪),发现阻塞错误时直接中止。
70
70
 
71
- `--clean` 只会清理带有 Expo Harmony 模板标记的原生目录:CNG manifest 缺失、目标不是项目内的普通目录或模板标记异常时都会拒绝删除。`--check` 是只读操作,会把项目镜像到临时目录(`node_modules` 以符号链接共享,不复制),在其中执行一次隔离的 prebuild 后比较受管文件;无差异时退出码为 `0`,有差异时列出变更并以 `2` 退出,且不能与其他会修改工程的选项同时使用。
71
+ `--clean` 只会清理带有 Expo Harmony 模板标记的原生目录:CNG manifest 缺失、目标不是项目内的普通目录或模板标记异常时都会拒绝删除。`--check` 是只读操作,会把项目镜像到临时目录(`node_modules` 中的包目录通过链接共享,Windows 下的普通文件复制到临时目录,无须文件符号链接权限),在其中执行一次隔离的 prebuild 后比较受管文件;无差异时退出码为 `0`,有差异时列出变更并以 `2` 退出,且不能与其他会修改工程的选项同时使用。
72
72
 
73
73
  ## Build
74
74
 
@@ -50,6 +50,7 @@ async function buildHarmonyUnlockedAsync(projectRoot, options = {}) {
50
50
  const buildEnv = {
51
51
  ...process.env,
52
52
  EXPO_HARMONY_NODE: process.env.EXPO_HARMONY_NODE || process.execPath,
53
+ EXPO_METRO_TARGET: 'harmony',
53
54
  HERMES_V1_ENABLED: 'true',
54
55
  ...(normalizedOptions.variant === 'release' ? { EXPO_HARMONY_BUNDLE_PREBUILT: '1' } : {}),
55
56
  ...(toolchain.sdkHome && !process.env.DEVECO_SDK_HOME
@@ -59,7 +59,19 @@ async function validateMetroConfigAsync(projectRoot) {
59
59
  })();
60
60
  throw new Error(`metro-config ${version} does not expose resolveConfig().`);
61
61
  }
62
- const resolved = await metroConfig.resolveConfig(undefined, projectRoot);
62
+ const hadMetroTarget = Object.hasOwn(process.env, 'EXPO_METRO_TARGET');
63
+ const previousMetroTarget = process.env.EXPO_METRO_TARGET;
64
+ let resolved;
65
+ try {
66
+ process.env.EXPO_METRO_TARGET = 'harmony';
67
+ resolved = await metroConfig.resolveConfig(undefined, projectRoot);
68
+ }
69
+ finally {
70
+ if (hadMetroTarget)
71
+ process.env.EXPO_METRO_TARGET = previousMetroTarget;
72
+ else
73
+ delete process.env.EXPO_METRO_TARGET;
74
+ }
63
75
  const config = resolved?.config;
64
76
  const platforms = config?.resolver?.platforms;
65
77
  const conditions = config?.resolver?.unstable_conditionsByPlatform?.harmony;
@@ -153,6 +165,7 @@ async function doctorUnlockedAsync(projectRoot, options = {}) {
153
165
  tools.unshift(['hdc', toolchain.hdc, ['-v'], unavailableToolStatus]);
154
166
  }
155
167
  for (const [id, tool, versionArgs, unavailableStatus] of tools) {
168
+ const command = [tool.command, ...tool.args].map(value => JSON.stringify(value)).join(' ');
156
169
  try {
157
170
  const result = await (0, process_1.spawnAsync)(tool.command, [...tool.args, ...versionArgs], {
158
171
  capture: true,
@@ -161,11 +174,11 @@ async function doctorUnlockedAsync(projectRoot, options = {}) {
161
174
  timeoutMs: 10_000,
162
175
  });
163
176
  checks.push(result.code === 0 && !result.timedOut
164
- ? check(id, 'pass', `${tool.command} is available through ${tool.source}.`)
165
- : check(id, unavailableStatus, `${tool.command} is unavailable or unhealthy; HAP build cannot be verified.`));
177
+ ? check(id, 'pass', `${command} is available through ${tool.source}.`)
178
+ : check(id, unavailableStatus, `${command} is unavailable or unhealthy; HAP build cannot be verified.`));
166
179
  }
167
180
  catch {
168
- checks.push(check(id, unavailableStatus, `${tool.command} is unavailable; generation remains available.`));
181
+ checks.push(check(id, unavailableStatus, `${command} is unavailable; generation remains available.`));
169
182
  }
170
183
  }
171
184
  if (options.validateGeneratedProject !== false) {
@@ -65,6 +65,7 @@ async function exportEmbedUnlockedAsync(projectRoot, options = {}) {
65
65
  cwd: projectRoot,
66
66
  env: {
67
67
  ...process.env,
68
+ EXPO_METRO_TARGET: 'harmony',
68
69
  HERMES_V1_ENABLED: 'true',
69
70
  NODE_ENV: 'production',
70
71
  },
@@ -31,6 +31,16 @@ function mirrorRoot(temp, project) {
31
31
  const segments = absolute.slice(parsed.root.length).split(node_path_1.default.sep).filter(Boolean);
32
32
  return node_path_1.default.join(temp, 'filesystem', volume, ...segments);
33
33
  }
34
+ async function linkModuleEntryAsync(source, target, entry) {
35
+ const directory = entry.isSymbolicLink()
36
+ ? (await node_fs_1.default.promises.stat(source)).isDirectory()
37
+ : entry.isDirectory();
38
+ if (process.platform === 'win32' && !directory) {
39
+ await node_fs_1.default.promises.copyFile(source, target);
40
+ return;
41
+ }
42
+ await node_fs_1.default.promises.symlink(node_path_1.default.resolve(source), target, directory ? process.platform === 'win32' ? 'junction' : 'dir' : 'file');
43
+ }
34
44
  async function linkModulesAsync(source, target) {
35
45
  await node_fs_1.default.promises.mkdir(target, { recursive: true });
36
46
  for (const entry of await node_fs_1.default.promises.readdir(source, { withFileTypes: true })) {
@@ -39,15 +49,11 @@ async function linkModulesAsync(source, target) {
39
49
  if (entry.name.startsWith('@') && entry.isDirectory() && !entry.isSymbolicLink()) {
40
50
  await node_fs_1.default.promises.mkdir(to);
41
51
  for (const child of await node_fs_1.default.promises.readdir(from, { withFileTypes: true })) {
42
- await node_fs_1.default.promises.symlink(node_path_1.default.join(from, child.name), node_path_1.default.join(to, child.name), process.platform === 'win32' && (child.isDirectory() || child.isSymbolicLink())
43
- ? 'junction'
44
- : child.isDirectory() ? 'dir' : 'file');
52
+ await linkModuleEntryAsync(node_path_1.default.join(from, child.name), node_path_1.default.join(to, child.name), child);
45
53
  }
46
54
  continue;
47
55
  }
48
- await node_fs_1.default.promises.symlink(from, to, process.platform === 'win32' && (entry.isDirectory() || entry.isSymbolicLink())
49
- ? 'junction'
50
- : entry.isDirectory() ? 'dir' : 'file');
56
+ await linkModuleEntryAsync(from, to, entry);
51
57
  }
52
58
  }
53
59
  function isAppLocalHarmonyPath(project, source) {
@@ -90,8 +96,8 @@ async function copyAsync(project, target, plan, temp = node_path_1.default.dirna
90
96
  // Keep a real node_modules directory in the isolated project and link its
91
97
  // entries. Dependency scanners then retain the isolated lexical package path
92
98
  // (including scoped packages) instead of collapsing the entire node_modules
93
- // root to the source project's realpath. The packages remain read-only and
94
- // are never copied or modified by --check.
99
+ // root to the source project's realpath. Package directories stay linked;
100
+ // Windows copies loose files to avoid requiring file-symlink privileges.
95
101
  await linkModulesAsync(modules, node_path_1.default.join(target, 'node_modules'));
96
102
  await (0, check_1.stageAsync)(project, target, temp);
97
103
  }
@@ -25,7 +25,7 @@ declare function startManagedProcess(command: string, args: string[], options?:
25
25
  completion: Promise<ProcessResult>;
26
26
  getStderr: () => string;
27
27
  getStdout: () => string;
28
- stop: (signal?: NodeJS.Signals, graceMs?: number) => Promise<any>;
28
+ stop: (signal?: NodeJS.Signals, graceMs?: number) => Promise<ProcessResult>;
29
29
  wasStopped: () => boolean;
30
30
  };
31
31
  export { formatDiagnostics, spawnAsync, startManagedProcess };
package/build/process.js CHANGED
@@ -7,6 +7,7 @@ exports.formatDiagnostics = formatDiagnostics;
7
7
  exports.spawnAsync = spawnAsync;
8
8
  exports.startManagedProcess = startManagedProcess;
9
9
  const cross_spawn_1 = __importDefault(require("cross-spawn"));
10
+ const tool_command_1 = require("@expo-harmony/expo-modules-autolinking/tool-command");
10
11
  const errors_1 = require("./errors");
11
12
  const DefaultOutputLimit = 1024 * 1024;
12
13
  const DefaultStopGraceMs = 3_000;
@@ -53,8 +54,14 @@ function formatDiagnostics(result, limit = 4_000) {
53
54
  }
54
55
  function spawnAsync(command, args, options = {}) {
55
56
  return new Promise((resolve, reject) => {
57
+ if (options.signal?.aborted) {
58
+ throw new errors_1.HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot run ${command}: operation aborted.`, {
59
+ cause: options.signal.reason,
60
+ operation: options.operation || 'spawn',
61
+ });
62
+ }
56
63
  const piped = Boolean(options.capture || options.onStdout || options.onStderr);
57
- const outputLimit = options.outputLimit || DefaultOutputLimit;
64
+ const limit = options.outputLimit || DefaultOutputLimit;
58
65
  const child = (0, cross_spawn_1.default)(command, args, {
59
66
  cwd: options.cwd,
60
67
  env: options.env || process.env,
@@ -62,11 +69,12 @@ function spawnAsync(command, args, options = {}) {
62
69
  stdio: piped ? ['ignore', 'pipe', 'pipe'] : 'inherit',
63
70
  windowsHide: true,
64
71
  });
65
- const stdout = new BoundedCapture(outputLimit);
66
- const stderr = new BoundedCapture(outputLimit);
72
+ const stdout = new BoundedCapture(limit);
73
+ const stderr = new BoundedCapture(limit);
67
74
  let timedOut = false;
68
75
  let settled = false;
69
- let forceKillTimer = null;
76
+ let escalation = null;
77
+ let stopping;
70
78
  if (piped) {
71
79
  child.stdout.on('data', (chunk) => {
72
80
  stdout.append(chunk);
@@ -77,67 +85,91 @@ function spawnAsync(command, args, options = {}) {
77
85
  options.onStderr?.(chunk);
78
86
  });
79
87
  }
80
- const stopChild = (signal = 'SIGTERM') => {
81
- child.kill(signal);
82
- if (forceKillTimer === null) {
83
- forceKillTimer = setTimeout(() => child.kill('SIGKILL'), options.stopGraceMs || DefaultStopGraceMs);
84
- forceKillTimer.unref?.();
88
+ const stop = (signal = 'SIGTERM') => {
89
+ if (stopping || settled)
90
+ return;
91
+ stopping = Promise.resolve().then(() => (0, tool_command_1.terminateProcess)(child, signal));
92
+ if (process.platform === 'win32') {
93
+ void finish(null, signal);
94
+ }
95
+ else {
96
+ stopping.catch((cause) => {
97
+ void finish(null, null, cause);
98
+ });
99
+ escalation = setTimeout(() => {
100
+ stopping = (0, tool_command_1.terminateProcess)(child, 'SIGKILL');
101
+ stopping.catch((cause) => {
102
+ void finish(null, null, cause);
103
+ });
104
+ }, options.stopGraceMs || DefaultStopGraceMs);
105
+ escalation.unref?.();
85
106
  }
86
107
  };
87
- const forwardSigint = () => stopChild('SIGINT');
88
- const forwardSigterm = () => stopChild('SIGTERM');
89
- process.once('SIGINT', forwardSigint);
90
- process.once('SIGTERM', forwardSigterm);
108
+ const interrupt = () => stop('SIGINT');
109
+ const terminate = () => stop('SIGTERM');
110
+ process.once('SIGINT', interrupt);
111
+ process.once('SIGTERM', terminate);
91
112
  const timeout = options.timeoutMs
92
113
  ? setTimeout(() => {
93
114
  timedOut = true;
94
- stopChild('SIGTERM');
115
+ stop('SIGTERM');
95
116
  }, options.timeoutMs)
96
117
  : null;
97
118
  timeout?.unref?.();
98
119
  const cleanup = () => {
99
120
  if (timeout)
100
121
  clearTimeout(timeout);
101
- if (forceKillTimer)
102
- clearTimeout(forceKillTimer);
103
- process.removeListener('SIGINT', forwardSigint);
104
- process.removeListener('SIGTERM', forwardSigterm);
122
+ if (escalation)
123
+ clearTimeout(escalation);
124
+ process.removeListener('SIGINT', interrupt);
125
+ process.removeListener('SIGTERM', terminate);
105
126
  options.signal?.removeEventListener('abort', abort);
106
127
  };
107
- const abort = () => stopChild('SIGTERM');
108
- if (options.signal?.aborted)
109
- abort();
110
- else
111
- options.signal?.addEventListener('abort', abort, { once: true });
112
- child.once('error', (cause) => {
128
+ const finish = async (code, signal, cause) => {
129
+ try {
130
+ await stopping;
131
+ }
132
+ catch (error) {
133
+ cause = error;
134
+ }
113
135
  if (settled)
114
136
  return;
115
137
  settled = true;
116
138
  cleanup();
117
- reject(new errors_1.HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
118
- cause,
119
- operation: options.operation || 'spawn',
120
- }));
139
+ if (cause)
140
+ reject(new errors_1.HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot run ${command}: ${cause.message}`, {
141
+ cause,
142
+ operation: options.operation || 'spawn',
143
+ }));
144
+ else
145
+ resolve({
146
+ code: code === null ? 1 : code,
147
+ signal,
148
+ stderr: stderr.toString(),
149
+ stdout: stdout.toString(),
150
+ timedOut,
151
+ });
152
+ };
153
+ child.once('error', (cause) => {
154
+ void finish(null, null, cause);
121
155
  });
122
- // `close` runs after stdout/stderr have closed, so captured diagnostics are
123
- // complete. `exit` can fire while pipe data is still pending.
124
156
  child.once('close', (code, signal) => {
125
- if (settled)
126
- return;
127
- settled = true;
128
- cleanup();
129
- resolve({
130
- code: code === null ? 1 : code,
131
- signal,
132
- stderr: stderr.toString(),
133
- stdout: stdout.toString(),
134
- timedOut,
135
- });
157
+ void finish(code, signal);
136
158
  });
159
+ const abort = () => stop('SIGTERM');
160
+ options.signal?.addEventListener('abort', abort, { once: true });
161
+ if (options.signal?.aborted)
162
+ abort();
137
163
  });
138
164
  }
139
165
  function startManagedProcess(command, args, options = {}) {
140
- const outputLimit = options.outputLimit || DefaultOutputLimit;
166
+ if (options.signal?.aborted) {
167
+ throw new errors_1.HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: operation aborted.`, {
168
+ cause: options.signal.reason,
169
+ operation: options.operation || 'spawn',
170
+ });
171
+ }
172
+ const limit = options.outputLimit || DefaultOutputLimit;
141
173
  const piped = options.stdio !== 'inherit';
142
174
  const child = (0, cross_spawn_1.default)(command, args, {
143
175
  cwd: options.cwd,
@@ -146,11 +178,14 @@ function startManagedProcess(command, args, options = {}) {
146
178
  stdio: piped ? ['ignore', 'pipe', 'pipe'] : 'inherit',
147
179
  windowsHide: true,
148
180
  });
149
- const stdout = new BoundedCapture(outputLimit);
150
- const stderr = new BoundedCapture(outputLimit);
151
- let spawnError = null;
181
+ const stdout = new BoundedCapture(limit);
182
+ const stderr = new BoundedCapture(limit);
183
+ let failure = null;
152
184
  let closed = false;
153
- let stopRequested = false;
185
+ let stopped = false;
186
+ let stopping;
187
+ let stopPromise;
188
+ let finish;
154
189
  if (piped) {
155
190
  child.stdout.on('data', (chunk) => {
156
191
  stdout.append(chunk);
@@ -161,32 +196,42 @@ function startManagedProcess(command, args, options = {}) {
161
196
  options.onStderr?.(chunk);
162
197
  });
163
198
  }
164
- const forwardSigint = () => {
165
- void stop('SIGINT');
199
+ const interrupt = () => {
200
+ void stop('SIGINT').catch(() => { });
166
201
  };
167
- const forwardSigterm = () => {
168
- void stop('SIGTERM');
202
+ const terminate = () => {
203
+ void stop('SIGTERM').catch(() => { });
169
204
  };
170
205
  const abort = () => {
171
- void stop('SIGTERM');
206
+ void stop('SIGTERM').catch(() => { });
172
207
  };
173
208
  const cleanup = () => {
174
- process.removeListener('SIGINT', forwardSigint);
175
- process.removeListener('SIGTERM', forwardSigterm);
209
+ process.removeListener('SIGINT', interrupt);
210
+ process.removeListener('SIGTERM', terminate);
176
211
  options.signal?.removeEventListener('abort', abort);
177
212
  };
178
213
  const completion = new Promise((resolve, reject) => {
179
214
  child.once('error', (cause) => {
180
- spawnError = new errors_1.HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
215
+ failure = new errors_1.HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
181
216
  cause,
182
217
  operation: options.operation || 'spawn',
183
218
  });
184
219
  });
185
- child.once('close', (code, signal) => {
220
+ finish = async (code, signal) => {
221
+ try {
222
+ await stopping;
223
+ }
224
+ catch (cause) {
225
+ failure = new errors_1.HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot stop ${command}: ${cause.message}`, {
226
+ cause, operation: options.operation || 'spawn',
227
+ });
228
+ }
229
+ if (closed)
230
+ return;
186
231
  closed = true;
187
232
  cleanup();
188
- if (spawnError)
189
- reject(spawnError);
233
+ if (failure)
234
+ reject(failure);
190
235
  else
191
236
  resolve({
192
237
  code: code === null ? 1 : code,
@@ -195,32 +240,59 @@ function startManagedProcess(command, args, options = {}) {
195
240
  stdout: stdout.toString(),
196
241
  timedOut: false,
197
242
  });
243
+ };
244
+ child.once('close', (code, signal) => {
245
+ void finish(code, signal);
198
246
  });
199
247
  });
200
248
  // A readiness probe may be the first consumer. Keep early spawn failures from
201
249
  // becoming unhandled rejections while the probe is still polling.
202
250
  completion.catch(() => { });
203
251
  async function stop(signal = 'SIGTERM', graceMs = DefaultStopGraceMs) {
252
+ if (stopPromise)
253
+ return stopPromise;
204
254
  if (closed)
205
255
  return completion;
206
- stopRequested = true;
207
- child.kill(signal);
208
- let timer;
209
- await Promise.race([
210
- completion.catch(() => undefined),
211
- new Promise((resolve) => {
212
- timer = setTimeout(resolve, graceMs);
213
- timer.unref?.();
214
- }),
215
- ]);
216
- if (timer)
217
- clearTimeout(timer);
218
- if (!closed)
219
- child.kill('SIGKILL');
220
- return completion.catch(() => undefined);
256
+ stopped = true;
257
+ stopping = Promise.resolve().then(() => (0, tool_command_1.terminateProcess)(child, signal));
258
+ stopPromise = (async () => {
259
+ if (process.platform === 'win32') {
260
+ void finish(null, signal);
261
+ return completion;
262
+ }
263
+ try {
264
+ await stopping;
265
+ }
266
+ catch {
267
+ await finish(null, signal);
268
+ return completion;
269
+ }
270
+ let timer;
271
+ await Promise.race([
272
+ completion.catch(() => undefined),
273
+ new Promise((resolve) => {
274
+ timer = setTimeout(resolve, graceMs);
275
+ timer.unref?.();
276
+ }),
277
+ ]);
278
+ if (timer)
279
+ clearTimeout(timer);
280
+ if (!closed) {
281
+ stopping = (0, tool_command_1.terminateProcess)(child, 'SIGKILL');
282
+ try {
283
+ await stopping;
284
+ }
285
+ catch {
286
+ await finish(null, signal);
287
+ }
288
+ }
289
+ return completion.catch(() => undefined);
290
+ })();
291
+ stopPromise.catch(() => { });
292
+ return stopPromise;
221
293
  }
222
- process.once('SIGINT', forwardSigint);
223
- process.once('SIGTERM', forwardSigterm);
294
+ process.once('SIGINT', interrupt);
295
+ process.once('SIGTERM', terminate);
224
296
  if (options.signal?.aborted)
225
297
  abort();
226
298
  else
@@ -231,6 +303,6 @@ function startManagedProcess(command, args, options = {}) {
231
303
  getStderr: () => stderr.toString(),
232
304
  getStdout: () => stdout.toString(),
233
305
  stop,
234
- wasStopped: () => stopRequested,
306
+ wasStopped: () => stopped,
235
307
  };
236
308
  }
@@ -88,6 +88,7 @@ async function startExpoMetroAsync(projectRoot, options) {
88
88
  cwd: projectRoot,
89
89
  env: {
90
90
  ...process.env,
91
+ EXPO_METRO_TARGET: 'harmony',
91
92
  },
92
93
  operation: 'expo-metro',
93
94
  outputLimit: 1024 * 1024,
package/build/run/run.js CHANGED
@@ -80,6 +80,7 @@ async function runHarmonyUnlockedAsync(projectRoot, options = {}) {
80
80
  const buildEnv = {
81
81
  ...process.env,
82
82
  EXPO_HARMONY_NODE: process.env.EXPO_HARMONY_NODE || process.execPath,
83
+ EXPO_METRO_TARGET: 'harmony',
83
84
  HERMES_V1_ENABLED: 'true',
84
85
  ...(normalizedOptions.variant === 'release' ? { EXPO_HARMONY_BUNDLE_PREBUILT: '1' } : {}),
85
86
  ...(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.5",
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.4",
42
42
  "@expo/config": "55.0.17",
43
43
  "cross-spawn": "^7.0.6",
44
44
  "json5": "2.2.3"
@@ -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/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
  : {}),