@expo-harmony/cli 55.0.26-harmony.12 → 55.0.26-harmony.13

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
@@ -49,6 +49,7 @@ npx expo-harmony run ./my-app --device <hdc-target>
49
49
  ```sh
50
50
  npx expo-harmony start
51
51
  npx expo-harmony start --port 8082
52
+ npx expo-harmony start --host 192.168.1.100
52
53
  npx expo-harmony start --clear
53
54
  ```
54
55
 
@@ -56,6 +57,8 @@ npx expo-harmony start --clear
56
57
 
57
58
  可以用 `--port <number>` 指定端口,默认是 `8081`。`--reset-cache` 清除 Metro 缓存,也可以写成 `--clear` 或 `-c`。
58
59
 
60
+ 多网卡环境下,可以用 `--host <IP 或主机名>` 指定设备连接的电脑地址,例如 `npx expo-harmony start --host 192.168.1.100 --port 8082`。地址应是设备能访问的网卡 IPv4 或主机名,不带协议和端口。
61
+
59
62
  端口上已有 Metro 时,命令会提示并退出,已有服务继续运行。要清除它的缓存,需要先停止服务,再带缓存选项启动。端口被其他进程占用时,命令会报错。
60
63
 
61
64
  要构建并启动应用,在另一个终端运行 `npx expo-harmony run --no-bundler`。如果指定了端口,两个命令应使用相同的 `--port`。
@@ -113,7 +116,7 @@ npx expo-harmony doctor
113
116
  - HDC、OHPM、Hvigor 是否可用
114
117
  - 原生工程的 Hvigor 文件和自动链接配置
115
118
 
116
- 有 `error` 级别的问题时,命令以非零退出码结束。CNG 工程未配置外部签名文件时会显示警告,仍可构建未签名的 HAP。证书是否有效,需要在构建时由 Hvigor 检查。
119
+ 有 `error` 级别的问题时,命令以非零退出码结束。CNG 工程未配置外部签名文件时会显示警告,仍可构建未签名的 HAP。首次生成、删除 `harmony/` 后重新生成或使用 `prebuild --clean` 时,若配置的签名文件或材料缺失、无效,会给出警告并按未签名生成;签名配置可用时仍会正常应用。对已有工程执行普通 prebuild 或 doctor 时,签名文件缺失或无效会被视为 `error`。证书是否有效,需要在构建时由 Hvigor 检查。
117
120
 
118
121
  ## Modules
119
122
 
@@ -161,6 +164,7 @@ npx expo-harmony run \
161
164
  - `--variant debug|release`:构建模式,默认 `debug`。Release 将 JS Bundle 和资源打包进应用,不启动 Metro。
162
165
  - `--device <id-or-name>`:选择已连接的 HDC 设备,或按完整名称启动本地模拟器,例如 `--device "Pura 90 Pro"`。有多个候选目标时必须指定。
163
166
  - `--port <number>`:电脑上的 Metro 端口,默认 `8081`。Debug 模式下会配置设备端口映射,让应用连接到这个端口。
167
+ - `--host <IP 或主机名>`:指定本次启动的 Metro 对外公布的地址,与 `start --host` 相同;使用 `--no-bundler` 时应在启动 Metro 的命令中指定。
164
168
  - `--no-bundler`:使用已运行的 Metro,找不到服务时报错。不加此选项时,会启动 Metro,或复用端口上已有的 Metro。
165
169
  - `--reset-cache`:Debug 模式下启动 Metro 时清除 Metro 缓存;Release 模式下清除生产导出缓存。
166
170
  - `--no-install`:仍会构建 HAP,但跳过安装,启动设备上已有的应用。
@@ -183,6 +187,7 @@ CLI 先读取环境变量指定的工具路径,再查找 DevEco Studio 的安
183
187
 
184
188
  | 环境变量 | 用途 |
185
189
  | --- | --- |
190
+ | `REACT_NATIVE_PACKAGER_HOSTNAME` | 指定 Metro 对外公布的 IP 或主机名,`--host` 优先 |
186
191
  | `HARMONY_HDC` | 指定 HDC 路径 |
187
192
  | `HARMONY_EMULATOR` | 指定模拟器命令行工具路径 |
188
193
  | `HARMONY_OHPM` | 指定 OHPM 路径 |
