runcloud 0.1.2 → 0.1.4

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/dist/api.js CHANGED
@@ -7,6 +7,18 @@ export class ApiError extends Error {
7
7
  this.detail = detail;
8
8
  }
9
9
  }
10
+ function parsedErrorDetail(body) {
11
+ try {
12
+ const detail = JSON.parse(body).detail;
13
+ if (typeof detail === 'string')
14
+ return detail;
15
+ if (detail !== undefined)
16
+ return JSON.stringify(detail);
17
+ }
18
+ catch {
19
+ }
20
+ return body;
21
+ }
10
22
  export class ApiClient {
11
23
  baseUrl;
12
24
  token;
@@ -24,12 +36,7 @@ export class ApiClient {
24
36
  body: body !== undefined ? JSON.stringify(body) : undefined,
25
37
  });
26
38
  if (!res.ok) {
27
- let detail = await res.text();
28
- try {
29
- detail = JSON.parse(detail).detail ?? detail;
30
- }
31
- catch {
32
- }
39
+ const detail = parsedErrorDetail(await res.text());
33
40
  throw new ApiError(res.status, detail);
34
41
  }
35
42
  const contentType = res.headers.get('content-type') ?? '';
@@ -41,6 +48,23 @@ export class ApiClient {
41
48
  post(path, body) {
42
49
  return this.request('POST', path, body);
43
50
  }
51
+ async postWithHeaders(path, body, headers) {
52
+ const res = await fetch(`${this.baseUrl}${path}`, {
53
+ method: 'POST',
54
+ headers: {
55
+ Authorization: `Bearer ${this.token}`,
56
+ 'Content-Type': 'application/json',
57
+ ...headers,
58
+ },
59
+ body: JSON.stringify(body),
60
+ });
61
+ if (!res.ok) {
62
+ const detail = parsedErrorDetail(await res.text());
63
+ throw new ApiError(res.status, detail);
64
+ }
65
+ const contentType = res.headers.get('content-type') ?? '';
66
+ return contentType.includes('application/json') ? res.json() : res;
67
+ }
44
68
  patch(path, body) {
45
69
  return this.request('PATCH', path, body);
46
70
  }
@@ -57,12 +81,7 @@ export class ApiClient {
57
81
  body: body,
58
82
  });
59
83
  if (!res.ok) {
60
- let detail = await res.text();
61
- try {
62
- detail = JSON.parse(detail).detail ?? detail;
63
- }
64
- catch {
65
- }
84
+ const detail = parsedErrorDetail(await res.text());
66
85
  throw new ApiError(res.status, detail);
67
86
  }
68
87
  const contentType_ = res.headers.get('content-type') ?? '';
@@ -77,12 +96,7 @@ export class ApiClient {
77
96
  body: form,
78
97
  });
79
98
  if (!res.ok) {
80
- let detail = await res.text();
81
- try {
82
- detail = JSON.parse(detail).detail ?? detail;
83
- }
84
- catch {
85
- }
99
+ const detail = parsedErrorDetail(await res.text());
86
100
  throw new ApiError(res.status, detail);
87
101
  }
88
102
  const contentType = res.headers.get('content-type') ?? '';
@@ -105,12 +119,7 @@ export class ApiClient {
105
119
  headers: { Authorization: `Bearer ${this.token}`, Accept: 'text/event-stream' },
106
120
  });
107
121
  if (!res.ok) {
108
- let detail = await res.text().catch(() => '');
109
- try {
110
- detail = JSON.parse(detail).detail ?? detail;
111
- }
112
- catch {
113
- }
122
+ const detail = parsedErrorDetail(await res.text().catch(() => ''));
114
123
  throw new ApiError(res.status, detail || res.statusText);
115
124
  }
116
125
  const decoder = new TextDecoder();
@@ -122,18 +131,50 @@ export class ApiClient {
122
131
  yield tail;
123
132
  }
124
133
  }
