@nocobase/cli 2.1.0-alpha.20 → 2.1.0-alpha.21
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 +256 -89
- package/README.zh-CN.md +332 -0
- package/bin/run.js +21 -2
- package/dist/commands/build.js +7 -1
- package/dist/commands/db/logs.js +85 -0
- package/dist/commands/db/ps.js +60 -0
- package/dist/commands/db/shared.js +81 -0
- package/dist/commands/db/start.js +55 -7
- package/dist/commands/db/stop.js +70 -0
- package/dist/commands/dev.js +112 -21
- package/dist/commands/down.js +193 -0
- package/dist/commands/download.js +622 -183
- package/dist/commands/env/add.js +233 -131
- package/dist/commands/env/auth.js +9 -8
- package/dist/commands/init.js +696 -103
- package/dist/commands/install.js +1588 -566
- package/dist/commands/logs.js +90 -0
- package/dist/commands/pm/disable.js +35 -3
- package/dist/commands/pm/enable.js +35 -3
- package/dist/commands/pm/list.js +37 -4
- package/dist/commands/prompts-stages.js +144 -0
- package/dist/commands/prompts-test.js +175 -0
- package/dist/commands/ps.js +116 -0
- package/dist/commands/start.js +171 -15
- package/dist/commands/stop.js +90 -0
- package/dist/commands/upgrade.js +559 -11
- package/dist/lib/app-runtime.js +142 -0
- package/dist/lib/auth-store.js +44 -3
- package/dist/lib/bootstrap.js +7 -3
- package/dist/lib/env-auth.js +427 -82
- package/dist/lib/prompt-catalog.js +552 -0
- package/dist/lib/prompt-validators.js +184 -0
- package/dist/lib/prompt-web-ui.js +2027 -0
- package/dist/lib/run-npm.js +71 -7
- package/package.json +3 -3
- package/dist/commands/restart.js +0 -32
- package/dist/lib/init-browser-wizard.js +0 -431
package/dist/lib/env-auth.js
CHANGED
|
@@ -18,8 +18,10 @@ import { printInfo, printVerbose, printWarning, printWarningBlock, updateTask }
|
|
|
18
18
|
const ACCESS_TOKEN_REFRESH_WINDOW_MS = 60_000;
|
|
19
19
|
const LOOPBACK_HOST = '127.0.0.1';
|
|
20
20
|
const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
|
+
const OAUTH_FETCH_TIMEOUT_MS = 15_000;
|
|
22
|
+
const OAUTH_FETCH_RETRY_DELAYS_MS = [500, 1_000, 2_000];
|
|
21
23
|
const DEFAULT_OAUTH_SCOPE = 'openid api offline_access';
|
|
22
|
-
const DEFAULT_CLIENT_NAME = 'NocoBase
|
|
24
|
+
const DEFAULT_CLIENT_NAME = 'NocoBase CLI';
|
|
23
25
|
function normalizeBaseUrl(baseUrl) {
|
|
24
26
|
return baseUrl.replace(/\/+$/, '');
|
|
25
27
|
}
|
|
@@ -89,11 +91,87 @@ function formatOauthFetchFailure(prefix, options) {
|
|
|
89
91
|
.filter(Boolean)
|
|
90
92
|
.join('\n');
|
|
91
93
|
}
|
|
94
|
+
function isRetryableOauthStatus(status) {
|
|
95
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
96
|
+
}
|
|
97
|
+
function getOauthFetchRetryDelays() {
|
|
98
|
+
const override = process.env.NOCOBASE_CLI_OAUTH_RETRY_DELAY_MS;
|
|
99
|
+
if (override !== undefined) {
|
|
100
|
+
const delay = Number(override);
|
|
101
|
+
if (Number.isFinite(delay) && delay >= 0) {
|
|
102
|
+
return OAUTH_FETCH_RETRY_DELAYS_MS.map(() => delay);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return OAUTH_FETCH_RETRY_DELAYS_MS;
|
|
106
|
+
}
|
|
107
|
+
function formatOauthRetryMessage(options) {
|
|
108
|
+
return `${options.operation} failed (${options.reason}). Retrying ${options.attempt}/${options.maxAttempts}...`;
|
|
109
|
+
}
|
|
110
|
+
async function sleep(ms) {
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
}
|
|
113
|
+
async function fetchWithOauthRetry(url, init, options) {
|
|
114
|
+
const retryDelaysMs = options.retryDelaysMs ?? getOauthFetchRetryDelays();
|
|
115
|
+
const maxAttempts = retryDelaysMs.length + 1;
|
|
116
|
+
let lastError;
|
|
117
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const timeout = setTimeout(() => {
|
|
120
|
+
controller.abort();
|
|
121
|
+
}, options.timeoutMs ?? OAUTH_FETCH_TIMEOUT_MS);
|
|
122
|
+
try {
|
|
123
|
+
const response = await fetch(url, {
|
|
124
|
+
...init,
|
|
125
|
+
signal: controller.signal,
|
|
126
|
+
});
|
|
127
|
+
if (!isRetryableOauthStatus(response.status) || attempt === maxAttempts) {
|
|
128
|
+
return response;
|
|
129
|
+
}
|
|
130
|
+
const reason = `HTTP ${response.status}`;
|
|
131
|
+
const message = formatOauthRetryMessage({
|
|
132
|
+
operation: options.operation,
|
|
133
|
+
attempt: attempt + 1,
|
|
134
|
+
maxAttempts,
|
|
135
|
+
reason,
|
|
136
|
+
});
|
|
137
|
+
printVerbose(message);
|
|
138
|
+
options.onRetry?.(message);
|
|
139
|
+
await sleep(retryDelaysMs[attempt - 1] ?? 0);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
lastError = error;
|
|
143
|
+
const reason = error instanceof Error && error.name === 'AbortError'
|
|
144
|
+
? `request timed out after ${Math.ceil((options.timeoutMs ?? OAUTH_FETCH_TIMEOUT_MS) / 1000)}s`
|
|
145
|
+
: error instanceof Error
|
|
146
|
+
? error.message
|
|
147
|
+
: String(error);
|
|
148
|
+
if (attempt === maxAttempts) {
|
|
149
|
+
throw new Error(reason);
|
|
150
|
+
}
|
|
151
|
+
const message = formatOauthRetryMessage({
|
|
152
|
+
operation: options.operation,
|
|
153
|
+
attempt: attempt + 1,
|
|
154
|
+
maxAttempts,
|
|
155
|
+
reason,
|
|
156
|
+
});
|
|
157
|
+
printVerbose(message);
|
|
158
|
+
options.onRetry?.(message);
|
|
159
|
+
await sleep(retryDelaysMs[attempt - 1] ?? 0);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
clearTimeout(timeout);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
throw lastError;
|
|
166
|
+
}
|
|
92
167
|
async function fetchOauthServerMetadata(baseUrl, options = {}) {
|
|
93
168
|
const metadataUrl = getOauthMetadataUrl(baseUrl);
|
|
94
169
|
let response;
|
|
95
170
|
try {
|
|
96
|
-
response = await
|
|
171
|
+
response = await fetchWithOauthRetry(metadataUrl, undefined, {
|
|
172
|
+
operation: 'Loading OAuth metadata',
|
|
173
|
+
onRetry: options.onRetry,
|
|
174
|
+
});
|
|
97
175
|
}
|
|
98
176
|
catch (error) {
|
|
99
177
|
throw new Error(formatOauthFetchFailure('Failed to load OAuth metadata.', {
|
|
@@ -116,26 +194,40 @@ async function fetchOauthServerMetadata(baseUrl, options = {}) {
|
|
|
116
194
|
}
|
|
117
195
|
return data;
|
|
118
196
|
}
|
|
119
|
-
async function registerOauthClient(metadata, redirectUri) {
|
|
197
|
+
async function registerOauthClient(metadata, redirectUri, options = {}) {
|
|
120
198
|
if (!metadata.registration_endpoint) {
|
|
121
199
|
throw new Error('OAuth server does not expose a dynamic client registration endpoint.');
|
|
122
200
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
201
|
+
let response;
|
|
202
|
+
try {
|
|
203
|
+
response = await fetchWithOauthRetry(metadata.registration_endpoint, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: {
|
|
206
|
+
accept: 'application/json',
|
|
207
|
+
'content-type': 'application/json',
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
client_name: DEFAULT_CLIENT_NAME,
|
|
211
|
+
application_type: 'native',
|
|
212
|
+
token_endpoint_auth_method: 'none',
|
|
213
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
214
|
+
response_types: ['code'],
|
|
215
|
+
scope: DEFAULT_OAUTH_SCOPE,
|
|
216
|
+
redirect_uris: [redirectUri],
|
|
217
|
+
}),
|
|
218
|
+
}, {
|
|
219
|
+
operation: 'Registering OAuth client',
|
|
220
|
+
onRetry: (message) => updateTask(message),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
throw new Error(formatOauthFetchFailure('Failed to register OAuth client.', {
|
|
225
|
+
envName: options.envName,
|
|
226
|
+
baseUrl: options.baseUrl,
|
|
227
|
+
url: metadata.registration_endpoint,
|
|
228
|
+
rawMessage: error?.message,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
139
231
|
const data = await parseJsonResponse(response);
|
|
140
232
|
if (!response.ok) {
|
|
141
233
|
throw new Error(formatOauthError('Failed to register OAuth client', data, response.status));
|
|
@@ -169,25 +261,256 @@ function escapeHtmlAttribute(value) {
|
|
|
169
261
|
.replace(/</g, '<')
|
|
170
262
|
.replace(/>/g, '>');
|
|
171
263
|
}
|
|
264
|
+
function escapeHtmlText(value) {
|
|
265
|
+
return escapeHtmlAttribute(value).replace(/\r?\n/g, '<br>');
|
|
266
|
+
}
|
|
172
267
|
function escapeScriptString(value) {
|
|
173
268
|
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
174
269
|
}
|
|
175
|
-
|
|
176
|
-
const
|
|
270
|
+
function buildOauthPage(options) {
|
|
271
|
+
const tone = options.statusTone === 'success'
|
|
272
|
+
? {
|
|
273
|
+
color: '#52c41a',
|
|
274
|
+
soft: '#f6ffed',
|
|
275
|
+
border: '#b7eb8f',
|
|
276
|
+
}
|
|
277
|
+
: options.statusTone === 'error'
|
|
278
|
+
? {
|
|
279
|
+
color: '#ff4d4f',
|
|
280
|
+
soft: '#fff2f0',
|
|
281
|
+
border: '#ffccc7',
|
|
282
|
+
}
|
|
283
|
+
: {
|
|
284
|
+
color: '#1677ff',
|
|
285
|
+
soft: '#e6f4ff',
|
|
286
|
+
border: '#91caff',
|
|
287
|
+
};
|
|
177
288
|
return `<!doctype html>
|
|
178
289
|
<html>
|
|
179
290
|
<head>
|
|
180
291
|
<meta charset="utf-8">
|
|
181
|
-
<meta
|
|
182
|
-
<title
|
|
292
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
293
|
+
<title>${escapeHtmlAttribute(options.title)}</title>
|
|
294
|
+
<style>
|
|
295
|
+
:root {
|
|
296
|
+
--bg: #f5f5f5;
|
|
297
|
+
--panel: #ffffff;
|
|
298
|
+
--panel-border: #f0f0f0;
|
|
299
|
+
--text: rgba(0, 0, 0, 0.88);
|
|
300
|
+
--muted: rgba(0, 0, 0, 0.45);
|
|
301
|
+
--status: ${tone.color};
|
|
302
|
+
--status-soft: ${tone.soft};
|
|
303
|
+
--status-border: ${tone.border};
|
|
304
|
+
--primary: #1677ff;
|
|
305
|
+
--shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
|
|
306
|
+
}
|
|
307
|
+
* { box-sizing: border-box; }
|
|
308
|
+
body {
|
|
309
|
+
margin: 0;
|
|
310
|
+
min-height: 100vh;
|
|
311
|
+
display: grid;
|
|
312
|
+
place-items: center;
|
|
313
|
+
padding: 24px;
|
|
314
|
+
background: var(--bg);
|
|
315
|
+
color: var(--text);
|
|
316
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
317
|
+
}
|
|
318
|
+
.shell {
|
|
319
|
+
width: min(100%, 560px);
|
|
320
|
+
border: 1px solid var(--panel-border);
|
|
321
|
+
border-radius: 12px;
|
|
322
|
+
background: var(--panel);
|
|
323
|
+
box-shadow: var(--shadow);
|
|
324
|
+
overflow: hidden;
|
|
325
|
+
}
|
|
326
|
+
.card-head {
|
|
327
|
+
display: flex;
|
|
328
|
+
align-items: center;
|
|
329
|
+
justify-content: space-between;
|
|
330
|
+
min-height: 56px;
|
|
331
|
+
padding: 0 24px;
|
|
332
|
+
border-bottom: 1px solid var(--panel-border);
|
|
333
|
+
background: #ffffff;
|
|
334
|
+
}
|
|
335
|
+
.card-title {
|
|
336
|
+
font-size: 16px;
|
|
337
|
+
font-weight: 600;
|
|
338
|
+
color: var(--text);
|
|
339
|
+
}
|
|
340
|
+
.card-extra {
|
|
341
|
+
font-size: 12px;
|
|
342
|
+
color: var(--primary);
|
|
343
|
+
font-weight: 500;
|
|
344
|
+
letter-spacing: 0.02em;
|
|
345
|
+
}
|
|
346
|
+
.card-body {
|
|
347
|
+
padding: 40px 32px 28px;
|
|
348
|
+
text-align: center;
|
|
349
|
+
}
|
|
350
|
+
.mark {
|
|
351
|
+
width: 64px;
|
|
352
|
+
height: 64px;
|
|
353
|
+
margin: 0 auto 24px;
|
|
354
|
+
display: grid;
|
|
355
|
+
place-items: center;
|
|
356
|
+
border-radius: 50%;
|
|
357
|
+
background: var(--status-soft);
|
|
358
|
+
border: 1px solid var(--status-border);
|
|
359
|
+
color: var(--status);
|
|
360
|
+
font-size: 30px;
|
|
361
|
+
font-weight: 700;
|
|
362
|
+
}
|
|
363
|
+
h1 {
|
|
364
|
+
margin: 0 0 12px;
|
|
365
|
+
font-size: 28px;
|
|
366
|
+
line-height: 1.2;
|
|
367
|
+
font-weight: 600;
|
|
368
|
+
}
|
|
369
|
+
p {
|
|
370
|
+
margin: 0;
|
|
371
|
+
color: var(--muted);
|
|
372
|
+
font-size: 14px;
|
|
373
|
+
line-height: 1.7;
|
|
374
|
+
}
|
|
375
|
+
.tip {
|
|
376
|
+
margin-top: 24px;
|
|
377
|
+
padding: 12px 16px;
|
|
378
|
+
border-radius: 8px;
|
|
379
|
+
background: #fafafa;
|
|
380
|
+
border: 1px solid #f0f0f0;
|
|
381
|
+
color: rgba(0, 0, 0, 0.65);
|
|
382
|
+
font-size: 13px;
|
|
383
|
+
}
|
|
384
|
+
.detail {
|
|
385
|
+
margin-top: 16px;
|
|
386
|
+
padding: 14px 16px;
|
|
387
|
+
border-radius: 8px;
|
|
388
|
+
background: #fff2f0;
|
|
389
|
+
border: 1px solid #ffccc7;
|
|
390
|
+
color: rgba(0, 0, 0, 0.72);
|
|
391
|
+
font-size: 13px;
|
|
392
|
+
line-height: 1.7;
|
|
393
|
+
text-align: left;
|
|
394
|
+
word-break: break-word;
|
|
395
|
+
}
|
|
396
|
+
.actions {
|
|
397
|
+
margin-top: 24px;
|
|
398
|
+
display: flex;
|
|
399
|
+
justify-content: center;
|
|
400
|
+
gap: 12px;
|
|
401
|
+
flex-wrap: wrap;
|
|
402
|
+
}
|
|
403
|
+
.actions a {
|
|
404
|
+
display: inline-flex;
|
|
405
|
+
align-items: center;
|
|
406
|
+
justify-content: center;
|
|
407
|
+
min-width: 148px;
|
|
408
|
+
height: 40px;
|
|
409
|
+
padding: 0 16px;
|
|
410
|
+
border-radius: 8px;
|
|
411
|
+
border: 1px solid #1677ff;
|
|
412
|
+
background: #1677ff;
|
|
413
|
+
color: #fff;
|
|
414
|
+
text-decoration: none;
|
|
415
|
+
font-size: 14px;
|
|
416
|
+
font-weight: 500;
|
|
417
|
+
}
|
|
418
|
+
.actions a.secondary {
|
|
419
|
+
background: #fff;
|
|
420
|
+
color: #1677ff;
|
|
421
|
+
}
|
|
422
|
+
.manual {
|
|
423
|
+
min-height: 22px;
|
|
424
|
+
margin-top: 14px;
|
|
425
|
+
font-size: 13px;
|
|
426
|
+
color: var(--muted);
|
|
427
|
+
}
|
|
428
|
+
.card-foot {
|
|
429
|
+
padding: 12px 24px;
|
|
430
|
+
border-top: 1px solid var(--panel-border);
|
|
431
|
+
background: #fafafa;
|
|
432
|
+
font-size: 12px;
|
|
433
|
+
color: var(--muted);
|
|
434
|
+
text-align: center;
|
|
435
|
+
}
|
|
436
|
+
</style>
|
|
437
|
+
${options.extraHeadHtml ?? ''}
|
|
183
438
|
</head>
|
|
184
439
|
<body>
|
|
185
|
-
<
|
|
186
|
-
|
|
440
|
+
<main class="shell">
|
|
441
|
+
<div class="card-head">
|
|
442
|
+
<div class="card-title">NocoBase CLI</div>
|
|
443
|
+
<div class="card-extra">${escapeHtmlText(options.cardExtra ?? 'OAuth')}</div>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="card-body">
|
|
446
|
+
<div class="mark">${escapeHtmlText(options.statusMark)}</div>
|
|
447
|
+
<h1>${escapeHtmlText(options.heading)}</h1>
|
|
448
|
+
<p>${escapeHtmlText(options.description)}</p>
|
|
449
|
+
${options.tip ? `<p class="tip">${escapeHtmlText(options.tip)}</p>` : ''}
|
|
450
|
+
${options.detailHtml ? `<div class="detail">${options.detailHtml}</div>` : ''}
|
|
451
|
+
${options.actionsHtml ? `<div class="actions">${options.actionsHtml}</div>` : ''}
|
|
452
|
+
<p id="manual" class="manual"></p>
|
|
453
|
+
</div>
|
|
454
|
+
<div class="card-foot">${escapeHtmlText(options.footer ?? 'You can close this page after returning to the terminal.')}</div>
|
|
455
|
+
</main>
|
|
456
|
+
${options.extraScriptHtml ?? ''}
|
|
187
457
|
</body>
|
|
188
458
|
</html>
|
|
189
459
|
`;
|
|
190
460
|
}
|
|
461
|
+
export function buildOauthRedirectHtml(url) {
|
|
462
|
+
const escapedUrl = escapeHtmlAttribute(url);
|
|
463
|
+
return buildOauthPage({
|
|
464
|
+
title: 'NocoBase OAuth Login',
|
|
465
|
+
cardExtra: 'OAuth',
|
|
466
|
+
statusTone: 'info',
|
|
467
|
+
statusMark: '→',
|
|
468
|
+
heading: 'Redirecting to sign-in',
|
|
469
|
+
description: 'Your browser is opening the NocoBase login page so you can finish authentication.',
|
|
470
|
+
tip: 'If the redirect does not start automatically, continue manually using the button below.',
|
|
471
|
+
actionsHtml: `<a href="${escapedUrl}">Continue to sign-in</a>` +
|
|
472
|
+
`<a class="secondary" href="${escapedUrl}">Open manually</a>`,
|
|
473
|
+
footer: 'After sign-in, this page will hand control back to the terminal.',
|
|
474
|
+
extraHeadHtml: ` <meta http-equiv="refresh" content="0; url=${escapedUrl}">`,
|
|
475
|
+
extraScriptHtml: ` <script>window.location.replace(${escapeScriptString(url)});</script>`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
export function buildOauthCompletionHtml() {
|
|
479
|
+
return buildOauthPage({
|
|
480
|
+
title: 'Authentication complete',
|
|
481
|
+
cardExtra: 'OAuth',
|
|
482
|
+
statusTone: 'success',
|
|
483
|
+
statusMark: '✓',
|
|
484
|
+
heading: 'Authentication complete',
|
|
485
|
+
description: 'Your sign-in finished successfully. You can return to the terminal and continue there.',
|
|
486
|
+
tip: 'This page will try to close automatically in a moment.',
|
|
487
|
+
footer: 'You can close this page after returning to the terminal.',
|
|
488
|
+
extraScriptHtml: ` <script>
|
|
489
|
+
setTimeout(function () {
|
|
490
|
+
window.close();
|
|
491
|
+
setTimeout(function () {
|
|
492
|
+
var el = document.getElementById('manual');
|
|
493
|
+
if (document.visibilityState === 'visible' && el) {
|
|
494
|
+
el.textContent = 'If this tab stays open, you can close it manually.';
|
|
495
|
+
}
|
|
496
|
+
}, 400);
|
|
497
|
+
}, 1000);
|
|
498
|
+
</script>`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
export function buildOauthErrorHtml(message, options) {
|
|
502
|
+
return buildOauthPage({
|
|
503
|
+
title: options?.title ?? 'Authentication failed',
|
|
504
|
+
cardExtra: 'OAuth',
|
|
505
|
+
statusTone: 'error',
|
|
506
|
+
statusMark: '!',
|
|
507
|
+
heading: options?.title ?? 'Authentication failed',
|
|
508
|
+
description: 'The OAuth sign-in flow could not be completed in this browser tab.',
|
|
509
|
+
detailHtml: escapeHtmlText(message),
|
|
510
|
+
tip: 'Return to the terminal to review the error details and try again if needed.',
|
|
511
|
+
footer: 'You can close this page and restart authentication from the CLI.',
|
|
512
|
+
});
|
|
513
|
+
}
|
|
191
514
|
async function createWindowsBrowserRedirectFile(url) {
|
|
192
515
|
const directory = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-oauth-'));
|
|
193
516
|
const filePath = path.join(directory, 'authorize.html');
|
|
@@ -218,20 +541,28 @@ async function maybeOpenBrowser(url) {
|
|
|
218
541
|
? [['cmd', '/c', 'start', '', target]]
|
|
219
542
|
: [['xdg-open', target]];
|
|
220
543
|
for (const [command, ...args] of candidates) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
544
|
+
const opened = await new Promise((resolve) => {
|
|
545
|
+
try {
|
|
546
|
+
const child = spawn(command, args, {
|
|
547
|
+
detached: true,
|
|
548
|
+
stdio: 'ignore',
|
|
549
|
+
});
|
|
550
|
+
child.once('error', () => resolve(false));
|
|
551
|
+
child.once('spawn', () => {
|
|
552
|
+
child.unref();
|
|
553
|
+
resolve(true);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
catch (_error) {
|
|
557
|
+
resolve(false);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
if (opened) {
|
|
227
561
|
return {
|
|
228
562
|
opened: true,
|
|
229
563
|
cleanup,
|
|
230
564
|
};
|
|
231
565
|
}
|
|
232
|
-
catch (_error) {
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
566
|
}
|
|
236
567
|
return {
|
|
237
568
|
opened: false,
|
|
@@ -250,42 +581,24 @@ async function createLoopbackServer(state) {
|
|
|
250
581
|
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
251
582
|
if (receivedState !== state) {
|
|
252
583
|
res.statusCode = 400;
|
|
253
|
-
res.end('
|
|
584
|
+
res.end(buildOauthErrorHtml('Invalid state.'));
|
|
585
|
+
rejectWaiter?.(new Error('OAuth authorization failed: invalid state.'));
|
|
254
586
|
return;
|
|
255
587
|
}
|
|
256
588
|
if (error) {
|
|
257
589
|
res.statusCode = 400;
|
|
258
|
-
res.end(
|
|
259
|
-
|
|
590
|
+
res.end(buildOauthErrorHtml(String(errorDescription || error)));
|
|
591
|
+
rejectWaiter?.(new Error(`OAuth authorization failed: ${errorDescription || error}`));
|
|
260
592
|
return;
|
|
261
593
|
}
|
|
262
594
|
if (!code) {
|
|
263
595
|
res.statusCode = 400;
|
|
264
|
-
res.end('
|
|
265
|
-
|
|
596
|
+
res.end(buildOauthErrorHtml('Missing authorization code.'));
|
|
597
|
+
rejectWaiter?.(new Error('OAuth authorization failed: missing authorization code.'));
|
|
266
598
|
return;
|
|
267
599
|
}
|
|
268
600
|
res.statusCode = 200;
|
|
269
|
-
res.end(
|
|
270
|
-
<html lang="en">
|
|
271
|
-
<head><meta charset="utf-8" /><title>Authentication complete</title></head>
|
|
272
|
-
<body>
|
|
273
|
-
<h1>Authentication complete</h1>
|
|
274
|
-
<p>You can return to the terminal.</p>
|
|
275
|
-
<p id="manual"></p>
|
|
276
|
-
<script>
|
|
277
|
-
setTimeout(function () {
|
|
278
|
-
window.close();
|
|
279
|
-
setTimeout(function () {
|
|
280
|
-
var el = document.getElementById('manual');
|
|
281
|
-
if (document.visibilityState === 'visible' && el) {
|
|
282
|
-
el.textContent = 'Please close this tab manually if it is still open.';
|
|
283
|
-
}
|
|
284
|
-
}, 400);
|
|
285
|
-
}, 1000);
|
|
286
|
-
</script>
|
|
287
|
-
</body>
|
|
288
|
-
</html>`);
|
|
601
|
+
res.end(buildOauthCompletionHtml());
|
|
289
602
|
resolveWaiter(code);
|
|
290
603
|
}
|
|
291
604
|
catch (error) {
|
|
@@ -337,14 +650,28 @@ async function exchangeAuthorizationCode(options) {
|
|
|
337
650
|
redirect_uri: options.redirectUri,
|
|
338
651
|
resource: options.resource,
|
|
339
652
|
});
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
653
|
+
let response;
|
|
654
|
+
try {
|
|
655
|
+
response = await fetchWithOauthRetry(options.metadata.token_endpoint, {
|
|
656
|
+
method: 'POST',
|
|
657
|
+
headers: {
|
|
658
|
+
accept: 'application/json',
|
|
659
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
660
|
+
},
|
|
661
|
+
body,
|
|
662
|
+
}, {
|
|
663
|
+
operation: 'Exchanging OAuth authorization code',
|
|
664
|
+
onRetry: (message) => updateTask(message),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
throw new Error(formatOauthFetchFailure('Failed to exchange OAuth authorization code.', {
|
|
669
|
+
envName: options.envName,
|
|
670
|
+
baseUrl: options.baseUrl,
|
|
671
|
+
url: options.metadata.token_endpoint,
|
|
672
|
+
rawMessage: error?.message,
|
|
673
|
+
}));
|
|
674
|
+
}
|
|
348
675
|
const data = await parseJsonResponse(response);
|
|
349
676
|
if (!response.ok) {
|
|
350
677
|
throw new Error(formatOauthError('Failed to exchange OAuth authorization code', data, response.status));
|
|
@@ -366,13 +693,15 @@ async function refreshOauthAccessToken(options) {
|
|
|
366
693
|
refresh_token: options.auth.refreshToken,
|
|
367
694
|
resource,
|
|
368
695
|
});
|
|
369
|
-
const response = await
|
|
696
|
+
const response = await fetchWithOauthRetry(metadata.token_endpoint, {
|
|
370
697
|
method: 'POST',
|
|
371
698
|
headers: {
|
|
372
699
|
accept: 'application/json',
|
|
373
700
|
'content-type': 'application/x-www-form-urlencoded',
|
|
374
701
|
},
|
|
375
702
|
body,
|
|
703
|
+
}, {
|
|
704
|
+
operation: `Refreshing OAuth session for env "${options.envName}"`,
|
|
376
705
|
}).catch((error) => {
|
|
377
706
|
throw new Error(formatOauthFetchFailure(`Failed to refresh OAuth session for env "${options.envName}".`, {
|
|
378
707
|
envName: options.envName,
|
|
@@ -452,20 +781,34 @@ export async function authenticateEnvWithOauth(options) {
|
|
|
452
781
|
const baseUrl = env?.baseUrl;
|
|
453
782
|
if (!baseUrl) {
|
|
454
783
|
throw new Error([
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
784
|
+
env
|
|
785
|
+
? `Environment "${envName}" does not have an API base URL yet.`
|
|
786
|
+
: `Environment "${envName}" has not been set up yet.`,
|
|
787
|
+
env
|
|
788
|
+
? `Run \`nb env add ${envName} --base-url <url>\` to finish setting it up.`
|
|
789
|
+
: `Run \`nb env add ${envName}\` first.`,
|
|
790
|
+
]
|
|
791
|
+
.filter(Boolean)
|
|
792
|
+
.join('\n'));
|
|
793
|
+
}
|
|
794
|
+
printVerbose(`Starting OAuth sign-in for env "${envName}" using ${baseUrl}`);
|
|
795
|
+
updateTask(`Checking sign-in settings for "${envName}"...`);
|
|
796
|
+
const metadata = await fetchOauthServerMetadata(baseUrl, {
|
|
797
|
+
envName,
|
|
798
|
+
onRetry: (message) => updateTask(message),
|
|
799
|
+
});
|
|
461
800
|
const state = encodeBase64Url(crypto.randomBytes(16));
|
|
462
801
|
const { codeVerifier, codeChallenge } = buildPkcePair();
|
|
463
802
|
const callback = await createLoopbackServer(state);
|
|
464
803
|
const resource = getOauthResource(metadata.issuer);
|
|
465
804
|
let cleanupBrowserOpenTarget;
|
|
466
805
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
806
|
+
printVerbose(`OAuth callback listener ready at ${callback.redirectUri}`);
|
|
807
|
+
updateTask(`Preparing secure browser sign-in for "${envName}"...`);
|
|
808
|
+
const registration = await registerOauthClient(metadata, callback.redirectUri, {
|
|
809
|
+
envName,
|
|
810
|
+
baseUrl,
|
|
811
|
+
});
|
|
469
812
|
const authorizationUrl = new URL(metadata.authorization_endpoint);
|
|
470
813
|
authorizationUrl.searchParams.set('response_type', 'code');
|
|
471
814
|
authorizationUrl.searchParams.set('client_id', registration.clientId);
|
|
@@ -476,18 +819,18 @@ export async function authenticateEnvWithOauth(options) {
|
|
|
476
819
|
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
|
|
477
820
|
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
|
|
478
821
|
authorizationUrl.searchParams.set('resource', resource);
|
|
479
|
-
updateTask(`Waiting for
|
|
822
|
+
updateTask(`Waiting for you to finish signing in for "${envName}"...`);
|
|
480
823
|
const browser = await maybeOpenBrowser(authorizationUrl.toString());
|
|
481
824
|
cleanupBrowserOpenTarget = browser.cleanup;
|
|
482
825
|
if (!browser.opened) {
|
|
483
|
-
printWarningBlock('
|
|
826
|
+
printWarningBlock('We could not open your browser automatically. Open this URL to continue signing in:');
|
|
484
827
|
}
|
|
485
828
|
else {
|
|
486
|
-
printInfo('
|
|
829
|
+
printInfo('Your browser should open shortly. Finish signing in there to continue.');
|
|
487
830
|
}
|
|
488
831
|
printInfo(authorizationUrl.toString());
|
|
489
832
|
const code = await new Promise((resolve, reject) => {
|
|
490
|
-
const timeout = setTimeout(() => reject(new Error(
|
|
833
|
+
const timeout = setTimeout(() => reject(new Error(`OAuth sign-in timed out after 5 minutes. Run \`nb env auth ${envName}\` to try again.`)), OAUTH_LOGIN_TIMEOUT_MS);
|
|
491
834
|
timeout.unref?.();
|
|
492
835
|
callback.waitForCode().then((value) => {
|
|
493
836
|
clearTimeout(timeout);
|
|
@@ -497,7 +840,7 @@ export async function authenticateEnvWithOauth(options) {
|
|
|
497
840
|
reject(error);
|
|
498
841
|
});
|
|
499
842
|
});
|
|
500
|
-
updateTask(`
|
|
843
|
+
updateTask(`Finishing sign-in for "${envName}"...`);
|
|
501
844
|
const tokenResponse = await exchangeAuthorizationCode({
|
|
502
845
|
metadata,
|
|
503
846
|
clientId: registration.clientId,
|
|
@@ -505,9 +848,11 @@ export async function authenticateEnvWithOauth(options) {
|
|
|
505
848
|
code,
|
|
506
849
|
codeVerifier,
|
|
507
850
|
resource,
|
|
851
|
+
envName,
|
|
852
|
+
baseUrl,
|
|
508
853
|
});
|
|
509
854
|
if (!tokenResponse.refresh_token) {
|
|
510
|
-
printWarning('
|
|
855
|
+
printWarning('Sign-in succeeded, but no refresh token was returned. You may need to sign in again when this session expires.');
|
|
511
856
|
}
|
|
512
857
|
await setEnvOauthSession(envName, {
|
|
513
858
|
type: 'oauth',
|