@@ -0,0 +1,2 @@
1
+ export declare function parseHostOption(host?: string): string | undefined;
2
+ export declare function resolveDevelopmentHostAsync(root: string, hostname?: string): Promise<string>;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseHostOption = parseHostOption;
7
+ exports.resolveDevelopmentHostAsync = resolveDevelopmentHostAsync;
8
+ const node_module_1 = require("node:module");
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ const errors_1 = require("../errors");
11
+ const expo_1 = require("../expo");
12
+ function parseHostOption(host) {
13
+ if (host !== undefined && !/^[A-Za-z0-9.-]+$/.test(host)) {
14
+ throw new errors_1.HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', '--host must be a hostname or IPv4 address without a port or scheme.', { operation: 'parse-arguments' });
15
+ }
16
+ return host;
17
+ }
18
+ function interfacePriority(name, info) {
19
+ // Interface names alone cannot identify every virtual adapter (especially on Windows).
20
+ const virtual = /^(utun|tun|tap|wg|ppp|ipsec|vmnet|vboxnet|virbr|docker|br-|bridge\d|veth|tailscale|zt)|virtual|vmware|vpn/i.test(name)
21
+ || /^(00:00:00:00:00:00|00:05:69:|00:0c:29:|00:1c:14:|00:50:56:|08:00:27:|00:15:5d:)/i.test(info.mac)
22
+ || /^198\.(18|19)\./.test(info.address);
23
+ return Number(virtual) + (info.address.startsWith('169.254.') ? 2 : 0);
24
+ }
25
+ async function resolveDevelopmentHostAsync(root, hostname) {
26
+ const override = hostname || process.env.REACT_NATIVE_PACKAGER_HOSTNAME;
27
+ if (override) {
28
+ if (!/^[A-Za-z0-9.-]+$/.test(override)) {
29
+ throw new errors_1.HarmonyCliError('ERR_HARMONY_MANIFEST_HOST', 'Harmony development requires an HTTP LAN hostname or IPv4 address.', { operation: 'development-manifest' });
30
+ }
31
+ return override;
32
+ }
33
+ const candidates = Object.entries(node_os_1.default.networkInterfaces()).flatMap(([name, addresses]) => (addresses || [])
34
+ .filter(info => info.family === 'IPv4' && !info.internal)
35
+ .map(info => ({ address: info.address, priority: interfacePriority(name, info) }))).sort((a, b) => a.priority - b.priority);
36
+ if (!candidates.length)
37
+ return '127.0.0.1';
38
+ const preferred = candidates.filter(candidate => candidate.priority === candidates[0].priority);
39
+ if (preferred.length > 1) {
40
+ // Use Expo's route discovery to choose between equally suitable interfaces,
41
+ // but do not let a VPN default route displace an available LAN interface.
42
+ const expo = (0, node_module_1.createRequire)((0, expo_1.resolveExpoCli)(root).cliPath);
43
+ const require = (0, node_module_1.createRequire)(expo.resolve('@expo/cli/package.json'));
44
+ const { getGatewayAsync } = require('./build/src/utils/ip');
45
+ const gateway = await getGatewayAsync();
46
+ if (preferred.some(candidate => candidate.address === gateway.address))
47
+ return gateway.address;
48
+ }
49
+ return preferred[0].address;
50
+ }
@@ -5,19 +5,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.readDevelopmentSessionAsync = readDevelopmentSessionAsync;
7
7
  const node_http_1 = __importDefault(require("node:http"));
8
- const node_os_1 = __importDefault(require("node:os"));
9
8
  const errors_1 = require("../errors");
10
9
  const config_1 = require("../runtime/config");
10
+ const host_1 = require("./host");
11
11
  const protocol_1 = require("./protocol");
