@thomas.f/prnext 0.1.12-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.
- package/README.md +262 -33
- package/build/actions.mjs +4 -0
- package/build/app-config.mjs +2 -0
- package/build/app.mjs +20 -5
- package/build/bundle-runtime.mjs +82 -0
- package/build/compiler.mjs +3 -1
- package/build/config.mjs +9 -2
- package/build/font-cache.mjs +50 -0
- package/build/font-files.mjs +5 -3
- package/build/fonts.mjs +1 -1
- package/build/index.mjs +5 -0
- package/build/metadata-conflicts.mjs +13 -0
- package/build/metadata-images.mjs +3 -3
- package/build/metadata-routes.mjs +3 -3
- package/build/native-packages.mjs +5 -1
- package/build/npm-packages.mjs +2 -1
- package/build/optimize-imports.mjs +112 -0
- package/build/proxy-body-config.mjs +10 -0
- package/build/scan.mjs +9 -7
- package/cli.mjs +16 -0
- package/compat/after.cjs +51 -0
- package/compat/index.d.ts +3 -1
- package/compat/server.cjs +2 -1
- package/compat/server.d.cts +2 -0
- package/native/launch.mjs +19 -0
- package/package.json +7 -7
- package/runtime/api.mjs +15 -5
- package/runtime/app-render.mjs +78 -33
- package/runtime/app-route-plan.mjs +45 -0
- package/runtime/app-routing-state.mjs +101 -21
- package/runtime/app-routing.mjs +37 -18
- package/runtime/app-segments.mjs +25 -11
- package/runtime/app-static.mjs +1 -1
- package/runtime/app-stream-html.mjs +2 -1
- package/runtime/cache-request.mjs +10 -2
- package/runtime/cloud/archive.mjs +316 -0
- package/runtime/cloud/client.mjs +658 -0
- package/runtime/cloud/environment.mjs +60 -0
- package/runtime/cloud/state.mjs +141 -0
- package/runtime/cloud/upload.mjs +48 -0
- package/runtime/html-envelope.mjs +70 -7
- package/runtime/http.mjs +7 -4
- package/runtime/middleware.mjs +3 -3
- package/runtime/request-transport.mjs +1 -1
- package/runtime/route-static.mjs +1 -1
- package/runtime/rsc-worker.mjs +27 -4
- package/runtime/stream-utils.mjs +22 -3
- package/runtime/transport.mjs +16 -0
- package/runtime/worker-metadata.mjs +23 -0
- package/runtime/worker.mjs +36 -9
|
@@ -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
|
+
}
|