@newly/cli 0.1.1 → 0.1.27

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.
@@ -1,12 +1,498 @@
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';
6
+ import { ApiClient, ApiError } from '../api.js';
7
+ import { createSnapshot } from '../archive.js';
8
+ import { readProjectLink, requireCredentials, resolveProject, writeProjectLink } from '../config.js';
1
9
  export function buildPlaceholderMessage(platform) {
2
10
  return (`In-house ${platform} builds are coming in an upcoming release.\n` +
3
11
  'Set up iOS signing first with `newly creds apple` (check readiness with `newly creds apple status`).');
4
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
+ }
56
+ function makeClient() {
57
+ const creds = requireCredentials();
58
+ return {
59
+ client: new ApiClient(creds.apiUrl, creds.token),
60
+ };
61
+ }
62
+ function assertDirectory(path, label) {
63
+ if (!existsSync(path))
64
+ throw new Error(`${label} does not exist: ${path}`);
65
+ if (!statSync(path).isDirectory())
66
+ throw new Error(`${label} is not a directory: ${path}`);
67
+ }
68
+ function archiveKib(size) {
69
+ return size < 1024 ? '<1' : Math.round(size / 1024).toString();
70
+ }
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') {
128
+ if (explicitProject)
129
+ return explicitProject;
130
+ const linked = readProjectLink(sourceDir);
131
+ if (linked?.projectId)
132
+ return linked.projectId;
133
+ const label = platform === 'android' ? 'Android' : 'iOS';
134
+ const name = basename(resolve(sourceDir)) || `${label} simulator build`;
135
+ log('No Newly project link found; creating a build-only project for this local source...');
136
+ const created = (await client.post('/newly_backend/create_project', {
137
+ name,
138
+ provision: false,
139
+ }));
140
+ const projectId = typeof created.project_id === 'string' ? created.project_id : '';
141
+ if (!projectId)
142
+ throw new Error('Backend did not return project_id for build-only project.');
143
+ writeProjectLink(sourceDir, {
144
+ projectId,
145
+ pulledSha: null,
146
+ pulledAt: new Date().toISOString(),
147
+ });
148
+ log(`Linked local source to Newly project ${projectId} for remote simulator builds.`);
149
+ return projectId;
150
+ }
151
+ function parseSseData(frame) {
152
+ const out = [];
153
+ for (const part of frame.split(/\n\n+/)) {
154
+ const data = part
155
+ .split(/\n/)
156
+ .filter((line) => line.startsWith('data:'))
157
+ .map((line) => line.slice(5).trimStart())
158
+ .join('\n');
159
+ if (!data)
160
+ continue;
161
+ try {
162
+ out.push(JSON.parse(data));
163
+ }
164
+ catch {
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+ async function waitForBuild(client, buildId, log) {
170
+ let status = 'queued';
171
+ const answeredPrompts = new Set();
172
+ for await (const chunk of client.stream(`/builds/${buildId}/stream`)) {
173
+ for (const event of parseSseData(chunk)) {
174
+ const promptId = typeof event.id === 'string' ? event.id : '';
175
+ if (promptId === 'jenkins-fallback-choice' && !answeredPrompts.has(promptId)) {
176
+ answeredPrompts.add(promptId);
177
+ if (typeof event.message === 'string')
178
+ log(event.message);
179
+ log('Default build system failed; leaving this remote simulator build failed.');
180
+ await client.post(`/builds/${buildId}/respond`, {
181
+ promptId,
182
+ value: 'keep_failed',
183
+ });
184
+ status = 'failed';
185
+ continue;
186
+ }
187
+ if (Array.isArray(event.new_logs)) {
188
+ for (const line of event.new_logs)
189
+ log(String(line));
190
+ }
191
+ if (typeof event.message === 'string')
192
+ log(event.message);
193
+ if (typeof event.status === 'string')
194
+ status = event.status;
195
+ }
196
+ }
197
+ if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') {
198
+ const deadline = Date.now() + 60 * 60 * 1000;
199
+ let lastLoggedStatus = status;
200
+ while (Date.now() < deadline) {
201
+ const build = (await client.get(`/builds/${buildId}`));
202
+ const current = typeof build.status === 'string' ? build.status : status;
203
+ if (current !== lastLoggedStatus) {
204
+ log(`Build ${buildId} ${current}`);
205
+ lastLoggedStatus = current;
206
+ }
207
+ status = current;
208
+ if (status === 'completed' || status === 'failed' || status === 'cancelled')
209
+ return status;
210
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
211
+ }
212
+ throw new Error(`iOS simulator build ${buildId} timed out`);
213
+ }
214
+ return status;
215
+ }
216
+ async function waitForAndroidApkBuild(client, buildId, log) {
217
+ const deadline = Date.now() + 60 * 60 * 1000;
218
+ let lastStatus = '';
219
+ while (Date.now() < deadline) {
220
+ const status = (await client.get(`/apk-builder/build-health/${buildId}`));
221
+ const current = typeof status.status === 'string' ? status.status : 'unknown';
222
+ if (current !== lastStatus) {
223
+ log(`Android build ${buildId} ${current}`);
224
+ lastStatus = current;
225
+ }
226
+ if (current === 'completed' || current === 'error')
227
+ return current;
228
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
229
+ }
230
+ throw new Error(`Android simulator build ${buildId} timed out`);
231
+ }
232
+ function serveSimPrepareUrl(session) {
233
+ if (typeof session.base_url !== 'string' || typeof session.install_url !== 'string')
234
+ return null;
235
+ const installUrl = new URL(session.install_url);
236
+ const prepareUrl = new URL(`${session.base_url.replace(/\/+$/, '')}/api/integration/prepare`);
237
+ const device = installUrl.searchParams.get('device');
238
+ if (device)
239
+ prepareUrl.searchParams.set('device', device);
240
+ prepareUrl.searchParams.set('async', '1');
241
+ return prepareUrl.toString();
242
+ }
243
+ function serveSimSessionUrl(session) {
244
+ if (typeof session.base_url !== 'string' || typeof session.install_url !== 'string')
245
+ return null;
246
+ const installUrl = new URL(session.install_url);
247
+ const sessionUrl = new URL(`${session.base_url.replace(/\/+$/, '')}/api/integration/session`);
248
+ const device = installUrl.searchParams.get('device');
249
+ if (device)
250
+ sessionUrl.searchParams.set('device', device);
251
+ return sessionUrl.toString();
252
+ }
253
+ async function prepareServeSimBuildFromCli(session, launchUrl) {
254
+ const prepareTimeoutMs = 300_000;
255
+ if (typeof session.token !== 'string') {
256
+ throw new Error('Backend returned a serve-sim session without a token.');
257
+ }
258
+ const prepareUrl = serveSimPrepareUrl(session);
259
+ const sessionUrl = serveSimSessionUrl(session);
260
+ if (!prepareUrl || !sessionUrl) {
261
+ throw new Error('Backend returned a serve-sim session without a prepare target.');
262
+ }
263
+ const controller = new AbortController();
264
+ const timeout = setTimeout(() => controller.abort(), prepareTimeoutMs);
265
+ try {
266
+ const res = await fetch(prepareUrl, {
267
+ method: 'POST',
268
+ headers: {
269
+ Authorization: `Bearer ${session.token}`,
270
+ 'Content-Type': 'application/json',
271
+ },
272
+ body: JSON.stringify({
273
+ artifactUrl: typeof session.artifact_url === 'string' ? session.artifact_url : undefined,
274
+ launchUrl,
275
+ }),
276
+ signal: controller.signal,
277
+ });
278
+ if (!res.ok) {
279
+ const body = await res.text().catch(() => '');
280
+ throw new Error(`serve-sim prepare failed: HTTP ${res.status}${body ? ` ${body}` : ''}`);
281
+ }
282
+ const deadline = Date.now() + prepareTimeoutMs;
283
+ while (Date.now() < deadline) {
284
+ const statusRes = await fetch(sessionUrl, {
285
+ headers: { Authorization: `Bearer ${session.token}` },
286
+ signal: controller.signal,
287
+ });
288
+ const statusBody = await statusRes.json().catch(() => ({}));
289
+ if (!statusRes.ok) {
290
+ throw new Error(`serve-sim session failed: HTTP ${statusRes.status}${statusBody.error ? ` ${statusBody.error}` : ''}`);
291
+ }
292
+ if (statusBody.lastPoolError)
293
+ throw new Error(`serve-sim prepare failed: ${statusBody.lastPoolError}`);
294
+ if (statusBody.phase === 'ready')
295
+ return;
296
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
297
+ }
298
+ throw new Error(`serve-sim prepare timed out after ${prepareTimeoutMs}ms`);
299
+ }
300
+ catch (error) {
301
+ if (error instanceof DOMException && error.name === 'AbortError') {
302
+ throw new Error(`serve-sim prepare timed out after ${prepareTimeoutMs}ms`);
303
+ }
304
+ throw error;
305
+ }
306
+ finally {
307
+ clearTimeout(timeout);
308
+ }
309
+ }
310
+ export async function runIosSimulatorBuild(client, projectId, opts = {}, log = console.log) {
311
+ let build;
312
+ let metroUrl = opts.metroUrl?.trim() || null;
313
+ const sourceDir = opts.sourcePath ?? '.';
314
+ const localMetroPort = opts.localMetroPort == null ? null : Number(opts.localMetroPort);
315
+ if (localMetroPort !== null && (!Number.isInteger(localMetroPort) || localMetroPort < 1 || localMetroPort > 65535)) {
316
+ throw new Error('--local-metro-port must be a valid TCP port.');
317
+ }
318
+ if (localMetroPort !== null && metroUrl)
319
+ throw new Error('--local-metro-port cannot be combined with --metro-url.');
320
+ if (opts.remoteSource) {
321
+ assertDirectory(sourceDir, 'iOS build source directory');
322
+ projectId = await ensureRemoteSourceProject(client, sourceDir, projectId, log);
323
+ const fingerprint = nativeFingerprint(sourceDir);
324
+ log(fingerprint ? `Native fingerprint: ${fingerprint.slice(0, 12)}…` : 'Native fingerprint unavailable; a native build may be required.');
325
+ log('Packaging iOS source...');
326
+ const archive = await createSnapshot(sourceDir);
327
+ log(`Uploading source snapshot (${archiveKib(archive.length)} KiB) for remote iOS simulator build...`);
328
+ const form = new FormData();
329
+ form.set('request', JSON.stringify({
330
+ project_id: projectId,
331
+ platform: 'ios',
332
+ mode: 'automatic',
333
+ build_profile: 'development-simulator',
334
+ bundle_url: localMetroPort === null ? metroUrl : undefined,
335
+ native_fingerprint: fingerprint,
336
+ }));
337
+ form.set('source', new Blob([new Uint8Array(archive)], { type: 'application/gzip' }), 'source.tar.gz');
338
+ build = (await client.uploadForm('/builds/source-upload', form));
339
+ }
340
+ else {
341
+ projectId = resolveProject(process.cwd(), projectId);
342
+ build = (await client.post('/builds', {
343
+ projectId,
344
+ platform: 'ios',
345
+ mode: 'automatic',
346
+ buildProfile: 'development-simulator',
347
+ }));
348
+ }
349
+ log(`Build ${build.id} queued (${build.status})`);
350
+ if (opts.wait) {
351
+ const status = await waitForBuild(client, build.id, log);
352
+ log(`Build ${build.id} ${status}`);
353
+ if (status !== 'completed')
354
+ throw new Error(`iOS simulator build ${status}`);
355
+ }
356
+ if (opts.loadToSimulator) {
357
+ const load = (provider) => {
358
+ const params = new URLSearchParams();
359
+ if (provider)
360
+ params.set('provider', provider);
361
+ if (provider === 'serve-sim' && metroUrl)
362
+ params.set('expo_url', metroUrl);
363
+ const qs = params.size ? `?${params.toString()}` : '';
364
+ return client.post(`/builds/${build.id}/load-to-simulator${qs}`, {});
365
+ };
366
+ let loaded;
367
+ try {
368
+ loaded = await load(opts.provider);
369
+ }
370
+ catch (e) {
371
+ if (localMetroPort === null && opts.provider === 'serve-sim' && e instanceof ApiError && (e.status === 503 || e.status === 500)) {
372
+ log('Cloud iOS simulator unavailable; falling back to Appetize...');
373
+ loaded = await load();
374
+ }
375
+ else {
376
+ throw e;
377
+ }
378
+ }
379
+ if (typeof loaded.appetize_public_key === 'string') {
380
+ log(`Appetize iOS key: ${loaded.appetize_public_key}`);
381
+ }
382
+ else if (loaded.serve_sim && typeof loaded.serve_sim === 'object') {
383
+ const session = loaded.serve_sim;
384
+ const url = typeof session.url === 'string' ? session.url : session.base_url;
385
+ if (typeof url === 'string')
386
+ log(`Cloud simulator URL: ${url}`);
387
+ if (localMetroPort !== null) {
388
+ log(`Connecting local Metro on 127.0.0.1:${localMetroPort} to the remote simulator...`);
389
+ metroUrl = await connectLocalMetroTunnel(session, localMetroPort, opts.sidecarUrl ?? 'http://127.0.0.1:4500');
390
+ log('Local Metro tunnel connected.');
391
+ }
392
+ await prepareServeSimBuildFromCli(session, metroUrl);
393
+ log(`Cloud simulator prepared on ${typeof url === 'string' ? url : 'ready'}`);
394
+ }
395
+ else {
396
+ log('Simulator load requested.');
397
+ }
398
+ }
399
+ }
400
+ export async function runAndroidSimulatorBuild(client, projectId, opts = {}, log = console.log) {
401
+ let metroUrl = opts.metroUrl?.trim() || null;
402
+ const sourceDir = opts.sourcePath ?? '.';
403
+ const localMetroPort = opts.localMetroPort == null ? null : Number(opts.localMetroPort);
404
+ if (localMetroPort !== null && (!Number.isInteger(localMetroPort) || localMetroPort < 1 || localMetroPort > 65535)) {
405
+ throw new Error('--local-metro-port must be a valid TCP port.');
406
+ }
407
+ if (localMetroPort !== null && metroUrl)
408
+ throw new Error('--local-metro-port cannot be combined with --metro-url.');
409
+ if (!opts.remoteSource) {
410
+ throw new Error('Android simulator builds currently require --remote-source.');
411
+ }
412
+ assertDirectory(sourceDir, 'Android build source directory');
413
+ projectId = await ensureRemoteSourceProject(client, sourceDir, projectId, log, 'android');
414
+ log('Packaging Android source...');
415
+ const archive = createZipSnapshot(sourceDir);
416
+ log(`Uploading source snapshot (${archiveKib(archive.length)} KiB) for remote Android simulator build...`);
417
+ const form = new FormData();
418
+ form.set('file', new Blob([new Uint8Array(archive)], { type: 'application/zip' }), 'source.zip');
419
+ form.set('bundle_identifier', androidPackageName(sourceDir));
420
+ form.set('project_type', 'expo');
421
+ form.set('project_id', projectId);
422
+ const uploaded = (await client.uploadForm('/apk-builder/upload', form));
423
+ const buildId = typeof uploaded.build_id === 'string' ? uploaded.build_id : '';
424
+ if (!buildId)
425
+ throw new Error('Backend did not return an Android build id.');
426
+ log(`Build ${buildId} uploaded (${String(uploaded.status ?? 'uploaded')})`);
427
+ await client.post('/apk-builder/start-build', { build_id: buildId });
428
+ log(`Build ${buildId} queued (processing)`);
429
+ if (opts.wait || opts.loadToSimulator) {
430
+ const status = await waitForAndroidApkBuild(client, buildId, log);
431
+ if (status !== 'completed')
432
+ throw new Error(`Android simulator build ${status}`);
433
+ }
434
+ if (opts.loadToSimulator) {
435
+ const provider = opts.provider || 'serve-sim';
436
+ const params = new URLSearchParams({ provider });
437
+ const loaded = (await client.post(`/projects/${projectId}/builds/${buildId}/load-to-simulator?${params.toString()}`, {}));
438
+ if (loaded.serve_sim && typeof loaded.serve_sim === 'object') {
439
+ const session = loaded.serve_sim;
440
+ const url = typeof session.url === 'string' ? session.url : session.base_url;
441
+ if (typeof url === 'string')
442
+ log(`Cloud simulator URL: ${url}`);
443
+ if (localMetroPort !== null) {
444
+ log(`Connecting local Metro on 127.0.0.1:${localMetroPort} to the remote Android emulator...`);
445
+ metroUrl = await connectLocalMetroTunnel(session, localMetroPort, opts.sidecarUrl ?? 'http://127.0.0.1:4500', 'android');
446
+ log('Local Metro tunnel connected.');
447
+ }
448
+ await prepareServeSimBuildFromCli(session, metroUrl);
449
+ log(`Cloud simulator prepared on ${typeof url === 'string' ? url : 'ready'}`);
450
+ }
451
+ else if (typeof loaded.appetize_public_key === 'string') {
452
+ log(`Appetize Android key: ${loaded.appetize_public_key}`);
453
+ }
454
+ else {
455
+ log('Simulator load requested.');
456
+ }
457
+ }
458
+ }
5
459
  export function registerBuild(program) {
6
460
  program
7
461
  .command('build [platform]')
8
- .description('Build the mobile app (coming soon)')
9
- .action((platform = 'ios') => {
10
- console.log(buildPlaceholderMessage(platform));
462
+ .description('Build the mobile app')
463
+ .option('--project <id>', 'project id (defaults to the linked directory)')
464
+ .option('--source-path <path>', 'local source directory to upload for --remote-source', '.')
465
+ .option('--simulator', 'build a simulator artifact', false)
466
+ .option('--remote-source', 'upload local source files and build in Newly cloud', false)
467
+ .option('--wait', 'stream logs until the build finishes', false)
468
+ .option('--load-to-simulator', 'load a completed simulator build into a cloud simulator', false)
469
+ .option('--provider <provider>', 'simulator provider for --load-to-simulator (serve-sim or appetize)')
470
+ .option('--metro-url <url>', 'reachable Metro bundle URL for remote simulator source builds')
471
+ .option('--local-metro-port <port>', 'connect local Metro through the bundled authenticated tunnel')
472
+ .option('--sidecar-url <url>', 'Newly Desktop sidecar URL for --local-metro-port', 'http://127.0.0.1:4500')
473
+ .action(async (platform = 'ios', opts) => {
474
+ try {
475
+ if ((platform !== 'ios' && platform !== 'android') || !opts.simulator) {
476
+ console.log(buildPlaceholderMessage(platform));
477
+ return;
478
+ }
479
+ if (opts.provider && !opts.loadToSimulator) {
480
+ throw new Error('--provider requires --load-to-simulator.');
481
+ }
482
+ const { client } = makeClient();
483
+ const projectId = opts.remoteSource
484
+ ? opts.project || readProjectLink(opts.sourcePath ?? '.')?.projectId
485
+ : resolveProject(process.cwd(), opts.project);
486
+ if (platform === 'android') {
487
+ await runAndroidSimulatorBuild(client, projectId, opts);
488
+ }
489
+ else {
490
+ await runIosSimulatorBuild(client, projectId, opts);
491
+ }
492
+ }
493
+ catch (e) {
494
+ console.error(e instanceof Error ? e.message : String(e));
495
+ process.exitCode = 1;
496
+ }
11
497
  });
12
498
  }
@@ -1,59 +1,135 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
3
  import { ApiClient, ApiError, friendlyApiError } from '../api.js';
4
- import { requireCredentials, writeProjectLink } from '../config.js';
5
- import { extractSnapshot } from '../archive.js';
6
- export async function runCreate(client, dir, opts) {
4
+ import { requireCredentials, writeEnvironmentSelection, writeProjectLink } from '../config.js';
5
+ import { runEnvironmentDeploy } from './deploy.js';
6
+ const POLL_INTERVAL_MS = 2_000;
7
+ const POLL_TIMEOUT_MS = 20 * 60 * 1_000;
8
+ function projectName(dir, requested) {
9
+ const value = requested?.trim() || basename(resolve(dir));
10
+ if (value.length < 1 || value.length > 100) {
11
+ throw new Error('Project name must be between 1 and 100 characters.');
12
+ }
13
+ return value;
14
+ }
15
+ export function scaffoldV3Backend(dir, name) {
16
+ const serviceRoot = join(dir, 'backend', 'api-service');
17
+ mkdirSync(join(serviceRoot, 'src'), { recursive: true });
18
+ writeFileSync(join(dir, 'backend', 'newly.services.json'), `${JSON.stringify({
19
+ services: [{
20
+ name: 'api',
21
+ path: 'api-service',
22
+ type: 'worker',
23
+ public: true,
24
+ bindings: { DATABASE_URL: { database: 'app' } },
25
+ }],
26
+ }, null, 2)}\n`);
27
+ writeFileSync(join(serviceRoot, 'package.json'), `${JSON.stringify({
28
+ name: `@newly/${name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '') || 'api'}`,
29
+ private: true,
30
+ type: 'module',
31
+ }, null, 2)}\n`);
32
+ writeFileSync(join(serviceRoot, 'src', 'index.ts'), `interface Env {
33
+ DATABASE_URL: string;
34
+ NEWLY_ENVIRONMENT_ID: string;
35
+ NEWLY_PROJECT_ID: string;
36
+ NEWLY_SERVICE_NAME: string;
37
+ }
38
+
39
+ function json(value: unknown, status = 200): Response {
40
+ return Response.json(value, { status });
41
+ }
42
+
43
+ export default {
44
+ async fetch(request: Request, env: Env): Promise<Response> {
45
+ const url = new URL(request.url);
46
+ if (url.pathname === "/health") {
47
+ return json({ ok: true, environmentId: env.NEWLY_ENVIRONMENT_ID });
48
+ }
49
+ if (url.pathname === "/") {
50
+ return json({
51
+ projectId: env.NEWLY_PROJECT_ID,
52
+ environmentId: env.NEWLY_ENVIRONMENT_ID,
53
+ service: env.NEWLY_SERVICE_NAME,
54
+ databaseConfigured: Boolean(env.DATABASE_URL),
55
+ });
56
+ }
57
+ return json({ error: "Not found" }, 404);
58
+ },
59
+ };
60
+ `);
61
+ }
62
+ async function waitForProduction(client, environmentId, runtime) {
63
+ const intervalMs = runtime.intervalMs ?? POLL_INTERVAL_MS;
64
+ const deadline = Date.now() + (runtime.timeoutMs ?? POLL_TIMEOUT_MS);
65
+ for (;;) {
66
+ const environment = await client.get(`/environments/${encodeURIComponent(environmentId)}`);
67
+ if (environment.status === 'ready' || environment.status === 'failed')
68
+ return environment;
69
+ if (Date.now() >= deadline) {
70
+ throw new Error(`Timed out provisioning production (${environment.stage}).`);
71
+ }
72
+ await new Promise((done) => setTimeout(done, intervalMs));
73
+ }
74
+ }
75
+ export async function runCreate(client, dir, opts, runtime = {}) {
7
76
  if (existsSync(join(dir, 'backend'))) {
8
77
  throw new Error('A `backend/` directory already exists here. Run `newly create` in a directory without one.');
9
78
  }
10
- const { project_id, status } = await client.post('/newly_backend/create_project', {
11
- name: opts.name,
12
- provision: opts.provision ?? true,
13
- });
79
+ const name = projectName(dir, opts.name);
80
+ console.log(`Creating ${name} with production PostgreSQL…`);
81
+ const created = await client.post('/projects', { name });
14
82
  writeProjectLink(dir, {
15
- projectId: project_id,
83
+ projectId: created.id,
16
84
  pulledSha: null,
17
85
  pulledAt: new Date().toISOString(),
18
86
  });
19
- try {
20
- const tar = await client.getBinary('/newly_backend/backend_template');
21
- await extractSnapshot(tar, dir);
87
+ scaffoldV3Backend(dir, name);
88
+ console.log(`Production database ${created.productionDatabaseId} is provisioning…`);
89
+ const production = await waitForProduction(client, created.productionEnvironmentId, runtime);
90
+ if (production.status === 'failed') {
91
+ throw new Error(`Production provisioning failed at ${production.stage}: ${production.lastError ?? created.lastError ?? 'unknown error'}`);
22
92
  }
23
- catch (e) {
24
- const msg = e instanceof Error ? e.message : String(e);
25
- throw new Error(`Project ${project_id} was created and linked, but scaffolding the backend template failed: ${msg}. The project exists server-side.`);
93
+ if (production.class !== 'production' || production.projectId !== created.id) {
94
+ throw new Error('The production bootstrap returned an environment for the wrong project.');
26
95
  }
27
- if (status === 'provisioning') {
28
- console.log(`Created backend project ${project_id} and scaffolded backend/ here. ` +
29
- `Provisioning the dev backend… check with \`newly status\`. Edit, then \`newly deploy\`.`);
96
+ writeEnvironmentSelection(dir, {
97
+ id: production.id,
98
+ name: production.name,
99
+ projectId: created.id,
100
+ });
101
+ console.log(`Using production (${production.id}) in this workspace.`);
102
+ if (opts.deploy ?? true) {
103
+ console.log('Deploying the starter backend to production…');
104
+ await (runtime.deploy ?? runEnvironmentDeploy)(created.id, production.id, client, dir);
105
+ console.log(`Created ${name}; production database and backend are ready.`);
30
106
  }
31
107
  else {
32
- console.log(`Created backend project ${project_id} and scaffolded backend/ here ` +
33
- `(not provisioned — run \`newly deploy\` when ready).`);
108
+ console.log(`Created ${name}; production database is ready. Deploy with: newly deploy`);
34
109
  }
35
- return project_id;
110
+ return created.id;
36
111
  }
37
112
  export function registerCreate(program) {
38
113
  program
39
114
  .command('create')
40
- .description('Create a new backend-only Newly project here (scaffold + provision)')
41
- .argument('[name]', 'project name (generated if omitted)')
42
- .option('--no-provision', 'create without provisioning the backend')
115
+ .description('Create a v3 project with its production database and backend')
116
+ .argument('[name]', 'project name (defaults to the current directory name)')
117
+ .option('--no-deploy', 'create production without deploying the starter backend')
43
118
  .action(async (name, opts) => {
44
119
  try {
45
120
  if (existsSync(join(process.cwd(), '.newly', 'project.json'))) {
46
121
  throw new Error('This directory is already linked to a project. Use a fresh directory for `newly create`.');
47
122
  }
48
123
  const creds = requireCredentials();
49
- const client = new ApiClient(creds.apiUrl, creds.token);
50
- await runCreate(client, process.cwd(), {
124
+ await runCreate(new ApiClient(creds.apiUrl, creds.token), process.cwd(), {
51
125
  name,
52
- provision: opts.provision,
126
+ deploy: opts.deploy,
53
127
  });
54
128
  }
55
- catch (e) {
56
- console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
129
+ catch (error) {
130
+ console.error(error instanceof ApiError
131
+ ? friendlyApiError(error)
132
+ : String(error instanceof Error ? error.message : error));
57
133
  process.exitCode = 1;
58
134
  }
59
135
  });