@thomas.f/prnext 0.1.11-alpha → 0.1.13-alpha

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.
Files changed (60) hide show
  1. package/README.md +395 -29
  2. package/build/actions.mjs +4 -0
  3. package/build/app-config.mjs +2 -0
  4. package/build/app.mjs +20 -5
  5. package/build/bundle-runtime.mjs +82 -0
  6. package/build/cache-handlers.mjs +4 -1
  7. package/build/compiler.mjs +4 -1
  8. package/build/config.mjs +20 -2
  9. package/build/font-cache.mjs +50 -0
  10. package/build/font-files.mjs +5 -3
  11. package/build/fonts.mjs +1 -1
  12. package/build/index.mjs +12 -1
  13. package/build/metadata-conflicts.mjs +13 -0
  14. package/build/metadata-images.mjs +3 -3
  15. package/build/metadata-routes.mjs +3 -3
  16. package/build/native-packages.mjs +5 -1
  17. package/build/npm-packages.mjs +14 -2
  18. package/build/optimize-imports.mjs +112 -0
  19. package/build/proxy-body-config.mjs +10 -0
  20. package/build/runtime-contract.mjs +16 -0
  21. package/build/scan.mjs +9 -7
  22. package/build/typescript-check.mjs +20 -0
  23. package/build/typescript.mjs +49 -0
  24. package/build/worker-retirement.mjs +14 -0
  25. package/cli.mjs +17 -0
  26. package/compat/after.cjs +51 -0
  27. package/compat/index.d.ts +13 -1
  28. package/compat/server.cjs +2 -1
  29. package/compat/server.d.cts +2 -0
  30. package/native/launch.mjs +19 -0
  31. package/package.json +25 -9
  32. package/release/verify.mjs +9 -0
  33. package/runtime/api.mjs +15 -5
  34. package/runtime/app-render.mjs +164 -53
  35. package/runtime/app-route-plan.mjs +45 -0
  36. package/runtime/app-routing-state.mjs +101 -21
  37. package/runtime/app-routing.mjs +37 -18
  38. package/runtime/app-segments.mjs +25 -11
  39. package/runtime/app-static.mjs +1 -1
  40. package/runtime/app-stream-html.mjs +3 -53
  41. package/runtime/cache-request.mjs +10 -2
  42. package/runtime/cloud/archive.mjs +316 -0
  43. package/runtime/cloud/client.mjs +658 -0
  44. package/runtime/cloud/environment.mjs +60 -0
  45. package/runtime/cloud/state.mjs +141 -0
  46. package/runtime/cloud/upload.mjs +48 -0
  47. package/runtime/html-envelope.mjs +136 -0
  48. package/runtime/http.mjs +9 -6
  49. package/runtime/middleware.mjs +3 -3
  50. package/runtime/profiles.json +13 -8
  51. package/runtime/request-transport.mjs +1 -1
  52. package/runtime/request-work.mjs +2 -2
  53. package/runtime/route-static.mjs +1 -1
  54. package/runtime/rsc-lifecycle.mjs +119 -0
  55. package/runtime/rsc-work-watchdog.mjs +75 -0
  56. package/runtime/rsc-worker.mjs +36 -9
  57. package/runtime/stream-utils.mjs +22 -3
  58. package/runtime/transport.mjs +89 -53
  59. package/runtime/worker-metadata.mjs +23 -0
  60. package/runtime/worker.mjs +119 -27