134
+ function rootCause(err) {
135
+ const cause = err?.cause;
136
+ if (cause && typeof cause === 'object' && Array.isArray(cause.errors)) {
137
+ return cause.errors[0] ?? cause;
138
+ }
139
+ return cause ?? err;
140
+ }
141
+ function networkError(err) {
142
+ const inner = rootCause(err);
143
+ const code = inner?.code;
144
+ if (!code)
145
+ return null;
146
+ const hints = {
147
+ ECONNREFUSED: 'the server refused the connection — is it running, and is the URL/port right?',
148
+ ENOTFOUND: 'the host could not be resolved (DNS) — check the URL',
149
+ EAI_AGAIN: 'temporary DNS failure — check your network connection',
150
+ ETIMEDOUT: 'the connection timed out — the server may be down or unreachable',
151
+ ECONNRESET: 'the connection was reset by the server',
152
+ CERT_HAS_EXPIRED: "the server's TLS certificate has expired",
153
+ };
154
+ const detail = inner instanceof Error ? inner.message : String(inner);
155
+ return `Could not reach the server (${code}): ${detail}${hints[code] ? ` — ${hints[code]}` : ''}`;
156
+ }
157
+ function detailSuffix(err) {
158
+ const stack = err instanceof Error && err.stack ? err.stack : String(err);
159
+ const cause = err?.cause;
160
+ return `\n${stack}${cause ? `\nCaused by: ${cause instanceof Error ? (cause.stack ?? cause.message) : String(cause)}` : ''}`;
161
+ }
162
+ function headline(err) {
163
+ if (err instanceof ApiError) {
164
+ if (err.status === 401)
165
+ return 'Session expired or invalid — run `newly login`.';
166
+ if (err.status === 403)
167
+ return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
168
+ if (err.status === 404)
169
+ return `Not found: ${err.detail}`;
170
+ if (err.status === 409)
171
+ return err.detail;
172
+ if (err.status === 503)
173
+ return 'The platform is at capacity right now — please try again in a few minutes.';
174
+ return err.message;
175
+ }
176
+ return networkError(err) ?? (err instanceof Error ? err.message : String(err));
177
+ }
125
178
  export function friendlyApiError(err) {
126
- if (!(err instanceof ApiError))
127
- return String(err);
128
- if (err.status === 401)
129
- return 'Session expired or invalid — run `newly login`.';
130
- if (err.status === 403)
131
- return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
132
- if (err.status === 404)
133
- return `Not found: ${err.detail}`;
134
- if (err.status === 409)
135
- return err.detail;
136
- if (err.status === 503)
137
- return 'The platform is at capacity right now — please try again in a few minutes.';
138
- return err.message;
179
+ return `${headline(err)}${detailSuffix(err)}`;
139
180
  }
@@ -1,5 +1,8 @@
1
- import { existsSync, statSync } from 'node:fs';
2
- import { basename, resolve } from 'node:path';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, join, resolve } from 'node:path';
3
6
  import { ApiClient, ApiError } from '../api.js';
4
7
  import { createSnapshot } from '../archive.js';
5
8
  import { readProjectLink, requireCredentials, resolveProject, writeProjectLink } from '../config.js';
@@ -7,6 +10,49 @@ export function buildPlaceholderMessage(platform) {
7
10
  return (`In-house ${platform} builds are coming in an upcoming release.\n` +
8
11
  'Set up iOS signing first with `newly creds apple` (check readiness with `newly creds apple status`).');
9
12
  }
