@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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [**GitHub 仓库**](https://github.com/renbaoshuo/expo-harmony/tree/master/packages/cli)
4
4
 
5
- Expo 项目提供 HarmonyOS 平台的命令行工具,支持 Metro 开发服务、CNG 原生工程生成、环境诊断、Harmony Expo Module 发现与校验、生产 Bundle 导出,以及 HAP 的构建、安装和启动。
5
+ Expo 项目的 HarmonyOS 命令行工具:启动 Metro 开发服务、生成原生工程、构建和安装 HAP
6
6
 
7
7
  ## 安装
8
8
 
@@ -10,11 +10,11 @@
10
10
  npm install --save-dev @expo-harmony/cli
11
11
  ```
12
12
 
13
- CLI 必须安装在项目本地,JS 打包调用项目内的 Expo CLI,Hermes 字节码编译调用项目内的 `@expo/metro-config`,Release 原生构建也会回调项目本地的 `expo-harmony` 二进制生成 Bundle。
13
+ CLI 必须装在项目本地。JS 打包调用项目内的 Expo CLI,Hermes 字节码编译调用项目内的 `@expo/metro-config`,Release 原生构建回调项目本地的 `expo-harmony` 二进制生成 Bundle。
14
14
 
15
- 项目需要 Node.js 20 或更高版本,在 Expo 配置中注册 `@expo-harmony/prebuild-config` 插件,并使用 `@expo-harmony/metro-config` 组合 Metro 配置。
15
+ 项目需要 Node.js 20 或更高版本,在 Expo 配置中注册 `@expo-harmony/prebuild-config` 插件,并用 `@expo-harmony/metro-config` 组合 Metro 配置。
16
16
 
17
- HAP 构建需要完整的 HarmonyOS SDK(含 HMS OpenHarmony 组件)、OHPM、Hvigor 和 HDC,这些环境可用 `doctor` 命令检查。
17
+ HAP 构建需要完整的 HarmonyOS SDK(含 HMS OpenHarmony 组件)、OHPM、Hvigor 和 HDC。用 `doctor` 检查这些环境。
18
18
 
19
19
  ## 命令
20
20
 
@@ -32,7 +32,7 @@ HAP 构建需要完整的 HarmonyOS SDK(含 HMS 与 OpenHarmony 组件)、OH
32
32
  | `expo-harmony export:embed` | 导出 Hermes 字节码、资源和 Source Map 并写入原生工程 |
33
33
  | `expo-harmony run` | 构建 HAP,在连接的 HarmonyOS 设备上安装并启动应用 |
34
34
 
35
- 所有命令都可接收可选的项目路径(`modules` 命令写在子命令之后);未提供时从当前目录向上查找最近的项目根目录作为默认值:
35
+ 所有命令都可以带一个可选的项目路径;`modules` 的路径写在子命令后面。不提供时,从当前目录向上找最近的项目根目录:
36
36
 
37
37
  ```sh
38
38
  npx expo-harmony start ./my-app
@@ -50,13 +50,13 @@ npx expo-harmony start --port 8082
50
50
  npx expo-harmony start --clear
51
51
  ```
52
52
 
53
- 所有命令都会在 CLI 入口、读取应用配置之前设置 `EXPO_HARMONY=1` 和 `EXPO_METRO_TARGET=harmony`。
53
+ CLI 入口先设置 `EXPO_HARMONY=1` 和 `EXPO_METRO_TARGET=harmony`,再读取应用配置,所有命令都如此。
54
54
 
55
- `start` 调用项目本地的 Expo CLI,以 `--dev-client` 模式启动 Metro。它只启动 JS 开发服务,不执行原生工程生成、构建、安装、应用启动或设备端口映射,也不要求 HarmonyOS SDK 和设备就绪。新启动的 Metro 在当前终端输出日志,按 Ctrl+C 退出。
55
+ `start` 调用项目本地的 Expo CLI,以 `--dev-client` 模式启动 Metro。它只启动 JS 开发服务:不生成原生工程,不构建、安装、启动应用,不做设备端口映射,也不要求 HarmonyOS SDK 和设备就绪。Metro 在当前终端输出日志,Ctrl+C 退出。
56
56
 
57
- 支持 `--port <number>`(默认 `8081`)和 `--reset-cache`(别名 `--clear`、`-c`)。端口上已有 Metro 时提示并退出,不停止已有服务;此时缓存选项不会生效,需要先停止已有 Metro 后重新执行命令。端口被其他进程占用时会报错。
57
+ 选项有 `--port <number>`(默认 `8081`)和 `--reset-cache`(别名 `--clear`、`-c`)。端口上已有 Metro 时提示并退出,不停止已有服务;这时缓存选项不生效,要先停掉已有的 Metro 再执行一次。端口被其他进程占用时报错。
58
58
 
59
- 需要构建并启动应用时,在另一个终端运行 `expo-harmony run --no-bundler`;若指定了端口,两个命令使用相同的 `--port`。
59
+ 要构建并启动应用,在另一个终端运行 `expo-harmony run --no-bundler`;指定了端口时,两个命令用同一个 `--port`。
60
60
 
61
61
  ## Prebuild
62
62
 
@@ -66,9 +66,11 @@ npx expo-harmony prebuild --clean
66
66
  npx expo-harmony prebuild --check
67
67
  ```
68
68
 
69
- `prebuild` 固定使用 HarmonyOS 平台和 `@expo-harmony/template` 模板,因此不像官方接受 `--platform` 和 `--template`。依赖安装相关选项会透传给 Expo CLI:`--no-install`、`--npm`/`--yarn`/`--pnpm`/`--bun`(最多选择一个)和 `--skip-dependency-update <packages>`。执行前会先运行一次 doctor(此阶段不要求构建工具就绪),发现阻塞错误时直接中止。
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 缺失、目标不是项目内的普通目录、模板标记异常,都拒绝删除。
72
+
73
+ `--check` 是只读操作。它把项目镜像到临时目录(`node_modules` 里的包目录用链接共享,Windows 下的普通文件复制过去,不需要文件符号链接权限),在临时目录里执行一次 prebuild,再比较受管文件。没有差异时退出码为 0;有差异时列出变更,退出码为 2。它不能和其他会修改工程的选项一起用。
72
74
 
73
75
  ## Build
74
76
 
@@ -78,9 +80,9 @@ npx expo-harmony build --variant release
78
80
  npx expo-harmony build --sync
79
81
  ```
80
82
 
81
- `build` 在不使用设备的前提下完成 HAP 构建。缺少原生工程时自动执行 prebuild,`--sync` 强制重新生成,默认则校验 CNG 期望状态并在发现差错时报错。随后依次执行 OHPM 依赖安装、原生构建缓存校验和 Hvigor `assembleHap`,最后确认产物 HAP 存在且非空。原生依赖指纹变化时会自动失效旧的 `.cxx` 和 `build` 缓存,避免链接到过期的产物。
83
+ `build` 构建 HAP,不选设备,也不安装和启动应用。没有原生工程时先执行 prebuild;`--sync` 强制重新生成;默认只校验 CNG 期望状态,发现差错就报错。之后安装 OHPM 依赖、校验原生构建缓存、执行 Hvigor `assembleHap`,最后确认产物 HAP 存在且非空。原生依赖的指纹变化时,旧的 `.cxx` 和 `build` 缓存会失效,不会链接到过期产物。
82
84
 
83
- 选项:`--variant debug|release`(默认 `debug`)和 `--sync`。Release 构建会先执行生产导出,再带着预生成的 Bundle 进入 Hvigor。
85
+ 选项:`--variant debug|release`(默认 `debug`)和 `--sync`。Release 构建先执行生产导出,再带着预生成的 Bundle Hvigor。
84
86
 
85
87
  ## Doctor
86
88
 
@@ -88,7 +90,18 @@ npx expo-harmony build --sync
88
90
  npx expo-harmony doctor
89
91
  ```
90
92
 
91
- `doctor` 逐项检查 Expo `harmony` 配置与 `@expo-harmony/prebuild-config` 插件注册、签名配置文件、启用 Harmony 的 Metro 配置、必需依赖(RNOH 运行时与 CLI、`@expo-harmony/expo-modules-autolinking`)、Harmony Expo Modules 校验、完整的 HarmonyOS SDK、HDC/OHPM/Hvigor 可用性,以及生成工程中的 Hvigor 文件和 RNOH 自动链接禁用状态。存在 error 级别的问题时命令以非零状态退出;未配置外部签名文件只会警告,不影响未签名构建。
93
+ `doctor` 逐项检查:
94
+
95
+ - Expo `harmony` 配置和 `@expo-harmony/prebuild-config` 插件注册
96
+ - 签名配置文件
97
+ - Metro 配置是否启用 Harmony
98
+ - 必需依赖:RNOH 运行时和 CLI、`@expo-harmony/expo-modules-autolinking`
99
+ - Harmony Expo Modules
100
+ - HarmonyOS SDK 是否完整
101
+ - HDC、OHPM、Hvigor 是否可用
102
+ - 生成工程中的 Hvigor 文件和 RNOH 自动链接禁用状态
103
+
104
+ 有 error 级别的问题时,命令以非零状态退出。未配置外部签名文件只警告,不影响未签名构建。
92
105
 
93
106
  ## Modules
94
107
 
@@ -98,7 +111,7 @@ npx expo-harmony modules inspect --package expo-linear-gradient
98
111
  npx expo-harmony modules verify
99
112
  ```
100
113
 
101
- `list` 列出发现的模块候选(包名、版本、来源),并标出不支持 Harmony 的模块、重复版本和缺失的必需依赖。`inspect` 展示模块解析后的完整元数据,包括包根、ArkTS modulesHAR 路径,`--package <name>` 可筛选单个模块,未发现时报错。`verify` 输出诊断信息,存在 error 时以非零状态退出。
114
+ `list` 列出发现的模块候选(包名、版本、来源),标出不支持 Harmony 的模块、重复版本和缺失的必需依赖。`inspect` 展示模块解析后的元数据:包根、ArkTS modulesHAR 路径。`--package <name>` 筛选单个模块,未发现时报错。`verify` 输出诊断信息,有 error 时以非零状态退出。
102
115
 
103
116
  三个子命令都支持 `--variant debug|release` 和 `--native-modules-dir <dir>`(app-local 模块目录,默认 `./modules`)。
104
117
 
@@ -110,7 +123,11 @@ npx expo-harmony export:embed --check
110
123
  npx expo-harmony export:embed --reset-cache
111
124
  ```
112
125
 
113
- `export:embed` 先运行 doctor,再用项目本地的 Expo CLI 生成 Harmony 平台的 JS Bundle 和资源,并交给项目本地 `@expo/metro-config` 的 Hermes 导出器编译字节码、合并 Source Map。产物经过校验(Hermes 字节码魔数、Source Map 不含宿主机绝对路径)后原子写入原生工程:Bundle 和资源进入模块的 `rawfile` 目录,Source Map 与清单写入 `.expo/harmony/export/`。清单 `.expo/harmony/export-manifest.json` 记录每个文件的哈希与大小,`--check` 据此校验已有导出而不重新构建,`--reset-cache` 会透传给 Metro 清除转换缓存。
126
+ `export:embed` 先运行 doctor,再用项目本地的 Expo CLI 生成 Harmony 平台的 JS Bundle 和资源,交给项目本地的 `@expo/metro-config` 的 Hermes 导出器编译字节码、合并 Source Map
127
+
128
+ 产物先校验再写入:检查 Hermes 字节码魔数、Source Map 不含宿主机绝对路径,然后原子写入原生工程。Bundle 和资源进入模块的 `rawfile` 目录,Source Map 和清单写入 `.expo/harmony/export/`。
129
+
130
+ 清单 `.expo/harmony/export-manifest.json` 记录每个文件的哈希和大小。`--check` 按清单校验已有的导出,不重新构建。`--reset-cache` 透传给 Metro,清除转换缓存。
114
131
 
115
132
  ## Run
116
133
 
@@ -121,28 +138,30 @@ npx expo-harmony run \
121
138
  --port 8081
122
139
  ```
123
140
 
124
- `run` 会依次执行环境诊断、确保原生工程(缺失时自动预构建,`--sync` 强制重新生成,否则校验 CNG 状态)、选择设备、Release 生产导出、OHPM 安装、Hvigor 构建、Metro 端口反向映射(仅 Debug)、HAP 安装和 Ability 启动。模块接线的最终正确性由 OHPM、Hvigor、CMake 和 ArkTS 编译器验证。
141
+ `run` 按顺序执行:环境诊断、确保原生工程(缺失时预构建,`--sync` 强制重新生成,否则复用已有工程)、选择设备、Release 生产导出、OHPM 安装、Hvigor 构建、Metro 端口反向映射(仅 Debug)、HAP 安装、Ability 启动。模块接线是否正确,由 OHPM、Hvigor、CMake 和 ArkTS 编译器验证。
125
142
 
126
143
  常用选项:
127
144
 
128
- - `--variant debug|release`:选择构建模式,默认为 `debug`。Release 构建会先执行生产导出,不启动 Metro。
129
- - `--device <id-or-name>`:选择已连接的 HDC 设备,或按完整名称启动本地模拟器,例如 `--device "Pura 90 Pro"`。存在多个候选目标时必须指定。
130
- - `--port <number>`:设置 Metro 端口及设备反向映射端口,默认为 `8081`。
131
- - `--no-bundler`:连接已经运行的 Expo Metro,不启动新的服务;端口空闲或被其他进程占用时会报错。不加此选项时,若端口上已有 Metro 在运行则直接复用。
145
+ - `--variant debug|release`:构建模式,默认 `debug`。Release 先执行生产导出,不启动 Metro。
146
+ - `--device <id-or-name>`:选择已连接的 HDC 设备,或按完整名称启动本地模拟器,例如 `--device "Pura 90 Pro"`。有多个候选目标时必须指定。
147
+ - `--port <number>`:Metro 端口和设备反向映射端口,默认 `8081`。
148
+ - `--no-bundler`:连接已经在运行的 Expo Metro,不启动新的服务;端口空闲或被其他进程占用时报错。不加此选项时,端口上已有 Metro 在运行就直接复用。
132
149
  - `--reset-cache`:Debug 模式下启动 Metro 时清除 Metro 缓存;Release 模式下清除生产导出缓存。
133
150
  - `--no-install`:跳过 HAP 安装,直接启动设备上已有的应用。
134
- - `--app-id <bundleName>`:指定要启动的应用;与生成包名不同时必须同时使用 `--no-install`。
151
+ - `--app-id <bundleName>`:指定要启动的应用;与生成的包名不同时,必须同时使用 `--no-install`。
135
152
  - `--sync`:构建前强制重新执行一次预构建。
136
153
 
137
- Debug 模式下由 CLI 启动的 Metro 会接管终端输出日志,按 Ctrl+C 退出。
154
+ Debug 模式下由 CLI 启动的 Metro 接管终端输出日志,Ctrl+C 退出。
138
155
 
139
- 未指定 `--device` 时,优先使用已连接的设备;没有连接的设备时,优先等待正在启动的模拟器,否则自动启动唯一的本地模拟器。存在多个候选模拟器时会列出名称,使用 `--device "模拟器名称"` 选择。通过模拟器名称选择或自动拉起时,等待 HDC 连接并确认该实例开机完成后才继续构建、安装和启动应用,最多等待 120 秒;退出 CLI 或 Metro 后模拟器继续运行。
156
+ 未指定 `--device` 时,优先用已连接的设备。没有连接的设备时,先等正在启动的模拟器;否则启动唯一的本地模拟器。有多个候选模拟器时列出名称,用 `--device "模拟器名称"` 选择。通过名称选择或自动拉起模拟器时,CLI HDC 连接、确认开机完成后才继续构建、安装和启动,最多等 120 秒。退出 CLI 或 Metro 后,模拟器继续运行。
140
157
 
141
- 自动启动需要 DevEco Studio 6.1.0 或更新版本,并已在 Device Manager 中创建模拟器。CLI 按[华为模拟器命令行文档](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-emulator-command-line)调用 `Emulator -list -details` 和 `Emulator -start <name>`,使用 DevEco Studio 配置的实例和镜像路径。启动日志保存在项目的 `.expo/harmony/emulator.log`。若启动失败,请先在 DevEco Studio 中处理首次使用协议或登录要求;文档注明需要登录开发者账号的模拟器版本无法通过命令行启动。
158
+ 自动启动需要 DevEco Studio 6.1.0 或更新版本,并且已在 Device Manager 中创建模拟器。CLI 按[华为模拟器命令行文档](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-emulator-command-line)调用 `Emulator -list -details` 和 `Emulator -start <name>`,使用 DevEco Studio 配置的实例和镜像路径。启动日志在项目的 `.expo/harmony/emulator.log`。启动失败时,先在 DevEco Studio 里处理首次使用协议或登录要求;文档注明需要登录开发者账号的模拟器版本无法从命令行启动。
142
159
 
143
160
  ## 工具链与环境变量
144
161
 
145
- 工具链按「环境变量覆盖 → DevEco Studio 安装布局 → PATH」的顺序解析。`HARMONY_HDC`、`HARMONY_EMULATOR`、`HARMONY_OHPM`、`HARMONY_HVIGORW` 和 `HARMONY_NODE` 可覆盖对应工具的路径,SDK 根目录可通过 `DEVECO_SDK_HOME`、`HARMONY_HOME` 或 `OHOS_SDK_HOME` 指定。Emulator 同时支持 DevEco Studio 的 `tools/emulator` 和 Command Line Tools 的 `emulator` 布局,仅在需要模拟器时调用。原生构建回调 CLI 时使用的 Node.js 可通过 `EXPO_HARMONY_NODE` 显式指定。
162
+ 工具链按「环境变量覆盖 → DevEco Studio 安装布局 → PATH」的顺序解析。
163
+
164
+ `HARMONY_HDC`、`HARMONY_EMULATOR`、`HARMONY_OHPM`、`HARMONY_HVIGORW` 和 `HARMONY_NODE` 覆盖对应工具的路径。SDK 根目录用 `DEVECO_SDK_HOME`、`HARMONY_HOME` 或 `OHOS_SDK_HOME` 指定。Emulator 支持 DevEco Studio 的 `tools/emulator` 和 Command Line Tools 的 `emulator` 两种布局,只在需要模拟器时调用。原生构建回调 CLI 时用的 Node.js 可以用 `EXPO_HARMONY_NODE` 指定。
146
165
 
147
166
  ## Author
148
167
 
@@ -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
@@ -1,7 +1,7 @@
1
1
  interface HarmonyBuildPipelineOptions {
2
2
  io?: Pick<Console, 'error' | 'log' | 'warn'>;
3
3
  requireDeviceTools?: boolean;
4
- /** @internal The caller already completed an isolated prebuild check. */
4
+ /** @internal Reuse the native project without an isolated drift check. */
5
5
  skipGeneratedProjectCheck?: boolean;
6
6
  sync: boolean;
7
7
  variant: 'debug' | 'release';
@@ -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,