@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0
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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/packages/evidence/artifact-integrity.mjs +65 -0
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -232
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { formatTerminalLookup, safeTerminalValue } from "./formatters/terminal.mjs";
|
|
3
|
+
import { presentTerminalOutput, terminalPresentation, wrapTerminalOutput, } from "./terminal-presentation.mjs";
|
|
4
|
+
const MIN_LOOKUP_BROWSER_HEIGHT = 12;
|
|
5
|
+
const MAX_LOOKUP_BROWSER_HEIGHT = 80;
|
|
6
|
+
const MAX_LOOKUP_BROWSER_PANELS = 9;
|
|
7
|
+
const ENTER_ALTERNATE_SCREEN = '\u001b[?1049h\u001b[?25l';
|
|
8
|
+
const LEAVE_ALTERNATE_SCREEN = '\u001b[?25h\u001b[?1049l';
|
|
9
|
+
const CLEAR_SCREEN = '\u001b[2J\u001b[H';
|
|
10
|
+
const ESCAPE_SEQUENCE_WAIT_MS = 40;
|
|
11
|
+
const MAX_PENDING_INPUT_LENGTH = 512;
|
|
12
|
+
const MAX_LOOKUP_BROWSER_INPUT_CHUNK_BYTES = 2_048;
|
|
13
|
+
const MAX_LOOKUP_BROWSER_SEARCH_BYTES = 256;
|
|
14
|
+
const MAX_LOOKUP_BROWSER_SEARCH_SCALARS = 80;
|
|
15
|
+
const MAX_LOOKUP_BROWSER_SEARCH_MATCHES = 256;
|
|
16
|
+
const ANSI_SEQUENCE_RE = /\u001b\[[0-?]*[ -/]*[@-~]/gu;
|
|
17
|
+
const COMPLETE_CSI_RE = /^\u001b\[[0-?]*[ -/]*[@-~]/u;
|
|
18
|
+
function boundedHeight(value) {
|
|
19
|
+
const height = Number(value);
|
|
20
|
+
if (!Number.isSafeInteger(height))
|
|
21
|
+
return 24;
|
|
22
|
+
return Math.max(MIN_LOOKUP_BROWSER_HEIGHT, Math.min(MAX_LOOKUP_BROWSER_HEIGHT, height));
|
|
23
|
+
}
|
|
24
|
+
function panelId(value, index) {
|
|
25
|
+
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '');
|
|
26
|
+
return normalized || `panel-${index + 1}`;
|
|
27
|
+
}
|
|
28
|
+
function terminalCharacterWidth(character) {
|
|
29
|
+
const codePoint = character.codePointAt(0) || 0;
|
|
30
|
+
if (/\p{Mark}/u.test(character)
|
|
31
|
+
|| codePoint === 0x200d
|
|
32
|
+
|| codePoint >= 0xfe00 && codePoint <= 0xfe0f
|
|
33
|
+
|| codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
|
34
|
+
return 0;
|
|
35
|
+
return codePoint >= 0x1100 && (codePoint <= 0x115f
|
|
36
|
+
|| codePoint === 0x2329
|
|
37
|
+
|| codePoint === 0x232a
|
|
38
|
+
|| codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f
|
|
39
|
+
|| codePoint >= 0xac00 && codePoint <= 0xd7a3
|
|
40
|
+
|| codePoint >= 0xf900 && codePoint <= 0xfaff
|
|
41
|
+
|| codePoint >= 0xfe10 && codePoint <= 0xfe19
|
|
42
|
+
|| codePoint >= 0xfe30 && codePoint <= 0xfe6f
|
|
43
|
+
|| codePoint >= 0xff00 && codePoint <= 0xff60
|
|
44
|
+
|| codePoint >= 0xffe0 && codePoint <= 0xffe6
|
|
45
|
+
|| codePoint >= 0x1f300 && codePoint <= 0x1faff
|
|
46
|
+
|| codePoint >= 0x20000 && codePoint <= 0x3fffd) ? 2 : 1;
|
|
47
|
+
}
|
|
48
|
+
function terminalDisplayWidth(value) {
|
|
49
|
+
return Array.from(value.replace(ANSI_SEQUENCE_RE, ''))
|
|
50
|
+
.reduce((total, character) => total + terminalCharacterWidth(character), 0);
|
|
51
|
+
}
|
|
52
|
+
function hardWrapTerminalLine(line, width) {
|
|
53
|
+
if (!line || terminalDisplayWidth(line) <= width)
|
|
54
|
+
return [line];
|
|
55
|
+
const lines = [];
|
|
56
|
+
let current = '';
|
|
57
|
+
let currentWidth = 0;
|
|
58
|
+
for (const character of line) {
|
|
59
|
+
const characterWidth = terminalCharacterWidth(character);
|
|
60
|
+
if (current && currentWidth + characterWidth > width) {
|
|
61
|
+
lines.push(current);
|
|
62
|
+
current = '';
|
|
63
|
+
currentWidth = 0;
|
|
64
|
+
}
|
|
65
|
+
current += character;
|
|
66
|
+
currentWidth += characterWidth;
|
|
67
|
+
}
|
|
68
|
+
if (current || !lines.length)
|
|
69
|
+
lines.push(current);
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
function clipTerminalLine(line, width) {
|
|
73
|
+
if (terminalDisplayWidth(line) <= width)
|
|
74
|
+
return line;
|
|
75
|
+
const maximumContentWidth = Math.max(1, width - 1);
|
|
76
|
+
let clipped = '';
|
|
77
|
+
let clippedWidth = 0;
|
|
78
|
+
for (const character of line) {
|
|
79
|
+
const characterWidth = terminalCharacterWidth(character);
|
|
80
|
+
if (clippedWidth + characterWidth > maximumContentWidth)
|
|
81
|
+
break;
|
|
82
|
+
clipped += character;
|
|
83
|
+
clippedWidth += characterWidth;
|
|
84
|
+
}
|
|
85
|
+
return `${clipped}…`;
|
|
86
|
+
}
|
|
87
|
+
function wrapLookupBrowserLines(value, width) {
|
|
88
|
+
const softlyWrapped = wrapTerminalOutput(`${value}\n`, width).replace(/\n$/u, '').split('\n');
|
|
89
|
+
return softlyWrapped.flatMap((line) => hardWrapTerminalLine(line, width));
|
|
90
|
+
}
|
|
91
|
+
function buildLookupBrowserPanels(document) {
|
|
92
|
+
const blocks = formatTerminalLookup(document, { detail: 'verbose' })
|
|
93
|
+
.trimEnd()
|
|
94
|
+
.split(/\n\n/gu)
|
|
95
|
+
.slice(0, MAX_LOOKUP_BROWSER_PANELS);
|
|
96
|
+
return blocks.flatMap((block, index) => {
|
|
97
|
+
const [heading, ...lines] = block.split('\n');
|
|
98
|
+
if (!heading?.endsWith(':') || lines.length === 0)
|
|
99
|
+
return [];
|
|
100
|
+
const label = heading.slice(0, -1);
|
|
101
|
+
return [Object.freeze({
|
|
102
|
+
id: panelId(label, index),
|
|
103
|
+
label,
|
|
104
|
+
lines: Object.freeze(lines),
|
|
105
|
+
})];
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function browserTabLine(panels, activeIndex, width) {
|
|
109
|
+
const labels = panels.map((panel, index) => index === activeIndex
|
|
110
|
+
? `[${index + 1} ${panel.label}]`
|
|
111
|
+
: `${index + 1} ${panel.label}`);
|
|
112
|
+
const complete = labels.join(' ');
|
|
113
|
+
if (terminalDisplayWidth(complete) <= width)
|
|
114
|
+
return complete;
|
|
115
|
+
const visible = [activeIndex - 1, activeIndex, activeIndex + 1]
|
|
116
|
+
.filter((index) => index >= 0 && index < panels.length)
|
|
117
|
+
.map((index) => labels[index]);
|
|
118
|
+
const windowed = `${activeIndex > 1 ? '‹ ' : ''}${visible.join(' ')}${activeIndex + 2 < panels.length ? ' ›' : ''}`;
|
|
119
|
+
return clipTerminalLine(windowed, width);
|
|
120
|
+
}
|
|
121
|
+
function boundedTargetLine(query, width) {
|
|
122
|
+
return clipTerminalLine(`Target ${query}`, width);
|
|
123
|
+
}
|
|
124
|
+
function boundedBrowserDimensions(options) {
|
|
125
|
+
return {
|
|
126
|
+
width: Math.max(40, Math.min(160, Number.isSafeInteger(options.width) ? Number(options.width) : 100)),
|
|
127
|
+
height: boundedHeight(options.height),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function boundedSearchText(value) {
|
|
131
|
+
const text = typeof value === 'string' ? value : '';
|
|
132
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_LOOKUP_BROWSER_SEARCH_BYTES
|
|
133
|
+
|| Array.from(text).length > MAX_LOOKUP_BROWSER_SEARCH_SCALARS
|
|
134
|
+
|| /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/u.test(text)) {
|
|
135
|
+
throw new RangeError(`Lookup browser search is limited to ${MAX_LOOKUP_BROWSER_SEARCH_SCALARS} characters and ${MAX_LOOKUP_BROWSER_SEARCH_BYTES} UTF-8 bytes.`);
|
|
136
|
+
}
|
|
137
|
+
return text;
|
|
138
|
+
}
|
|
139
|
+
function findLookupBrowserMatches(panels, query) {
|
|
140
|
+
const normalized = boundedSearchText(query).trim().toLocaleLowerCase('en-US');
|
|
141
|
+
if (!normalized)
|
|
142
|
+
return { matches: [], truncated: false };
|
|
143
|
+
const matches = [];
|
|
144
|
+
let truncated = false;
|
|
145
|
+
outer: for (let panelIndex = 0; panelIndex < panels.length; panelIndex += 1) {
|
|
146
|
+
const panel = panels[panelIndex];
|
|
147
|
+
const lines = [panel.label, ...panel.lines];
|
|
148
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
149
|
+
if (!String(lines[lineIndex] || '').toLocaleLowerCase('en-US').includes(normalized))
|
|
150
|
+
continue;
|
|
151
|
+
if (matches.length >= MAX_LOOKUP_BROWSER_SEARCH_MATCHES) {
|
|
152
|
+
truncated = true;
|
|
153
|
+
break outer;
|
|
154
|
+
}
|
|
155
|
+
matches.push(Object.freeze({ panelIndex, lineIndex: Math.max(0, lineIndex - 1) }));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { matches, truncated };
|
|
159
|
+
}
|
|
160
|
+
function renderedPanelLineOffset(panel, lineIndex, width) {
|
|
161
|
+
if (lineIndex <= 0)
|
|
162
|
+
return 0;
|
|
163
|
+
return panel.lines
|
|
164
|
+
.slice(0, Math.min(lineIndex, panel.lines.length))
|
|
165
|
+
.reduce((total, line) => total + wrapLookupBrowserLines(line, width).length, 0);
|
|
166
|
+
}
|
|
167
|
+
function renderStandaloneBrowserFrame(lines, options) {
|
|
168
|
+
const { width, height } = boundedBrowserDimensions(options);
|
|
169
|
+
const clipped = lines.slice(0, height).map((line) => clipTerminalLine(line, width));
|
|
170
|
+
return presentTerminalOutput(`${clipped.join('\n')}\n`, {
|
|
171
|
+
color: options.color === true,
|
|
172
|
+
interactive: true,
|
|
173
|
+
palette: options.palette || 'auto',
|
|
174
|
+
width: null,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function buildLookupBrowserHelpFrame(document, options = {}) {
|
|
178
|
+
const query = safeTerminalValue(document?.query ?? options.query, 'unknown target');
|
|
179
|
+
const mode = safeTerminalValue(document?.mode ?? options.mode, 'unknown mode');
|
|
180
|
+
return renderStandaloneBrowserFrame([
|
|
181
|
+
'WHOISleuth evidence browser help',
|
|
182
|
+
boundedTargetLine(query, boundedBrowserDimensions(options).width),
|
|
183
|
+
`Mode ${mode}`,
|
|
184
|
+
'',
|
|
185
|
+
options.collecting ? 'Collection' : 'Panels',
|
|
186
|
+
options.collecting
|
|
187
|
+
? ' q / Ctrl-C cancel the active collection'
|
|
188
|
+
: ' ←/→ h/l Tab move panels · 1-9 select',
|
|
189
|
+
options.collecting
|
|
190
|
+
? ' progress is transient and is never retained'
|
|
191
|
+
: ' ↑/↓ j/k PgUp/PgDn Home/End scroll',
|
|
192
|
+
options.collecting ? ' ? / Esc return to collection progress' : ' / search rendered panels · n/N matches',
|
|
193
|
+
' ? / Esc return · q close',
|
|
194
|
+
], options);
|
|
195
|
+
}
|
|
196
|
+
function buildLookupBrowserSearchFrame(document, query, notice, options = {}) {
|
|
197
|
+
return renderStandaloneBrowserFrame([
|
|
198
|
+
'WHOISleuth evidence browser search',
|
|
199
|
+
boundedTargetLine(safeTerminalValue(document.query, 'unknown target'), boundedBrowserDimensions(options).width),
|
|
200
|
+
`Mode ${safeTerminalValue(document.mode, 'unknown mode')}`,
|
|
201
|
+
'',
|
|
202
|
+
`Search ${query || 'type a bounded term'}`,
|
|
203
|
+
notice || 'Search covers only the bounded text rendered in the evidence panels.',
|
|
204
|
+
'',
|
|
205
|
+
'Enter apply · Backspace edit · Esc cancel',
|
|
206
|
+
], options);
|
|
207
|
+
}
|
|
208
|
+
function buildLookupCollectionFrame(queryValue, modeValue, sources, options = {}) {
|
|
209
|
+
const { width, height } = boundedBrowserDimensions(options);
|
|
210
|
+
const query = safeTerminalValue(queryValue, 'unknown target');
|
|
211
|
+
const mode = modeValue === 'deep' ? 'deep' : 'fast';
|
|
212
|
+
const header = [
|
|
213
|
+
'WHOISleuth evidence browser',
|
|
214
|
+
boundedTargetLine(query, width),
|
|
215
|
+
`Mode ${mode}`,
|
|
216
|
+
'Collection in progress:',
|
|
217
|
+
];
|
|
218
|
+
const footer = ['? help · progress is transient', 'q/Esc/Ctrl-C cancel'];
|
|
219
|
+
const bodyCapacity = Math.max(1, height - header.length - footer.length);
|
|
220
|
+
const body = mode === 'fast'
|
|
221
|
+
? [
|
|
222
|
+
'Registration evidence collecting',
|
|
223
|
+
'Fast mode waits for the final bounded result.',
|
|
224
|
+
'Per-source states are not fabricated.',
|
|
225
|
+
]
|
|
226
|
+
: sources.flatMap((source) => {
|
|
227
|
+
const state = source.state === 'pending'
|
|
228
|
+
? 'pending'
|
|
229
|
+
: `${source.state}${source.complete ? ' · complete' : ' · incomplete'}${source.truncated ? ' · truncated' : ''}`;
|
|
230
|
+
const limitation = source.state !== 'pending' && source.limitation
|
|
231
|
+
? ` · ${source.limitation}`
|
|
232
|
+
: '';
|
|
233
|
+
return [clipTerminalLine(`${source.source.replaceAll('_', ' ')} ${state}${limitation}`, width)];
|
|
234
|
+
});
|
|
235
|
+
const visible = body.slice(0, bodyCapacity);
|
|
236
|
+
if (body.length > visible.length && visible.length) {
|
|
237
|
+
visible[visible.length - 1] = `+${body.length - visible.length + 1} source states not visible at this height`;
|
|
238
|
+
}
|
|
239
|
+
return renderStandaloneBrowserFrame([...header, ...visible, ...footer], {
|
|
240
|
+
...options,
|
|
241
|
+
width,
|
|
242
|
+
height,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function buildLookupBrowserFrame(document, options = {}) {
|
|
246
|
+
const panels = buildLookupBrowserPanels(document);
|
|
247
|
+
const { width, height } = boundedBrowserDimensions(options);
|
|
248
|
+
if (!panels.length)
|
|
249
|
+
return Object.freeze({
|
|
250
|
+
value: `${clipTerminalLine('WHOISleuth evidence browser', width)}\n${clipTerminalLine('No bounded terminal panels were available.', width)}\n`,
|
|
251
|
+
scrollOffset: 0,
|
|
252
|
+
maximumScrollOffset: 0,
|
|
253
|
+
pageSize: 0,
|
|
254
|
+
totalBodyLines: 0,
|
|
255
|
+
});
|
|
256
|
+
const activeIndex = Math.max(0, Math.min(panels.length - 1, Number.isSafeInteger(options.activeIndex) ? Number(options.activeIndex) : 0));
|
|
257
|
+
const panel = panels[activeIndex];
|
|
258
|
+
const query = safeTerminalValue(document.query, 'unknown target');
|
|
259
|
+
const mode = safeTerminalValue(document.mode, 'unknown mode');
|
|
260
|
+
const header = [
|
|
261
|
+
'WHOISleuth evidence browser',
|
|
262
|
+
boundedTargetLine(query, width),
|
|
263
|
+
`Mode ${clipTerminalLine(mode, Math.max(1, width - 8))}`,
|
|
264
|
+
browserTabLine(panels, activeIndex, width),
|
|
265
|
+
`${panel.label}:`,
|
|
266
|
+
];
|
|
267
|
+
const footerLineCount = 2;
|
|
268
|
+
const body = wrapLookupBrowserLines(panel.lines.join('\n'), width);
|
|
269
|
+
const bodyCapacity = Math.max(1, height - header.length - footerLineCount);
|
|
270
|
+
const maximumScrollOffset = Math.max(0, body.length - bodyCapacity);
|
|
271
|
+
const requestedScrollOffset = Number.isSafeInteger(options.scrollOffset) ? Number(options.scrollOffset) : 0;
|
|
272
|
+
const scrollOffset = Math.max(0, Math.min(maximumScrollOffset, requestedScrollOffset));
|
|
273
|
+
const visibleBody = body.slice(scrollOffset, scrollOffset + bodyCapacity);
|
|
274
|
+
const firstVisibleLine = body.length ? scrollOffset + 1 : 0;
|
|
275
|
+
const lastVisibleLine = body.length ? scrollOffset + visibleBody.length : 0;
|
|
276
|
+
const footer = [
|
|
277
|
+
options.searchStatus || `View ${activeIndex + 1}/${panels.length} · lines ${firstVisibleLine}-${lastVisibleLine}/${body.length}`,
|
|
278
|
+
'←/→ panels · ↑/↓ scroll · / search · ? help · q close',
|
|
279
|
+
].map((line) => clipTerminalLine(line, width));
|
|
280
|
+
const value = [...header, ...visibleBody, ...footer].slice(0, height).join('\n');
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
value: presentTerminalOutput(`${value}\n`, {
|
|
283
|
+
color: options.color === true,
|
|
284
|
+
interactive: true,
|
|
285
|
+
palette: options.palette || 'auto',
|
|
286
|
+
width: null,
|
|
287
|
+
}),
|
|
288
|
+
scrollOffset,
|
|
289
|
+
maximumScrollOffset,
|
|
290
|
+
pageSize: bodyCapacity,
|
|
291
|
+
totalBodyLines: body.length,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
function renderLookupBrowser(document, options = {}) {
|
|
295
|
+
return buildLookupBrowserFrame(document, options).value;
|
|
296
|
+
}
|
|
297
|
+
function canBrowseLookup(input, output, environment = process.env) {
|
|
298
|
+
return input?.isTTY === true
|
|
299
|
+
&& output?.isTTY === true
|
|
300
|
+
&& typeof input.setRawMode === 'function'
|
|
301
|
+
&& typeof input.resume === 'function'
|
|
302
|
+
&& typeof input.pause === 'function'
|
|
303
|
+
&& typeof input.on === 'function'
|
|
304
|
+
&& typeof input.off === 'function'
|
|
305
|
+
&& typeof output.write === 'function'
|
|
306
|
+
&& !(Number.isFinite(output.columns) && Number(output.columns) < 40)
|
|
307
|
+
&& !(Number.isFinite(output.rows) && Number(output.rows) < MIN_LOOKUP_BROWSER_HEIGHT)
|
|
308
|
+
&& environment.TERM !== 'dumb'
|
|
309
|
+
&& !environment.CI;
|
|
310
|
+
}
|
|
311
|
+
function runLookupBrowserSession(options) {
|
|
312
|
+
const environment = options.environment || process.env;
|
|
313
|
+
if (!canBrowseLookup(options.input, options.output, environment)) {
|
|
314
|
+
throw new TypeError('Lookup browsing requires interactive terminal input and output.');
|
|
315
|
+
}
|
|
316
|
+
if (options.signal?.aborted)
|
|
317
|
+
return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
|
318
|
+
if (!options.document && (!options.collect || !options.query || !options.mode)) {
|
|
319
|
+
throw new TypeError('Lookup browser collection requires a target, mode, and collection operation.');
|
|
320
|
+
}
|
|
321
|
+
const input = options.input;
|
|
322
|
+
const output = options.output;
|
|
323
|
+
const previousRaw = input.isRaw === true;
|
|
324
|
+
const wasPaused = input.isPaused?.() === true;
|
|
325
|
+
const collectionController = new AbortController();
|
|
326
|
+
const collectionSignal = options.signal
|
|
327
|
+
? AbortSignal.any([options.signal, collectionController.signal])
|
|
328
|
+
: collectionController.signal;
|
|
329
|
+
const sourceOrder = [...(options.plannedSources || [])];
|
|
330
|
+
if (sourceOrder.length > 16
|
|
331
|
+
|| new Set(sourceOrder).size !== sourceOrder.length
|
|
332
|
+
|| sourceOrder.some((source) => !/^[a-z][a-z0-9_]{0,39}$/u.test(source))) {
|
|
333
|
+
throw new TypeError('Lookup browser planned sources are invalid or exceed their bound.');
|
|
334
|
+
}
|
|
335
|
+
const sourceStates = new Map(sourceOrder.map((source) => [source, Object.freeze({
|
|
336
|
+
source,
|
|
337
|
+
state: 'pending',
|
|
338
|
+
complete: false,
|
|
339
|
+
truncated: false,
|
|
340
|
+
limitation: null,
|
|
341
|
+
})]));
|
|
342
|
+
const settledSourceIds = new Set();
|
|
343
|
+
let document = options.document || null;
|
|
344
|
+
let collecting = document === null;
|
|
345
|
+
let panels = document ? buildLookupBrowserPanels(document) : [];
|
|
346
|
+
let activeIndex = 0;
|
|
347
|
+
let scrollOffset = 0;
|
|
348
|
+
let maximumScrollOffset = 0;
|
|
349
|
+
let pageSize = 1;
|
|
350
|
+
let settled = false;
|
|
351
|
+
let pendingInput = '';
|
|
352
|
+
let escapeTimer = null;
|
|
353
|
+
let view = 'normal';
|
|
354
|
+
let searchDraft = '';
|
|
355
|
+
let searchNotice = '';
|
|
356
|
+
let searchQuery = '';
|
|
357
|
+
let searchMatches = [];
|
|
358
|
+
let searchMatchIndex = 0;
|
|
359
|
+
let searchTruncated = false;
|
|
360
|
+
let renderedWidth = 100;
|
|
361
|
+
let inputDecoder = new StringDecoder('utf8');
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
const render = () => {
|
|
364
|
+
if (Number.isFinite(output.columns) && Number(output.columns) < 40
|
|
365
|
+
|| Number.isFinite(output.rows) && Number(output.rows) < MIN_LOOKUP_BROWSER_HEIGHT) {
|
|
366
|
+
throw new RangeError(`Lookup browsing requires a terminal of at least 40 columns by ${MIN_LOOKUP_BROWSER_HEIGHT} rows.`);
|
|
367
|
+
}
|
|
368
|
+
const presentation = terminalPresentation(output, options.color !== false, environment, options.palette);
|
|
369
|
+
const geometry = {
|
|
370
|
+
width: presentation.width ?? output.columns ?? 100,
|
|
371
|
+
...(output.rows !== undefined ? { height: output.rows } : {}),
|
|
372
|
+
color: presentation.color,
|
|
373
|
+
palette: presentation.palette,
|
|
374
|
+
};
|
|
375
|
+
renderedWidth = boundedBrowserDimensions(geometry).width;
|
|
376
|
+
let value;
|
|
377
|
+
if (view === 'help') {
|
|
378
|
+
value = buildLookupBrowserHelpFrame(document, {
|
|
379
|
+
...geometry,
|
|
380
|
+
...(options.query ? { query: options.query } : {}),
|
|
381
|
+
...(options.mode ? { mode: options.mode } : {}),
|
|
382
|
+
collecting,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
else if (view === 'search' && document) {
|
|
386
|
+
value = buildLookupBrowserSearchFrame(document, searchDraft, searchNotice, geometry);
|
|
387
|
+
}
|
|
388
|
+
else if (collecting) {
|
|
389
|
+
value = buildLookupCollectionFrame(options.query, options.mode || 'fast', sourceOrder.map((source) => sourceStates.get(source)), geometry);
|
|
390
|
+
}
|
|
391
|
+
else if (document) {
|
|
392
|
+
const searchStatus = searchQuery
|
|
393
|
+
? searchMatches.length
|
|
394
|
+
? `Match ${searchMatchIndex + 1}/${searchMatches.length}${searchTruncated ? '+' : ''} · ${safeTerminalValue(searchQuery, 'search')}`
|
|
395
|
+
: `No matches · ${safeTerminalValue(searchQuery, 'search')}`
|
|
396
|
+
: undefined;
|
|
397
|
+
const frame = buildLookupBrowserFrame(document, {
|
|
398
|
+
activeIndex,
|
|
399
|
+
scrollOffset,
|
|
400
|
+
...geometry,
|
|
401
|
+
environment,
|
|
402
|
+
...(searchStatus ? { searchStatus } : {}),
|
|
403
|
+
});
|
|
404
|
+
scrollOffset = frame.scrollOffset;
|
|
405
|
+
maximumScrollOffset = frame.maximumScrollOffset;
|
|
406
|
+
pageSize = Math.max(1, frame.pageSize);
|
|
407
|
+
value = frame.value;
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
throw new Error('Lookup browser has no collection or final document to render.');
|
|
411
|
+
}
|
|
412
|
+
output.write(CLEAR_SCREEN);
|
|
413
|
+
output.write(value);
|
|
414
|
+
};
|
|
415
|
+
const redraw = () => {
|
|
416
|
+
try {
|
|
417
|
+
render();
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
if (collecting && !collectionController.signal.aborted)
|
|
421
|
+
collectionController.abort(error);
|
|
422
|
+
finish(error instanceof Error ? error : new Error('Lookup browser could not redraw.'));
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const cleanup = () => {
|
|
426
|
+
let failure = null;
|
|
427
|
+
const attempt = (operation) => {
|
|
428
|
+
try {
|
|
429
|
+
operation();
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
failure ||= error instanceof Error ? error : new Error('Lookup browser terminal cleanup failed.');
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
if (escapeTimer)
|
|
436
|
+
clearTimeout(escapeTimer);
|
|
437
|
+
escapeTimer = null;
|
|
438
|
+
attempt(() => input.off?.('data', onData));
|
|
439
|
+
attempt(() => input.off?.('end', onEnd));
|
|
440
|
+
attempt(() => output.off?.('resize', onResize));
|
|
441
|
+
attempt(() => options.signal?.removeEventListener('abort', onAbort));
|
|
442
|
+
attempt(() => input.setRawMode?.(previousRaw));
|
|
443
|
+
if (wasPaused)
|
|
444
|
+
attempt(() => input.pause?.());
|
|
445
|
+
attempt(() => output.write(LEAVE_ALTERNATE_SCREEN));
|
|
446
|
+
return failure;
|
|
447
|
+
};
|
|
448
|
+
const finish = (error) => {
|
|
449
|
+
if (settled)
|
|
450
|
+
return;
|
|
451
|
+
settled = true;
|
|
452
|
+
const cleanupFailure = cleanup();
|
|
453
|
+
if (error && cleanupFailure) {
|
|
454
|
+
reject(new AggregateError([error, cleanupFailure], `${error.message}; terminal cleanup also failed: ${cleanupFailure.message}`));
|
|
455
|
+
}
|
|
456
|
+
else if (error || cleanupFailure)
|
|
457
|
+
reject(error || cleanupFailure);
|
|
458
|
+
else if (document)
|
|
459
|
+
resolve(document);
|
|
460
|
+
else
|
|
461
|
+
reject(new Error('Lookup browser closed without a final document.'));
|
|
462
|
+
};
|
|
463
|
+
const clearSearch = () => {
|
|
464
|
+
searchQuery = '';
|
|
465
|
+
searchMatches = [];
|
|
466
|
+
searchMatchIndex = 0;
|
|
467
|
+
searchTruncated = false;
|
|
468
|
+
};
|
|
469
|
+
const movePanel = (offset, preserveSearch = false) => {
|
|
470
|
+
if (!panels.length)
|
|
471
|
+
return;
|
|
472
|
+
activeIndex = (activeIndex + offset + panels.length) % panels.length;
|
|
473
|
+
scrollOffset = 0;
|
|
474
|
+
if (!preserveSearch)
|
|
475
|
+
clearSearch();
|
|
476
|
+
redraw();
|
|
477
|
+
};
|
|
478
|
+
const scrollBy = (offset) => {
|
|
479
|
+
scrollOffset = Math.max(0, Math.min(maximumScrollOffset, scrollOffset + offset));
|
|
480
|
+
redraw();
|
|
481
|
+
};
|
|
482
|
+
const scrollTo = (offset) => {
|
|
483
|
+
scrollOffset = Math.max(0, Math.min(maximumScrollOffset, offset));
|
|
484
|
+
redraw();
|
|
485
|
+
};
|
|
486
|
+
const cancelCollection = () => {
|
|
487
|
+
const error = new DOMException('Aborted', 'AbortError');
|
|
488
|
+
if (!collectionController.signal.aborted)
|
|
489
|
+
collectionController.abort(error);
|
|
490
|
+
finish(error);
|
|
491
|
+
};
|
|
492
|
+
const handleEscape = () => {
|
|
493
|
+
if (view !== 'normal') {
|
|
494
|
+
view = 'normal';
|
|
495
|
+
searchDraft = '';
|
|
496
|
+
searchNotice = '';
|
|
497
|
+
redraw();
|
|
498
|
+
}
|
|
499
|
+
else if (collecting)
|
|
500
|
+
cancelCollection();
|
|
501
|
+
else
|
|
502
|
+
finish();
|
|
503
|
+
};
|
|
504
|
+
const scheduleEscapeAction = () => {
|
|
505
|
+
if (escapeTimer)
|
|
506
|
+
return;
|
|
507
|
+
escapeTimer = setTimeout(() => {
|
|
508
|
+
escapeTimer = null;
|
|
509
|
+
pendingInput = '';
|
|
510
|
+
handleEscape();
|
|
511
|
+
}, ESCAPE_SEQUENCE_WAIT_MS);
|
|
512
|
+
};
|
|
513
|
+
const scheduleIncompleteCsiDiscard = () => {
|
|
514
|
+
if (escapeTimer)
|
|
515
|
+
return;
|
|
516
|
+
escapeTimer = setTimeout(() => {
|
|
517
|
+
escapeTimer = null;
|
|
518
|
+
pendingInput = '';
|
|
519
|
+
}, ESCAPE_SEQUENCE_WAIT_MS);
|
|
520
|
+
};
|
|
521
|
+
const applySearchMatch = () => {
|
|
522
|
+
const match = searchMatches[searchMatchIndex];
|
|
523
|
+
if (!match)
|
|
524
|
+
return;
|
|
525
|
+
activeIndex = match.panelIndex;
|
|
526
|
+
const panel = panels[match.panelIndex];
|
|
527
|
+
scrollOffset = panel ? renderedPanelLineOffset(panel, match.lineIndex, renderedWidth) : 0;
|
|
528
|
+
redraw();
|
|
529
|
+
};
|
|
530
|
+
const onResize = () => {
|
|
531
|
+
if (searchMatches.length) {
|
|
532
|
+
const match = searchMatches[searchMatchIndex];
|
|
533
|
+
const panel = match ? panels[match.panelIndex] : null;
|
|
534
|
+
const width = boundedBrowserDimensions(output.columns === undefined ? {} : { width: output.columns }).width;
|
|
535
|
+
if (match && panel)
|
|
536
|
+
scrollOffset = renderedPanelLineOffset(panel, match.lineIndex, width);
|
|
537
|
+
}
|
|
538
|
+
redraw();
|
|
539
|
+
};
|
|
540
|
+
const commitSearch = () => {
|
|
541
|
+
searchQuery = searchDraft.trim();
|
|
542
|
+
const result = findLookupBrowserMatches(panels, searchQuery);
|
|
543
|
+
searchMatches = result.matches;
|
|
544
|
+
searchTruncated = result.truncated;
|
|
545
|
+
searchMatchIndex = 0;
|
|
546
|
+
view = 'normal';
|
|
547
|
+
searchDraft = '';
|
|
548
|
+
searchNotice = '';
|
|
549
|
+
if (searchMatches.length)
|
|
550
|
+
applySearchMatch();
|
|
551
|
+
else
|
|
552
|
+
redraw();
|
|
553
|
+
};
|
|
554
|
+
const moveSearchMatch = (offset) => {
|
|
555
|
+
if (!searchMatches.length)
|
|
556
|
+
return;
|
|
557
|
+
searchMatchIndex = (searchMatchIndex + offset + searchMatches.length) % searchMatches.length;
|
|
558
|
+
applySearchMatch();
|
|
559
|
+
};
|
|
560
|
+
const appendSearchCharacter = (character) => {
|
|
561
|
+
const candidate = `${searchDraft}${character}`;
|
|
562
|
+
try {
|
|
563
|
+
searchDraft = boundedSearchText(candidate);
|
|
564
|
+
searchNotice = '';
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
searchNotice = error instanceof Error ? error.message : 'Search input exceeds its bound.';
|
|
568
|
+
}
|
|
569
|
+
redraw();
|
|
570
|
+
};
|
|
571
|
+
const processSearchCharacter = (character) => {
|
|
572
|
+
if (view !== 'search')
|
|
573
|
+
return false;
|
|
574
|
+
if (character === '\r' || character === '\n') {
|
|
575
|
+
commitSearch();
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
if (character === '\u0008' || character === '\u007f') {
|
|
579
|
+
searchDraft = Array.from(searchDraft).slice(0, -1).join('');
|
|
580
|
+
searchNotice = '';
|
|
581
|
+
redraw();
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
if (!/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/u.test(character)) {
|
|
585
|
+
appendSearchCharacter(character);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
searchNotice = 'Search input is not valid bounded UTF-8 text.';
|
|
589
|
+
redraw();
|
|
590
|
+
}
|
|
591
|
+
return true;
|
|
592
|
+
};
|
|
593
|
+
const processPendingInput = () => {
|
|
594
|
+
while (pendingInput && !settled) {
|
|
595
|
+
if (pendingInput.startsWith('\u001b')) {
|
|
596
|
+
if (pendingInput.length === 1) {
|
|
597
|
+
scheduleEscapeAction();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (pendingInput[1] === '[') {
|
|
601
|
+
const sequence = pendingInput.match(COMPLETE_CSI_RE)?.[0];
|
|
602
|
+
if (!sequence) {
|
|
603
|
+
if (pendingInput.length < MAX_PENDING_INPUT_LENGTH) {
|
|
604
|
+
scheduleIncompleteCsiDiscard();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
pendingInput = '';
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
pendingInput = pendingInput.slice(sequence.length);
|
|
611
|
+
if (!collecting && view === 'normal') {
|
|
612
|
+
if (sequence === '\u001b[D' || sequence === '\u001b[Z')
|
|
613
|
+
movePanel(-1);
|
|
614
|
+
else if (sequence === '\u001b[C')
|
|
615
|
+
movePanel(1);
|
|
616
|
+
else if (sequence === '\u001b[A')
|
|
617
|
+
scrollBy(-1);
|
|
618
|
+
else if (sequence === '\u001b[B')
|
|
619
|
+
scrollBy(1);
|
|
620
|
+
else if (sequence === '\u001b[5~')
|
|
621
|
+
scrollBy(-pageSize);
|
|
622
|
+
else if (sequence === '\u001b[6~')
|
|
623
|
+
scrollBy(pageSize);
|
|
624
|
+
else if (sequence === '\u001b[H' || sequence === '\u001b[1~')
|
|
625
|
+
scrollTo(0);
|
|
626
|
+
else if (sequence === '\u001b[F' || sequence === '\u001b[4~')
|
|
627
|
+
scrollTo(maximumScrollOffset);
|
|
628
|
+
}
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
pendingInput = pendingInput.slice(1);
|
|
632
|
+
handleEscape();
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const character = Array.from(pendingInput)[0];
|
|
636
|
+
pendingInput = pendingInput.slice(character.length);
|
|
637
|
+
if (character === '\u0003') {
|
|
638
|
+
if (collecting)
|
|
639
|
+
cancelCollection();
|
|
640
|
+
else
|
|
641
|
+
finish(new DOMException('Aborted', 'AbortError'));
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (character === '\u0004') {
|
|
645
|
+
cancelCollection();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (processSearchCharacter(character))
|
|
649
|
+
continue;
|
|
650
|
+
if (character === '?') {
|
|
651
|
+
view = view === 'help' ? 'normal' : 'help';
|
|
652
|
+
redraw();
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (character === 'q' || character === 'Q') {
|
|
656
|
+
if (collecting)
|
|
657
|
+
cancelCollection();
|
|
658
|
+
else
|
|
659
|
+
finish();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (collecting || view === 'help')
|
|
663
|
+
continue;
|
|
664
|
+
if (character === '\r' || character === '\n') {
|
|
665
|
+
finish();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (character === '/') {
|
|
669
|
+
view = 'search';
|
|
670
|
+
searchDraft = '';
|
|
671
|
+
searchNotice = '';
|
|
672
|
+
redraw();
|
|
673
|
+
}
|
|
674
|
+
else if (character === 'n')
|
|
675
|
+
moveSearchMatch(1);
|
|
676
|
+
else if (character === 'N')
|
|
677
|
+
moveSearchMatch(-1);
|
|
678
|
+
else if (character === '\t' || character === 'l' || character === 'L')
|
|
679
|
+
movePanel(1);
|
|
680
|
+
else if (character === 'h' || character === 'H')
|
|
681
|
+
movePanel(-1);
|
|
682
|
+
else if (character === 'j' || character === 'J')
|
|
683
|
+
scrollBy(1);
|
|
684
|
+
else if (character === 'k' || character === 'K')
|
|
685
|
+
scrollBy(-1);
|
|
686
|
+
else if (/^[1-9]$/u.test(character)) {
|
|
687
|
+
const requested = Number(character) - 1;
|
|
688
|
+
if (requested < panels.length) {
|
|
689
|
+
activeIndex = requested;
|
|
690
|
+
scrollOffset = 0;
|
|
691
|
+
clearSearch();
|
|
692
|
+
redraw();
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
const onData = (chunk) => {
|
|
698
|
+
if (escapeTimer)
|
|
699
|
+
clearTimeout(escapeTimer);
|
|
700
|
+
escapeTimer = null;
|
|
701
|
+
let rawChunk;
|
|
702
|
+
if (Buffer.isBuffer(chunk)) {
|
|
703
|
+
if (chunk.length > MAX_LOOKUP_BROWSER_INPUT_CHUNK_BYTES) {
|
|
704
|
+
inputDecoder = new StringDecoder('utf8');
|
|
705
|
+
pendingInput = '';
|
|
706
|
+
if (view === 'search') {
|
|
707
|
+
searchNotice = `Search input is limited to ${MAX_LOOKUP_BROWSER_SEARCH_SCALARS} characters and ${MAX_LOOKUP_BROWSER_SEARCH_BYTES} UTF-8 bytes.`;
|
|
708
|
+
redraw();
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
rawChunk = chunk;
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
const supplied = String(chunk ?? '');
|
|
716
|
+
if (supplied.length > MAX_LOOKUP_BROWSER_INPUT_CHUNK_BYTES
|
|
717
|
+
|| Buffer.byteLength(supplied, 'utf8') > MAX_LOOKUP_BROWSER_INPUT_CHUNK_BYTES) {
|
|
718
|
+
inputDecoder = new StringDecoder('utf8');
|
|
719
|
+
pendingInput = '';
|
|
720
|
+
if (view === 'search') {
|
|
721
|
+
searchNotice = `Search input is limited to ${MAX_LOOKUP_BROWSER_SEARCH_SCALARS} characters and ${MAX_LOOKUP_BROWSER_SEARCH_BYTES} UTF-8 bytes.`;
|
|
722
|
+
redraw();
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
rawChunk = Buffer.from(supplied, 'utf8');
|
|
727
|
+
}
|
|
728
|
+
const value = inputDecoder.write(rawChunk);
|
|
729
|
+
if (value.includes('\ufffd')) {
|
|
730
|
+
inputDecoder = new StringDecoder('utf8');
|
|
731
|
+
pendingInput = '';
|
|
732
|
+
if (view === 'search') {
|
|
733
|
+
searchNotice = 'Search input must be valid UTF-8 text.';
|
|
734
|
+
redraw();
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const combinedInput = `${pendingInput}${value}`;
|
|
739
|
+
if (combinedInput.length > MAX_PENDING_INPUT_LENGTH) {
|
|
740
|
+
inputDecoder = new StringDecoder('utf8');
|
|
741
|
+
pendingInput = '';
|
|
742
|
+
if (view === 'search') {
|
|
743
|
+
searchNotice = `Search input is limited to ${MAX_LOOKUP_BROWSER_SEARCH_SCALARS} characters and ${MAX_LOOKUP_BROWSER_SEARCH_BYTES} UTF-8 bytes.`;
|
|
744
|
+
redraw();
|
|
745
|
+
}
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
pendingInput = combinedInput;
|
|
749
|
+
processPendingInput();
|
|
750
|
+
};
|
|
751
|
+
const onEnd = () => cancelCollection();
|
|
752
|
+
const onAbort = () => {
|
|
753
|
+
const suppliedReason = options.signal?.reason;
|
|
754
|
+
const error = suppliedReason instanceof Error && suppliedReason.name !== 'AbortError'
|
|
755
|
+
? suppliedReason
|
|
756
|
+
: new DOMException('Aborted', 'AbortError');
|
|
757
|
+
if (collecting && !collectionController.signal.aborted)
|
|
758
|
+
collectionController.abort(error);
|
|
759
|
+
finish(error);
|
|
760
|
+
};
|
|
761
|
+
const onSourceSettled = (settlement) => {
|
|
762
|
+
if (settled || !collecting)
|
|
763
|
+
return;
|
|
764
|
+
if (!sourceStates.has(settlement.source) || settledSourceIds.has(settlement.source)) {
|
|
765
|
+
const error = new Error('Lookup browser received an unplanned or duplicate source settlement.');
|
|
766
|
+
if (!collectionController.signal.aborted)
|
|
767
|
+
collectionController.abort(error);
|
|
768
|
+
finish(error);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
settledSourceIds.add(settlement.source);
|
|
772
|
+
const limitation = typeof settlement.fragment?.limitation === 'string'
|
|
773
|
+
? safeTerminalValue(settlement.fragment.limitation, 'Source limitation unavailable')
|
|
774
|
+
: null;
|
|
775
|
+
sourceStates.set(settlement.source, Object.freeze({
|
|
776
|
+
source: settlement.source,
|
|
777
|
+
state: settlement.state,
|
|
778
|
+
complete: settlement.complete,
|
|
779
|
+
truncated: settlement.truncated,
|
|
780
|
+
limitation,
|
|
781
|
+
}));
|
|
782
|
+
redraw();
|
|
783
|
+
};
|
|
784
|
+
const startCollection = () => {
|
|
785
|
+
if (settled || !collecting || !options.collect)
|
|
786
|
+
return;
|
|
787
|
+
void new Promise((resolve) => setImmediate(resolve))
|
|
788
|
+
.then(() => {
|
|
789
|
+
if (settled || collectionSignal.aborted) {
|
|
790
|
+
throw collectionSignal.reason || new DOMException('Aborted', 'AbortError');
|
|
791
|
+
}
|
|
792
|
+
return options.collect({ signal: collectionSignal, onSourceSettled });
|
|
793
|
+
})
|
|
794
|
+
.then((nextDocument) => {
|
|
795
|
+
if (settled)
|
|
796
|
+
return;
|
|
797
|
+
if (!nextDocument || typeof nextDocument !== 'object' || Array.isArray(nextDocument)) {
|
|
798
|
+
throw new TypeError('Lookup collection did not return one final document.');
|
|
799
|
+
}
|
|
800
|
+
document = nextDocument;
|
|
801
|
+
panels = buildLookupBrowserPanels(nextDocument);
|
|
802
|
+
collecting = false;
|
|
803
|
+
view = 'normal';
|
|
804
|
+
activeIndex = 0;
|
|
805
|
+
scrollOffset = 0;
|
|
806
|
+
redraw();
|
|
807
|
+
})
|
|
808
|
+
.catch((error) => {
|
|
809
|
+
if (settled)
|
|
810
|
+
return;
|
|
811
|
+
finish(error instanceof Error ? error : new Error('Lookup collection failed.'));
|
|
812
|
+
});
|
|
813
|
+
};
|
|
814
|
+
try {
|
|
815
|
+
output.write(ENTER_ALTERNATE_SCREEN);
|
|
816
|
+
input.on?.('data', onData);
|
|
817
|
+
input.on?.('end', onEnd);
|
|
818
|
+
output.on?.('resize', onResize);
|
|
819
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
820
|
+
if (options.signal?.aborted) {
|
|
821
|
+
onAbort();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
input.setRawMode?.(true);
|
|
825
|
+
if (settled)
|
|
826
|
+
return;
|
|
827
|
+
render();
|
|
828
|
+
if (settled)
|
|
829
|
+
return;
|
|
830
|
+
input.resume?.();
|
|
831
|
+
startCollection();
|
|
832
|
+
}
|
|
833
|
+
catch (error) {
|
|
834
|
+
finish(error instanceof Error ? error : new Error('Lookup browser could not start.'));
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
function browseLookupDocument(document, options) {
|
|
839
|
+
return runLookupBrowserSession({ ...options, document }).then(() => undefined);
|
|
840
|
+
}
|
|
841
|
+
function browseLookupOperation(options) {
|
|
842
|
+
return runLookupBrowserSession(options);
|
|
843
|
+
}
|
|
844
|
+
export { MAX_LOOKUP_BROWSER_PANELS, MAX_LOOKUP_BROWSER_INPUT_CHUNK_BYTES, MAX_LOOKUP_BROWSER_SEARCH_BYTES, MAX_LOOKUP_BROWSER_SEARCH_MATCHES, MAX_LOOKUP_BROWSER_SEARCH_SCALARS, browseLookupOperation, browseLookupDocument, boundedSearchText, buildLookupBrowserHelpFrame, buildLookupBrowserFrame, buildLookupBrowserPanels, buildLookupBrowserSearchFrame, buildLookupCollectionFrame, canBrowseLookup, findLookupBrowserMatches, renderedPanelLineOffset, renderLookupBrowser, terminalDisplayWidth, };
|