@expo-harmony/cli 55.0.26-harmony.1

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.
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ HarmonyNativeInputsFingerprintVersion,
6
+ fingerprintHarmonyNativeInputsSync,
7
+ } from '@expo-harmony/config-plugins/native-inputs';
8
+
9
+ import { HarmonyCliError } from '../errors';
10
+ import type { HarmonyBuildPlan } from '../tools';
11
+
12
+ export interface HarmonyNativeBuildCacheState {
13
+ artifactCount: number;
14
+ cacheFile: string;
15
+ changed: boolean;
16
+ fingerprint: string;
17
+ fingerprintVersion: number;
18
+ }
19
+
20
+ const CacheSchemaVersion = 1;
21
+
22
+ async function readOptionalFile(file) {
23
+ try {
24
+ return await fs.promises.readFile(file);
25
+ } catch (error) {
26
+ if (error?.code === 'ENOENT') return null;
27
+ throw new HarmonyCliError(
28
+ error.code || 'ERR_HARMONY_NATIVE_CACHE',
29
+ error.message || `Cannot read a Harmony native cache input: ${file}`,
30
+ { cause: error, exitCode: error.exitCode, operation: error.operation }
31
+ );
32
+ }
33
+ }
34
+
35
+ async function resolveNativeDependencyFingerprintAsync(
36
+ projectRoot: string,
37
+ plan: HarmonyBuildPlan
38
+ ) {
39
+ try {
40
+ return fingerprintHarmonyNativeInputsSync({
41
+ lockfile: plan.nativeInputs.lockfile,
42
+ manifest: plan.nativeInputs.manifest,
43
+ projectRoot,
44
+ });
45
+ } catch (cause) {
46
+ throw new HarmonyCliError(
47
+ 'ERR_HARMONY_NATIVE_CACHE',
48
+ `Cannot fingerprint generated Harmony native dependencies: ${cause.message}`,
49
+ { cause, operation: 'native-cache' }
50
+ );
51
+ }
52
+ }
53
+
54
+ async function prepareHarmonyNativeBuildCacheAsync(
55
+ projectRoot: string,
56
+ plan: HarmonyBuildPlan
57
+ ): Promise<HarmonyNativeBuildCacheState> {
58
+ const current = await resolveNativeDependencyFingerprintAsync(projectRoot, plan);
59
+ const file = plan.nativeCache.stateFile;
60
+ const source = await readOptionalFile(file);
61
+ let saved = null;
62
+
63
+ if (source) {
64
+ try {
65
+ saved = JSON.parse(source.toString('utf8'));
66
+ } catch {
67
+ saved = null;
68
+ }
69
+ }
70
+
71
+ const changed = saved?.schemaVersion !== CacheSchemaVersion
72
+ || saved?.fingerprintVersion !== HarmonyNativeInputsFingerprintVersion
73
+ || saved?.fingerprint !== current.fingerprint;
74
+
75
+ if (changed) {
76
+ for (const root of plan.nativeCache.invalidationRoots) {
77
+ await fs.promises.rm(root, { force: true, recursive: true });
78
+ }
79
+ }
80
+
81
+ return {
82
+ ...current,
83
+ cacheFile: file,
84
+ changed,
85
+ };
86
+ }
87
+
88
+ async function commitHarmonyNativeBuildCacheAsync(state: HarmonyNativeBuildCacheState): Promise<void> {
89
+ const root = path.dirname(state.cacheFile);
90
+ const temp = `${state.cacheFile}.${process.pid}.tmp`;
91
+
92
+ await fs.promises.mkdir(root, { recursive: true });
93
+ await fs.promises.writeFile(temp, `${JSON.stringify({
94
+ artifactCount: state.artifactCount,
95
+ fingerprint: state.fingerprint,
96
+ fingerprintVersion: state.fingerprintVersion,
97
+ schemaVersion: CacheSchemaVersion,
98
+ }, null, 2)}\n`);
99
+ await fs.promises.rename(temp, state.cacheFile);
100
+ }
101
+
102
+ export {
103
+ commitHarmonyNativeBuildCacheAsync,
104
+ prepareHarmonyNativeBuildCacheAsync,
105
+ };
@@ -0,0 +1,302 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+
3
+ import { listEmulatorsAsync, startEmulator } from './emulators';
4
+ import { HarmonyCliError } from '../errors';
5
+ import type { HarmonyTool } from '../tools';
6
+ import { formatDiagnostics, spawnAsync, type ProcessResult } from '../process';
7
+
8
+ interface Device {
9
+ aliases: string[];
10
+ connectTool: string | null;
11
+ id: string;
12
+ location: string | null;
13
+ state: string;
14
+ transport: string;
15
+ }
16
+
17
+ interface HdcOptions {
18
+ allowFailure?: boolean;
19
+ code?: string;
20
+ cwd?: string;
21
+ devicePort?: number;
22
+ message?: string;
23
+ operation?: string;
24
+ outputLimit?: number;
25
+ timeoutMs?: number;
26
+ }
27
+
28
+ interface DeviceSelectionOptions extends HdcOptions {
29
+ emulator?: HarmonyTool;
30
+ emulatorLogFile?: string;
31
+ onProgress?: (message: string) => void;
32
+ }
33
+
34
+ function parseHdcTargets(output: string): Device[] {
35
+ const trimmed = String(output).trim();
36
+
37
+ if (!trimmed || trimmed === '[Empty]') return [];
38
+
39
+ return trimmed.split(/\r?\n/u).filter(Boolean).map((line) => {
40
+ const fields = line.trim().split(/\s+/u);
41
+
42
+ if (fields.length < 3 || !fields[0]) {
43
+ throw new HarmonyCliError('ERR_HARMONY_DEVICE_OUTPUT', 'HDC returned an unsupported target-list format.', { operation: 'list-devices' });
44
+ }
45
+
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
+ aliases: [fields[0]],
50
+ connectTool: fields[4] || null,
51
+ id: fields[0],
52
+ location: fields[3] || null,
53
+ state: fields[2],
54
+ transport: fields[1],
55
+ };
56
+ });
57
+ }
58
+
59
+ function hasCommandFailure(result: ProcessResult): boolean {
60
+ const output = `${result.stdout || ''}\n${result.stderr || ''}`;
61
+ 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.
66
+ || /\[(?:Fail|Error)\]|\b(?:Failure|failed)\b|(?:失败|错误)/iu.test(output);
67
+ }
68
+
69
+ async function runHdcAsync(
70
+ hdc: HarmonyTool,
71
+ args: string[],
72
+ options: HdcOptions = {}
73
+ ): Promise<ProcessResult> {
74
+ const result = await spawnAsync(hdc.command, [
75
+ ...hdc.args,
76
+ ...args,
77
+ ], {
78
+ capture: true,
79
+ cwd: options.cwd,
80
+ operation: options.operation || 'hdc',
81
+ outputLimit: options.outputLimit || 256 * 1024,
82
+ timeoutMs: options.timeoutMs || 60_000,
83
+ });
84
+
85
+ if (!options.allowFailure && hasCommandFailure(result)) {
86
+ const diagnostics = formatDiagnostics(result, 2_000);
87
+ throw new HarmonyCliError(
88
+ options.code || 'ERR_HARMONY_DEVICE_COMMAND',
89
+ `${options.message || 'HDC command failed'}${diagnostics ? `: ${diagnostics}` : '.'}`,
90
+ { exitCode: result.code || 1, operation: options.operation || 'hdc' }
91
+ );
92
+ }
93
+
94
+ return result;
95
+ }
96
+
97
+ async function listConnectedDevicesAsync(hdc: HarmonyTool, options: HdcOptions): Promise<Device[]> {
98
+ const result = await runHdcAsync(hdc, ['list', 'targets', '-v'], {
99
+ code: 'ERR_HARMONY_DEVICE_LIST',
100
+ cwd: options.cwd,
101
+ message: 'Cannot list Harmony devices',
102
+ operation: 'list-devices',
103
+ timeoutMs: options.timeoutMs || 15_000,
104
+ });
105
+ const targets = parseHdcTargets(result.stdout || result.stderr);
106
+ return targets.filter(target => target.state.toLowerCase() === 'connected');
107
+ }
108
+
109
+ async function selectDeviceAsync(
110
+ hdc: HarmonyTool,
111
+ requested?: string,
112
+ options: DeviceSelectionOptions = {}
113
+ ): Promise<Device> {
114
+ if (requested !== undefined && (!requested || /[\0\r\n]/u.test(requested) || requested.trim() !== requested)) {
115
+ throw new HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', '--device must be a non-empty HDC id or exact emulator name.', { operation: 'select-device' });
116
+ }
117
+
118
+ const connected = await listConnectedDevicesAsync(hdc, { cwd: options.cwd });
119
+
120
+ if (requested) {
121
+ const matches = connected.filter(target => target.id === requested || target.aliases.includes(requested));
122
+
123
+ if (matches.length === 1) return matches[0];
124
+
125
+ if (matches.length > 1) {
126
+ throw new HarmonyCliError('ERR_HARMONY_DEVICE_AMBIGUOUS', `The requested device name matches multiple connected targets: ${requested}.`, { operation: 'select-device' });
127
+ }
128
+
129
+ if (!options.emulator) {
130
+ throw new HarmonyCliError('ERR_HARMONY_DEVICE_NOT_FOUND', `The requested Harmony device is not connected: ${requested}.`, { operation: 'select-device' });
131
+ }
132
+ } else {
133
+ if (connected.length === 1) return connected[0];
134
+
135
+ if (connected.length > 1) {
136
+ throw new HarmonyCliError(
137
+ 'ERR_HARMONY_DEVICE_AMBIGUOUS',
138
+ `Multiple Harmony devices are connected (${connected.map(target => target.id).join(', ')}); use --device.`,
139
+ { operation: 'select-device' }
140
+ );
141
+ }
142
+ }
143
+
144
+ if (!options.emulator) {
145
+ throw new HarmonyCliError('ERR_HARMONY_DEVICE_NOT_FOUND', 'No connected Harmony device was reported by HDC.', { operation: 'select-device' });
146
+ }
147
+
148
+ const instances = await listEmulatorsAsync(options.emulator, { cwd: options.cwd });
149
+ // Prefer an instance that is already booting when HDC is not connected yet.
150
+ const running = instances.filter(instance => instance.running);
151
+ const candidates = requested
152
+ ? instances.filter(instance => instance.name === requested)
153
+ : running.length ? running : instances;
154
+ if (candidates.length === 0) {
155
+ throw new HarmonyCliError(
156
+ 'ERR_HARMONY_DEVICE_NOT_FOUND',
157
+ requested
158
+ ? `No connected HDC target or local emulator matches ${requested}. Available emulators: ${instances.map(instance => instance.name).join(', ') || 'none'}.`
159
+ : 'No connected Harmony device or local emulator was found. Create an emulator in DevEco Studio Device Manager first.',
160
+ { operation: 'select-device' }
161
+ );
162
+ }
163
+ if (candidates.length > 1) {
164
+ throw new HarmonyCliError(
165
+ 'ERR_HARMONY_DEVICE_AMBIGUOUS',
166
+ `Multiple Harmony emulators are available (${candidates.map(instance => JSON.stringify(instance.name)).join(', ')}); use --device <emulator-name>.`,
167
+ { operation: 'select-device' }
168
+ );
169
+ }
170
+
171
+ const instance = candidates[0];
172
+ let launch: ReturnType<typeof startEmulator> | undefined;
173
+ if (!instance.running) {
174
+ if (!options.emulatorLogFile) {
175
+ throw new HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', 'An emulator log file is required to start a Harmony emulator.', { operation: 'start-emulator' });
176
+ }
177
+ options.onProgress?.(`Starting Harmony emulator ${instance.name}`);
178
+ launch = startEmulator(options.emulator, instance.name, options.emulatorLogFile, options.cwd);
179
+ }
180
+
181
+ options.onProgress?.(`Waiting for Harmony emulator ${instance.name} to connect`);
182
+ const deadline = Date.now() + (options.timeoutMs || 120_000);
183
+ while (Date.now() < deadline) {
184
+ launch?.assertRunning();
185
+ const devices = await listConnectedDevicesAsync(hdc, {
186
+ cwd: options.cwd,
187
+ timeoutMs: Math.max(1, Math.min(15_000, deadline - Date.now())),
188
+ });
189
+ for (const device of devices) {
190
+ if (Date.now() >= deadline) break;
191
+ // DevEco may report hw.hdc.port as "notset" even while running. Query
192
+ // the guest's instance name instead of guessing from a newly seen ID.
193
+ if (!/^(?:127\.0\.0\.1|localhost|\[::1\]):\d+$/u.test(device.id)) continue;
194
+ const identity = await runHdcAsync(hdc, ['-t', device.id, 'shell', 'param', 'get', 'ohos.qemu.hvd.name'], {
195
+ allowFailure: true,
196
+ cwd: options.cwd,
197
+ operation: 'identify-emulator',
198
+ timeoutMs: Math.max(1, Math.min(5_000, deadline - Date.now())),
199
+ });
200
+ if (identity.code !== 0 || identity.timedOut || identity.stdout.trim() !== instance.name) continue;
201
+ if (Date.now() >= deadline) break;
202
+ const boot = await runHdcAsync(hdc, ['-t', device.id, 'shell', 'param', 'get', 'bootevent.boot.completed'], {
203
+ allowFailure: true,
204
+ cwd: options.cwd,
205
+ operation: 'wait-emulator-boot',
206
+ timeoutMs: Math.max(1, Math.min(5_000, deadline - Date.now())),
207
+ });
208
+ if (!hasCommandFailure(boot) && boot.stdout.trim() === 'true') return device;
209
+ }
210
+ if (Date.now() >= deadline) break;
211
+ await delay(Math.min(1_000, deadline - Date.now()));
212
+ }
213
+
214
+ throw new HarmonyCliError(
215
+ 'ERR_HARMONY_EMULATOR_TIMEOUT',
216
+ `Timed out waiting for Harmony emulator ${instance.name} to connect to HDC.${launch ? ` See ${options.emulatorLogFile}.` : ''} Try starting it in DevEco Studio to resolve any license or login requirements.`,
217
+ { operation: 'start-emulator' }
218
+ );
219
+ }
220
+
221
+ async function installHapAsync(
222
+ hdc: HarmonyTool,
223
+ device: Device,
224
+ hap: string,
225
+ options: HdcOptions = {}
226
+ ): Promise<void> {
227
+ await runHdcAsync(hdc, ['-t', device.id, 'install', '-r', hap], {
228
+ code: 'ERR_HARMONY_INSTALL_FAILED',
229
+ cwd: options.cwd,
230
+ message: `Cannot install the Harmony HAP on ${device.id}`,
231
+ operation: 'install-hap',
232
+ timeoutMs: options.timeoutMs || 2 * 60_000,
233
+ });
234
+ }
235
+
236
+ async function configureMetroPortAsync(
237
+ hdc: HarmonyTool,
238
+ device: Device,
239
+ port: number,
240
+ options: HdcOptions = {}
241
+ ): Promise<void> {
242
+ const deviceEndpoint = `tcp:${options.devicePort || 8081}`;
243
+ const hostEndpoint = `tcp:${port}`;
244
+
245
+ await runHdcAsync(hdc, ['-t', device.id, 'fport', 'rm', deviceEndpoint, hostEndpoint], {
246
+ allowFailure: true,
247
+ cwd: options.cwd,
248
+ operation: 'remove-metro-port',
249
+ timeoutMs: 15_000,
250
+ });
251
+
252
+ await runHdcAsync(hdc, ['-t', device.id, 'rport', deviceEndpoint, hostEndpoint], {
253
+ code: 'ERR_HARMONY_METRO_FORWARD',
254
+ cwd: options.cwd,
255
+ message: `Cannot reverse Metro port ${port} for ${device.id}`,
256
+ operation: 'reverse-metro-port',
257
+ timeoutMs: 15_000,
258
+ });
259
+ }
260
+
261
+ async function launchAppAsync(
262
+ hdc: HarmonyTool,
263
+ device: Device,
264
+ bundleName: string,
265
+ abilityName: string,
266
+ options: HdcOptions = {}
267
+ ): Promise<void> {
268
+ await runHdcAsync(hdc, [
269
+ '-t', device.id,
270
+ 'shell',
271
+ 'aa',
272
+ 'force-stop',
273
+ bundleName,
274
+ ], {
275
+ allowFailure: true,
276
+ cwd: options.cwd,
277
+ operation: 'force-stop-app',
278
+ timeoutMs: 15_000,
279
+ });
280
+
281
+ await runHdcAsync(hdc, [
282
+ '-t', device.id,
283
+ 'shell',
284
+ 'aa',
285
+ 'start',
286
+ '-a', abilityName,
287
+ '-b', bundleName,
288
+ ], {
289
+ code: 'ERR_HARMONY_LAUNCH_FAILED',
290
+ cwd: options.cwd,
291
+ message: `Cannot launch ${bundleName} on ${device.id}`,
292
+ operation: 'launch-app',
293
+ timeoutMs: 30_000,
294
+ });
295
+ }
296
+
297
+ export {
298
+ configureMetroPortAsync,
299
+ installHapAsync,
300
+ launchAppAsync,
301
+ selectDeviceAsync,
302
+ };
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import spawn from 'cross-spawn';
4
+
5
+ import { HarmonyCliError } from '../errors';
6
+ import { formatDiagnostics, spawnAsync } from '../process';
7
+ import type { HarmonyTool } from '../tools';
8
+
9
+ interface HarmonyEmulator {
10
+ name: string;
11
+ running: boolean;
12
+ }
13
+
14
+ async function listEmulatorsAsync(
15
+ tool: HarmonyTool,
16
+ options: { cwd?: string; timeoutMs?: number } = {}
17
+ ): Promise<HarmonyEmulator[]> {
18
+ let result;
19
+ try {
20
+ result = await spawnAsync(tool.command, [...tool.args, '-list', '-details'], {
21
+ capture: true,
22
+ cwd: options.cwd,
23
+ operation: 'list-emulators',
24
+ timeoutMs: options.timeoutMs || 15_000,
25
+ });
26
+ } catch (cause) {
27
+ throw new HarmonyCliError(
28
+ 'ERR_HARMONY_EMULATOR_LIST',
29
+ 'Cannot run Emulator. Install DevEco Studio 6.1.0 or newer, add tools/emulator to PATH, or set HARMONY_EMULATOR.',
30
+ { cause, operation: 'list-emulators' }
31
+ );
32
+ }
33
+
34
+ if (result.code !== 0 || result.timedOut) {
35
+ throw new HarmonyCliError(
36
+ 'ERR_HARMONY_EMULATOR_LIST',
37
+ `Cannot list Harmony emulators: ${formatDiagnostics(result, 2_000) || 'Emulator timed out or exited unsuccessfully.'}`,
38
+ { operation: 'list-emulators' }
39
+ );
40
+ }
41
+
42
+ try {
43
+ const instances: unknown = JSON.parse(result.stdout);
44
+ if (!Array.isArray(instances)) throw new Error('Expected an array.');
45
+
46
+ return instances.map((instance) => {
47
+ if (!instance || typeof instance.name !== 'string' || !instance.name.trim()
48
+ || /[\0\r\n]/u.test(instance.name)
49
+ || ![true, false, 'true', 'false'].includes(instance.isRunning)) {
50
+ throw new Error('Invalid emulator instance.');
51
+ }
52
+ return {
53
+ name: instance.name,
54
+ running: instance.isRunning === true || instance.isRunning === 'true',
55
+ };
56
+ });
57
+ } catch (cause) {
58
+ throw new HarmonyCliError(
59
+ 'ERR_HARMONY_EMULATOR_OUTPUT',
60
+ 'Emulator returned an unsupported instance list. Use DevEco Studio 6.1.0 or newer with Emulator -list -details support.',
61
+ { cause, operation: 'list-emulators' }
62
+ );
63
+ }
64
+ }
65
+
66
+ function startEmulator(tool: HarmonyTool, name: string, logFile: string, cwd?: string) {
67
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
68
+ const log = fs.openSync(logFile, 'w', 0o600);
69
+ let failure: string | null = null;
70
+ 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.
74
+ const child = spawn(tool.command, [...tool.args, '-start', name], {
75
+ cwd,
76
+ detached: true,
77
+ shell: false,
78
+ stdio: ['ignore', log, log],
79
+ windowsHide: false,
80
+ });
81
+ child.once('error', (cause) => {
82
+ failure = cause.message;
83
+ });
84
+ 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.
87
+ if (code !== 0) failure = signal ? `terminated by ${signal}` : `exited with code ${code}`;
88
+ });
89
+ child.unref();
90
+ } finally {
91
+ fs.closeSync(log);
92
+ }
93
+
94
+ return {
95
+ assertRunning() {
96
+ if (failure) {
97
+ throw new HarmonyCliError(
98
+ 'ERR_HARMONY_EMULATOR_START',
99
+ `Cannot start Harmony emulator ${name}: ${failure}. See ${logFile}. Try starting it in DevEco Studio to resolve any license or login requirements.`,
100
+ { operation: 'start-emulator' }
101
+ );
102
+ }
103
+ },
104
+ };
105
+ }
106
+
107
+ export { listEmulatorsAsync, startEmulator };
@@ -0,0 +1,41 @@
1
+ import { HarmonyCliError } from '../errors';
2
+ import type { HarmonyBuildPlan, HarmonyToolchain } from '../tools';
3
+ import { formatDiagnostics, spawnAsync, type ProcessResult } from '../process';
4
+
5
+ interface InstallOptions {
6
+ timeoutMs?: number;
7
+ }
8
+
9
+ function installMessage(result: ProcessResult): string {
10
+ const diagnostics = formatDiagnostics(result);
11
+ return `OHPM install exited with code ${result.code}${result.timedOut ? ' after timing out' : ''}.`
12
+ + `${diagnostics ? `\n${diagnostics}` : ''}`;
13
+ }
14
+
15
+ /** Runs OHPM against the generated project without rewriting its manifest. */
16
+ async function installHarmonyDependenciesAsync(
17
+ plan: HarmonyBuildPlan,
18
+ toolchain: HarmonyToolchain,
19
+ options: InstallOptions = {}
20
+ ): Promise<void> {
21
+ const result = await spawnAsync(
22
+ toolchain.ohpm.command,
23
+ [...toolchain.ohpm.args, 'install', '--all'],
24
+ {
25
+ capture: true,
26
+ cwd: plan.harmonyRoot,
27
+ operation: 'ohpm-install',
28
+ outputLimit: 2 * 1024 * 1024,
29
+ timeoutMs: options.timeoutMs || 5 * 60_000,
30
+ }
31
+ );
32
+
33
+ if (result.code !== 0 || result.timedOut) {
34
+ throw new HarmonyCliError('ERR_HARMONY_OHPM_FAILED', installMessage(result), {
35
+ exitCode: result.code || 1,
36
+ operation: 'ohpm-install',
37
+ });
38
+ }
39
+ }
40
+
41
+ export { installHarmonyDependenciesAsync };