@@ -0,0 +1,658 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes, createHash, randomUUID } from 'node:crypto';
3
+ import { hostname, tmpdir } from 'node:os';
4
+ import { rm, realpath, mkdtemp, readFile } from 'node:fs/promises';
5
+ import { uploadSource } from './upload.mjs';
6
+ import path from 'node:path';
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { archiveProject } from './archive.mjs';
9
+ import { detectEnvFiles, readDeploymentEnv, confirmEnvImport } from './environment.mjs';
10
+ import {
11
+ cloudOrigin,
12
+ credentialPath,
13
+ linkPath,
14
+ readState,
15
+ writeState,
16
+ saveLink,
17
+ withStateLock
18
+ } from './state.mjs';
19
+
20
+ export const help = `PRNext Cloud
21
+
22
+ prn login [--server URL] [--no-browser] Connect through your browser
23
+ prn logout [--server URL] Revoke this terminal's access
24
+ prn whoami [--server URL] Show the connected account
25
+ prn deploy [directory] [--name NAME] Upload and deploy; link this folder
26
+ prn deploy [directory] --project ID Link an existing upload project
27
+ prn update [directory] Deploy new sources to the linked project
28
+
29
+ Deploy options: --dry-run (list files, no upload), --no-wait, --timeout SECONDS
30
+ Environment: prompts before importing production .env files; --with-env or --without-env for automation
31
+ Local development: --server http://127.0.0.1:3003
32
+ Default server: https://app.prnext.dev (or PRNEXT_CLOUD_URL)
33
+ Credentials stay in your user profile, outside project sources.
34
+ Source excludes secrets/build output and respects root .gitignore/.prnextignore.
35
+ `;
36
+ const clean = (value) =>
37
+ String(value || '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
38
+
39
+ async function resolveCredentialDirectory(directory) {
40
+ let existing = directory;
41
+ const missing = [];
42
+ for (;;) {
43
+ try {
44
+ return path.join(await realpath(existing), ...missing);
45
+ } catch (error) {
46
+ const parent = path.dirname(existing);
47
+ if (error.code !== 'ENOENT' || parent === existing) throw error;
48
+ // Resolve existing ancestors even when the credential directory has not
49
+ // been created yet (macOS /var aliases, Windows short paths, symlinks).
50
+ missing.unshift(path.basename(existing));
51
+ existing = parent;
52
+ }
53
+ }
54
+ }
55
+
56
+ export function parseCloudArgs(command, argv) {
57
+ const options = { directory: '.', timeout: 600 };
58
+ const flags = new Map([
59
+ ['--server', 'server'],
60
+ ['--name', 'name'],
61
+ ['--project', 'project'],
62
+ ['--timeout', 'timeout']
63
+ ]);
64
+ let directory = false;
65
+ const seen = new Set();
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const arg = argv[i];
68
+ if (arg.startsWith('-')) {
69
+ if (seen.has(arg)) throw new Error(`Duplicate option: ${arg}`);
70
+ seen.add(arg);
71
+ if (['--no-browser', '--dry-run', '--no-wait', '--with-env', '--without-env'].includes(arg))
72
+ options[arg.slice(2)] = true;
73
+ else if (flags.has(arg)) {
74
+ if (!argv[i + 1] || argv[i + 1].startsWith('--'))
75
+ throw new Error(`Missing value for ${arg}`);
76
+ options[flags.get(arg)] = argv[++i];
77
+ } else throw new Error(`Unknown Cloud option: ${arg}`);
78
+ } else {
79
+ if (directory || !['deploy', 'update'].includes(command))
80
+ throw new Error(
81
+ 'Unexpected directory or argument. Use prn ' + command + ' --help.'
82
+ );
83
+ directory = true;
84
+ options.directory = arg;
85
+ }
86
+ }
87
+ const allowed =
88
+ command === 'login'
89
+ ? ['server', 'name', 'no-browser']
90
+ : ['logout', 'whoami'].includes(command)
91
+ ? ['server']
92
+ : command === 'update'
93
+ ? ['server', 'timeout', 'dry-run', 'no-wait', 'with-env', 'without-env']
94
+ : ['server', 'name', 'project', 'timeout', 'dry-run', 'no-wait', 'with-env', 'without-env'];
95
+ for (const arg of seen)
96
+ if (!allowed.includes(flags.get(arg) || arg.slice(2)))
97
+ throw new Error(`${arg} is not supported by prn ${command}.`);
98
+ options.timeout = Number(options.timeout);
99
+ if (options['with-env'] && options['without-env']) throw new Error('Choose either --with-env or --without-env.');
100
+ if (
101
+ !Number.isInteger(options.timeout) ||
102
+ options.timeout < 10 ||
103
+ options.timeout > 1800
104
+ )
105
+ throw new Error('--timeout must be between 10 and 1800 seconds.');
106
+ if (
107
+ options.name &&
108
+ (options.name.length > 80 || /[\x00-\x1f\x7f]/.test(options.name))
109
+ )
110
+ throw new Error(
111
+ 'Use a name of at most 80 characters without control characters.'
112
+ );
113
+ if (options.project && !/^[a-f0-9-]{36}$/.test(options.project))
114
+ throw new Error('--project must be a project ID from the dashboard.');
115
+ return options;
116
+ }
117
+ export function createCloudClient(
118
+ origin,
119
+ { token, signal, fetcher = fetch } = {}
120
+ ) {
121
+ origin = cloudOrigin(origin);
122
+ return async (endpoint, { method = 'GET', body } = {}) => {
123
+ if (!endpoint.startsWith('/') || endpoint.startsWith('//'))
124
+ throw new Error('Invalid API path.');
125
+ let response;
126
+ try {
127
+ response = await fetcher(`${origin}/api${endpoint}`, {
128
+ method,
129
+ redirect: 'error',
130
+ headers: {
131
+ accept: 'application/json',
132
+ 'x-prnext-cli': '1',
133
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
134
+ ...(body !== undefined
135
+ ? {
136
+ 'content-type': Buffer.isBuffer(body)
137
+ ? 'application/zip'
138
+ : 'application/json'
139
+ }
140
+ : {})
141
+ },
142
+ body:
143
+ body === undefined
144
+ ? undefined
145
+ : Buffer.isBuffer(body)
146
+ ? body
147
+ : JSON.stringify(body),
148
+ signal: AbortSignal.any([
149
+ AbortSignal.timeout(endpoint.endsWith('/complete') ? 300000 : Buffer.isBuffer(body) ? 180000 : 30000),
150
+ ...(signal ? [signal] : [])
151
+ ])
152
+ });
153
+ } catch (error) {
154
+ if (signal?.aborted) throw signal.reason;
155
+ throw new Error(
156
+ `Cannot reach ${origin}. ${method === 'GET' ? 'Check the server and your connection.' : 'The request may have reached the server; check the dashboard before repeating it.'}`
157
+ );
158
+ }
159
+ const reader = response.body?.getReader(),
160
+ chunks = [];
161
+ let size = 0;
162
+ try {
163
+ while (reader) {
164
+ const item = await reader.read();
165
+ if (item.done) break;
166
+ size += item.value.length;
167
+ if (size > 8 * 1048576) {
168
+ await reader.cancel();
169
+ throw new Error('Cloud response exceeds the safe size limit.');
170
+ }
171
+ chunks.push(item.value);
172
+ }
173
+ } finally {
174
+ reader?.releaseLock();
175
+ }
176
+ let data;
177
+ try {
178
+ data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
179
+ } catch {
180
+ throw new Error(
181
+ `Cloud returned an invalid response (HTTP ${response.status}).`
182
+ );
183
+ }
184
+ if (!response.ok) {
185
+ const error = new Error(
186
+ clean(
187
+ data.error?.message ||
188
+ `Cloud request failed (HTTP ${response.status}).`
189
+ ).slice(0, 1000)
190
+ );
191
+ error.status = response.status;
192
+ error.code = data.error?.code;
193
+ error.interval = data.interval;
194
+ throw error;
195
+ }
196
+ return data;
197
+ };
198
+ }
199
+ export async function openBrowser(url) {
200
+ const parsed = new URL(url);
201
+ cloudOrigin(parsed.origin);
202
+ const args =
203
+ process.platform === 'win32'
204
+ ? [
205
+ 'powershell.exe',
206
+ [
207
+ '-NoProfile',
208
+ '-NonInteractive',
209
+ '-EncodedCommand',
210
+ Buffer.from(
211
+ `Start-Process -FilePath '${parsed.href.replaceAll("'", "''")}'`,
212
+ 'utf16le'
213
+ ).toString('base64')
214
+ ]
215
+ ]
216
+ : process.platform === 'darwin'
217
+ ? ['open', [parsed.href]]
218
+ : ['xdg-open', [parsed.href]];
219
+ return new Promise((resolve) => {
220
+ const child = spawn(args[0], args[1], {
221
+ stdio: 'ignore',
222
+ windowsHide: true
223
+ });
224
+ const timer = setTimeout(() => {
225
+ child.unref();
226
+ resolve(false);
227
+ }, 5000);
228
+ child.once('error', () => {
229
+ clearTimeout(timer);
230
+ resolve(false);
231
+ });
232
+ child.once('exit', (code) => {
233
+ clearTimeout(timer);
234
+ resolve(code === 0);
235
+ });
236
+ });
237
+ }
238
+ async function login(origin, options, context) {
239
+ const { env, signal, log, browser } = context;
240
+ const api = createCloudClient(origin, { signal });
241
+ const verifier = randomBytes(32).toString('base64url');
242
+ const grant = await api('/cli/device', {
243
+ method: 'POST',
244
+ body: {
245
+ name: options.name || hostname().slice(0, 80),
246
+ codeChallenge: createHash('sha256').update(verifier).digest('base64url')
247
+ }
248
+ });
249
+ const url = new URL(grant.verificationUrl);
250
+ if (
251
+ url.origin !== origin ||
252
+ url.pathname !== '/' ||
253
+ !/^#cli\?code=[A-F0-9]{4}(?:-[A-F0-9]{4}){2}$/.test(url.hash) ||
254
+ !/^[A-Za-z0-9_-]{43}$/.test(grant.deviceCode) ||
255
+ !/^[A-F0-9]{4}(?:-[A-F0-9]{4}){2}$/.test(grant.userCode)
256
+ )
257
+ throw new Error('Cloud returned an invalid login request.');
258
+ log(`Connect your terminal: ${url.href}\nCode: ${grant.userCode}`);
259
+ let completed = false;
260
+ try {
261
+ if (!options['no-browser'] && !(await browser(url.href)))
262
+ log('Open the link above in your browser to continue.');
263
+ const deadline =
264
+ Date.now() + Math.min(600, Number(grant.expiresIn) || 600) * 1000;
265
+ let seconds = Math.max(5, Math.min(30, Number(grant.interval) || 5));
266
+ while (Date.now() < deadline) {
267
+ await delay(seconds * 1000, undefined, { signal });
268
+ let result;
269
+ try {
270
+ result = await api('/cli/token', {
271
+ method: 'POST',
272
+ body: { deviceCode: grant.deviceCode, codeVerifier: verifier }
273
+ });
274
+ } catch (error) {
275
+ if (error.code !== 'slow_down') throw error;
276
+ seconds = Math.min(30, seconds + 5);
277
+ continue;
278
+ }
279
+ if (result.status === 'authorization_pending') continue;
280
+ if (
281
+ !/^prn_[A-Za-z0-9_-]{43}$/.test(result.accessToken || '') ||
282
+ !Number.isSafeInteger(result.expiresAt) ||
283
+ result.expiresAt <= Date.now()
284
+ )
285
+ throw new Error('Cloud returned invalid terminal credentials.');
286
+ const authorized = createCloudClient(origin, {
287
+ token: result.accessToken,
288
+ signal
289
+ });
290
+ let identity, previous;
291
+ try {
292
+ identity = await authorized('/session');
293
+ if (!identity.user?.id || !identity.workspace?.id)
294
+ throw new Error('Could not verify the connected account.');
295
+ const filename = credentialPath(origin, env);
296
+ previous = await readState(filename);
297
+ await writeState(filename, {
298
+ version: 1,
299
+ origin,
300
+ accessToken: result.accessToken,
301
+ expiresAt: result.expiresAt,
302
+ userId: identity.user.id,
303
+ email: identity.user.email,
304
+ workspaceId: identity.workspace.id
305
+ });
306
+ } catch (error) {
307
+ try {
308
+ await createCloudClient(origin, {
309
+ token: result.accessToken,
310
+ signal: AbortSignal.timeout(2000)
311
+ })('/cli/logout', { method: 'POST', body: {} });
312
+ } catch {
313
+ log(
314
+ 'Login could not be saved. Revoke this terminal in workspace settings if its connection is still listed.'
315
+ );
316
+ }
317
+ throw error;
318
+ }
319
+ completed = true;
320
+ if (
321
+ previous?.origin === origin &&
322
+ previous.accessToken &&
323
+ previous.accessToken !== result.accessToken
324
+ ) {
325
+ try {
326
+ await createCloudClient(origin, {
327
+ token: previous.accessToken,
328
+ signal
329
+ })('/cli/logout', { method: 'POST', body: {} });
330
+ } catch (error) {
331
+ if (error.status !== 401)
332
+ log(
333
+ 'Your previous terminal connection could not be revoked; remove it in workspace settings.'
334
+ );
335
+ }
336
+ }
337
+ log(
338
+ `Connected as ${clean(identity.user.email)} (${clean(identity.workspace.name)}).`
339
+ );
340
+ return;
341
+ }
342
+ throw new Error('Login timed out. Run prn login again.');
343
+ } finally {
344
+ if (!completed) {
345
+ try {
346
+ await createCloudClient(origin, { signal: AbortSignal.timeout(2000) })(
347
+ '/cli/cancel',
348
+ {
349
+ method: 'POST',
350
+ body: { deviceCode: grant.deviceCode, codeVerifier: verifier }
351
+ }
352
+ );
353
+ } catch {}
354
+ }
355
+ }
356
+ }
357
+ const terminalStates = new Set([
358
+ 'ready',
359
+ 'succeeded',
360
+ 'failed',
361
+ 'cancelled',
362
+ 'canceled',
363
+ 'interrupted'
364
+ ]);
365
+ async function deploy(
366
+ command,
367
+ options,
368
+ origin,
369
+ directory,
370
+ link,
371
+ api,
372
+ identity,
373
+ context
374
+ ) {
375
+ const { signal, log } = context;
376
+ if (
377
+ link &&
378
+ (link.origin !== origin || link.workspaceId !== identity.workspace.id)
379
+ )
380
+ throw new Error(
381
+ 'This folder is linked to another Cloud server or workspace. Use its account or a separate project directory.'
382
+ );
383
+ if (command === 'update' && !link?.projectId)
384
+ throw new Error('This folder is not linked yet. Run prn deploy first.');
385
+ if (options.project && link && options.project !== link.projectId)
386
+ throw new Error(
387
+ 'This folder is already linked to another project. Use a separate directory to avoid replacing the wrong site.'
388
+ );
389
+ let project =
390
+ link || (options.project ? { projectId: options.project } : null),
391
+ detail;
392
+ if (project) {
393
+ detail = await api(`/projects/${encodeURIComponent(project.projectId)}`);
394
+ if (detail.project.sourceType !== 'upload')
395
+ throw new Error(
396
+ 'This project deploys from GitHub. Choose an upload project for CLI deployments.'
397
+ );
398
+ }
399
+ if (!link?.pending && !identity.capabilities?.runtime?.canDeploy)
400
+ throw new Error(
401
+ identity.capabilities?.runtime?.reason ||
402
+ 'The isolated deployment worker is unavailable.'
403
+ );
404
+ if (!link?.pending) {
405
+ let variables;
406
+ const envFiles = options['without-env'] ? [] : await detectEnvFiles(directory);
407
+ if (envFiles.length) {
408
+ const target = detail?.project?.name || options.name || path.basename(directory);
409
+ const consent = options['with-env'] || await context.confirmEnv(`Detected ${envFiles.join(', ')}. Import these variables to ${clean(target)} on ${origin}? Matching server values will be replaced. NEXT_PUBLIC_ variables can be public.`, { signal });
410
+ if (consent) variables = await readDeploymentEnv(directory, envFiles);
411
+ else log('Environment files not imported. Existing server variables are unchanged.');
412
+ }
413
+ if (variables && !identity.capabilities.environmentImport) throw new Error('This Host server must be updated before importing environment files. No source was uploaded.');
414
+ const transfer = identity.capabilities.uploads;
415
+ const temporary = await mkdtemp(path.join(tmpdir(),'prnext-source-'));
416
+ try {
417
+ const archive = await archiveProject(directory, {
418
+ signal,
419
+ outputFile: path.join(temporary,'source.zip'),
420
+ ...(transfer?.resumable ? {maxSourceBytes:Math.min(500*1048576,transfer.sourceBytes),maxFileBytes:Math.min(64*1048576,transfer.fileBytes),maxFiles:15000} : {}),
421
+ maxArchiveBytes: Math.min(
422
+ transfer?.resumable ? 100 * 1048576 : 7 * 1048576,
423
+ identity.capabilities.uploadMaxBytes || 7 * 1048576
424
+ )
425
+ });
426
+ log(
427
+ `Source: ${archive.files.length} files, ${(archive.size / 1048576).toFixed(2)} MiB (${archive.excluded} excluded).`
428
+ );
429
+ if (!project) {
430
+ const suggested =
431
+ options.name ||
432
+ String(archive.packageJson.name || path.basename(directory))
433
+ .replace(/^@[^/]+\//, '')
434
+ .replace(/[^a-zA-Z0-9._ -]/g, '-')
435
+ .slice(0, 80);
436
+ const created = await api('/projects', {
437
+ method: 'POST',
438
+ body: { name: suggested, sourceType: 'upload' }
439
+ });
440
+ project = { projectId: created.project.id };
441
+ }
442
+ link = {
443
+ ...link,
444
+ version: 1,
445
+ origin,
446
+ workspaceId: identity.workspace.id,
447
+ projectId: project.projectId
448
+ };
449
+ await saveLink(directory, link);
450
+ const upload = transfer?.resumable ? await uploadSource(api,link,archive,{signal,log,save: link => saveLink(directory,link)}) : await api(
451
+ `/projects/${encodeURIComponent(link.projectId)}/upload`,
452
+ { method: 'POST', body: await readFile(archive.path) }
453
+ );
454
+ link.pending = { uploadId: upload.uploadId, requestId: randomUUID() };
455
+ if (variables) {
456
+ await api(`/projects/${encodeURIComponent(link.projectId)}/env/import`, { method: 'PUT', body: { variables } });
457
+ log(`${Object.keys(variables).length} environment variables imported separately from sources.`);
458
+ }
459
+ await saveLink(directory, link);
460
+ delete link.upload;
461
+ await saveLink(directory,link);
462
+ } finally { await rm(temporary,{recursive:true,force:true}); }
463
+ } else
464
+ log(
465
+ 'Resuming the recorded deployment request; no duplicate source upload.'
466
+ );
467
+ const dashboard = `${origin}/#project/${encodeURIComponent(link.projectId)}/deployments`;
468
+ if (!link.pending.deploymentId) {
469
+ try {
470
+ let queued;
471
+ for (let attempt=0;;attempt++) {
472
+ try {
473
+ queued = await api(`/projects/${encodeURIComponent(link.projectId)}/deploy`,{method:'POST',body:link.pending});
474
+ break;
475
+ } catch(error) {
476
+ if (error.code !== 'job_active' || attempt >= 15) throw error;
477
+ if (!attempt) log('Waiting for the previous project operation to finish…');
478
+ await delay(2000,undefined,{signal});
479
+ }
480
+ }
481
+ link.pending.deploymentId = queued.deployment.id;
482
+ await saveLink(directory, link);
483
+ } catch (error) {
484
+ log(
485
+ `Deployment request retained. Run prn update to resume it.\n${dashboard}`
486
+ );
487
+ throw error;
488
+ }
489
+ }
490
+ log(`Deployment ${clean(link.pending.deploymentId)}\n${dashboard}`);
491
+ if (options['no-wait']) {
492
+ log(
493
+ 'Queued. Run prn update to resume waiting, or follow progress in the dashboard.'
494
+ );
495
+ return;
496
+ }
497
+ const deadline = Date.now() + options.timeout * 1000;
498
+ let lastStatus;
499
+ while (Date.now() < deadline) {
500
+ signal?.throwIfAborted();
501
+ const current = await api(
502
+ `/projects/${encodeURIComponent(link.projectId)}`
503
+ );
504
+ const deployment = current.deployments?.find(
505
+ (item) => item.id === link.pending.deploymentId
506
+ );
507
+ if (!deployment)
508
+ throw new Error(
509
+ `Deployment is no longer in recent history. Check ${dashboard}`
510
+ );
511
+ if (deployment.status !== lastStatus) {
512
+ lastStatus = deployment.status;
513
+ log(`Status: ${clean(lastStatus)}`);
514
+ }
515
+ if (terminalStates.has(deployment.status)) {
516
+ link.lastDeploymentId = deployment.id;
517
+ delete link.pending;
518
+ await saveLink(directory, link);
519
+ if (!['ready', 'succeeded'].includes(deployment.status)) {
520
+ if (deployment.logs) log(clean(deployment.logs).slice(-4000));
521
+ throw new Error(
522
+ clean(
523
+ deployment.error ||
524
+ `Deployment ${deployment.status}. The previous live deployment is preserved.`
525
+ )
526
+ );
527
+ }
528
+ log(
529
+ `Deployed successfully.${current.project.domain ? ` https://${clean(current.project.domain)}` : ' Add a domain in the dashboard to visit your site.'}`
530
+ );
531
+ return;
532
+ }
533
+ await delay(2000, undefined, { signal });
534
+ }
535
+ throw new Error(
536
+ `Still deploying. Run prn update to resume waiting without deploying twice.\n${dashboard}`
537
+ );
538
+ }
539
+ export async function cloudCommand(
540
+ command,
541
+ argv,
542
+ {
543
+ env = process.env,
544
+ log = console.log,
545
+ browser = openBrowser,
546
+ confirmEnv = confirmEnvImport,
547
+ signal: suppliedSignal
548
+ } = {}
549
+ ) {
550
+ if (argv.includes('--help') || argv.includes('-h')) {
551
+ log(help);
552
+ return;
553
+ }
554
+ const options = parseCloudArgs(command, argv),
555
+ directory = ['deploy', 'update'].includes(command)
556
+ ? await realpath(path.resolve(options.directory))
557
+ : process.cwd();
558
+ const linked = ['deploy', 'update'].includes(command)
559
+ ? await readState(linkPath(directory))
560
+ : null;
561
+ if (
562
+ linked &&
563
+ (linked.version !== 1 ||
564
+ !/^[a-f0-9-]{36}$/.test(linked.projectId || '') ||
565
+ !linked.workspaceId)
566
+ )
567
+ throw new Error(
568
+ 'Invalid .prnext-cloud/project.json. Restore its project link before deploying.'
569
+ );
570
+ const origin = cloudOrigin(
571
+ options.server || env.PRNEXT_CLOUD_URL || linked?.origin
572
+ );
573
+ const controller = new AbortController();
574
+ const signal = suppliedSignal
575
+ ? AbortSignal.any([suppliedSignal, controller.signal])
576
+ : controller.signal;
577
+ const interrupt = () =>
578
+ controller.abort(new Error('Cloud command cancelled.'));
579
+ process.once('SIGINT', interrupt);
580
+ process.once('SIGTERM', interrupt);
581
+ const context = { env, log, browser, confirmEnv, signal };
582
+ try {
583
+ if (['deploy', 'update'].includes(command)) {
584
+ const requestedHome = path.dirname(credentialPath(origin, env));
585
+ const actualHome = await resolveCredentialDirectory(requestedHome);
586
+ const credentialsRelative = path.relative(directory, actualHome);
587
+ if (
588
+ !credentialsRelative ||
589
+ (!credentialsRelative.startsWith(`..${path.sep}`) &&
590
+ credentialsRelative !== '..' &&
591
+ !path.isAbsolute(credentialsRelative))
592
+ )
593
+ throw new Error(
594
+ 'PRNEXT_CLOUD_HOME must be outside the project being deployed.'
595
+ );
596
+ }
597
+ if (options['dry-run']) {
598
+ const temporary = await mkdtemp(path.join(tmpdir(),'prnext-source-'));
599
+ try {
600
+ const archive = await archiveProject(directory, { signal,outputFile:path.join(temporary,'source.zip'),maxArchiveBytes:100*1048576,maxSourceBytes:500*1048576,maxFileBytes:64*1048576,maxFiles:15000 });
601
+ log(archive.files.join('\n'));
602
+ log(
603
+ `${archive.files.length} files, ${(archive.size / 1048576).toFixed(2)} MiB compressed; ${archive.excluded} excluded. No upload performed.`
604
+ );
605
+ return;
606
+ } finally {await rm(temporary,{recursive:true,force:true});}
607
+ }
608
+ if (command === 'login') return await login(origin, options, context);
609
+ const filename = credentialPath(origin, env),
610
+ credential = await readState(filename);
611
+ if (
612
+ !credential ||
613
+ credential.origin !== origin ||
614
+ !/^prn_[A-Za-z0-9_-]{43}$/.test(credential.accessToken || '')
615
+ )
616
+ throw new Error(
617
+ `Not connected to ${origin}. Run prn login${options.server ? ` --server ${origin}` : ''}.`
618
+ );
619
+ const api = createCloudClient(origin, {
620
+ token: credential.accessToken,
621
+ signal
622
+ });
623
+ if (command === 'logout') {
624
+ try {
625
+ await api('/cli/logout', { method: 'POST', body: {} });
626
+ } catch (error) {
627
+ if (error.status !== 401) throw error;
628
+ }
629
+ await rm(filename);
630
+ log(`Terminal disconnected from ${origin}.`);
631
+ return;
632
+ }
633
+ if (credential.expiresAt <= Date.now())
634
+ throw new Error('Terminal access expired. Run prn login again.');
635
+ const identity = await api('/session');
636
+ if (command === 'whoami') {
637
+ log(
638
+ `${clean(identity.user.email)} · ${clean(identity.workspace.name)}\n${origin}`
639
+ );
640
+ return;
641
+ }
642
+ return await withStateLock(linkPath(directory) + '.lock', async () =>
643
+ deploy(
644
+ command,
645
+ options,
646
+ origin,
647
+ directory,
648
+ await readState(linkPath(directory)),
649
+ api,
650
+ identity,
651
+ context
652
+ )
653
+ );
654
+ } finally {
655
+ process.removeListener('SIGINT', interrupt);
656
+ process.removeListener('SIGTERM', interrupt);
657
+ }
658
+ }