@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,119 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { HarmonyCliError } from '../errors';
7
+ import { spawnAsync } from '../process';
8
+
9
+ interface Api {
10
+ ROOT_ENV: string;
11
+ resolveBundled(): {
12
+ root: string;
13
+ version: string;
14
+ };
15
+ }
16
+
17
+ function resolve(project: string) {
18
+ const load = createRequire(path.join(project, 'package.json'));
19
+ let api: Api;
20
+
21
+ try {
22
+ api = load('@expo-harmony/prebuild-config/template');
23
+ } catch (cause) {
24
+ throw new HarmonyCliError(
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.',
27
+ { cause, operation: 'resolve-template' }
28
+ );
29
+ }
30
+
31
+ if (typeof api?.ROOT_ENV !== 'string' || typeof api.resolveBundled !== 'function') {
32
+ throw new HarmonyCliError(
33
+ 'ERR_HARMONY_TEMPLATE_INVALID',
34
+ 'The project-local @expo-harmony/prebuild-config template API is invalid.',
35
+ { operation: 'resolve-template' }
36
+ );
37
+ }
38
+
39
+ let template: ReturnType<Api['resolveBundled']>;
40
+ try {
41
+ template = api.resolveBundled();
42
+ } catch (cause) {
43
+ throw new HarmonyCliError(
44
+ 'ERR_HARMONY_TEMPLATE_INVALID',
45
+ 'Cannot resolve the Harmony template declared by the project-local prebuild config.',
46
+ { cause, operation: 'resolve-template' }
47
+ );
48
+ }
49
+
50
+ if (!template
51
+ || typeof template.root !== 'string'
52
+ || !path.isAbsolute(template.root)
53
+ || typeof template.version !== 'string'
54
+ || !template.version) {
55
+ throw new HarmonyCliError(
56
+ 'ERR_HARMONY_TEMPLATE_INVALID',
57
+ 'The project-local @expo-harmony/prebuild-config returned an invalid template descriptor.',
58
+ { operation: 'resolve-template' }
59
+ );
60
+ }
61
+
62
+ return {
63
+ env: { [api.ROOT_ENV]: template.root },
64
+ root: template.root,
65
+ };
66
+ }
67
+
68
+ async function packAsync(project: string) {
69
+ const template = resolve(project);
70
+ const temp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'expo-harmony-template-'));
71
+ const result = await spawnAsync('npm', [
72
+ 'pack',
73
+ '--json',
74
+ '--ignore-scripts',
75
+ '--cache', path.join(temp, '.npm-cache'),
76
+ '--pack-destination', temp,
77
+ template.root,
78
+ ], { capture: true, cwd: project, operation: 'pack-template' });
79
+
80
+ if (result.code !== 0) {
81
+ await fs.promises.rm(temp, { recursive: true, force: true });
82
+ throw new HarmonyCliError('ERR_HARMONY_TEMPLATE_INVALID', `npm pack failed: ${result.stderr.trim()}`, {
83
+ exitCode: result.code,
84
+ operation: 'pack-template',
85
+ });
86
+ }
87
+
88
+ let packs;
89
+
90
+ try {
91
+ packs = JSON.parse(result.stdout);
92
+ } catch (cause) {
93
+ await fs.promises.rm(temp, { recursive: true, force: true });
94
+ throw new HarmonyCliError('ERR_HARMONY_TEMPLATE_INVALID', 'npm pack returned invalid JSON.', {
95
+ cause,
96
+ operation: 'pack-template',
97
+ });
98
+ }
99
+
100
+ const filename = packs?.[0]?.filename;
101
+ const tarball = filename && path.join(temp, filename);
102
+
103
+ if (!tarball || !fs.existsSync(tarball)) {
104
+ await fs.promises.rm(temp, { recursive: true, force: true });
105
+ throw new HarmonyCliError('ERR_HARMONY_TEMPLATE_INVALID', 'npm pack did not create a template tarball.', { operation: 'pack-template' });
106
+ }
107
+
108
+ return {
109
+ tarball,
110
+ env: template.env,
111
+ async cleanup() {
112
+ await fs.promises.rm(temp, { recursive: true, force: true });
113
+ },
114
+ };
115
+ }
116
+
117
+ export {
118
+ packAsync,
119
+ };
package/src/process.ts ADDED
@@ -0,0 +1,275 @@
1
+ import spawn from 'cross-spawn';
2
+
3
+ import { HarmonyCliError } from './errors';
4
+
5
+ const DefaultOutputLimit = 1024 * 1024;
6
+ const DefaultStopGraceMs = 3_000;
7
+
8
+ export interface ProcessOptions {
9
+ capture?: boolean;
10
+ cwd?: string;
11
+ env?: NodeJS.ProcessEnv;
12
+ onStderr?: (chunk: Uint8Array) => void;
13
+ onStdout?: (chunk: Uint8Array) => void;
14
+ operation?: string;
15
+ outputLimit?: number;
16
+ signal?: AbortSignal;
17
+ stdio?: 'inherit' | 'pipe';
18
+ stopGraceMs?: number;
19
+ timeoutMs?: number;
20
+ }
21
+
22
+ export interface ProcessResult {
23
+ code: number;
24
+ signal: NodeJS.Signals | null;
25
+ stderr: string;
26
+ stdout: string;
27
+ timedOut: boolean;
28
+ }
29
+
30
+ class BoundedCapture {
31
+ private buffers: Uint8Array[] = [];
32
+ private head = 0;
33
+ private length = 0;
34
+
35
+ constructor(private readonly limit: number) {
36
+ }
37
+
38
+ append(value: Uint8Array | string): void {
39
+ const buffer = typeof value === 'string' ? new TextEncoder().encode(value) : Uint8Array.from(value);
40
+ if (buffer.length >= this.limit) {
41
+ this.buffers = [buffer.subarray(buffer.length - this.limit)];
42
+ this.head = 0;
43
+ this.length = this.limit;
44
+ return;
45
+ }
46
+
47
+ while (this.length + buffer.length > this.limit && this.head < this.buffers.length) {
48
+ const first = this.buffers[this.head];
49
+ const excess = this.length + buffer.length - this.limit;
50
+ if (first.length > excess) {
51
+ this.buffers[this.head] = first.subarray(excess);
52
+ this.length -= excess;
53
+ break;
54
+ }
55
+ this.length -= first.length;
56
+ this.head += 1;
57
+ }
58
+
59
+ this.buffers.push(buffer);
60
+ this.length += buffer.length;
61
+
62
+ if (this.head > 128 && this.head * 2 > this.buffers.length) {
63
+ this.buffers = this.buffers.slice(this.head);
64
+ this.head = 0;
65
+ }
66
+ }
67
+
68
+ toString(): string {
69
+ return Buffer.concat(this.buffers.slice(this.head), this.length).toString('utf8');
70
+ }
71
+ }
72
+
73
+ function formatDiagnostics(result: Pick<ProcessResult, 'stderr' | 'stdout'>, limit = 4_000): string {
74
+ return (result.stderr || result.stdout || '').slice(-limit).trim();
75
+ }
76
+
77
+ function spawnAsync(command: string, args: string[], options: ProcessOptions = {}): Promise<ProcessResult> {
78
+ return new Promise<ProcessResult>((resolve, reject) => {
79
+ const piped = Boolean(options.capture || options.onStdout || options.onStderr);
80
+ const outputLimit = options.outputLimit || DefaultOutputLimit;
81
+ const child = spawn(command, args, {
82
+ cwd: options.cwd,
83
+ env: options.env || process.env,
84
+ shell: false,
85
+ stdio: piped ? ['ignore', 'pipe', 'pipe'] : 'inherit',
86
+ windowsHide: true,
87
+ });
88
+
89
+ const stdout = new BoundedCapture(outputLimit);
90
+ const stderr = new BoundedCapture(outputLimit);
91
+ let timedOut = false;
92
+ let settled = false;
93
+ let forceKillTimer: NodeJS.Timeout | null = null;
94
+
95
+ if (piped) {
96
+ child.stdout.on('data', (chunk) => {
97
+ stdout.append(chunk);
98
+ options.onStdout?.(chunk);
99
+ });
100
+ child.stderr.on('data', (chunk) => {
101
+ stderr.append(chunk);
102
+ options.onStderr?.(chunk);
103
+ });
104
+ }
105
+
106
+ const stopChild = (signal: NodeJS.Signals = 'SIGTERM') => {
107
+ child.kill(signal);
108
+ if (forceKillTimer === null) {
109
+ forceKillTimer = setTimeout(
110
+ () => child.kill('SIGKILL'),
111
+ options.stopGraceMs || DefaultStopGraceMs
112
+ );
113
+ forceKillTimer.unref?.();
114
+ }
115
+ };
116
+
117
+ const forwardSigint = () => stopChild('SIGINT');
118
+ const forwardSigterm = () => stopChild('SIGTERM');
119
+ process.once('SIGINT', forwardSigint);
120
+ process.once('SIGTERM', forwardSigterm);
121
+
122
+ const timeout = options.timeoutMs
123
+ ? setTimeout(() => {
124
+ timedOut = true;
125
+ stopChild('SIGTERM');
126
+ }, options.timeoutMs)
127
+ : null;
128
+ timeout?.unref?.();
129
+
130
+ const cleanup = () => {
131
+ if (timeout) clearTimeout(timeout);
132
+ if (forceKillTimer) clearTimeout(forceKillTimer);
133
+ process.removeListener('SIGINT', forwardSigint);
134
+ process.removeListener('SIGTERM', forwardSigterm);
135
+ options.signal?.removeEventListener('abort', abort);
136
+ };
137
+
138
+ const abort = () => stopChild('SIGTERM');
139
+ if (options.signal?.aborted) abort();
140
+ else options.signal?.addEventListener('abort', abort, { once: true });
141
+
142
+ child.once('error', (cause) => {
143
+ if (settled) return;
144
+ settled = true;
145
+ cleanup();
146
+
147
+ reject(new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
148
+ cause,
149
+ operation: options.operation || 'spawn',
150
+ }));
151
+ });
152
+ // `close` runs after stdout/stderr have closed, so captured diagnostics are
153
+ // complete. `exit` can fire while pipe data is still pending.
154
+ child.once('close', (code, signal) => {
155
+ if (settled) return;
156
+ settled = true;
157
+ cleanup();
158
+
159
+ resolve({
160
+ code: code === null ? 1 : code,
161
+ signal,
162
+ stderr: stderr.toString(),
163
+ stdout: stdout.toString(),
164
+ timedOut,
165
+ });
166
+ });
167
+ });
168
+ }
169
+
170
+ function startManagedProcess(command: string, args: string[], options: ProcessOptions = {}) {
171
+ const outputLimit = options.outputLimit || DefaultOutputLimit;
172
+ const piped = options.stdio !== 'inherit';
173
+ const child = spawn(command, args, {
174
+ cwd: options.cwd,
175
+ env: options.env || process.env,
176
+ shell: false,
177
+ stdio: piped ? ['ignore', 'pipe', 'pipe'] : 'inherit',
178
+ windowsHide: true,
179
+ });
180
+
181
+ const stdout = new BoundedCapture(outputLimit);
182
+ const stderr = new BoundedCapture(outputLimit);
183
+ let spawnError: HarmonyCliError | null = null;
184
+ let closed = false;
185
+ let stopRequested = false;
186
+
187
+ if (piped) {
188
+ child.stdout.on('data', (chunk) => {
189
+ stdout.append(chunk);
190
+ options.onStdout?.(chunk);
191
+ });
192
+ child.stderr.on('data', (chunk) => {
193
+ stderr.append(chunk);
194
+ options.onStderr?.(chunk);
195
+ });
196
+ }
197
+
198
+ const forwardSigint = () => {
199
+ void stop('SIGINT');
200
+ };
201
+ const forwardSigterm = () => {
202
+ void stop('SIGTERM');
203
+ };
204
+ const abort = () => {
205
+ void stop('SIGTERM');
206
+ };
207
+
208
+ const cleanup = () => {
209
+ process.removeListener('SIGINT', forwardSigint);
210
+ process.removeListener('SIGTERM', forwardSigterm);
211
+ options.signal?.removeEventListener('abort', abort);
212
+ };
213
+
214
+ const completion = new Promise<ProcessResult>((resolve, reject) => {
215
+ child.once('error', (cause) => {
216
+ spawnError = new HarmonyCliError('ERR_HARMONY_PROCESS_FAILED', `Cannot launch ${command}: ${cause.message}`, {
217
+ cause,
218
+ operation: options.operation || 'spawn',
219
+ });
220
+ });
221
+ child.once('close', (code, signal) => {
222
+ closed = true;
223
+ cleanup();
224
+
225
+ if (spawnError) reject(spawnError);
226
+ else resolve({
227
+ code: code === null ? 1 : code,
228
+ signal,
229
+ stderr: stderr.toString(),
230
+ stdout: stdout.toString(),
231
+ timedOut: false,
232
+ });
233
+ });
234
+ });
235
+ // A readiness probe may be the first consumer. Keep early spawn failures from
236
+ // becoming unhandled rejections while the probe is still polling.
237
+ completion.catch(() => {});
238
+
239
+ async function stop(signal: NodeJS.Signals = 'SIGTERM', graceMs = DefaultStopGraceMs) {
240
+ if (closed) return completion;
241
+
242
+ stopRequested = true;
243
+ child.kill(signal);
244
+ let timer: NodeJS.Timeout | undefined;
245
+
246
+ await Promise.race([
247
+ completion.catch(() => undefined),
248
+ new Promise((resolve) => {
249
+ timer = setTimeout(resolve, graceMs);
250
+ timer.unref?.();
251
+ }),
252
+ ]);
253
+
254
+ if (timer) clearTimeout(timer);
255
+ if (!closed) child.kill('SIGKILL');
256
+
257
+ return completion.catch(() => undefined);
258
+ }
259
+
260
+ process.once('SIGINT', forwardSigint);
261
+ process.once('SIGTERM', forwardSigterm);
262
+ if (options.signal?.aborted) abort();
263
+ else options.signal?.addEventListener('abort', abort, { once: true });
264
+
265
+ return {
266
+ child,
267
+ completion,
268
+ getStderr: () => stderr.toString(),
269
+ getStdout: () => stdout.toString(),
270
+ stop,
271
+ wasStopped: () => stopRequested,
272
+ };
273
+ }
274
+
275
+ export { formatDiagnostics, spawnAsync, startManagedProcess };
package/src/project.ts ADDED
@@ -0,0 +1,21 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { HarmonyCliError } from './errors';
5
+
6
+ function resolveProject(start = process.cwd()) {
7
+ let current = path.resolve(start);
8
+
9
+ if (fs.existsSync(current) && fs.statSync(current).isFile()) current = path.dirname(current);
10
+
11
+ while (true) {
12
+ if (fs.existsSync(path.join(current, 'package.json'))) return fs.realpathSync(current);
13
+ const parent = path.dirname(current);
14
+ if (parent === current) break;
15
+ current = parent;
16
+ }
17
+
18
+ throw new HarmonyCliError('ERR_HARMONY_CONFIG_INVALID', `No package.json was found from ${start}.`, { operation: 'resolve-project' });
19
+ }
20
+
21
+ export { resolveProject };
@@ -0,0 +1,229 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ import { HarmonyCliError } from './errors';
7
+
8
+ const DefaultMalformedLockGraceMs = 30_000;
9
+ const DefaultPollIntervalMs = 100;
10
+ const DefaultWaitTimeoutMs = 5_000;
11
+ const LockRelativePath = '.expo/harmony/native-operation.lock';
12
+
13
+ interface HarmonyProjectLockLease {
14
+ releaseAsync(): Promise<void>;
15
+ }
16
+
17
+ interface ActiveProjectLock {
18
+ projectRoot: string;
19
+ released: boolean;
20
+ }
21
+
22
+ interface LockOwner {
23
+ createdAt: string;
24
+ operation: string;
25
+ pid: number;
26
+ projectRoot: string;
27
+ token: string;
28
+ }
29
+
30
+ const ActiveLock = new AsyncLocalStorage<ActiveProjectLock>();
31
+
32
+ async function canonicalProjectRootAsync(projectRoot: string): Promise<string> {
33
+ try {
34
+ return await fs.promises.realpath(path.resolve(projectRoot));
35
+ } catch (cause) {
36
+ throw new HarmonyCliError(
37
+ 'ERR_HARMONY_PROJECT_LOCK',
38
+ 'Cannot resolve the application project before acquiring its Harmony native operation lock.',
39
+ { cause, operation: 'project-lock' }
40
+ );
41
+ }
42
+ }
43
+
44
+ function projectLockPath(projectRoot: string): string {
45
+ return path.join(projectRoot, ...LockRelativePath.split('/'));
46
+ }
47
+
48
+ async function processIsAliveAsync(pid: unknown): Promise<boolean> {
49
+ if (!Number.isInteger(pid) || Number(pid) <= 0) return false;
50
+
51
+ try {
52
+ process.kill(Number(pid), 0);
53
+ return true;
54
+ } catch (cause) {
55
+ return cause?.code !== 'ESRCH';
56
+ }
57
+ }
58
+
59
+ async function readLockOwnerAsync(lockPath: string): Promise<LockOwner | null> {
60
+ try {
61
+ const candidate = JSON.parse(await fs.promises.readFile(lockPath, 'utf8'));
62
+ if (!candidate
63
+ || typeof candidate !== 'object'
64
+ || !Number.isInteger(candidate.pid)
65
+ || candidate.pid <= 0
66
+ || typeof candidate.createdAt !== 'string'
67
+ || typeof candidate.operation !== 'string'
68
+ || typeof candidate.projectRoot !== 'string'
69
+ || typeof candidate.token !== 'string') {
70
+ return null;
71
+ }
72
+ return candidate as LockOwner;
73
+ } catch (_cause) {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ async function removeStaleProjectLockAsync(
79
+ lockPath: string
80
+ ): Promise<boolean> {
81
+ let observed;
82
+ try {
83
+ observed = await fs.promises.lstat(lockPath);
84
+ } catch (_cause) {
85
+ return false;
86
+ }
87
+ if (!observed.isFile() || observed.isSymbolicLink()) return false;
88
+
89
+ const owner = await readLockOwnerAsync(lockPath);
90
+ if (owner) {
91
+ if (await processIsAliveAsync(owner.pid)) return false;
92
+ } else if (Date.now() - observed.mtimeMs < DefaultMalformedLockGraceMs) {
93
+ return false;
94
+ }
95
+
96
+ try {
97
+ const current = await fs.promises.lstat(lockPath);
98
+ if (!current.isFile() || current.isSymbolicLink()
99
+ || current.dev !== observed.dev
100
+ || current.ino !== observed.ino
101
+ || current.mtimeMs !== observed.mtimeMs
102
+ || current.size !== observed.size) {
103
+ return false;
104
+ }
105
+ await fs.promises.unlink(lockPath);
106
+ return true;
107
+ } catch (_cause) {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ async function releaseOwnedLockAsync(
113
+ handle: fs.promises.FileHandle,
114
+ lockPath: string,
115
+ owned: fs.Stats | undefined
116
+ ): Promise<boolean> {
117
+ await handle.close().catch(() => {});
118
+ if (!owned) return false;
119
+
120
+ try {
121
+ const current = await fs.promises.lstat(lockPath);
122
+ if (!current.isFile() || current.isSymbolicLink()
123
+ || current.dev !== owned.dev || current.ino !== owned.ino) {
124
+ return false;
125
+ }
126
+ await fs.promises.unlink(lockPath);
127
+ return true;
128
+ } catch (_cause) {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ function delayAsync(milliseconds: number): Promise<void> {
134
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
135
+ }
136
+
137
+ async function acquireHarmonyProjectLockAsync(
138
+ projectRoot: string,
139
+ operation: string
140
+ ): Promise<HarmonyProjectLockLease> {
141
+ const canonicalRoot = await canonicalProjectRootAsync(projectRoot);
142
+ const lockPath = projectLockPath(canonicalRoot);
143
+ const deadline = Date.now() + DefaultWaitTimeoutMs;
144
+
145
+ await fs.promises.mkdir(path.dirname(lockPath), { recursive: true });
146
+
147
+ while (true) {
148
+ try {
149
+ const handle = await fs.promises.open(lockPath, 'wx', 0o600);
150
+ let owned: fs.Stats | undefined;
151
+ const owner: LockOwner = {
152
+ createdAt: new Date().toISOString(),
153
+ operation,
154
+ pid: process.pid,
155
+ projectRoot: canonicalRoot,
156
+ token: crypto.randomUUID(),
157
+ };
158
+
159
+ try {
160
+ owned = await handle.stat();
161
+ await handle.writeFile(`${JSON.stringify(owner)}\n`);
162
+ await handle.sync();
163
+ } catch (cause) {
164
+ await releaseOwnedLockAsync(handle, lockPath, owned);
165
+ throw new HarmonyCliError(
166
+ 'ERR_HARMONY_PROJECT_LOCK',
167
+ 'Cannot initialize the Harmony native operation lock.',
168
+ { cause, operation: 'project-lock' }
169
+ );
170
+ }
171
+
172
+ let released = false;
173
+ return {
174
+ async releaseAsync() {
175
+ if (released) return;
176
+ released = true;
177
+ if (await releaseOwnedLockAsync(handle, lockPath, owned)) return;
178
+ throw new HarmonyCliError(
179
+ 'ERR_HARMONY_PROJECT_LOCK',
180
+ 'The Harmony native operation lock changed before it could be released safely.',
181
+ { operation: 'project-lock' }
182
+ );
183
+ },
184
+ };
185
+ } catch (cause) {
186
+ if (cause instanceof HarmonyCliError) throw cause;
187
+ if (cause?.code !== 'EEXIST') {
188
+ throw new HarmonyCliError(
189
+ 'ERR_HARMONY_PROJECT_LOCK',
190
+ 'Cannot acquire the Harmony native operation lock.',
191
+ { cause, operation: 'project-lock' }
192
+ );
193
+ }
194
+
195
+ if (await removeStaleProjectLockAsync(lockPath)) continue;
196
+ if (Date.now() >= deadline) {
197
+ const owner = await readLockOwnerAsync(lockPath);
198
+ const detail = owner?.operation ? ` (${owner.operation}, pid ${owner.pid})` : '';
199
+ throw new HarmonyCliError(
200
+ 'ERR_HARMONY_PROJECT_BUSY',
201
+ `Another Harmony native operation is using this project${detail}. Retry after it finishes.`,
202
+ { operation }
203
+ );
204
+ }
205
+ await delayAsync(Math.min(DefaultPollIntervalMs, Math.max(1, deadline - Date.now())));
206
+ }
207
+ }
208
+ }
209
+
210
+ async function withHarmonyProjectLockAsync<T>(
211
+ projectRoot: string,
212
+ operation: string,
213
+ callback: () => Promise<T> | T
214
+ ): Promise<T> {
215
+ const canonicalRoot = await canonicalProjectRootAsync(projectRoot);
216
+ const active = ActiveLock.getStore();
217
+ if (active?.projectRoot === canonicalRoot && !active.released) return await callback();
218
+
219
+ const lease = await acquireHarmonyProjectLockAsync(canonicalRoot, operation);
220
+ const context: ActiveProjectLock = { projectRoot: canonicalRoot, released: false };
221
+ try {
222
+ return await ActiveLock.run(context, callback);
223
+ } finally {
224
+ context.released = true;
225
+ await lease.releaseAsync();
226
+ }
227
+ }
228
+
229
+ export { withHarmonyProjectLockAsync };