13
+ function serveSimDevice(session) {
14
+ if (typeof session.install_url !== 'string')
15
+ throw new Error('Backend returned a serve-sim session without a device.');
16
+ const device = new URL(session.install_url).searchParams.get('device');
17
+ if (!device)
18
+ throw new Error('Backend returned a serve-sim session without a device.');
19
+ return device;
20
+ }
21
+ async function connectLocalMetroTunnel(session, localPort, sidecarUrl, platform = 'ios') {
22
+ if (typeof session.base_url !== 'string' || typeof session.token !== 'string') {
23
+ throw new Error('Backend returned an incomplete serve-sim session for local Metro.');
24
+ }
25
+ const device = serveSimDevice(session);
26
+ const username = `desktop_${randomBytes(12).toString('hex')}`;
27
+ const password = randomBytes(32).toString('base64url');
28
+ const allocation = await fetch(`${session.base_url.replace(/\/+$/, '')}/api/integration/tunnel?device=${encodeURIComponent(device)}`, {
29
+ method: 'POST',
30
+ headers: { Authorization: `Bearer ${session.token}`, 'Content-Type': 'application/json' },
31
+ body: JSON.stringify({ username, password, localService: 'metro', localHost: 'localhost', localPort }),
32
+ signal: AbortSignal.timeout(15_000),
33
+ });
34
+ const tunnel = (await allocation.json().catch(() => ({})));
35
+ if (!allocation.ok)
36
+ throw new Error(typeof tunnel.error === 'string' ? tunnel.error : `serve-sim tunnel allocation failed (${allocation.status})`);
37
+ if (typeof tunnel.port !== 'number' || typeof tunnel.serverUrl !== 'string' || typeof tunnel.fingerprint !== 'string') {
38
+ throw new Error('serve-sim returned an incomplete tunnel allocation.');
39
+ }
40
+ const sessionId = `${device}:${tunnel.port}`;
41
+ const connected = await fetch(`${sidecarUrl.replace(/\/+$/, '')}/run-cloud/tunnel`, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({
45
+ sessionId, serverUrl: tunnel.serverUrl, fingerprint: tunnel.fingerprint,
46
+ username, password, remotePort: tunnel.port, localHost: 'localhost', localPort, localService: 'metro',
47
+ }),
48
+ signal: AbortSignal.timeout(20_000),
49
+ });
50
+ const status = (await connected.json().catch(() => ({})));
51
+ if (!connected.ok || status.tunnel?.state !== 'connected') {
52
+ throw new Error(typeof status.error === 'string' ? status.error : 'Newly Desktop could not connect the local Metro tunnel.');
53
+ }
54
+ return `http://127.0.0.1:${tunnel.port}/.expo/.virtual-metro-entry.bundle?platform=${platform}&dev=true&minify=false`;
55
+ }
10
56
  function makeClient() {
11
57
  const creds = requireCredentials();
12
58
  return {
@@ -22,13 +68,70 @@ function assertDirectory(path, label) {
22
68
  function archiveKib(size) {
23
69
  return size < 1024 ? '<1' : Math.round(size / 1024).toString();
24
70
  }
25
- async function ensureRemoteSourceProject(client, sourceDir, explicitProject, log) {
71
+ function nativeFingerprint(sourceDir) {
72
+ try {
73
+ const output = execFileSync(process.execPath, [
74
+ '-e',
75
+ "require('@expo/fingerprint').createFingerprintAsync(process.cwd()).then(f => process.stdout.write(f.hash)).catch(() => process.exit(1))",
76
+ ], { cwd: sourceDir, stdio: ['ignore', 'pipe', 'ignore'], timeout: 120_000 });
77
+ const expoFingerprint = output.toString().trim();
78
+ if (!expoFingerprint)
79
+ return null;
80
+ return createHash('sha256')
81
+ .update(`newly-remote-ios-development-client-v4:${expoFingerprint}`)
82
+ .digest('hex');
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ function androidPackageName(sourceDir) {
89
+ try {
90
+ const appJson = JSON.parse(readFileSync(resolve(sourceDir, 'app.json'), 'utf8'));
91
+ const configured = appJson.expo?.android?.package;
92
+ if (typeof configured === 'string' && configured.trim())
93
+ return configured.trim();
94
+ const slug = typeof appJson.expo?.slug === 'string' ? appJson.expo.slug : basename(resolve(sourceDir));
95
+ const suffix = slug.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'app';
96
+ return `com.newly.${suffix}`;
97
+ }
98
+ catch {
99
+ const suffix = basename(resolve(sourceDir)).toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'app';
100
+ return `com.newly.${suffix}`;
101
+ }
102
+ }
103
+ function createZipSnapshot(sourceDir) {
104
+ const tempDir = mkdtempSync(join(tmpdir(), 'newly-android-build-'));
105
+ const zipPath = join(tempDir, 'source.zip');
106
+ try {
107
+ execFileSync('zip', [
108
+ '-qr',
109
+ zipPath,
110
+ '.',
111
+ '-x',
112
+ 'node_modules/*',
113
+ '.git/*',
114
+ '.expo/*',
115
+ '.next/*',
116
+ 'dist/*',
117
+ 'build/*',
118
+ 'android/app/build/*',
119
+ 'ios/build/*',
120
+ ], { cwd: sourceDir, stdio: ['ignore', 'ignore', 'pipe'], timeout: 120_000 });
121
+ return readFileSync(zipPath);
122
+ }
123
+ finally {
124
+ rmSync(tempDir, { recursive: true, force: true });
125
+ }
126
+ }
127
+ async function ensureRemoteSourceProject(client, sourceDir, explicitProject, log, platform = 'ios') {
26
128
  if (explicitProject)
27
129
  return explicitProject;
28
130
  const linked = readProjectLink(sourceDir);
29
131
  if (linked?.projectId)
30
132
  return linked.projectId;
31
- const name = basename(resolve(sourceDir)) || 'iOS simulator build';
133
+ const label = platform === 'android' ? 'Android' : 'iOS';
134
+ const name = basename(resolve(sourceDir)) || `${label} simulator build`;
32
135
  log('No Newly project link found; creating a build-only project for this local source...');
33
136
  const created = (await client.post('/newly_backend/create_project', {
34
137
  name,
@@ -93,6 +196,22 @@ async function waitForBuild(client, buildId, log) {
93
196
  }
94
197
  return status;
95
198
  }
199
+ async function waitForAndroidApkBuild(client, buildId, log) {
200
+ const deadline = Date.now() + 60 * 60 * 1000;
201
+ let lastStatus = '';
202
+ while (Date.now() < deadline) {
203
+ const status = (await client.get(`/apk-builder/build-health/${buildId}`));
204
+ const current = typeof status.status === 'string' ? status.status : 'unknown';
205
+ if (current !== lastStatus) {
206
+ log(`Android build ${buildId} ${current}`);
207
+ lastStatus = current;
208
+ }
209
+ if (current === 'completed' || current === 'error')
210
+ return current;
211
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
212
+ }
213
+ throw new Error(`Android simulator build ${buildId} timed out`);
214
+ }
96
215
  function serveSimPrepareUrl(session) {
97
216
  if (typeof session.base_url !== 'string' || typeof session.install_url !== 'string')
98
217
  return null;
@@ -104,16 +223,28 @@ function serveSimPrepareUrl(session) {
104
223
  prepareUrl.searchParams.set('async', '1');
105
224
  return prepareUrl.toString();
106
225
  }
226
+ function serveSimSessionUrl(session) {
227
+ if (typeof session.base_url !== 'string' || typeof session.install_url !== 'string')
228
+ return null;
229
+ const installUrl = new URL(session.install_url);
230
+ const sessionUrl = new URL(`${session.base_url.replace(/\/+$/, '')}/api/integration/session`);
231
+ const device = installUrl.searchParams.get('device');
232
+ if (device)
233
+ sessionUrl.searchParams.set('device', device);
234
+ return sessionUrl.toString();
235
+ }
107
236
  async function prepareServeSimBuildFromCli(session, launchUrl) {
237
+ const prepareTimeoutMs = 300_000;
108
238
  if (typeof session.token !== 'string') {
109
239
  throw new Error('Backend returned a serve-sim session without a token.');
110
240
  }
111
241
  const prepareUrl = serveSimPrepareUrl(session);
112
- if (!prepareUrl) {
242
+ const sessionUrl = serveSimSessionUrl(session);
243
+ if (!prepareUrl || !sessionUrl) {
113
244
  throw new Error('Backend returned a serve-sim session without a prepare target.');
114
245
  }
115
246
  const controller = new AbortController();
116
- const timeout = setTimeout(() => controller.abort(), 20_000);
247
+ const timeout = setTimeout(() => controller.abort(), prepareTimeoutMs);
117
248
  try {
118
249
  const res = await fetch(prepareUrl, {
119
250
  method: 'POST',
@@ -131,10 +262,27 @@ async function prepareServeSimBuildFromCli(session, launchUrl) {
131
262
  const body = await res.text().catch(() => '');
132
263
  throw new Error(`serve-sim prepare failed: HTTP ${res.status}${body ? ` ${body}` : ''}`);
133
264
  }
265
+ const deadline = Date.now() + prepareTimeoutMs;
266
+ while (Date.now() < deadline) {
267
+ const statusRes = await fetch(sessionUrl, {
268
+ headers: { Authorization: `Bearer ${session.token}` },
269
+ signal: controller.signal,
270
+ });
271
+ const statusBody = await statusRes.json().catch(() => ({}));
272
+ if (!statusRes.ok) {
273
+ throw new Error(`serve-sim session failed: HTTP ${statusRes.status}${statusBody.error ? ` ${statusBody.error}` : ''}`);
274
+ }
275
+ if (statusBody.lastPoolError)
276
+ throw new Error(`serve-sim prepare failed: ${statusBody.lastPoolError}`);
277
+ if (statusBody.phase === 'ready')
278
+ return;
279
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
280
+ }
281
+ throw new Error(`serve-sim prepare timed out after ${prepareTimeoutMs}ms`);
134
282
  }
135
283
  catch (error) {
136
284
  if (error instanceof DOMException && error.name === 'AbortError') {
137
- throw new Error('serve-sim prepare timed out after 20000ms');
285
+ throw new Error(`serve-sim prepare timed out after ${prepareTimeoutMs}ms`);
138
286
  }
139
287
  throw error;
140
288
  }
@@ -144,11 +292,19 @@ async function prepareServeSimBuildFromCli(session, launchUrl) {
144
292
  }
145
293
  export async function runIosSimulatorBuild(client, projectId, opts = {}, log = console.log) {
146
294
  let build;
147
- const metroUrl = opts.metroUrl?.trim() || null;
295
+ let metroUrl = opts.metroUrl?.trim() || null;
296
+ const sourceDir = opts.sourcePath ?? '.';
297
+ const localMetroPort = opts.localMetroPort == null ? null : Number(opts.localMetroPort);
298
+ if (localMetroPort !== null && (!Number.isInteger(localMetroPort) || localMetroPort < 1 || localMetroPort > 65535)) {
299
+ throw new Error('--local-metro-port must be a valid TCP port.');
300
+ }
301
+ if (localMetroPort !== null && metroUrl)
302
+ throw new Error('--local-metro-port cannot be combined with --metro-url.');
148
303
  if (opts.remoteSource) {
149
- const sourceDir = opts.sourcePath ?? '.';
150
304
  assertDirectory(sourceDir, 'iOS build source directory');
151
305
  projectId = await ensureRemoteSourceProject(client, sourceDir, projectId, log);
306
+ const fingerprint = nativeFingerprint(sourceDir);
307
+ log(fingerprint ? `Native fingerprint: ${fingerprint.slice(0, 12)}…` : 'Native fingerprint unavailable; a native build may be required.');
152
308
  log('Packaging iOS source...');
153
309
  const archive = await createSnapshot(sourceDir);
154
310
  log(`Uploading source snapshot (${archiveKib(archive.length)} KiB) for remote iOS simulator build...`);
@@ -158,6 +314,8 @@ export async function runIosSimulatorBuild(client, projectId, opts = {}, log = c
158
314
  platform: 'ios',
159
315
  mode: 'automatic',
160
316
  build_profile: 'development-simulator',
317
+ bundle_url: localMetroPort === null ? metroUrl : undefined,
318
+ native_fingerprint: fingerprint,
161
319
  }));
162
320
  form.set('source', new Blob([new Uint8Array(archive)], { type: 'application/gzip' }), 'source.tar.gz');
163
321
  build = (await client.uploadForm('/builds/source-upload', form));
@@ -193,7 +351,7 @@ export async function runIosSimulatorBuild(client, projectId, opts = {}, log = c
193
351
  loaded = await load(opts.provider);
194
352
  }
195
353
  catch (e) {
196
- if (opts.provider === 'serve-sim' && e instanceof ApiError && (e.status === 503 || e.status === 500)) {
354
+ if (localMetroPort === null && opts.provider === 'serve-sim' && e instanceof ApiError && (e.status === 503 || e.status === 500)) {
197
355
  log('Cloud iOS simulator unavailable; falling back to Appetize...');
198
356
  loaded = await load();
199
357
  }
@@ -206,10 +364,76 @@ export async function runIosSimulatorBuild(client, projectId, opts = {}, log = c
206
364
  }
207
365
  else if (loaded.serve_sim && typeof loaded.serve_sim === 'object') {
208
366
  const session = loaded.serve_sim;
367
+ const url = typeof session.url === 'string' ? session.url : session.base_url;
368
+ if (typeof url === 'string')
369
+ log(`Cloud simulator URL: ${url}`);
370
+ if (localMetroPort !== null) {
371
+ log(`Connecting local Metro on 127.0.0.1:${localMetroPort} to the remote simulator...`);
372
+ metroUrl = await connectLocalMetroTunnel(session, localMetroPort, opts.sidecarUrl ?? 'http://127.0.0.1:4500');
373
+ log('Local Metro tunnel connected.');
374
+ }
209
375
  await prepareServeSimBuildFromCli(session, metroUrl);
376
+ log(`Cloud simulator prepared on ${typeof url === 'string' ? url : 'ready'}`);
377
+ }
378
+ else {
379
+ log('Simulator load requested.');
380
+ }
381
+ }
382
+ }
383
+ export async function runAndroidSimulatorBuild(client, projectId, opts = {}, log = console.log) {
384
+ let metroUrl = opts.metroUrl?.trim() || null;
385
+ const sourceDir = opts.sourcePath ?? '.';
386
+ const localMetroPort = opts.localMetroPort == null ? null : Number(opts.localMetroPort);
387
+ if (localMetroPort !== null && (!Number.isInteger(localMetroPort) || localMetroPort < 1 || localMetroPort > 65535)) {
388
+ throw new Error('--local-metro-port must be a valid TCP port.');
389
+ }
390
+ if (localMetroPort !== null && metroUrl)
391
+ throw new Error('--local-metro-port cannot be combined with --metro-url.');
392
+ if (!opts.remoteSource) {
393
+ throw new Error('Android simulator builds currently require --remote-source.');
394
+ }
395
+ assertDirectory(sourceDir, 'Android build source directory');
396
+ projectId = await ensureRemoteSourceProject(client, sourceDir, projectId, log, 'android');
397
+ log('Packaging Android source...');
398
+ const archive = createZipSnapshot(sourceDir);
399
+ log(`Uploading source snapshot (${archiveKib(archive.length)} KiB) for remote Android simulator build...`);
400
+ const form = new FormData();
401
+ form.set('file', new Blob([new Uint8Array(archive)], { type: 'application/zip' }), 'source.zip');
402
+ form.set('bundle_identifier', androidPackageName(sourceDir));
403
+ form.set('project_type', 'expo');
404
+ form.set('project_id', projectId);
405
+ const uploaded = (await client.uploadForm('/apk-builder/upload', form));
406
+ const buildId = typeof uploaded.build_id === 'string' ? uploaded.build_id : '';
407
+ if (!buildId)
408
+ throw new Error('Backend did not return an Android build id.');
409
+ log(`Build ${buildId} uploaded (${String(uploaded.status ?? 'uploaded')})`);
410
+ await client.post('/apk-builder/start-build', { build_id: buildId });
411
+ log(`Build ${buildId} queued (processing)`);
412
+ if (opts.wait || opts.loadToSimulator) {
413
+ const status = await waitForAndroidApkBuild(client, buildId, log);
414
+ if (status !== 'completed')
415
+ throw new Error(`Android simulator build ${status}`);
416
+ }
417
+ if (opts.loadToSimulator) {
418
+ const provider = opts.provider || 'serve-sim';
419
+ const params = new URLSearchParams({ provider });
420
+ const loaded = (await client.post(`/projects/${projectId}/builds/${buildId}/load-to-simulator?${params.toString()}`, {}));
421
+ if (loaded.serve_sim && typeof loaded.serve_sim === 'object') {
422
+ const session = loaded.serve_sim;
210
423
  const url = typeof session.url === 'string' ? session.url : session.base_url;
424
+ if (typeof url === 'string')
425
+ log(`Cloud simulator URL: ${url}`);
426
+ if (localMetroPort !== null) {
427
+ log(`Connecting local Metro on 127.0.0.1:${localMetroPort} to the remote Android emulator...`);
428
+ metroUrl = await connectLocalMetroTunnel(session, localMetroPort, opts.sidecarUrl ?? 'http://127.0.0.1:4500', 'android');
429
+ log('Local Metro tunnel connected.');
430
+ }
431
+ await prepareServeSimBuildFromCli(session, metroUrl);
211
432
  log(`Cloud simulator prepared on ${typeof url === 'string' ? url : 'ready'}`);
212
433
  }
434
+ else if (typeof loaded.appetize_public_key === 'string') {
435
+ log(`Appetize Android key: ${loaded.appetize_public_key}`);
436
+ }
213
437
  else {
214
438
  log('Simulator load requested.');
215
439
  }
@@ -221,15 +445,17 @@ export function registerBuild(program) {
221
445
  .description('Build the mobile app')
222
446
  .option('--project <id>', 'project id (defaults to the linked directory)')
223
447
  .option('--source-path <path>', 'local source directory to upload for --remote-source', '.')
224
- .option('--simulator', 'build an iOS simulator artifact', false)
448
+ .option('--simulator', 'build a simulator artifact', false)
225
449
  .option('--remote-source', 'upload local source files and build in Newly cloud', false)
226
450
  .option('--wait', 'stream logs until the build finishes', false)
227
451
  .option('--load-to-simulator', 'load a completed simulator build into a cloud simulator', false)
228
452
  .option('--provider <provider>', 'simulator provider for --load-to-simulator (serve-sim or appetize)')
229
453
  .option('--metro-url <url>', 'reachable Metro bundle URL for remote simulator source builds')
454
+ .option('--local-metro-port <port>', 'connect local Metro through the bundled authenticated tunnel')
455
+ .option('--sidecar-url <url>', 'Newly Desktop sidecar URL for --local-metro-port', 'http://127.0.0.1:4500')
230
456
  .action(async (platform = 'ios', opts) => {
231
457
  try {
232
- if (platform !== 'ios' || !opts.simulator) {
458
+ if ((platform !== 'ios' && platform !== 'android') || !opts.simulator) {
233
459
  console.log(buildPlaceholderMessage(platform));
234
460
  return;
235
461
  }
@@ -240,7 +466,12 @@ export function registerBuild(program) {
240
466
  const projectId = opts.remoteSource
241
467
  ? opts.project || readProjectLink(opts.sourcePath ?? '.')?.projectId
242
468
  : resolveProject(process.cwd(), opts.project);
243
- await runIosSimulatorBuild(client, projectId, opts);
469
+ if (platform === 'android') {
470
+ await runAndroidSimulatorBuild(client, projectId, opts);
471
+ }
472
+ else {
473
+ await runIosSimulatorBuild(client, projectId, opts);
474
+ }
244
475
  }
245
476
  catch (e) {
246
477
  console.error(e instanceof Error ? e.message : String(e));