@expo-harmony/cli 55.0.26-harmony.8 → 55.0.26-harmony.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +74 -46
  2. package/build/bin/expo-harmony.js +1 -1
  3. package/build/buildHap/build.d.ts +3 -10
  4. package/build/buildHap/build.js +11 -69
  5. package/build/buildHap/index.d.ts +2 -0
  6. package/build/buildHap/index.js +19 -0
  7. package/build/cli.d.ts +3 -3
  8. package/build/cli.js +21 -187
  9. package/build/command.d.ts +9 -0
  10. package/build/command.js +20 -0
  11. package/build/doctor/doctor.d.ts +1 -1
  12. package/build/doctor/doctor.js +64 -56
  13. package/build/doctor/index.d.ts +2 -0
  14. package/build/doctor/index.js +16 -0
  15. package/build/exportEmbed/export.d.ts +2 -2
  16. package/build/exportEmbed/export.js +53 -49
  17. package/build/exportEmbed/index.d.ts +2 -0
  18. package/build/exportEmbed/index.js +19 -0
  19. package/build/file.d.ts +2 -1
  20. package/build/file.js +18 -8
  21. package/build/help.d.ts +1 -0
  22. package/build/help.js +36 -0
  23. package/build/index.d.ts +5 -3
  24. package/build/index.js +6 -5
  25. package/build/internal/prebuild.d.ts +3 -0
  26. package/build/internal/prebuild.js +9 -0
  27. package/build/log.d.ts +4 -0
  28. package/build/log.js +6 -0
  29. package/build/modules/index.d.ts +2 -0
  30. package/build/modules/index.js +16 -0
  31. package/build/native/bare.d.ts +4 -0
  32. package/build/native/bare.js +130 -0
  33. package/build/native/build.d.ts +15 -0
  34. package/build/native/build.js +67 -0
  35. package/build/{run → native}/cache.d.ts +2 -2
  36. package/build/{run → native}/cache.js +8 -8
  37. package/build/native/hvigor.d.ts +2 -0
  38. package/build/native/hvigor.js +77 -0
  39. package/build/native/install.d.ts +5 -0
  40. package/build/native/install.js +13 -0
  41. package/build/native/prepareProject.d.ts +11 -0
  42. package/build/native/prepareProject.js +68 -0
  43. package/build/native/project.d.ts +8 -0
  44. package/build/native/project.js +58 -0
  45. package/build/native/toolchain.d.ts +16 -0
  46. package/build/{tools.js → native/toolchain.js} +1 -57
  47. package/build/native/types.d.ts +47 -0
  48. package/build/native/types.js +2 -0
  49. package/build/prebuild/check.d.ts +7 -3
  50. package/build/prebuild/check.js +26 -20
  51. package/build/prebuild/clean.js +5 -5
  52. package/build/prebuild/index.d.ts +2 -0
  53. package/build/prebuild/index.js +51 -0
  54. package/build/prebuild/prebuild.js +11 -3
  55. package/build/prebuild/template.js +2 -2
  56. package/build/process.d.ts +11 -1
  57. package/build/process.js +16 -0
  58. package/build/profile.d.ts +1 -0
  59. package/build/profile.js +12 -0
  60. package/build/projectLock.js +7 -2
  61. package/build/run/devices.d.ts +1 -1
  62. package/build/run/devices.js +15 -10
  63. package/build/run/emulators.d.ts +1 -1
  64. package/build/run/emulators.js +2 -5
  65. package/build/run/index.d.ts +2 -0
  66. package/build/run/index.js +25 -0
  67. package/build/run/run.d.ts +4 -10
  68. package/build/run/run.js +45 -90
  69. package/build/start/index.d.ts +2 -0
  70. package/build/start/index.js +33 -0
  71. package/package.json +14 -4
  72. package/src/bin/expo-harmony.ts +2 -2
  73. package/src/buildHap/build.ts +13 -105
  74. package/src/buildHap/index.ts +19 -0
  75. package/src/cli.ts +24 -227
  76. package/src/command.ts +24 -0
  77. package/src/doctor/doctor.ts +63 -61
  78. package/src/doctor/index.ts +16 -0
  79. package/src/exportEmbed/export.ts +64 -55
  80. package/src/exportEmbed/index.ts +17 -0
  81. package/src/file.ts +20 -11
  82. package/src/help.ts +33 -0
  83. package/src/index.ts +5 -3
  84. package/src/internal/prebuild.ts +3 -0
  85. package/src/log.ts +5 -0
  86. package/src/modules/index.ts +16 -0
  87. package/src/native/bare.ts +145 -0
  88. package/src/native/build.ts +81 -0
  89. package/src/{run → native}/cache.ts +10 -9
  90. package/src/native/hvigor.ts +81 -0
  91. package/src/native/install.ts +17 -0
  92. package/src/native/prepareProject.ts +99 -0
  93. package/src/native/project.ts +88 -0
  94. package/src/{tools.ts → native/toolchain.ts} +11 -118
  95. package/src/native/types.ts +44 -0
  96. package/src/prebuild/check.ts +30 -19
  97. package/src/prebuild/clean.ts +3 -3
  98. package/src/prebuild/index.ts +56 -0
  99. package/src/prebuild/prebuild.ts +11 -1
  100. package/src/prebuild/template.ts +2 -2
  101. package/src/process.ts +38 -1
  102. package/src/profile.ts +13 -0
  103. package/src/projectLock.ts +9 -1
  104. package/src/run/devices.ts +16 -11
  105. package/src/run/emulators.ts +4 -6
  106. package/src/run/index.ts +25 -0
  107. package/src/run/run.ts +56 -129
  108. package/src/start/index.ts +31 -0
  109. package/build/buildHap/common.d.ts +0 -24
  110. package/build/buildHap/common.js +0 -92
  111. package/build/run/install.d.ts +0 -7
  112. package/build/run/install.js +0 -26
  113. package/build/tools.d.ts +0 -39
  114. package/src/buildHap/common.ts +0 -145
  115. package/src/run/install.ts +0 -41
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -5,20 +6,22 @@ import path from 'node:path';
5
6
  import {
6
7
  compareAsync,
7
8
  stageAsync,
8
- } from '@expo-harmony/prebuild-config/check';
9
+ } from '@expo-harmony/prebuild-config/internal';
9
10
 
