@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.
@@ -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 CTL';
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 fetch(metadataUrl);
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
- const response = await fetch(metadata.registration_endpoint, {
124
- method: 'POST',
125
- headers: {
126
- accept: 'application/json',
127
- 'content-type': 'application/json',
128
- },
129
- body: JSON.stringify({
130
- client_name: DEFAULT_CLIENT_NAME,
131
- application_type: 'native',
132
- token_endpoint_auth_method: 'none',
133
- grant_types: ['authorization_code', 'refresh_token'],
134
- response_types: ['code'],
135
- scope: DEFAULT_OAUTH_SCOPE,
136
- redirect_uris: [redirectUri],
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, '&lt;')
170
262
  .replace(/>/g, '&gt;');
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
- export function buildOauthRedirectHtml(url) {
176
- const escapedUrl = escapeHtmlAttribute(url);
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 http-equiv="refresh" content="0; url=${escapedUrl}">
182
- <title>NocoBase OAuth Login</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
- <script>window.location.replace(${escapeScriptString(url)});</script>
186
- <p>Redirecting to the OAuth login page. If nothing happens, <a href="${escapedUrl}">continue manually</a>.</p>
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
- try {
222
- const child = spawn(command, args, {
223
- detached: true,
224
- stdio: 'ignore',
225
- });
226
- child.unref();
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('<html><body><h1>Authentication failed</h1><p>Invalid state.</p></body></html>');
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(`<html><body><h1>Authentication failed</h1><p>${errorDescription || error}</p></body></html>`);
259
- reject(new Error(`OAuth authorization failed: ${errorDescription || error}`));
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('<html><body><h1>Authentication failed</h1><p>Missing authorization code.</p></body></html>');
265
- reject(new Error('OAuth authorization failed: missing authorization code.'));
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(`<!DOCTYPE html>
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
- const response = await fetch(options.metadata.token_endpoint, {
341
- method: 'POST',
342
- headers: {
343
- accept: 'application/json',
344
- 'content-type': 'application/x-www-form-urlencoded',
345
- },
346
- body,
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 fetch(metadata.token_endpoint, {
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
- `Env "${envName}" is missing a base URL.`,
456
- 'Run `nb env add <name> --base-url <url>` first.',
457
- ].join('\n'));
458
- }
459
- updateTask(`Loading OAuth metadata for env "${envName}"...`);
460
- const metadata = await fetchOauthServerMetadata(baseUrl, { envName });
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
- updateTask(`Registering OAuth client for env "${envName}"...`);
468
- const registration = await registerOauthClient(metadata, callback.redirectUri);
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 OAuth login for env "${envName}"...`);
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('Unable to open the browser automatically. Open this URL manually:');
826
+ printWarningBlock('We could not open your browser automatically. Open this URL to continue signing in:');
484
827
  }
485
828
  else {
486
- printInfo('Complete the OAuth login in your browser.');
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('OAuth login timed out.')), OAUTH_LOGIN_TIMEOUT_MS);
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(`Exchanging OAuth code for env "${envName}"...`);
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('OAuth login succeeded but no refresh_token was returned. The server did not grant offline access for this client/session.');
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',