@newly/cli 0.1.2 → 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.
- package/README.md +20 -19
- package/README.runcloud.md +253 -0
- package/dist/api.js +123 -43
- package/dist/commands/appStore.js +162 -0
- package/dist/commands/appStoreEvaluate.js +515 -0
- package/dist/commands/boxAccess.js +349 -0
- package/dist/commands/build.js +261 -13
- package/dist/commands/create.js +106 -30
- package/dist/commands/deploy.js +100 -129
- package/dist/commands/desktop.js +55 -0
- package/dist/commands/environment.js +290 -0
- package/dist/commands/image.js +145 -0
- package/dist/commands/login.js +265 -18
- package/dist/commands/run-cloud.js +318 -108
- package/dist/commands/sandbox.js +787 -0
- package/dist/commands/sandboxOutput.js +70 -0
- package/dist/commands/secrets.js +203 -0
- package/dist/config.js +56 -17
- package/dist/deviceLogin.js +48 -0
- package/dist/index.js +6 -0
- package/dist/prompt.js +196 -0
- package/dist/run-cloud.js +9 -2
- package/dist/runCloudAuth.js +79 -0
- package/dist/sandboxExec.js +91 -0
- package/dist/services-manifest.js +30 -1
- package/dist/shellSession.js +86 -0
- package/dist/terminal.js +158 -0
- package/dist/version.js +1 -0
- package/package.json +13 -7
package/dist/commands/build.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -91,8 +194,41 @@ async function waitForBuild(client, buildId, log) {
|
|
|
91
194
|
status = event.status;
|
|
92
195
|
}
|
|
93
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
|
+
}
|
|
94
214
|
return status;
|
|
95
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
|
+
}
|
|
96
232
|
function serveSimPrepareUrl(session) {
|
|
97
233
|
if (typeof session.base_url !== 'string' || typeof session.install_url !== 'string')
|
|
98
234
|
return null;
|
|
@@ -104,16 +240,28 @@ function serveSimPrepareUrl(session) {
|
|
|
104
240
|
prepareUrl.searchParams.set('async', '1');
|
|
105
241
|
return prepareUrl.toString();
|
|
106
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
|
+
}
|
|
107
253
|
async function prepareServeSimBuildFromCli(session, launchUrl) {
|
|
254
|
+
const prepareTimeoutMs = 300_000;
|
|
108
255
|
if (typeof session.token !== 'string') {
|
|
109
256
|
throw new Error('Backend returned a serve-sim session without a token.');
|
|
110
257
|
}
|
|
111
258
|
const prepareUrl = serveSimPrepareUrl(session);
|
|
112
|
-
|
|
259
|
+
const sessionUrl = serveSimSessionUrl(session);
|
|
260
|
+
if (!prepareUrl || !sessionUrl) {
|
|
113
261
|
throw new Error('Backend returned a serve-sim session without a prepare target.');
|
|
114
262
|
}
|
|
115
263
|
const controller = new AbortController();
|
|
116
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
264
|
+
const timeout = setTimeout(() => controller.abort(), prepareTimeoutMs);
|
|
117
265
|
try {
|
|
118
266
|
const res = await fetch(prepareUrl, {
|
|
119
267
|
method: 'POST',
|
|
@@ -131,10 +279,27 @@ async function prepareServeSimBuildFromCli(session, launchUrl) {
|
|
|
131
279
|
const body = await res.text().catch(() => '');
|
|
132
280
|
throw new Error(`serve-sim prepare failed: HTTP ${res.status}${body ? ` ${body}` : ''}`);
|
|
133
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`);
|
|
134
299
|
}
|
|
135
300
|
catch (error) {
|
|
136
301
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
137
|
-
throw new Error(
|
|
302
|
+
throw new Error(`serve-sim prepare timed out after ${prepareTimeoutMs}ms`);
|
|
138
303
|
}
|
|
139
304
|
throw error;
|
|
140
305
|
}
|
|
@@ -144,11 +309,19 @@ async function prepareServeSimBuildFromCli(session, launchUrl) {
|
|
|
144
309
|
}
|
|
145
310
|
export async function runIosSimulatorBuild(client, projectId, opts = {}, log = console.log) {
|
|
146
311
|
let build;
|
|
147
|
-
|
|
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.');
|
|
148
320
|
if (opts.remoteSource) {
|
|
149
|
-
const sourceDir = opts.sourcePath ?? '.';
|
|
150
321
|
assertDirectory(sourceDir, 'iOS build source directory');
|
|
151
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.');
|
|
152
325
|
log('Packaging iOS source...');
|
|
153
326
|
const archive = await createSnapshot(sourceDir);
|
|
154
327
|
log(`Uploading source snapshot (${archiveKib(archive.length)} KiB) for remote iOS simulator build...`);
|
|
@@ -158,6 +331,8 @@ export async function runIosSimulatorBuild(client, projectId, opts = {}, log = c
|
|
|
158
331
|
platform: 'ios',
|
|
159
332
|
mode: 'automatic',
|
|
160
333
|
build_profile: 'development-simulator',
|
|
334
|
+
bundle_url: localMetroPort === null ? metroUrl : undefined,
|
|
335
|
+
native_fingerprint: fingerprint,
|
|
161
336
|
}));
|
|
162
337
|
form.set('source', new Blob([new Uint8Array(archive)], { type: 'application/gzip' }), 'source.tar.gz');
|
|
163
338
|
build = (await client.uploadForm('/builds/source-upload', form));
|
|
@@ -193,7 +368,7 @@ export async function runIosSimulatorBuild(client, projectId, opts = {}, log = c
|
|
|
193
368
|
loaded = await load(opts.provider);
|
|
194
369
|
}
|
|
195
370
|
catch (e) {
|
|
196
|
-
if (opts.provider === 'serve-sim' && e instanceof ApiError && (e.status === 503 || e.status === 500)) {
|
|
371
|
+
if (localMetroPort === null && opts.provider === 'serve-sim' && e instanceof ApiError && (e.status === 503 || e.status === 500)) {
|
|
197
372
|
log('Cloud iOS simulator unavailable; falling back to Appetize...');
|
|
198
373
|
loaded = await load();
|
|
199
374
|
}
|
|
@@ -206,10 +381,76 @@ export async function runIosSimulatorBuild(client, projectId, opts = {}, log = c
|
|
|
206
381
|
}
|
|
207
382
|
else if (loaded.serve_sim && typeof loaded.serve_sim === 'object') {
|
|
208
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
|
+
}
|
|
209
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;
|
|
210
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);
|
|
211
449
|
log(`Cloud simulator prepared on ${typeof url === 'string' ? url : 'ready'}`);
|
|
212
450
|
}
|
|
451
|
+
else if (typeof loaded.appetize_public_key === 'string') {
|
|
452
|
+
log(`Appetize Android key: ${loaded.appetize_public_key}`);
|
|
453
|
+
}
|
|
213
454
|
else {
|
|
214
455
|
log('Simulator load requested.');
|
|
215
456
|
}
|
|
@@ -221,15 +462,17 @@ export function registerBuild(program) {
|
|
|
221
462
|
.description('Build the mobile app')
|
|
222
463
|
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
223
464
|
.option('--source-path <path>', 'local source directory to upload for --remote-source', '.')
|
|
224
|
-
.option('--simulator', 'build
|
|
465
|
+
.option('--simulator', 'build a simulator artifact', false)
|
|
225
466
|
.option('--remote-source', 'upload local source files and build in Newly cloud', false)
|
|
226
467
|
.option('--wait', 'stream logs until the build finishes', false)
|
|
227
468
|
.option('--load-to-simulator', 'load a completed simulator build into a cloud simulator', false)
|
|
228
469
|
.option('--provider <provider>', 'simulator provider for --load-to-simulator (serve-sim or appetize)')
|
|
229
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')
|
|
230
473
|
.action(async (platform = 'ios', opts) => {
|
|
231
474
|
try {
|
|
232
|
-
if (platform !== 'ios' || !opts.simulator) {
|
|
475
|
+
if ((platform !== 'ios' && platform !== 'android') || !opts.simulator) {
|
|
233
476
|
console.log(buildPlaceholderMessage(platform));
|
|
234
477
|
return;
|
|
235
478
|
}
|
|
@@ -240,7 +483,12 @@ export function registerBuild(program) {
|
|
|
240
483
|
const projectId = opts.remoteSource
|
|
241
484
|
? opts.project || readProjectLink(opts.sourcePath ?? '.')?.projectId
|
|
242
485
|
: resolveProject(process.cwd(), opts.project);
|
|
243
|
-
|
|
486
|
+
if (platform === 'android') {
|
|
487
|
+
await runAndroidSimulatorBuild(client, projectId, opts);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
await runIosSimulatorBuild(client, projectId, opts);
|
|
491
|
+
}
|
|
244
492
|
}
|
|
245
493
|
catch (e) {
|
|
246
494
|
console.error(e instanceof Error ? e.message : String(e));
|
package/dist/commands/create.js
CHANGED
|
@@ -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 {
|
|
6
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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:
|
|
83
|
+
projectId: created.id,
|
|
16
84
|
pulledSha: null,
|
|
17
85
|
pulledAt: new Date().toISOString(),
|
|
18
86
|
});
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
110
|
+
return created.id;
|
|
36
111
|
}
|
|
37
112
|
export function registerCreate(program) {
|
|
38
113
|
program
|
|
39
114
|
.command('create')
|
|
40
|
-
.description('Create a
|
|
41
|
-
.argument('[name]', 'project name (
|
|
42
|
-
.option('--no-
|
|
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
|
-
|
|
50
|
-
await runCreate(client, process.cwd(), {
|
|
124
|
+
await runCreate(new ApiClient(creds.apiUrl, creds.token), process.cwd(), {
|
|
51
125
|
name,
|
|
52
|
-
|
|
126
|
+
deploy: opts.deploy,
|
|
53
127
|
});
|
|
54
128
|
}
|
|
55
|
-
catch (
|
|
56
|
-
console.error(
|
|
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
|
});
|