10
11
  import { HarmonyCliError } from '../errors';
11
12
  import { isInside } from '../path';
12
13
  import { spawnAsync } from '../process';
13
14
  import { withHarmonyProjectLockAsync } from '../projectLock';
14
- import { createHarmonyToolchainEnv, resolveHarmonyBuildPlanAsync, type HarmonyBuildPlan } from '../tools';
15
+ import { createHarmonyToolchainEnv } from '../native/toolchain';
16
+ import { resolveHarmonyBuildPlanAsync } from '../native/project';
17
+ import { type HarmonyBuildPlan } from '../native/types';
15
18
  import { resolveExpoCli } from '../expo';
16
19
  import { packAsync } from './template';
17
20
 
18
- export type { Change as CheckChange } from '@expo-harmony/prebuild-config/check';
21
+ export type { Change as CheckChange } from '@expo-harmony/prebuild-config/internal';
19
22
 
20
- const IgnoredProjectDirectories = new Set(['.expo', '.git', '.hvigor', '.yarn', 'node_modules']);
21
- const IgnoredHarmonyGeneratedDirectories = new Set([
23
+ const IgnoredDirectories = new Set(['.expo', '.git', '.hvigor', '.yarn', 'node_modules']);
24
+ const GeneratedDirectories = new Set([
22
25
  '.cxx',
23
26
  '.git',
24
27
  '.hvigor',
@@ -103,16 +106,18 @@ function shouldCopyPrebuildCheckPath(
103
106
  if (!relative) return true;
104
107
 
105
108
  const segments = relative.split(path.sep);
106
- if (IgnoredProjectDirectories.has(segments[0])) return false;
109
+ if (IgnoredDirectories.has(segments[0])) return false;
107
110
 
108
- const nativeRoot = isInside(plan.harmonyRoot, source)
111
+ const root = isInside(plan.harmonyRoot, source)
109
112
  ? plan.harmonyRoot
110
113
  : isAppLocalHarmonyPath(project, source)
111
114
  ? path.join(project, ...segments.slice(0, 3))
112
115
  : null;
113
- if (nativeRoot) {
114
- const native = path.relative(nativeRoot, source).split(path.sep);
115
- if (native.some(segment => IgnoredHarmonyGeneratedDirectories.has(segment))) return false;
116
+ if (root) {
117
+ const native = path.relative(root, source).split(path.sep);
118
+ if (GeneratedDirectories.has(native[0])
119
+ || (GeneratedDirectories.has(native[1])
120
+ && fs.existsSync(path.join(root, native[0], 'src/main/module.json5')))) return false;
116
121
  }
117
122
 
118
123
  return source !== plan.exportPaths.bundle;
@@ -126,6 +131,7 @@ async function copyAsync(
126
131
  ) {
127
132
  await fs.promises.cp(project, target, {
128
133
  recursive: true,
134
+ verbatimSymlinks: true,
129
135
  filter: source => shouldCopyPrebuildCheckPath(project, source, plan),
130
136
  });
131
137
 
@@ -137,16 +143,16 @@ async function copyAsync(
137
143
  });
138
144
  }
139
145
 
140
- // Keep a real node_modules directory in the isolated project and link its
141
- // entries. Dependency scanners then retain the isolated lexical package path
142
- // (including scoped packages) instead of collapsing the entire node_modules
143
- // root to the source project's realpath. Package directories stay linked;
144
- // Windows copies loose files to avoid requiring file-symlink privileges.
146
+ // Link individual packages so dependency scanners retain paths inside the mirror.
145
147
  await linkModulesAsync(modules, path.join(target, 'node_modules'));
146
148
  await stageAsync(project, target, temp);
147
149
  }
148
150
 
149
- async function checkUnlockedAsync(project, options: CheckOptions) {
151
+ async function withGeneratedProjectAsync<T>(
152
+ project: string,
153
+ options: CheckOptions & { clean?: boolean; skipPatches?: boolean },
154
+ action: (expected: string, checksum: string) => Promise<T>
155
+ ): Promise<T> {
150
156
  project = path.resolve(project);
151
157
  const plan = await resolveHarmonyBuildPlanAsync(project);
152
158
  const temp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'expo-harmony-check-'));
@@ -155,6 +161,8 @@ async function checkUnlockedAsync(project, options: CheckOptions) {
155
161
 
156
162
  try {
157
163
  await copyAsync(project, expected, plan, temp);
164
+ if (options.clean) await fs.promises.rm(path.join(expected, 'harmony'), { recursive: true, force: true });
165
+
158
166
  packed = await packAsync(project);
159
167
 
160
168
  const expo = resolveExpoCli(project);
@@ -173,6 +181,7 @@ async function checkUnlockedAsync(project, options: CheckOptions) {
173
181
  ...packed.env,
174
182
  ...(options.buildType ? { EXPO_HARMONY_BUILD_TYPE: options.buildType } : {}),
175
183
  EXPO_HARMONY_CHECK_MIRROR_ROOT: temp,
184
+ ...(options.skipPatches ? { EXPO_HARMONY_SKIP_PATCHES: '1' } : {}),
176
185
  },
177
186
  operation: 'check-prebuild',
178
187
  });
@@ -185,7 +194,9 @@ async function checkUnlockedAsync(project, options: CheckOptions) {
185
194
  );
186
195
  }
187
196
 
188
- return compareAsync(project, expected);
197
+ const checksum = createHash('md5').update(Uint8Array.from(await fs.promises.readFile(packed.tarball))).digest('hex');
198
+
199
+ return await action(expected, checksum);
189
200
  } finally {
190
201
  if (packed) await packed.cleanup();
191
202
  await fs.promises.rm(temp, { recursive: true, force: true });
@@ -196,8 +207,8 @@ async function checkAsync(project, options: CheckOptions = {}) {
196
207
  return withHarmonyProjectLockAsync(
197
208
  project,
198
209
  'prebuild-check',
199
- () => checkUnlockedAsync(project, options)
210
+ () => withGeneratedProjectAsync(project, options, expected => compareAsync(project, expected))
200
211
  );
201
212
  }
202
213
 
203
- export { checkAsync };
214
+ export { checkAsync, withGeneratedProjectAsync };
@@ -1,11 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
- import { HarmonyPlatformDirectory } from '@expo-harmony/prebuild-config/build-descriptor';
4
+ import { HarmonyPlatformDirectory } from '@expo-harmony/prebuild-config/internal';
5
5
 
6
6
  import { HarmonyCliError } from '../errors';
7
7
  import { isInside } from '../path';
8
- import { resolveHarmonyBuildPlanIfPresentAsync } from '../tools';
8
+ import { resolveHarmonyBuildPlanIfPresentAsync } from '../native/project';
9
9
 
10
10
  async function assertSafeCleanTarget(projectRoot) {
11
11
  const root = await fs.promises.realpath(projectRoot);
@@ -24,7 +24,7 @@ async function assertSafeCleanTarget(projectRoot) {
24
24
  );
25
25
  }
26
26
 
27
- if (!plan) {
27
+ if (!plan || plan.workflow !== 'cng') {
28
28
  throw new HarmonyCliError(
29
29
  'ERR_HARMONY_CLEAN_TARGET',
30
30
  `Refusing to clean ${target} because its CNG manifest is missing. Please delete it manually or run prebuild without --clean.`,
@@ -0,0 +1,56 @@
1
+ import { parseCommandArgs, type Command } from '../command';
2
+ import { HarmonyCliError } from '../errors';
3
+ import { withHarmonyProjectLockAsync } from '../projectLock';
4
+ import { parsePrebuildArgs } from './options';
5
+
6
+ export const command: Command = async (argv, io) => {
7
+ const args = parseCommandArgs(args => parsePrebuildArgs(args, { allowProject: true }), argv, io);
8
+ if (!args) return 0;
9
+
10
+ const { projectRoot: root, options } = args;
11
+ const { check, clean, passthrough } = options;
12
+
13
+ if (check && passthrough.length) {
14
+ throw new HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', '--check cannot be combined with mutating prebuild options.');
15
+ }
16
+
17
+ return withHarmonyProjectLockAsync(
18
+ root,
19
+ check ? 'prebuild-check' : 'prebuild',
20
+ async () => {
21
+ if (clean) {
22
+ const { assertSafeCleanTarget } = await import('./clean.js');
23
+ await assertSafeCleanTarget(root);
24
+ }
25
+
26
+ const { doctorAsync, formatDoctor } = await import('../doctor/doctor.js');
27
+ const doctor = await doctorAsync(root, {
28
+ requireBuildTools: false,
29
+ validateGeneratedProject: !clean,
30
+ validateModules: false,
31
+ });
32
+ if (!doctor.ok) {
33
+ io.error(formatDoctor(doctor));
34
+ throw new HarmonyCliError('ERR_HARMONY_DOCTOR_FAILED', 'Harmony doctor found blocking errors.', {
35
+ operation: 'doctor',
36
+ });
37
+ }
38
+
39
+ for (const item of doctor.checks.filter(item => item.status === 'warn')) io.warn(`! ${item.message}`);
40
+
41
+ if (check) {
42
+ const { checkAsync } = await import('./check.js');
43
+ const result = await checkAsync(root);
44
+
45
+ if (result.clean) io.log('Harmony CNG output is up to date.');
46
+ else for (const change of result.changes) io.log(`${change.type}: ${change.path}`);
47
+
48
+ return result.clean ? 0 : 2;
49
+ }
50
+
51
+ const { prebuildParsedAsync } = await import('./prebuild.js');
52
+ await prebuildParsedAsync(root, passthrough);
53
+
54
+ return 0;
55
+ });
56
+ };
@@ -1,10 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
 
3
3
  import { HarmonyCliError } from '../errors';
4
+ import { isBareHarmonyProject } from '../native/bare';
4
5
  import { formatDiagnostics, spawnAsync } from '../process';
5
6
  import { withHarmonyProjectLockAsync } from '../projectLock';
6
7
  import { resolveExpoCli } from '../expo';
7
- import { createHarmonyToolchainEnv, resolveHarmonyBuildPlanAsync } from '../tools';
8
+ import { createHarmonyToolchainEnv } from '../native/toolchain';
9
+ import { resolveHarmonyBuildPlanAsync } from '../native/project';
8
10
  import { assertSafeCleanTarget } from './clean';
9
11
  import { packAsync } from './template';
10
12
 
@@ -18,6 +20,9 @@ async function prebuildParsedUnlockedAsync(
18
20
  passthrough: string[],
19
21
  options: PrebuildOptions = {}
20
22
  ) {
23
+ if (isBareHarmonyProject(projectRoot)) {
24
+ throw new HarmonyCliError('ERR_HARMONY_BARE_PREBUILD', 'This Harmony project is manually maintained. Build it directly; prebuild cannot overwrite a bare project.', { operation: 'prebuild' });
25
+ }
21
26
  if (passthrough.includes('--clean')) await assertSafeCleanTarget(projectRoot);
22
27
 
23
28
  const expo = resolveExpoCli(projectRoot);
@@ -44,6 +49,7 @@ async function prebuildParsedUnlockedAsync(
44
49
 
45
50
  if (result.code !== 0) {
46
51
  const diagnostics = options.capture ? formatDiagnostics(result) : '';
52
+
47
53
  throw new HarmonyCliError('ERR_HARMONY_PREBUILD_FAILED', `Expo prebuild exited with code ${result.code}.${diagnostics ? `\n${diagnostics}` : ''}`, {
48
54
  exitCode: result.code,
49
55
  operation: 'expo-prebuild',
@@ -54,6 +60,10 @@ async function prebuildParsedUnlockedAsync(
54
60
  const plan = await resolveHarmonyBuildPlanAsync(projectRoot, {
55
61
  buildMode: options.buildType,
56
62
  });
63
+ if (plan.workflow !== 'cng') {
64
+ throw new HarmonyCliError('ERR_HARMONY_TEMPLATE_INVALID', 'Prebuild did not produce a CNG project.', { operation: 'prebuild' });
65
+ }
66
+
57
67
  await fs.promises.access(plan.projectFiles.templateMarker, fs.constants.R_OK);
58
68
  } catch (cause) {
59
69
  throw new HarmonyCliError('ERR_HARMONY_TEMPLATE_INVALID', 'Expo prebuild exited successfully but the Harmony marker or CNG manifest is invalid.', { cause, operation: 'verify-prebuild' });
@@ -19,11 +19,11 @@ function resolve(project: string) {
19
19
  let api: Api;
20
20
 
21
21
  try {
22
- api = load('@expo-harmony/prebuild-config/template');
22
+ api = load('@expo-harmony/prebuild-config/internal/template');
23
23
  } catch (cause) {
24
24
  throw new HarmonyCliError(
25
25
  'ERR_HARMONY_TEMPLATE_INVALID',
26
- 'Cannot load @expo-harmony/prebuild-config/template from the project. Update the project-local prebuild config to a compatible version.',
26
+ 'Cannot load @expo-harmony/prebuild-config/internal/template from the project. Update the project-local prebuild config to a compatible version.',
27
27
  { cause, operation: 'resolve-template' }
28
28
  );
29
29
  }
package/src/process.ts CHANGED
@@ -28,6 +28,16 @@ export interface ProcessResult {
28
28
  timedOut: boolean;
29
29
  }
30
30
 
31
+ interface CheckedProcessOptions {
32
+ code: string;
33
+ cwd: string;
34
+ env?: NodeJS.ProcessEnv;
35
+ message: string;
36
+ operation: string;
37
+ outputLimit?: number;
38
+ timeoutMs?: number;
39
+ }
40
+
31
41
  class BoundedCapture {
32
42
  private buffers: Uint8Array[] = [];
33
43
  private head = 0;
@@ -346,4 +356,31 @@ function startManagedProcess(command: string, args: string[], options: ProcessOp
346
356
  };
347
357
  }
348
358
 
349
- export { formatDiagnostics, spawnAsync, startManagedProcess };
359
+ async function runCheckedAsync(
360
+ command: string,
361
+ args: string[],
362
+ options: CheckedProcessOptions
363
+ ) {
364
+ const result = await spawnAsync(command, args, {
365
+ capture: true,
366
+ cwd: options.cwd,
367
+ env: options.env,
368
+ operation: options.operation,
369
+ outputLimit: options.outputLimit || 2 * 1024 * 1024,
370
+ timeoutMs: options.timeoutMs,
371
+ });
372
+
373
+ if (result.code !== 0 || result.timedOut) {
374
+ const diagnostics = formatDiagnostics(result);
375
+
376
+ throw new HarmonyCliError(
377
+ options.code,
378
+ `${options.message} exited with code ${result.code}${result.timedOut ? ' after timing out' : ''}.${diagnostics ? `\n${diagnostics}` : ''}`,
379
+ { exitCode: result.code || 1, operation: options.operation }
380
+ );
381
+ }
382
+
383
+ return result;
384
+ }
385
+
386
+ export { formatDiagnostics, runCheckedAsync, spawnAsync, startManagedProcess };
package/src/profile.ts ADDED
@@ -0,0 +1,13 @@
1
+ export async function timedAsync<T>(
2
+ steps: Record<string, number>,
3
+ name: string,
4
+ operation: () => Promise<T> | T
5
+ ): Promise<T> {
6
+ const started = performance.now();
7
+
8
+ try {
9
+ return await operation();
10
+ } finally {
11
+ steps[name] = Math.max(0, Math.round(performance.now() - started));
12
+ }
13
+ }
@@ -162,6 +162,7 @@ async function acquireHarmonyProjectLockAsync(
162
162
  await handle.sync();
163
163
  } catch (cause) {
164
164
  await releaseOwnedLockAsync(handle, lockPath, owned);
165
+
165
166
  throw new HarmonyCliError(
166
167
  'ERR_HARMONY_PROJECT_LOCK',
167
168
  'Cannot initialize the Harmony native operation lock.',
@@ -183,7 +184,14 @@ async function acquireHarmonyProjectLockAsync(
183
184
  },
184
185
  };
185
186
  } catch (cause) {
186
- if (cause instanceof HarmonyCliError) throw cause;
187
+ if (cause instanceof HarmonyCliError) {
188
+ throw new HarmonyCliError(cause.code, cause.message, {
189
+ cause,
190
+ exitCode: cause.exitCode,
191
+ operation: cause.operation,
192
+ });
193
+ }
194
+
187
195
  if (cause?.code !== 'EEXIST') {
188
196
  throw new HarmonyCliError(
189
197
  'ERR_HARMONY_PROJECT_LOCK',
@@ -2,7 +2,7 @@ import { setTimeout as delay } from 'node:timers/promises';
2
2
 
3
3
  import { listEmulatorsAsync, startEmulator } from './emulators';
4
4
  import { HarmonyCliError } from '../errors';
5
- import type { HarmonyTool } from '../tools';
5
+ import { type HarmonyTool } from '../native/toolchain';
6
6
  import { formatDiagnostics, spawnAsync, type ProcessResult } from '../process';
7
7
 
8
8
  interface Device {
@@ -44,8 +44,6 @@ function parseHdcTargets(output: string): Device[] {
44
44
  }
45
45
 
46
46
  return {
47
- // Only the first HDC column is a selectable target name. The remaining
48
- // verbose columns describe transport, state, location and connect tool.
49
47
  aliases: [fields[0]],
50
48
  connectTool: fields[4] || null,
51
49
  id: fields[0],
@@ -59,10 +57,7 @@ function parseHdcTargets(output: string): Device[] {
59
57
  function hasCommandFailure(result: ProcessResult): boolean {
60
58
  const output = `${result.stdout || ''}\n${result.stderr || ''}`;
61
59
  return result.code !== 0 || result.timedOut
62
- // HDC occasionally reports transport and package-manager failures on
63
- // stdout while still returning exit code 0 (for example,
64
- // "Connect server failed"). Treat its documented failure vocabulary as
65
- // authoritative regardless of where it appears on the line.
60
+ // HDC can print failures to stdout while returning exit code 0.
66
61
  || /\[(?:Fail|Error)\]|\b(?:Failure|failed)\b|(?:失败|错误)/iu.test(output);
67
62
  }
68
63
 
@@ -146,7 +141,6 @@ async function selectDeviceAsync(
146
141
  }
147
142
 
148
143
  const instances = await listEmulatorsAsync(options.emulator, { cwd: options.cwd });
149
- // Prefer an instance that is already booting when HDC is not connected yet.
150
144
  const running = instances.filter(instance => instance.running);
151
145
  const candidates = requested
152
146
  ? instances.filter(instance => instance.name === requested)
@@ -242,13 +236,24 @@ async function configureMetroPortAsync(
242
236
  const deviceEndpoint = `tcp:${options.devicePort || 8081}`;
243
237
  const hostEndpoint = `tcp:${port}`;
244
238
 
245
- await runHdcAsync(hdc, ['-t', device.id, 'fport', 'rm', deviceEndpoint, hostEndpoint], {
246
- allowFailure: true,
239
+ const forwards = await runHdcAsync(hdc, ['-t', device.id, 'fport', 'ls'], {
247
240
  cwd: options.cwd,
248
- operation: 'remove-metro-port',
241
+ operation: 'list-metro-ports',
249
242
  timeoutMs: 15_000,
250
243
  });
251
244
 
245
+ for (const line of forwards.stdout.split(/\r?\n/u)) {
246
+ const [target, remote, local, direction] = line.trim().split(/\s+/u);
247
+ if (target !== device.id || remote !== deviceEndpoint || direction !== '[Reverse]') continue;
248
+ if (local === hostEndpoint) return;
249
+
250
+ await runHdcAsync(hdc, ['-t', device.id, 'fport', 'rm', remote, local], {
251
+ cwd: options.cwd,
252
+ operation: 'remove-metro-port',
253
+ timeoutMs: 15_000,
254
+ });
255
+ }
256
+
252
257
  await runHdcAsync(hdc, ['-t', device.id, 'rport', deviceEndpoint, hostEndpoint], {
253
258
  code: 'ERR_HARMONY_METRO_FORWARD',
254
259
  cwd: options.cwd,
@@ -4,7 +4,7 @@ import spawn from 'cross-spawn';
4
4
 
5
5
  import { HarmonyCliError } from '../errors';
6
6
  import { formatDiagnostics, spawnAsync } from '../process';
7
- import type { HarmonyTool } from '../tools';
7
+ import { type HarmonyTool } from '../native/toolchain';
8
8
 
9
9
  interface HarmonyEmulator {
10
10
  name: string;
@@ -67,10 +67,9 @@ function startEmulator(tool: HarmonyTool, name: string, logFile: string, cwd?: s
67
67
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
68
68
  const log = fs.openSync(logFile, 'w', 0o600);
69
69
  let failure: string | null = null;
70
+
70
71
  try {
71
- // Emulator may remain alive for the whole GUI session. Give it its own
72
- // process group and file-backed output so it survives the CLI/Metro exit.
73
- // Default instance/image paths match the ones used by -list -details.
72
+ // A detached process with file output lets the emulator survive CLI exit.
74
73
  const child = spawn(tool.command, [...tool.args, '-start', name], {
75
74
  cwd,
76
75
  detached: true,
@@ -82,8 +81,7 @@ function startEmulator(tool: HarmonyTool, name: string, logFile: string, cwd?: s
82
81
  failure = cause.message;
83
82
  });
84
83
  child.once('exit', (code, signal) => {
85
- // Some versions use a short-lived launcher. A zero exit alone does not
86
- // prove readiness; the caller still checks the guest's name and boot state.
84
+ // Some launchers exit before the guest boots; HDC still has to confirm readiness.
87
85
  if (code !== 0) failure = signal ? `terminated by ${signal}` : `exited with code ${code}`;
88
86
  });
89
87
  child.unref();
@@ -0,0 +1,25 @@
1
+ import { parseCommandArgs, type Command } from '../command';
2
+ import { parseRunArgs } from './options';
3
+
4
+ export const command: Command = async (argv, io) => {
5
+ const args = parseCommandArgs(parseRunArgs, argv, io);
6
+ if (!args) return 0;
7
+
8
+ const { projectRoot: root, options } = args;
9
+ const { runHarmonySessionAsync } = await import('./run.js');
10
+
11
+ const session = await runHarmonySessionAsync(root, {
12
+ ...options,
13
+ interactiveBundler: true,
14
+ io,
15
+ });
16
+ const { result } = session;
17
+
18
+ io.log(`Launched ${result.bundleName} on ${result.device.id} (${result.variant}).`);
19
+ if (session.metro.owner === 'started') {
20
+ io.log('\n› Logs for your project will appear below. Press Ctrl+C to exit.');
21
+ await session.metro.waitAsync();
22
+ }
23
+
24
+ return 0;
25
+ };