12
12
  async function readDevelopmentSessionAsync(root, port, hostname) {
13
13
  const project = await (0, config_1.readProjectRuntimeAsync)(root);
14
14
  const { config } = project;
15
- const host = hostname || process.env.REACT_NATIVE_PACKAGER_HOSTNAME
16
- || Object.values(node_os_1.default.networkInterfaces()).flat().find(item => item?.family === 'IPv4' && !item.internal)?.address
17
- || '127.0.0.1';
18
- if (!/^[A-Za-z0-9.-]+$/.test(host)) {
19
- throw new errors_1.HarmonyCliError('ERR_HARMONY_MANIFEST_HOST', 'Harmony development requires an HTTP LAN hostname or IPv4 address.', { operation: 'development-manifest' });
20
- }
15
+ const host = await (0, host_1.resolveDevelopmentHostAsync)(root, hostname);
21
16
  const manifest = await new Promise((resolve, reject) => {
22
17
  const request = node_http_1.default.get({
23
18
  host: '127.0.0.1',
@@ -10,6 +10,7 @@ export interface DoctorResult {
10
10
  projectRoot: string;
11
11
  }
12
12
  interface DoctorOptions {
13
+ freshPrebuild?: boolean;
13
14
  requireBuildTools?: boolean;
14
15
  requireDeviceTools?: boolean;
15
16
  validateGeneratedProject?: boolean;
@@ -116,7 +116,10 @@ async function doctorUnlockedAsync(root, options = {}) {
116
116
  checks.push(check('signing', 'pass', `Harmony signing config ${signing.name} is valid.`));
117
117
  }
118
118
  catch (error) {
119
- checks.push(check('signing', 'error', error.message, { code: error.code || 'ERR_HARMONY_SIGNING_INVALID' }));
119
+ const fresh = options.freshPrebuild || !node_fs_1.default.existsSync(node_path_1.default.join(root, internal_1.HarmonyPlatformDirectory));
120
+ checks.push(check('signing', fresh ? 'warn' : 'error', fresh
121
+ ? `The fresh Harmony project will be generated unsigned because signing materials are unavailable: ${error.message}`
122
+ : error.message, { code: error.code || 'ERR_HARMONY_SIGNING_INVALID' }));
120
123
  }
121
124
  }
122
125
  else {
package/build/help.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const Help = "Usage: expo-harmony <command> [project] [options]\n\nCommands:\n start Start Expo Metro for Harmony development\n runtime Describe project requirements; --client file checks compatibility\n runtime --publish Embed capabilities from prepared native build inputs\n prebuild Generate the Harmony native project with Expo CNG\n prebuild --clean Safely recreate the managed Harmony directory\n prebuild --check Compare generated desired state without project writes\n build Build a HAP without selecting or contacting a device\n doctor Validate config, versions, Metro, RNOH, SDK, and signing\n modules list List native module candidates and their discovery source\n modules inspect Resolve Harmony metadata (--package narrows the result)\n modules verify Validate module config, registration conflicts and safe paths\n export Export an OTA update, assets, metadata, and source map\n export:embed Export validated Hermes bytecode, assets, and a source map\n run Build, install, and launch the Harmony app\n\nOptions:\n --no-install Skip dependency install (prebuild) or HAP install (run)\n --npm|--yarn|--pnpm|--bun\n Select the package manager used by prebuild dependency install\n --skip-dependency-update <packages>\n Preserve comma-separated dependency versions\n --device <id-or-name> Select an HDC target or start a local emulator by name\n --variant <mode> Build debug or release (default: debug)\n --no-bundler Use an already-running Expo Metro server\n --app-id <bundleName>\n Launch another installed app (requires --no-install when different)\n --host <hostname> Advertised HTTP LAN hostname (start)\n --private-key-path <path>\n Sign development manifests using the configured Updates certificate\n --port <number> Metro and device reverse port (default: 8081)\n --sync Re-run prebuild before building\n --output-dir <path> OTA export directory (default: dist)\n --asset-url <url> HTTP(S) root hosting exported OTA assets\n --check Validate an existing export without writing\n --reset-cache Reset Metro while exporting or starting\n -c, --clear Alias for --reset-cache (start)\n -h, --help Show this help\n";
1
+ export declare const Help = "Usage: expo-harmony <command> [project] [options]\n\nCommands:\n start Start Expo Metro for Harmony development\n runtime Describe project requirements; --client file checks compatibility\n runtime --publish Embed capabilities from prepared native build inputs\n prebuild Generate the Harmony native project with Expo CNG\n prebuild --clean Safely recreate the managed Harmony directory\n prebuild --check Compare generated desired state without project writes\n build Build a HAP without selecting or contacting a device\n doctor Validate config, versions, Metro, RNOH, SDK, and signing\n modules list List native module candidates and their discovery source\n modules inspect Resolve Harmony metadata (--package narrows the result)\n modules verify Validate module config, registration conflicts and safe paths\n export Export an OTA update, assets, metadata, and source map\n export:embed Export validated Hermes bytecode, assets, and a source map\n run Build, install, and launch the Harmony app\n\nOptions:\n --no-install Skip dependency install (prebuild) or HAP install (run)\n --npm|--yarn|--pnpm|--bun\n Select the package manager used by prebuild dependency install\n --skip-dependency-update <packages>\n Preserve comma-separated dependency versions\n --device <id-or-name> Select an HDC target or start a local emulator by name\n --variant <mode> Build debug or release (default: debug)\n --no-bundler Use an already-running Expo Metro server\n --app-id <bundleName>\n Launch another installed app (requires --no-install when different)\n --host <hostname> Advertised LAN hostname or IPv4 address (start, run)\n --private-key-path <path>\n Sign development manifests using the configured Updates certificate\n --port <number> Metro and device reverse port (default: 8081)\n --sync Re-run prebuild before building\n --output-dir <path> OTA export directory (default: dist)\n --asset-url <url> HTTP(S) root hosting exported OTA assets\n --check Validate an existing export without writing\n --reset-cache Reset Metro while exporting or starting\n -c, --clear Alias for --reset-cache (start)\n -h, --help Show this help\n";
package/build/help.js CHANGED
@@ -30,7 +30,7 @@ Options:
30
30
  --no-bundler Use an already-running Expo Metro server
31
31
  --app-id <bundleName>
32
32
  Launch another installed app (requires --no-install when different)
33
- --host <hostname> Advertised HTTP LAN hostname (start)
33
+ --host <hostname> Advertised LAN hostname or IPv4 address (start, run)
34
34
  --private-key-path <path>
35
35
  Sign development manifests using the configured Updates certificate
36
36
  --port <number> Metro and device reverse port (default: 8081)
@@ -21,6 +21,7 @@ const command = async (argv, io) => {
21
21
  }
22
22
  const { doctorAsync, formatDoctor } = await import('../doctor/doctor.js');
23
23
  const doctor = await doctorAsync(root, {
24
+ freshPrebuild: clean,
24
25
  requireBuildTools: false,
25
26
  validateGeneratedProject: !clean,
26
27
  validateModules: false,
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.prebuildParsedAsync = prebuildParsedAsync;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const internal_1 = require("@expo-harmony/prebuild-config/internal");
8
10
  const errors_1 = require("../errors");
9
11
  const bare_1 = require("../native/bare");
10
12
  const process_1 = require("../process");
@@ -20,6 +22,8 @@ async function prebuildParsedUnlockedAsync(projectRoot, passthrough, options = {
20
22
  }
21
23
  if (passthrough.includes('--clean'))
22
24
  await (0, clean_1.assertSafeCleanTarget)(projectRoot);
25
+ const freshHarmonyProject = passthrough.includes('--clean')
26
+ || !node_fs_1.default.existsSync(node_path_1.default.join(projectRoot, internal_1.HarmonyPlatformDirectory));
23
27
  const expo = (0, expo_1.resolveExpoCli)(projectRoot);
24
28
  const packed = await (0, template_1.packAsync)(projectRoot);
25
29
  try {
@@ -35,6 +39,7 @@ async function prebuildParsedUnlockedAsync(projectRoot, passthrough, options = {
35
39
  env: {
36
40
  ...(0, toolchain_1.createHarmonyToolchainEnv)(),
37
41
  ...packed.env,
42
+ EXPO_HARMONY_PREBUILD_FRESH: freshHarmonyProject ? '1' : '0',
38
43
  ...(options.buildType ? { EXPO_HARMONY_BUILD_TYPE: options.buildType } : {}),
39
44
  },
40
45
  operation: 'expo-prebuild',
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.requireExistingMetroAsync = requireExistingMetroAsync;
7
7
  exports.startExpoMetroAsync = startExpoMetroAsync;
8
8
  const node_http_1 = __importDefault(require("node:http"));
9
+ const host_1 = require("../development/host");
9
10
  const session_1 = require("../development/session");
10
11
  const errors_1 = require("../errors");
11
12
  const process_1 = require("../process");
@@ -66,10 +67,11 @@ async function requireExistingMetroAsync(port) {
66
67
  throw new errors_1.HarmonyCliError(code, message, { operation: 'metro-probe' });
67
68
  }
68
69
  async function startExpoMetroAsync(projectRoot, options) {
70
+ const host = await (0, host_1.resolveDevelopmentHostAsync)(projectRoot, options.host);
69
71
  const before = await probeMetroAsync(options.port);
70
72
  if (before === 'metro') {
71
73
  return {
72
- development: await (0, session_1.readDevelopmentSessionAsync)(projectRoot, options.port, options.host),
74
+ development: await (0, session_1.readDevelopmentSessionAsync)(projectRoot, options.port, host),
73
75
  owner: 'existing',
74
76
  port: options.port,
75
77
  stop: async () => { },
@@ -94,7 +96,7 @@ async function startExpoMetroAsync(projectRoot, options) {
94
96
  env: {
95
97
  ...process.env,
96
98
  EXPO_METRO_TARGET: 'harmony',
97
- ...(options.host ? { REACT_NATIVE_PACKAGER_HOSTNAME: options.host } : {}),
99
+ REACT_NATIVE_PACKAGER_HOSTNAME: host,
98
100
  },
99
101
  operation: 'expo-metro',
100
102
  outputLimit: 1024 * 1024,
@@ -128,7 +130,7 @@ async function startExpoMetroAsync(projectRoot, options) {
128
130
  }
129
131
  if (await probeMetroAsync(options.port) === 'metro') {
130
132
  return {
131
- development: await (0, session_1.readDevelopmentSessionAsync)(projectRoot, options.port, options.host),
133
+ development: await (0, session_1.readDevelopmentSessionAsync)(projectRoot, options.port, host),
132
134
  owner: 'started',
133
135
  port: options.port,
134
136
  process: managed,
@@ -2,6 +2,7 @@ declare function parseRunArgs(argv: string[]): {
2
2
  appId: string;
3
3
  device: string;
4
4
  help: boolean;
5
+ host: string;
5
6
  noBundler: boolean;
6
7
  noInstall: boolean;
7
8
  port: number;
@@ -3,10 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseRunArgs = parseRunArgs;
4
4
  const errors_1 = require("../errors");
5
5
  const args_1 = require("../args");
6
+ const host_1 = require("../development/host");
6
7
  const RunOptions = {
7
8
  ...args_1.CommonOptions,
8
9
  'app-id': { type: 'string' },
9
10
  'device': { type: 'string' },
11
+ 'host': { type: 'string' },
10
12
  'no-bundler': { type: 'boolean' },
11
13
  'no-install': { type: 'boolean' },
12
14
  'port': { type: 'string' },
@@ -50,6 +52,7 @@ function parseRunArgs(argv) {
50
52
  appId,
51
53
  device,
52
54
  help: Boolean(values.help),
55
+ host: (0, host_1.parseHostOption)(values.host),
53
56
  noBundler: Boolean(values['no-bundler']),
54
57
  noInstall: Boolean(values['no-install']),
55
58
  port,
@@ -3,6 +3,7 @@ import { type NativeBuildResult } from '../native/build';
3
3
  export interface HarmonyRunOptions {
4
4
  appId?: string;
5
5
  device?: string;
6
+ host?: string;
6
7
  io?: Pick<Console, 'error' | 'log' | 'warn'>;
7
8
  noBundler?: boolean;
8
9
  noInstall?: boolean;
package/build/run/run.js CHANGED
@@ -33,6 +33,7 @@ async function runHarmonyUnlockedAsync(root, options = {}) {
33
33
  const settings = {
34
34
  appId: options.appId,
35
35
  device: options.device,
36
+ host: options.host,
36
37
  interactiveBundler: Boolean(options.interactiveBundler),
37
38
  io: options.io || console,
38
39
  noBundler: Boolean(options.noBundler),
@@ -72,6 +73,7 @@ async function runHarmonyUnlockedAsync(root, options = {}) {
72
73
  metro = await (0, profile_1.timedAsync)(steps, 'metro', () => settings.noBundler
73
74
  ? (0, metro_1.requireExistingMetroAsync)(settings.port)
74
75
  : (0, metro_1.startExpoMetroAsync)(root, {
76
+ host: settings.host,
75
77
  interactive: settings.interactiveBundler,
76
78
  port: settings.port,
77
79
  privateKeyPath: settings.privateKeyPath,
@@ -40,10 +40,6 @@ async function createRuntimeContractAsync(root, project, modules, metro) {
40
40
  modules: (0, expo_modules_autolinking_1.createNativeModuleContracts)(modules),
41
41
  config: project.native,
42
42
  };
43
- if (contract.rnoh !== '0.84.1' || contract.react !== '19.2.3' || contract.hermes !== '250829098.0.9'
44
- || !contract.expo.startsWith('55.') || !modules.some(module => module.packageName === '@expo-harmony/expo-modules-core')) {
45
- throw new errors_1.HarmonyCliError('ERR_HARMONY_RUNTIME_UNSUPPORTED', 'The fixed development runtime requires Expo SDK 55, Harmony Expo Modules Core, RNOH 0.84.1, React 19.2.3, and Hermes 250829098.0.9. Check the Metro React alias.', { operation: 'runtime-contract' });
46
- }
47
43
  const issues = (0, runtime_1.validateHarmonyRuntime)(contract);
48
44
  if (issues.length > 0) {
49
45
  throw new errors_1.HarmonyCliError('ERR_HARMONY_RUNTIME_CONFIG', issues.map(issue => issue.message).join('\n'), { operation: 'runtime-contract' });
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseStartArgs = parseStartArgs;
4
4
  const args_1 = require("../args");
5
+ const host_1 = require("../development/host");
5
6
  const errors_1 = require("../errors");
6
7
  const StartOptions = {
7
8
  ...args_1.CommonOptions,
@@ -24,12 +25,9 @@ function parseStartArgs(argv) {
24
25
  operation: 'parse-arguments',
25
26
  });
26
27
  }
27
- if (values.host !== undefined && !/^[A-Za-z0-9.-]+$/.test(values.host)) {
28
- throw new errors_1.HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', '--host must be a hostname or IPv4 address without a port or scheme.', { operation: 'parse-arguments' });
29
- }
30
28
  return {
31
29
  help: Boolean(values.help),
32
- host: values.host,
30
+ host: (0, host_1.parseHostOption)(values.host),
33
31
  port,
34
32
  privateKeyPath: values['private-key-path'],
35
33
  project: positionals[0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo-harmony/cli",
3
- "version": "55.0.26-harmony.12",
3
+ "version": "55.0.26-harmony.13",
4
4
  "keywords": [
5
5
  "react-native",
6
6
  "expo",
@@ -53,7 +53,7 @@
53
53
  "dependencies": {
54
54
  "@expo-harmony/config-plugins": "55.0.10-harmony.5",
55
55
  "@expo-harmony/expo-modules-autolinking": "55.0.25-harmony.5",
56
- "@expo-harmony/prebuild-config": "55.0.0-harmony.9",
56
+ "@expo-harmony/prebuild-config": "55.0.0-harmony.10",
57
57
  "@expo/config": "55.0.17",
58
58
  "@expo/fingerprint": "0.16.7",
59
59
  "cross-spawn": "^7.0.6",
@@ -0,0 +1,62 @@
1
+ import { createRequire } from 'node:module';
2
+ import os from 'node:os';
3
+
4
+ import { HarmonyCliError } from '../errors';
5
+ import { resolveExpoCli } from '../expo';
6
+
7
+ export function parseHostOption(host?: string): string | undefined {
8
+ if (host !== undefined && !/^[A-Za-z0-9.-]+$/.test(host)) {
9
+ throw new HarmonyCliError(
10
+ 'ERR_HARMONY_CONFIG_INVALID',
11
+ '--host must be a hostname or IPv4 address without a port or scheme.',
12
+ { operation: 'parse-arguments' }
13
+ );
14
+ }
15
+
16
+ return host;
17
+ }
18
+
19
+ function interfacePriority(name: string, info: os.NetworkInterfaceInfo): number {
20
+ // Interface names alone cannot identify every virtual adapter (especially on Windows).
21
+ const virtual = /^(utun|tun|tap|wg|ppp|ipsec|vmnet|vboxnet|virbr|docker|br-|bridge\d|veth|tailscale|zt)|virtual|vmware|vpn/i.test(name)
22
+ || /^(00:00:00:00:00:00|00:05:69:|00:0c:29:|00:1c:14:|00:50:56:|08:00:27:|00:15:5d:)/i.test(info.mac)
23
+ || /^198\.(18|19)\./.test(info.address);
24
+
25
+ return Number(virtual) + (info.address.startsWith('169.254.') ? 2 : 0);
26
+ }
27
+
28
+ export async function resolveDevelopmentHostAsync(root: string, hostname?: string): Promise<string> {
29
+ const override = hostname || process.env.REACT_NATIVE_PACKAGER_HOSTNAME;
30
+ if (override) {
31
+ if (!/^[A-Za-z0-9.-]+$/.test(override)) {
32
+ throw new HarmonyCliError(
33
+ 'ERR_HARMONY_MANIFEST_HOST',
34
+ 'Harmony development requires an HTTP LAN hostname or IPv4 address.',
35
+ { operation: 'development-manifest' }
36
+ );
37
+ }
38
+
39
+ return override;
40
+ }
41
+
42
+ const candidates = Object.entries(os.networkInterfaces()).flatMap(([name, addresses]) =>
43
+ (addresses || [])
44
+ .filter(info => info.family === 'IPv4' && !info.internal)
45
+ .map(info => ({ address: info.address, priority: interfacePriority(name, info) }))
46
+ ).sort((a, b) => a.priority - b.priority);
47
+
48
+ if (!candidates.length) return '127.0.0.1';
49
+
50
+ const preferred = candidates.filter(candidate => candidate.priority === candidates[0].priority);
51
+ if (preferred.length > 1) {
52
+ // Use Expo's route discovery to choose between equally suitable interfaces,
53
+ // but do not let a VPN default route displace an available LAN interface.
54
+ const expo = createRequire(resolveExpoCli(root).cliPath);
55
+ const require = createRequire(expo.resolve('@expo/cli/package.json'));
56
+ const { getGatewayAsync } = require('./build/src/utils/ip');
57
+ const gateway = await getGatewayAsync();
58
+ if (preferred.some(candidate => candidate.address === gateway.address)) return gateway.address;
59
+ }
60
+
61
+ return preferred[0].address;
62
+ }
@@ -1,26 +1,17 @@
1
1
  import http from 'node:http';
2
- import os from 'node:os';
3
2
 
4
3
  import type { HarmonyDevelopmentManifest } from '@expo-harmony/expo-modules-autolinking/runtime';
5
4
 
6
5
  import { HarmonyCliError } from '../errors';
7
6
  import { readProjectRuntimeAsync } from '../runtime/config';
7
+ import { resolveDevelopmentHostAsync } from './host';
8
8
  import { createHarmonyLaunchLink, HarmonyManifestPath } from './protocol';
9
9
 
10
10
  export async function readDevelopmentSessionAsync(root: string, port: number, hostname?: string) {
11
11
  const project = await readProjectRuntimeAsync(root);
12
12
  const { config } = project;
13
13
 
14
- const host = hostname || process.env.REACT_NATIVE_PACKAGER_HOSTNAME
15
- || Object.values(os.networkInterfaces()).flat().find(item => item?.family === 'IPv4' && !item.internal)?.address
16
- || '127.0.0.1';
17
- if (!/^[A-Za-z0-9.-]+$/.test(host)) {
18
- throw new HarmonyCliError(
19
- 'ERR_HARMONY_MANIFEST_HOST',
20
- 'Harmony development requires an HTTP LAN hostname or IPv4 address.',
21
- { operation: 'development-manifest' }
22
- );
23
- }
14
+ const host = await resolveDevelopmentHostAsync(root, hostname);
24
15
 
25
16
  const manifest = await new Promise<HarmonyDevelopmentManifest>((resolve, reject) => {
26
17
  const request = http.get({
@@ -5,7 +5,11 @@ import path from 'node:path';
5
5
  import { getConfig } from '@expo/config';
6
6
  import { normalizeHarmonyConfig } from '@expo-harmony/config-plugins';
7
7
  import { verifyModulesAsync } from '@expo-harmony/expo-modules-autolinking';
8
- import { isRnohAutolinkingDisabled, validateHarmonySigningConfigFile } from '@expo-harmony/prebuild-config/internal';
8
+ import {
9
+ HarmonyPlatformDirectory,
10
+ isRnohAutolinkingDisabled,
11
+ validateHarmonySigningConfigFile,
12
+ } from '@expo-harmony/prebuild-config/internal';
9
13
 
10
14
  import { spawnAsync } from '../process';
11
15
  import { resolveTimeoutMs } from '../timeout';
@@ -29,6 +33,7 @@ export interface DoctorResult {
29
33
  }
30
34
 
31
35
  interface DoctorOptions {
36
+ freshPrebuild?: boolean;
32
37
  requireBuildTools?: boolean;
33
38
  requireDeviceTools?: boolean;
34
39
  validateGeneratedProject?: boolean;
@@ -140,7 +145,15 @@ async function doctorUnlockedAsync(root: string, options: DoctorOptions = {}): P
140
145
  const signing = await validateHarmonySigningConfigFile(root, harmony.signingConfigFile);
141
146
  checks.push(check('signing', 'pass', `Harmony signing config ${signing.name} is valid.`));
142
147
  } catch (error) {
143
- checks.push(check('signing', 'error', error.message, { code: error.code || 'ERR_HARMONY_SIGNING_INVALID' }));
148
+ const fresh = options.freshPrebuild || !fs.existsSync(path.join(root, HarmonyPlatformDirectory));
149
+ checks.push(check(
150
+ 'signing',
151
+ fresh ? 'warn' : 'error',
152
+ fresh
153
+ ? `The fresh Harmony project will be generated unsigned because signing materials are unavailable: ${error.message}`
154
+ : error.message,
155
+ { code: error.code || 'ERR_HARMONY_SIGNING_INVALID' }
156
+ ));
144
157
  }
145
158
  } else {
146
159
  checks.push(check('signing', 'warn', 'No external signing config is set; unsigned generation remains available.'));
package/src/help.ts CHANGED
@@ -27,7 +27,7 @@ Options:
27
27
  --no-bundler Use an already-running Expo Metro server
28
28
  --app-id <bundleName>
29
29
  Launch another installed app (requires --no-install when different)
30
- --host <hostname> Advertised HTTP LAN hostname (start)
30
+ --host <hostname> Advertised LAN hostname or IPv4 address (start, run)
31
31
  --private-key-path <path>
32
32
  Sign development manifests using the configured Updates certificate
33
33
  --port <number> Metro and device reverse port (default: 8081)
@@ -25,6 +25,7 @@ export const command: Command = async (argv, io) => {
25
25
 
26
26
  const { doctorAsync, formatDoctor } = await import('../doctor/doctor.js');
27
27
  const doctor = await doctorAsync(root, {
28
+ freshPrebuild: clean,
28
29
  requireBuildTools: false,
29
30
  validateGeneratedProject: !clean,
30
31
  validateModules: false,
@@ -1,4 +1,7 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { HarmonyPlatformDirectory } from '@expo-harmony/prebuild-config/internal';
2
5
 
3
6
  import { HarmonyCliError } from '../errors';
4
7
  import { isBareHarmonyProject } from '../native/bare';
@@ -25,6 +28,9 @@ async function prebuildParsedUnlockedAsync(
25
28
  }
26
29
  if (passthrough.includes('--clean')) await assertSafeCleanTarget(projectRoot);
27
30
 
31
+ const freshHarmonyProject = passthrough.includes('--clean')
32
+ || !fs.existsSync(path.join(projectRoot, HarmonyPlatformDirectory));
33
+
28
34
  const expo = resolveExpoCli(projectRoot);
29
35
  const packed = await packAsync(projectRoot);
30
36
 
@@ -41,6 +47,7 @@ async function prebuildParsedUnlockedAsync(
41
47
  env: {
42
48
  ...createHarmonyToolchainEnv(),
43
49
  ...packed.env,
50
+ EXPO_HARMONY_PREBUILD_FRESH: freshHarmonyProject ? '1' : '0',
44
51
  ...(options.buildType ? { EXPO_HARMONY_BUILD_TYPE: options.buildType } : {}),
45
52
  },
46
53
  operation: 'expo-prebuild',
package/src/run/metro.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import http from 'node:http';
2
2
 
3
+ import { resolveDevelopmentHostAsync } from '../development/host';
3
4
  import { readDevelopmentSessionAsync } from '../development/session';
4
5
  import { HarmonyCliError } from '../errors';
5
6
  import { formatDiagnostics, startManagedProcess, type ProcessResult } from '../process';
@@ -102,11 +103,12 @@ async function startExpoMetroAsync(
102
103
  projectRoot: string,
103
104
  options: MetroOptions
104
105
  ): Promise<MetroSession> {
106
+ const host = await resolveDevelopmentHostAsync(projectRoot, options.host);
105
107
  const before = await probeMetroAsync(options.port);
106
108
 
107
109
  if (before === 'metro') {
108
110
  return {
109
- development: await readDevelopmentSessionAsync(projectRoot, options.port, options.host),
111
+ development: await readDevelopmentSessionAsync(projectRoot, options.port, host),
110
112
  owner: 'existing',
111
113
  port: options.port,
112
114
  stop: async () => {},
@@ -137,7 +139,7 @@ async function startExpoMetroAsync(
137
139
  env: {
138
140
  ...process.env,
139
141
  EXPO_METRO_TARGET: 'harmony',
140
- ...(options.host ? { REACT_NATIVE_PACKAGER_HOSTNAME: options.host } : {}),
142
+ REACT_NATIVE_PACKAGER_HOSTNAME: host,
141
143
  },
142
144
  operation: 'expo-metro',
143
145
  outputLimit: 1024 * 1024,
@@ -193,7 +195,7 @@ async function startExpoMetroAsync(
193
195
 
194
196
  if (await probeMetroAsync(options.port) === 'metro') {
195
197
  return {
196
- development: await readDevelopmentSessionAsync(projectRoot, options.port, options.host),
198
+ development: await readDevelopmentSessionAsync(projectRoot, options.port, host),
197
199
  owner: 'started',
198
200
  port: options.port,
199
201
  process: managed,
@@ -1,10 +1,12 @@
1
1
  import { HarmonyCliError } from '../errors';
2
2
  import { CommonOptions, parseArgs } from '../args';
3
+ import { parseHostOption } from '../development/host';
3
4
 
4
5
  const RunOptions = {
5
6
  ...CommonOptions,
6
7
  'app-id': { type: 'string' },
7
8
  'device': { type: 'string' },
9
+ 'host': { type: 'string' },
8
10
  'no-bundler': { type: 'boolean' },
9
11
  'no-install': { type: 'boolean' },
10
12
  'port': { type: 'string' },
@@ -59,6 +61,7 @@ function parseRunArgs(argv: string[]) {
59
61
  appId,
60
62
  device,
61
63
  help: Boolean(values.help),
64
+ host: parseHostOption(values.host),
62
65
  noBundler: Boolean(values['no-bundler']),
63
66
  noInstall: Boolean(values['no-install']),
64
67
  port,
package/src/run/run.ts CHANGED
@@ -24,6 +24,7 @@ import { timedAsync } from '../profile';
24
24
  export interface HarmonyRunOptions {
25
25
  appId?: string;
26
26
  device?: string;
27
+ host?: string;
27
28
  io?: Pick<Console, 'error' | 'log' | 'warn'>;
28
29
  noBundler?: boolean;
29
30
  noInstall?: boolean;
@@ -60,8 +61,8 @@ interface HarmonyRunSession {
60
61
  result: HarmonyRunResult;
61
62
  }
62
63
 
63
- type NormalizedRunOptions = Required<Omit<HarmonyRunSessionOptions, 'appId' | 'device' | 'privateKeyPath'>>
64
- & Pick<HarmonyRunSessionOptions, 'appId' | 'device' | 'privateKeyPath'>;
64
+ type NormalizedRunOptions = Required<Omit<HarmonyRunSessionOptions, 'appId' | 'device' | 'host' | 'privateKeyPath'>>
65
+ & Pick<HarmonyRunSessionOptions, 'appId' | 'device' | 'host' | 'privateKeyPath'>;
65
66
 
66
67
  const BundleName = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*){2,}$/u;
67
68
 
@@ -91,6 +92,7 @@ async function runHarmonyUnlockedAsync(
91
92
  const settings: NormalizedRunOptions = {
92
93
  appId: options.appId,
93
94
  device: options.device,
95
+ host: options.host,
94
96
  interactiveBundler: Boolean(options.interactiveBundler),
95
97
  io: options.io || console,
96
98
  noBundler: Boolean(options.noBundler),
@@ -140,6 +142,7 @@ async function runHarmonyUnlockedAsync(
140
142
  metro = await timedAsync(steps, 'metro', () => settings.noBundler
141
143
  ? requireExistingMetroAsync(settings.port)
142
144
  : startExpoMetroAsync(root, {
145
+ host: settings.host,
143
146
  interactive: settings.interactiveBundler,
144
147
  port: settings.port,
145
148
  privateKeyPath: settings.privateKeyPath,
@@ -49,15 +49,6 @@ async function createRuntimeContractAsync(
49
49
  config: project.native,
50
50
  };
51
51
 
52
- if (contract.rnoh !== '0.84.1' || contract.react !== '19.2.3' || contract.hermes !== '250829098.0.9'
53
- || !contract.expo.startsWith('55.') || !modules.some(module => module.packageName === '@expo-harmony/expo-modules-core')) {
54
- throw new HarmonyCliError(
55
- 'ERR_HARMONY_RUNTIME_UNSUPPORTED',
56
- 'The fixed development runtime requires Expo SDK 55, Harmony Expo Modules Core, RNOH 0.84.1, React 19.2.3, and Hermes 250829098.0.9. Check the Metro React alias.',
57
- { operation: 'runtime-contract' }
58
- );
59
- }
60
-
61
52
  const issues = validateHarmonyRuntime(contract);
62
53
  if (issues.length > 0) {
63
54
  throw new HarmonyCliError(
@@ -1,4 +1,5 @@
1
1
  import { CommonOptions, parseArgs } from '../args';
2
+ import { parseHostOption } from '../development/host';
2
3
  import { HarmonyCliError } from '../errors';
3
4
 
4
5
  const StartOptions = {
@@ -27,17 +28,9 @@ function parseStartArgs(argv: string[]) {
27
28
  });
28
29
  }
29
30
 
30
- if (values.host !== undefined && !/^[A-Za-z0-9.-]+$/.test(values.host)) {
31
- throw new HarmonyCliError(
32
- 'ERR_HARMONY_CONFIG_INVALID',
33
- '--host must be a hostname or IPv4 address without a port or scheme.',
34
- { operation: 'parse-arguments' }
35
- );
36
- }
37
-
38
31
  return {
39
32
  help: Boolean(values.help),
40
- host: values.host,
33
+ host: parseHostOption(values.host),
41
34
  port,
42
35
  privateKeyPath: values['private-key-path'],
43
36
  project: positionals[0],