@nbtca/prompt 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
1
  import { marked } from 'marked';
2
2
  import { markedTerminal } from 'marked-terminal';
3
3
  import chalk from 'chalk';
4
- import open from 'open';
5
4
  import { createHash } from 'node:crypto';
6
5
  import { runMenu, menuFooter } from '../core/components/menu.js';
7
6
  import { runTextInput } from '../core/components/text-input.js';
@@ -12,8 +11,9 @@ import { spawn, execFileSync } from 'child_process';
12
11
  import { URLS } from '../config/data.js';
13
12
  import { t, fmt, getCurrentLanguage } from '../i18n/index.js';
14
13
  import { enterScreen, breadcrumb } from '../core/transitions.js';
15
- import { sanitizeTerminalLine, sanitizeTerminalText, truncate } from '../core/text.js';
16
- import { createDocsClient } from '@nbtca/docs';
14
+ import { sanitizeTerminalLine, sanitizeTerminalText, stripAnsi, truncate } from '../core/text.js';
15
+ import { clearDocsClients, runDocsClientOperation } from './docs-client.js';
16
+ import { launchBrowserUrl } from './links.js';
17
17
  function detectTerminalType() {
18
18
  const term = (process.env['TERM'] ?? '').toLowerCase();
19
19
  const termProgram = (process.env['TERM_PROGRAM'] ?? '').toLowerCase();
@@ -62,7 +62,8 @@ export function ensureMarkedConfigured() {
62
62
  if (_markedConfigured)
63
63
  return;
64
64
  _markedConfigured = true;
65
- const extension = markedTerminal(getRendererOptions(getTerminalType()));
65
+ const terminalType = getTerminalType();
66
+ const extension = markedTerminal(getRendererOptions(terminalType));
66
67
  const renderer = extension.renderer ?? (extension.renderer = {});
67
68
  const renderExternalLink = renderer.link;
68
69
  if (renderExternalLink) {
@@ -147,9 +148,7 @@ const METADATA_CACHE_TTL_MS = 10 * 60 * 1000;
147
148
  const METADATA_CACHE_MAX = 200;
148
149
  const METADATA_CONCURRENCY = 4;
149
150
  const metadataCache = new Map();
150
- const metadataRequests = new Map();
151
151
  let cacheGeneration = 0;
152
- const docsClient = createDocsClient();
153
152
  function getFreshRender(key) {
154
153
  const entry = renderCache.get(key);
155
154
  return entry && entry.expiresAt > Date.now() ? entry.value : null;
@@ -176,14 +175,13 @@ function renderCacheKey(filePath) {
176
175
  }
177
176
  export function clearDocsCache() {
178
177
  cacheGeneration += 1;
179
- docsClient.clear();
178
+ clearDocsClients();
180
179
  renderCache.clear();
181
180
  metadataCache.clear();
182
- metadataRequests.clear();
183
181
  }
184
- async function fetchDocument(path) {
182
+ async function fetchDocument(path, signal) {
185
183
  try {
186
- return await docsClient.getDocument(path);
184
+ return await runDocsClientOperation(signal, (client) => client.getDocument(path));
187
185
  }
188
186
  catch (err) {
189
187
  const trans = t();
@@ -218,31 +216,31 @@ function setMetadata(path, value) {
218
216
  metadataCache.delete(oldest);
219
217
  }
220
218
  }
221
- function loadDocMetadata(path) {
219
+ function loadDocMetadata(path, signal) {
222
220
  const cached = getFreshMetadata(path);
223
221
  if (cached)
224
222
  return Promise.resolve(cached);
225
- const pending = metadataRequests.get(path);
226
- if (pending)
227
- return pending;
228
223
  const generation = cacheGeneration;
229
- const request = fetchDocument(path).then((page) => {
224
+ return fetchDocument(path, signal).then((page) => {
230
225
  const metadata = metadataFromPage(page);
231
226
  if (generation === cacheGeneration)
232
227
  setMetadata(path, metadata);
233
228
  return metadata;
234
229
  });
235
- metadataRequests.set(path, request);
236
- const release = () => {
237
- if (metadataRequests.get(path) === request)
238
- metadataRequests.delete(path);
239
- };
240
- void request.then(release, release);
241
- return request;
242
230
  }
243
- async function loadRenderedDoc(filePath) {
231
+ function normalizeRenderedTasks(content, type) {
232
+ if (type !== 'basic')
233
+ return content;
234
+ return content
235
+ .split('\n')
236
+ .map((line) => /^\s*(?:[-*+]|\d+[.)])\s+\[X\](?:\s|$)/.test(stripAnsi(line))
237
+ ? line.replace('[X]', '[x]')
238
+ : line)
239
+ .join('\n');
240
+ }
241
+ async function loadRenderedDoc(filePath, signal) {
244
242
  const generation = cacheGeneration;
245
- const page = await fetchDocument(filePath);
243
+ const page = await fetchDocument(filePath, signal);
246
244
  if (generation === cacheGeneration)
247
245
  setMetadata(filePath, metadataFromPage(page));
248
246
  const rawContent = page.content;
@@ -251,12 +249,14 @@ async function loadRenderedDoc(filePath) {
251
249
  const cached = getFreshRender(cacheKey);
252
250
  if (cached?.fingerprint === fingerprint)
253
251
  return { rawContent, renderedDoc: cached };
254
- const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
252
+ const terminalType = getTerminalType();
253
+ const cleaned = cleanMarkdownContent(rawContent, terminalType);
255
254
  const title = sanitizeTerminalLine(page.title) || cleanFileName(filePath.split('/').pop() ?? filePath);
255
+ const markedOutput = normalizeRenderedTasks(await marked(cleaned), terminalType);
256
256
  const renderedDoc = {
257
257
  fingerprint,
258
258
  cleaned,
259
- rendered: await marked(cleaned),
259
+ rendered: chalk.level === 0 ? sanitizeTerminalText(markedOutput) : markedOutput,
260
260
  title,
261
261
  readTime: estimateReadTime(cleaned),
262
262
  };
@@ -392,10 +392,101 @@ function replaceDocumentComponents(content) {
392
392
  });
393
393
  return result;
394
394
  }
395
- export function cleanMarkdownContent(content, type = getTerminalType()) {
396
- let c = sanitizeTerminalText(content);
397
- c = c.replace(/^---\n[\s\S]*?\n---(?:\n|$)/, '');
398
- c = processFencedCodeBlocks(c);
395
+ function replaceHtmlCheckboxes(content, type) {
396
+ return content.replace(/<input\b([^>]*)\/?\s*>/gi, (tag, attributes) => {
397
+ const typeMatch = /(?:^|\s)type\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>/]+))/i.exec(attributes);
398
+ const inputType = (typeMatch?.[1] ?? typeMatch?.[2] ?? typeMatch?.[3] ?? '')
399
+ .trim()
400
+ .toLowerCase();
401
+ if (inputType !== 'checkbox')
402
+ return tag;
403
+ const checked = /(?:^|\s)checked(?=\s|=|\/|$)/i.test(attributes);
404
+ if (type === 'basic')
405
+ return checked ? '[x]' : '[ ]';
406
+ return checked ? '☑' : '☐';
407
+ });
408
+ }
409
+ function expandHtmlDetails(content) {
410
+ return content
411
+ .replace(/<summary\b[^>]*>([\s\S]*?)<\/summary\s*>/gi, (_match, label) => {
412
+ const summary = sanitizeTerminalLine(label);
413
+ return summary ? `\n\n### ${summary}\n\n` : '\n';
414
+ })
415
+ .replace(/<\/?(?:details|summary)\b[^>]*>/gi, '');
416
+ }
417
+ function transformOutsideFencedCodeBlocks(content, transform) {
418
+ const output = [];
419
+ let plain = [];
420
+ let fenceCharacter = '';
421
+ let fenceWidth = 0;
422
+ let listIndent = 0;
423
+ let listQuoteDepth = 0;
424
+ const flushPlain = () => {
425
+ if (plain.length === 0)
426
+ return;
427
+ output.push(transform(plain.join('\n')));
428
+ plain = [];
429
+ };
430
+ const lineContext = (line) => {
431
+ let rest = line;
432
+ let quoteDepth = 0;
433
+ for (;;) {
434
+ const quote = /^[ \t]{0,3}>[ \t]?/.exec(rest)?.[0];
435
+ if (!quote)
436
+ break;
437
+ quoteDepth += 1;
438
+ rest = rest.slice(quote.length);
439
+ }
440
+ return { quoteDepth, rest };
441
+ };
442
+ const fenceMarker = (line) => {
443
+ let { rest } = lineContext(line);
444
+ const list = /^[ \t]*(?:[-+*]|\d+[.)])[ \t]+/.exec(rest)?.[0];
445
+ if (list)
446
+ rest = rest.slice(list.length);
447
+ else if (listIndent > 0 && rest.startsWith(' '.repeat(listIndent))) {
448
+ rest = rest.slice(listIndent);
449
+ }
450
+ return /^ {0,3}(`{3,}|~{3,})/.exec(rest)?.[1];
451
+ };
452
+ for (const line of content.split('\n')) {
453
+ if (fenceWidth === 0) {
454
+ const { quoteDepth, rest } = lineContext(line);
455
+ const list = /^([ ]*)(?:[-+*]|\d+[.)])([ \t]+)/.exec(rest);
456
+ if (list) {
457
+ listIndent = list[0].length;
458
+ listQuoteDepth = quoteDepth;
459
+ }
460
+ else if (rest.trim() && (quoteDepth !== listQuoteDepth || rest.search(/\S/) < listIndent)) {
461
+ listIndent = 0;
462
+ listQuoteDepth = quoteDepth;
463
+ }
464
+ const opening = fenceMarker(line);
465
+ if (!opening) {
466
+ plain.push(line);
467
+ continue;
468
+ }
469
+ flushPlain();
470
+ fenceCharacter = opening[0] ?? '';
471
+ fenceWidth = opening.length;
472
+ output.push(line);
473
+ continue;
474
+ }
475
+ output.push(line);
476
+ const closing = fenceMarker(line);
477
+ const closingSuffix = closing ? line.slice(line.lastIndexOf(closing) + closing.length) : line;
478
+ if (closing?.[0] === fenceCharacter &&
479
+ closing.length >= fenceWidth &&
480
+ closingSuffix.trim() === '') {
481
+ fenceCharacter = '';
482
+ fenceWidth = 0;
483
+ }
484
+ }
485
+ flushPlain();
486
+ return output.join('\n');
487
+ }
488
+ function cleanMarkdownOutsideFences(content, terminalType) {
489
+ let c = content;
399
490
  c = c.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
400
491
  c = c.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
401
492
  c = replaceDocumentComponents(c);
@@ -414,7 +505,7 @@ export function cleanMarkdownContent(content, type = getTerminalType()) {
414
505
  c = c.replace(/\[\[toc\]\]/gi, '');
415
506
  c = c.replace(/^(#{1,6}\s+[^\n]*?)\s*\{#[^}]+\}\s*$/gm, '$1');
416
507
  c = c.replace(/==([^=\n]+)==/g, '**$1**');
417
- if (type === 'basic') {
508
+ if (terminalType === 'basic') {
418
509
  c = c.replace(/!\[([^\]]*)\]\([^)]+\)/g, (_match, alt) => `${pickIcon('📎', '[image]')} ${alt.length > 0 ? alt : 'image'}`);
419
510
  }
420
511
  else {
@@ -425,14 +516,27 @@ export function cleanMarkdownContent(content, type = getTerminalType()) {
425
516
  });
426
517
  }
427
518
  c = c.replace(/<!--[\s\S]*?-->/g, '');
428
- c = c.replace(/<br\s*\/?>/gi, '\n'); // void: line break
429
- c = c.replace(/<(?:hr|input|link|meta)\b[^>]*\/?>/gi, ''); // void: discard
519
+ c = replaceHtmlCheckboxes(c, terminalType);
520
+ c = expandHtmlDetails(c);
521
+ c = c.replace(/<br\s*\/?>/gi, '\n');
522
+ c = c.replace(/<(?:hr|input|link|meta)\b[^>]*\/?>/gi, '');
430
523
  c = c.replace(/<([a-z][a-z0-9]*)\b[^>]*>([\s\S]*?)<\/\1>/gi, '$2');
431
524
  c = c.replace(/<[a-z][a-z0-9]*\b[^>]*\/>/gi, '');
432
525
  c = c.replace(/<\/(?:Split|TimelineEntry)>/gi, '');
433
- c = c.replace(/^(\s*[-*+] )\[x\] /gim, '$1☑ ');
434
- c = c.replace(/^(\s*[-*+] )\[ \] /gm, '$1☐ ');
435
- c = c.replace(/\n{3,}/g, '\n\n');
526
+ if (terminalType === 'basic') {
527
+ c = c.replace(/^(\s*(?:[-*+]|\d+[.)]) )\[x\]/gim, '$1[x]');
528
+ }
529
+ else {
530
+ c = c.replace(/^(\s*(?:[-*+]|\d+[.)]) )\[x\] /gim, '$1☑ ');
531
+ c = c.replace(/^(\s*(?:[-*+]|\d+[.)]) )\[ \] /gm, '$1☐ ');
532
+ }
533
+ return c.replace(/\n{3,}/g, '\n\n');
534
+ }
535
+ export function cleanMarkdownContent(content, type = getTerminalType()) {
536
+ let c = sanitizeTerminalText(content);
537
+ c = c.replace(/^---\n[\s\S]*?\n---(?:\n|$)/, '');
538
+ c = processFencedCodeBlocks(c);
539
+ c = transformOutsideFencedCodeBlocks(c, (value) => cleanMarkdownOutsideFences(value, type));
436
540
  return c.trim();
437
541
  }
438
542
  function estimateReadTime(text) {
@@ -494,9 +598,9 @@ export function resolveInternalHref(href, fromPath) {
494
598
  target += '.md';
495
599
  return target;
496
600
  }
497
- export async function loadDocForReader(filePath) {
601
+ export async function loadDocForReader(filePath, signal) {
498
602
  ensureMarkedConfigured();
499
- const { renderedDoc } = await loadRenderedDoc(filePath);
603
+ const { renderedDoc } = await loadRenderedDoc(filePath, signal);
500
604
  const seen = new Set();
501
605
  const links = [];
502
606
  for (const raw of extractInternalLinks(renderedDoc.cleaned)) {
@@ -544,7 +648,7 @@ function listedDoc(item, metadata) {
544
648
  summary: sanitizeTerminalLine(metadata?.summary ?? ''),
545
649
  };
546
650
  }
547
- export async function fetchDocMetadata(items) {
651
+ export async function fetchDocMetadata(items, signal) {
548
652
  const results = items.map((item) => listedDoc(item));
549
653
  let nextIndex = 0;
550
654
  async function worker() {
@@ -556,10 +660,17 @@ export async function fetchDocMetadata(items) {
556
660
  const item = items[index];
557
661
  if (!item)
558
662
  continue;
663
+ if (signal?.aborted) {
664
+ throw signal.reason instanceof Error
665
+ ? signal.reason
666
+ : new DOMException('Aborted', 'AbortError');
667
+ }
559
668
  try {
560
- results[index] = listedDoc(item, await loadDocMetadata(item.path));
669
+ results[index] = listedDoc(item, await loadDocMetadata(item.path, signal));
561
670
  }
562
- catch {
671
+ catch (error) {
672
+ if (signal?.aborted)
673
+ throw error;
563
674
  results[index] = listedDoc(item);
564
675
  }
565
676
  }
@@ -568,8 +679,8 @@ export async function fetchDocMetadata(items) {
568
679
  await Promise.all(Array.from({ length: workerCount }, worker));
569
680
  return results;
570
681
  }
571
- export async function fetchSectionMetadata(section) {
572
- const files = await fetchDocMetadata(section.files);
682
+ export async function fetchSectionMetadata(section, signal) {
683
+ const files = await fetchDocMetadata(section.files, signal);
573
684
  return { ...section, count: files.length, files };
574
685
  }
575
686
  function searchDoc(result) {
@@ -585,8 +696,8 @@ function searchDoc(result) {
585
696
  section: result.section,
586
697
  };
587
698
  }
588
- export async function searchDocuments(query) {
589
- return (await docsClient.search(query, { limit: 20 })).map(searchDoc);
699
+ export async function searchDocuments(query, signal) {
700
+ return (await runDocsClientOperation(signal, (client) => client.search(query, { limit: 20 }))).map(searchDoc);
590
701
  }
591
702
  export function buildSections(all) {
592
703
  const groups = new Map();
@@ -627,11 +738,11 @@ export function getArchivedGroups(files) {
627
738
  }
628
739
  return groups;
629
740
  }
630
- export async function fetchAllDocs() {
631
- return docsClient.listAll();
741
+ export async function fetchAllDocs(signal) {
742
+ return runDocsClientOperation(signal, (client) => client.listAll());
632
743
  }
633
- export async function fetchSections() {
634
- return buildSections(await fetchAllDocs());
744
+ export async function fetchSections(signal) {
745
+ return buildSections(await fetchAllDocs(signal));
635
746
  }
636
747
  async function loadSections() {
637
748
  const trans = t();
@@ -646,9 +757,19 @@ async function loadSections() {
646
757
  return null;
647
758
  }
648
759
  }
649
- function pipeToPager(command, args, content) {
760
+ function pipeToPager(command, args, content, plain = false) {
650
761
  return new Promise((resolve) => {
651
- const child = spawn(command, args, { stdio: ['pipe', 'inherit', 'inherit'] });
762
+ const env = { ...process.env };
763
+ if (plain) {
764
+ delete env['FORCE_COLOR'];
765
+ env['NO_COLOR'] = '1';
766
+ env['CLICOLOR'] = '0';
767
+ env['CLICOLOR_FORCE'] = '0';
768
+ }
769
+ const child = spawn(command, args, {
770
+ stdio: ['pipe', 'inherit', 'inherit'],
771
+ ...(plain ? { env } : {}),
772
+ });
652
773
  let settled = false;
653
774
  const finish = (started) => {
654
775
  if (settled)
@@ -673,7 +794,9 @@ function pipeToPager(command, args, content) {
673
794
  }
674
795
  });
675
796
  }
676
- async function displayWithGlow(cleanedMarkdown) {
797
+ export async function displayWithGlow(cleanedMarkdown) {
798
+ if (chalk.level === 0)
799
+ return false;
677
800
  const cols = String(Math.min(process.stdout.columns || 80, 80));
678
801
  return pipeToPager('glow', ['--pager', '--width', cols, '-'], cleanedMarkdown);
679
802
  }
@@ -699,16 +822,21 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
699
822
  '',
700
823
  ].join('\n');
701
824
  const footer = ['', rule, chalk.dim(` ${trans.docs.endOfDocument}`), ''].join('\n');
702
- const fullContent = header + rendered + footer;
825
+ const plain = chalk.level === 0;
826
+ const fullContent = plain
827
+ ? sanitizeTerminalText(header + rendered + footer)
828
+ : header + rendered + footer;
703
829
  const pagerSetting = (process.env['PAGER'] ?? 'less').trim();
704
830
  const [pagerCommand = 'less', ...pagerArgs] = pagerSetting.split(/\s+/).filter(Boolean);
705
831
  const isLess = /(?:^|[\\/])less(?:\.exe)?$/i.test(pagerCommand);
706
- const args = isLess ? [...pagerArgs, '-R', '-F', '-X', '-i', '-j4'] : pagerArgs;
832
+ const args = isLess
833
+ ? [...pagerArgs, ...(plain ? [] : ['-R']), '-F', '-X', '-i', '-j4']
834
+ : pagerArgs;
707
835
  if (!commandExists(pagerCommand)) {
708
836
  console.log(fullContent);
709
837
  return;
710
838
  }
711
- if (!(await pipeToPager(pagerCommand, args, fullContent)))
839
+ if (!(await pipeToPager(pagerCommand, args, fullContent, plain)))
712
840
  console.log(fullContent);
713
841
  }
714
842
  async function showDocSection(section) {
@@ -798,7 +926,7 @@ async function viewMarkdownFile(filePath) {
798
926
  const { rawContent, renderedDoc } = await loadRenderedDoc(filePath);
799
927
  s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
800
928
  const toc = extractTOC(renderedDoc.cleaned);
801
- if (hasGlow()) {
929
+ if (chalk.level > 0 && hasGlow()) {
802
930
  if (!(await displayWithGlow(renderedDoc.cleaned))) {
803
931
  await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
804
932
  }
@@ -833,32 +961,60 @@ async function viewMarkdownFile(filePath) {
833
961
  }
834
962
  }
835
963
  }
836
- export async function openDocsInBrowser(path) {
964
+ export async function openDocsInBrowser(path, signal) {
837
965
  const trans = t();
838
966
  const s = createSpinner(trans.docs.opening);
967
+ let url = docsUrlFromPath(path);
839
968
  try {
840
- let route = path ? docsRouteFromPath(path) : '';
841
969
  if (path) {
842
970
  try {
843
- route = (await loadDocMetadata(path)).route;
971
+ url = docsUrlFromRoute((await loadDocMetadata(path, signal)).route);
844
972
  }
845
973
  catch {
846
- route = docsRouteFromPath(path);
974
+ if (signal?.aborted) {
975
+ s.stop();
976
+ return false;
977
+ }
978
+ url = docsUrlFromPath(path);
847
979
  }
848
980
  }
849
- const encodedRoute = route
850
- .split('/')
851
- .map((segment) => encodeURIComponent(segment))
852
- .join('/');
853
- const url = path ? `${URLS.docs}${encodedRoute}` : URLS.docs;
854
- await open(url);
981
+ if (signal?.aborted) {
982
+ s.stop();
983
+ return false;
984
+ }
985
+ if (!(await launchBrowserUrl(url)))
986
+ throw new Error('Browser launcher failed');
987
+ if (signal?.aborted) {
988
+ s.stop();
989
+ return false;
990
+ }
855
991
  s.stop(trans.docs.browserOpened);
992
+ console.log();
993
+ return true;
856
994
  }
857
995
  catch {
996
+ if (signal?.aborted) {
997
+ s.stop();
998
+ return false;
999
+ }
858
1000
  s.error(trans.docs.browserError);
859
- console.log(chalk.gray(` ${trans.docs.browserErrorHint}`));
1001
+ console.log(chalk.gray(` ${fmt(t().links.openManually, { url })}`));
1002
+ console.log();
1003
+ return false;
860
1004
  }
861
- console.log();
1005
+ }
1006
+ function docsUrlFromRoute(route) {
1007
+ const safeRoute = sanitizeTerminalLine(route);
1008
+ if (!safeRoute)
1009
+ return URLS.docs;
1010
+ const encodedRoute = safeRoute
1011
+ .split('/')
1012
+ .map((segment) => encodeURIComponent(segment))
1013
+ .join('/');
1014
+ return `${URLS.docs}${encodedRoute}`;
1015
+ }
1016
+ export function docsUrlFromPath(path) {
1017
+ return path ? docsUrlFromRoute(docsRouteFromPath(sanitizeTerminalLine(path))) : URLS.docs;
862
1018
  }
863
1019
  export function docsRouteFromPath(path) {
864
1020
  const withoutExtension = path.replace(/\.md$/i, '');
@@ -0,0 +1,51 @@
1
+ import chalk from 'chalk';
2
+ import open from 'open';
3
+ import { sanitizeTerminalLine } from '../core/text.js';
4
+ import { fmt, t } from '../i18n/index.js';
5
+ const BROWSER_LAUNCH_SETTLE_MS = 1000;
6
+ function settleBrowserLauncher(child) {
7
+ return new Promise((resolve) => {
8
+ let settled = false;
9
+ const timer = setTimeout(() => {
10
+ finish(true);
11
+ }, BROWSER_LAUNCH_SETTLE_MS);
12
+ function finish(success) {
13
+ if (settled)
14
+ return;
15
+ settled = true;
16
+ child.off('close', onClose);
17
+ child.off('error', onError);
18
+ clearTimeout(timer);
19
+ resolve(success);
20
+ }
21
+ function onClose(code, signal) {
22
+ finish(code === 0 && signal === null);
23
+ }
24
+ function onError() {
25
+ finish(false);
26
+ }
27
+ child.once('close', onClose);
28
+ child.once('error', onError);
29
+ if (child.exitCode !== null || child.signalCode !== null) {
30
+ finish(child.exitCode === 0 && child.signalCode === null);
31
+ return;
32
+ }
33
+ });
34
+ }
35
+ export async function launchBrowserUrl(url) {
36
+ try {
37
+ return await settleBrowserLauncher(await open(url));
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ export async function openUrlInBrowser(url) {
44
+ const safeUrl = sanitizeTerminalLine(url);
45
+ if (await launchBrowserUrl(safeUrl))
46
+ return true;
47
+ const trans = t().links;
48
+ console.error(chalk.red(trans.error));
49
+ console.error(chalk.dim(fmt(trans.openManually, { url: safeUrl })));
50
+ return false;
51
+ }
@@ -4,6 +4,66 @@ import { pickIcon } from '../core/icons.js';
4
4
  import { padEndV, sanitizeTerminalLine, visualWidth } from '../core/text.js';
5
5
  import { c } from '../core/theme.js';
6
6
  import { t } from '../i18n/index.js';
7
+ function abortError(signal) {
8
+ return signal.reason instanceof Error
9
+ ? signal.reason
10
+ : new DOMException('The status check was aborted.', 'AbortError');
11
+ }
12
+ function cancelResponseBody(response) {
13
+ try {
14
+ const body = response?.body;
15
+ if (!body)
16
+ return;
17
+ void body.cancel().catch(() => {
18
+ // Cancellation is best-effort and must not delay status results.
19
+ });
20
+ }
21
+ catch {
22
+ // A custom transport may expose an inaccessible, locked, or closed body.
23
+ }
24
+ }
25
+ function raceWithSignal(operation, signal, onLateValue) {
26
+ return new Promise((resolve, reject) => {
27
+ let settled = false;
28
+ const cleanup = () => {
29
+ signal.removeEventListener('abort', onAbort);
30
+ };
31
+ const onAbort = () => {
32
+ if (settled)
33
+ return;
34
+ settled = true;
35
+ cleanup();
36
+ reject(abortError(signal));
37
+ };
38
+ signal.addEventListener('abort', onAbort, { once: true });
39
+ void operation
40
+ .then((value) => {
41
+ if (settled) {
42
+ try {
43
+ onLateValue?.(value);
44
+ }
45
+ catch {
46
+ // Late cleanup is best-effort.
47
+ }
48
+ return;
49
+ }
50
+ settled = true;
51
+ cleanup();
52
+ resolve(value);
53
+ }, (error) => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ cleanup();
58
+ reject(error instanceof Error ? error : new Error(String(error)));
59
+ })
60
+ .catch(() => {
61
+ // Both operation outcomes are handled above. This only guards cleanup code.
62
+ });
63
+ if (signal.aborted)
64
+ onAbort();
65
+ });
66
+ }
7
67
  function getServiceTargets() {
8
68
  const trans = t();
9
69
  return [
@@ -17,25 +77,34 @@ function getServiceTargets() {
17
77
  { name: trans.status.serviceMirror, url: URLS.mirror, group: 'intranet', intranet: true },
18
78
  ];
19
79
  }
20
- async function checkService(name, url, timeoutMs) {
80
+ async function checkService(name, url, timeoutMs, signal) {
21
81
  const start = Date.now();
22
82
  const controller = new AbortController();
83
+ const onAbort = () => {
84
+ controller.abort(signal?.reason);
85
+ };
86
+ signal?.addEventListener('abort', onAbort, { once: true });
87
+ if (signal?.aborted)
88
+ onAbort();
23
89
  const timeout = setTimeout(() => {
24
90
  controller.abort();
25
91
  }, timeoutMs);
26
- timeout.unref();
27
92
  let response;
28
93
  try {
29
- response = await fetch(url, {
94
+ response = await raceWithSignal(fetch(url, {
30
95
  signal: controller.signal,
31
96
  redirect: 'follow',
32
97
  headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` },
33
- });
98
+ }), controller.signal, cancelResponseBody);
99
+ if (signal?.aborted)
100
+ throw abortError(signal);
34
101
  const latencyMs = Date.now() - start;
35
102
  const ok = response.status >= 200 && response.status < 400;
36
103
  return { name, url, ok, statusCode: response.status, latencyMs };
37
104
  }
38
105
  catch (err) {
106
+ if (signal?.aborted)
107
+ throw abortError(signal);
39
108
  const latencyMs = Date.now() - start;
40
109
  const error = sanitizeTerminalLine(err instanceof Error
41
110
  ? err.name === 'AbortError'
@@ -46,21 +115,19 @@ async function checkService(name, url, timeoutMs) {
46
115
  }
47
116
  finally {
48
117
  clearTimeout(timeout);
49
- try {
50
- await response?.body?.cancel();
51
- }
52
- catch {
53
- // The response may already be closed by the runtime.
54
- }
118
+ signal?.removeEventListener('abort', onAbort);
119
+ cancelResponseBody(response);
55
120
  }
56
121
  }
57
- async function checkServiceWithRetry(target, timeoutMs, retries) {
58
- let lastResult = await checkService(target.name, target.url, timeoutMs);
122
+ async function checkServiceWithRetry(target, timeoutMs, retries, signal) {
123
+ let lastResult = await checkService(target.name, target.url, timeoutMs, signal);
59
124
  if (!lastResult.ok) {
60
125
  for (let attempt = 0; attempt < retries; attempt++) {
61
126
  if (!lastResult.error && !(lastResult.statusCode != null && lastResult.statusCode >= 500))
62
127
  break;
63
- lastResult = await checkService(target.name, target.url, timeoutMs);
128
+ if (signal?.aborted)
129
+ throw abortError(signal);
130
+ lastResult = await checkService(target.name, target.url, timeoutMs, signal);
64
131
  if (lastResult.ok)
65
132
  break;
66
133
  }
@@ -74,7 +141,9 @@ async function checkServiceWithRetry(target, timeoutMs, retries) {
74
141
  export async function checkServices(options = {}) {
75
142
  const timeoutMs = options.timeoutMs ?? 6000;
76
143
  const retries = options.retries ?? 1;
77
- return Promise.all(getServiceTargets().map((t) => checkServiceWithRetry(t, timeoutMs, retries)));
144
+ if (options.signal?.aborted)
145
+ throw abortError(options.signal);
146
+ return Promise.all(getServiceTargets().map((target) => checkServiceWithRetry(target, timeoutMs, retries, options.signal)));
78
147
  }
79
148
  export function serializeServiceStatus(items) {
80
149
  return items.map((item) => ({