@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.
@@ -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,
@@ -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
- if (!prepareUrl) {
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(), 20_000);
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('serve-sim prepare timed out after 20000ms');
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
- const metroUrl = opts.metroUrl?.trim() || null;
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 an iOS simulator artifact', false)
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
- await runIosSimulatorBuild(client, projectId, opts);
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));
@@ -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
  });