@taurgis/bonsai 3.1.0 → 3.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
- package/agents/instructions/salesforce-research.instructions.md +2 -1
- package/bin/cli.mjs +9 -17
- package/dist/base-command.d.ts +37 -10
- package/dist/base-command.js +113 -46
- package/dist/commands/config/base.js +11 -2
- package/dist/commands/config/get.js +8 -12
- package/dist/commands/config/index.js +2 -2
- package/dist/commands/config/list.js +10 -16
- package/dist/commands/config/set.d.ts +2 -1
- package/dist/commands/config/set.js +34 -27
- package/dist/commands/config/unset.d.ts +2 -1
- package/dist/commands/config/unset.js +25 -24
- package/dist/commands/fetch.d.ts +7 -15
- package/dist/commands/fetch.js +58 -401
- package/dist/commands/import.d.ts +4 -9
- package/dist/commands/import.js +56 -280
- package/dist/commands/inspect.d.ts +7 -1
- package/dist/commands/inspect.js +103 -52
- package/dist/commands/list.d.ts +13 -1
- package/dist/commands/list.js +79 -32
- package/dist/commands/prune.d.ts +4 -1
- package/dist/commands/prune.js +56 -65
- package/dist/commands/status.d.ts +3 -2
- package/dist/commands/status.js +52 -79
- package/dist/hooks/command-not-found/suggest.d.ts +1 -0
- package/dist/hooks/command-not-found/suggest.js +66 -39
- package/dist/lib/argv.d.ts +24 -3
- package/dist/lib/argv.js +124 -53
- package/dist/lib/batch.d.ts +41 -0
- package/dist/lib/batch.js +39 -0
- package/dist/lib/cache-view.d.ts +21 -0
- package/dist/lib/cache-view.js +30 -0
- package/dist/lib/cli-emit.d.ts +28 -0
- package/dist/lib/cli-emit.js +44 -0
- package/dist/lib/cli-error-policy.d.ts +57 -0
- package/dist/lib/cli-error-policy.js +74 -0
- package/dist/lib/cli-flag-manifest.d.ts +5 -0
- package/dist/lib/cli-flag-manifest.js +8 -0
- package/dist/lib/cli-presentation.d.ts +39 -0
- package/dist/lib/cli-presentation.js +50 -0
- package/dist/lib/cli-result-types.d.ts +88 -0
- package/dist/lib/cli-result-types.js +2 -0
- package/dist/lib/config/index.d.ts +22 -4
- package/dist/lib/config/index.js +59 -4
- package/dist/lib/config/resolve.d.ts +21 -0
- package/dist/lib/config/resolve.js +55 -6
- package/dist/lib/config-persist.d.ts +28 -0
- package/dist/lib/config-persist.js +29 -0
- package/dist/lib/envelope.d.ts +41 -3
- package/dist/lib/envelope.js +147 -4
- package/dist/lib/help-preflight.d.ts +5 -8
- package/dist/lib/help-preflight.js +13 -32
- package/dist/lib/json-meta.d.ts +0 -2
- package/dist/lib/json-meta.js +21 -25
- package/dist/lib/limit-flag.d.ts +11 -1
- package/dist/lib/limit-flag.js +19 -6
- package/dist/lib/parse-error-ux.d.ts +5 -0
- package/dist/lib/parse-error-ux.js +73 -0
- package/dist/lib/prune-flags.d.ts +28 -0
- package/dist/lib/prune-flags.js +74 -0
- package/dist/lib/research/artifact-index.d.ts +11 -2
- package/dist/lib/research/artifact-index.js +7 -4
- package/dist/lib/research/browser.d.ts +41 -2
- package/dist/lib/research/browser.js +139 -50
- package/dist/lib/research/capture.d.ts +24 -2
- package/dist/lib/research/capture.js +24 -4
- package/dist/lib/research/cli-io.d.ts +33 -0
- package/dist/lib/research/cli-io.js +27 -0
- package/dist/lib/research/compress.d.ts +7 -0
- package/dist/lib/research/compress.js +7 -3
- package/dist/lib/research/docs/capabilities.d.ts +15 -0
- package/dist/lib/research/docs/capabilities.js +5 -0
- package/dist/lib/research/docs/clean-dom.d.ts +2 -0
- package/dist/lib/research/docs/clean-dom.js +2 -0
- package/dist/lib/research/docs/code-blocks.d.ts +2 -0
- package/dist/lib/research/docs/code-blocks.js +2 -0
- package/dist/lib/research/docs/detect.d.ts +4 -0
- package/dist/lib/research/docs/detect.js +4 -0
- package/dist/lib/research/docs/machine-readable.d.ts +18 -0
- package/dist/lib/research/docs/machine-readable.js +10 -0
- package/dist/lib/research/docs/markdown-source.d.ts +20 -0
- package/dist/lib/research/docs/markdown-source.js +19 -2
- package/dist/lib/research/docs/quality-gates.d.ts +5 -0
- package/dist/lib/research/docs/quality-gates.js +4 -0
- package/dist/lib/research/docs/rsc.d.ts +9 -2
- package/dist/lib/research/docs/rsc.js +5 -2
- package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
- package/dist/lib/research/docs/section-artifacts.js +12 -0
- package/dist/lib/research/docs/sections.d.ts +18 -0
- package/dist/lib/research/docs/sections.js +17 -1
- package/dist/lib/research/docs/source-map.d.ts +28 -0
- package/dist/lib/research/docs/source-map.js +27 -0
- package/dist/lib/research/docs/validate.d.ts +35 -2
- package/dist/lib/research/docs/validate.js +47 -2
- package/dist/lib/research/extract.d.ts +16 -0
- package/dist/lib/research/extract.js +12 -1
- package/dist/lib/research/fetch-command-service.d.ts +36 -0
- package/dist/lib/research/fetch-command-service.js +310 -0
- package/dist/lib/research/fetch-result.d.ts +114 -0
- package/dist/lib/research/fetch-result.js +212 -0
- package/dist/lib/research/fetcher.d.ts +46 -8
- package/dist/lib/research/fetcher.js +58 -30
- package/dist/lib/research/freshness.d.ts +54 -11
- package/dist/lib/research/freshness.js +91 -31
- package/dist/lib/research/import-command-service.d.ts +56 -0
- package/dist/lib/research/import-command-service.js +381 -0
- package/dist/lib/research/keywords.js +5 -1
- package/dist/lib/research/markdown.d.ts +12 -1
- package/dist/lib/research/markdown.js +13 -5
- package/dist/lib/research/persist-artifact.d.ts +23 -0
- package/dist/lib/research/persist-artifact.js +27 -0
- package/dist/lib/research/prompt-injection.d.ts +8 -0
- package/dist/lib/research/prompt-injection.js +42 -13
- package/dist/lib/research/proxy.d.ts +14 -8
- package/dist/lib/research/proxy.js +14 -10
- package/dist/lib/research/revalidate.d.ts +49 -20
- package/dist/lib/research/revalidate.js +80 -15
- package/dist/lib/research/schema.d.ts +7 -0
- package/dist/lib/research/schema.js +2 -0
- package/dist/lib/research/secret-scan.d.ts +6 -0
- package/dist/lib/research/secret-scan.js +8 -0
- package/dist/lib/research/secure-write.d.ts +11 -1
- package/dist/lib/research/secure-write.js +38 -7
- package/dist/lib/research/storage.d.ts +5 -1
- package/dist/lib/research/storage.js +8 -3
- package/dist/lib/research/summarize.d.ts +15 -0
- package/dist/lib/research/summarize.js +11 -0
- package/dist/lib/research/url.d.ts +9 -6
- package/dist/lib/research/url.js +33 -21
- package/dist/lib/text.d.ts +7 -2
- package/dist/lib/text.js +13 -2
- package/dist/lib/value-flags.d.ts +14 -0
- package/dist/lib/value-flags.js +28 -0
- package/dist/sites/coveo.d.ts +10 -0
- package/dist/sites/coveo.js +10 -0
- package/dist/sites/index.d.ts +16 -0
- package/dist/sites/index.js +16 -4
- package/dist/sites/markdown-twin.d.ts +31 -0
- package/dist/sites/markdown-twin.js +56 -0
- package/dist/sites/salesforce/fetch-page.d.ts +8 -2
- package/dist/sites/salesforce/fetch-page.js +14 -3
- package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
- package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
- package/dist/sites/salesforce/index.d.ts +1 -0
- package/dist/sites/salesforce/index.js +1 -0
- package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
- package/dist/sites/salesforce-developer/fetch-page.js +6 -1
- package/dist/sites/salesforce-developer/index.d.ts +1 -0
- package/dist/sites/salesforce-developer/index.js +1 -0
- package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
- package/dist/sites/salesforce-developer/route-markdown.js +29 -67
- package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
- package/dist/sites/salesforce-doc-fetch.js +52 -7
- package/dist/sites/salesforce-dom-probe.d.ts +20 -2
- package/dist/sites/salesforce-dom-probe.js +20 -2
- package/dist/sites/types.d.ts +14 -8
- package/dist/sites/types.js +3 -0
- package/oclif.manifest.json +192 -87
- package/package.json +19 -7
- package/agents/agents/official-docs-researcher.agent.md +0 -87
- package/agents/instructions/web-research.instructions.md +0 -44
- package/agents/skills/web-research/SKILL.md +0 -118
package/dist/commands/fetch.js
CHANGED
|
@@ -1,38 +1,21 @@
|
|
|
1
|
-
import { Args, Flags, ux
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
1
|
+
import { Args, Flags, ux } from '@oclif/core';
|
|
5
2
|
import { BaseCommand } from '../base-command.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
|
|
11
|
-
import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
|
|
12
|
-
import { PROXY_TUNNEL_REJECTION_PATTERN } from '../lib/research/proxy.js';
|
|
13
|
-
import { fetchRenderedHtml, SANDBOX_EGRESS_ERROR_MARKER } from '../lib/research/browser.js';
|
|
14
|
-
import { capturePage } from '../lib/research/capture.js';
|
|
15
|
-
import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
|
|
16
|
-
import { applyAutoTags } from '../lib/research/keywords.js';
|
|
17
|
-
import { detectSite } from '../sites/index.js';
|
|
18
|
-
import { applySiteFetchProvenance } from '../sites/types.js';
|
|
19
|
-
const CAPTURE_DEPS = {
|
|
20
|
-
fetchStatic: (url) => fetchStaticHtml(url),
|
|
21
|
-
fetchRendered: (url) => fetchRenderedHtml(url),
|
|
22
|
-
fetchText: (url) => fetchText(url),
|
|
23
|
-
};
|
|
3
|
+
import { enrichRowErrorEnvelope } from '../lib/envelope.js';
|
|
4
|
+
import { runFetchCommandService, validateFetchCommandFlags, } from '../lib/research/fetch-command-service.js';
|
|
5
|
+
import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
|
|
6
|
+
/** Hidden named `fetch` command; usually invoked as `bonsai <url>`. */
|
|
24
7
|
export default class FetchCommand extends BaseCommand {
|
|
25
8
|
static id = 'fetch';
|
|
26
9
|
static hidden = true;
|
|
27
|
-
static summary = '
|
|
28
|
-
static description = '
|
|
10
|
+
static summary = 'Fetch and cache URL research Markdown';
|
|
11
|
+
static description = 'Fetch a URL, convert the main content to Markdown, and cache compressed and detailed variants.\n\nUsually invoked as `bonsai <url>`; run `bonsai help fetch` for URL-form flags.';
|
|
29
12
|
static examples = [
|
|
30
13
|
{
|
|
31
|
-
description: '
|
|
32
|
-
command: '<%= config.bin %> https://docs.nestjs.com/ --topic "Backend Frameworks" --tags "Node" --tags "NestJS" --format detailed --ttl
|
|
14
|
+
description: 'cache docs with detailed output, topic, tags, and TTL',
|
|
15
|
+
command: '<%= config.bin %> https://docs.nestjs.com/ --topic "Backend Frameworks" --tags "Node" --tags "NestJS" --format detailed --ttl 7d',
|
|
33
16
|
},
|
|
34
17
|
{
|
|
35
|
-
description: '
|
|
18
|
+
description: 'cache a volatile page with compressed JSON output',
|
|
36
19
|
command: '<%= config.bin %> https://news.ycombinator.com/ --format compressed --ttl 2h --json',
|
|
37
20
|
},
|
|
38
21
|
];
|
|
@@ -40,425 +23,99 @@ export default class FetchCommand extends BaseCommand {
|
|
|
40
23
|
static args = {
|
|
41
24
|
url: Args.string({
|
|
42
25
|
required: true,
|
|
43
|
-
description: '
|
|
26
|
+
description: 'HTTP(S) URL to research',
|
|
44
27
|
}),
|
|
45
28
|
};
|
|
46
29
|
static flags = {
|
|
47
30
|
topic: Flags.string({
|
|
48
31
|
char: 't',
|
|
49
|
-
description:
|
|
32
|
+
description: CLI_FLAG_DESCRIPTIONS.fetchTopic,
|
|
50
33
|
}),
|
|
51
34
|
tags: Flags.string({
|
|
52
35
|
char: 'g',
|
|
53
|
-
description:
|
|
36
|
+
description: CLI_FLAG_DESCRIPTIONS.fetchTags,
|
|
54
37
|
multiple: true,
|
|
55
38
|
}),
|
|
56
39
|
format: Flags.option({
|
|
57
40
|
char: 'f',
|
|
58
|
-
description:
|
|
41
|
+
description: CLI_FLAG_DESCRIPTIONS.format,
|
|
59
42
|
options: ['compressed', 'detailed'],
|
|
60
43
|
default: 'compressed',
|
|
61
44
|
})(),
|
|
62
45
|
tier: Flags.option({
|
|
63
|
-
description:
|
|
46
|
+
description: CLI_FLAG_DESCRIPTIONS.freshnessTierPolicy,
|
|
64
47
|
options: ['stable', 'standard', 'volatile'],
|
|
65
48
|
default: 'standard',
|
|
66
49
|
})(),
|
|
67
50
|
ttl: Flags.string({
|
|
68
51
|
char: 'l',
|
|
69
|
-
description:
|
|
52
|
+
description: CLI_FLAG_DESCRIPTIONS.fetchTtl,
|
|
70
53
|
}),
|
|
71
54
|
'max-age': Flags.string({
|
|
72
|
-
description:
|
|
55
|
+
description: CLI_FLAG_DESCRIPTIONS.maxAge,
|
|
73
56
|
}),
|
|
74
57
|
force: Flags.boolean({
|
|
75
|
-
description: '
|
|
58
|
+
description: 'fetch fresh content, ignoring cached entries',
|
|
76
59
|
default: false,
|
|
77
60
|
}),
|
|
78
61
|
'dry-run': Flags.boolean({
|
|
79
|
-
description: '
|
|
62
|
+
description: 'fetch and validate without writing cache',
|
|
80
63
|
default: false,
|
|
81
64
|
}),
|
|
82
65
|
'allow-stale': Flags.boolean({
|
|
83
|
-
|
|
66
|
+
// Stale content within the grace window is served either way after a failed revalidation;
|
|
67
|
+
// this only suppresses the exit-5 "served stale" signal. It has no effect once an entry is
|
|
68
|
+
// past its grace window (that failure is always a hard error). See README's Freshness
|
|
69
|
+
// section and docs/concepts/caching-and-freshness.md for the full decision flow.
|
|
70
|
+
description: 'suppress exit code 5 when stale cache is served within grace after failed revalidation',
|
|
84
71
|
default: false,
|
|
85
72
|
}),
|
|
86
73
|
rendered: Flags.boolean({
|
|
87
|
-
description: '
|
|
74
|
+
description: 'use browser-rendered capture for dynamic pages',
|
|
88
75
|
default: false,
|
|
89
76
|
}),
|
|
90
77
|
storage: Flags.option({
|
|
91
|
-
description:
|
|
78
|
+
description: CLI_FLAG_DESCRIPTIONS.fetchStorage,
|
|
92
79
|
options: ['global', 'project'],
|
|
93
80
|
})(),
|
|
94
81
|
};
|
|
95
82
|
static stdoutIsPrimaryData = true;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
: evaluateFreshness(cached.metadata, currentTime, ttl);
|
|
105
|
-
if (freshnessState === 'fresh') {
|
|
106
|
-
return { cacheStatus: 'hit', freshnessState, artifact: cached };
|
|
107
|
-
}
|
|
108
|
-
const revalResult = await revalidateCache(targetDir, cached, currentTime, {
|
|
109
|
-
allowStale: Boolean(allowStale),
|
|
110
|
-
ttlOverride: ttl,
|
|
111
|
-
rendered: Boolean(rendered),
|
|
112
|
-
summaryLevel,
|
|
113
|
-
});
|
|
114
|
-
handleStaleRevalidationResult(this, revalResult);
|
|
83
|
+
/**
|
|
84
|
+
* When a multi-URL batch has any per-URL failure, keep the result array (including hits) and
|
|
85
|
+
* surface FETCH_FAILED on the envelope — same batch contract as status/inspect CACHE_MISS.
|
|
86
|
+
*/
|
|
87
|
+
toSuccessJson(data) {
|
|
88
|
+
return enrichRowErrorEnvelope(this.baseSuccessJson(data), data);
|
|
89
|
+
}
|
|
90
|
+
serviceFlags() {
|
|
115
91
|
return {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
92
|
+
topic: this.flags.topic,
|
|
93
|
+
tags: this.flags.tags,
|
|
94
|
+
format: this.flags.format,
|
|
95
|
+
tier: this.flags.tier,
|
|
96
|
+
ttl: this.flags.ttl,
|
|
97
|
+
maxAge: this.flags['max-age'],
|
|
98
|
+
force: this.flags.force,
|
|
99
|
+
allowStale: this.flags['allow-stale'],
|
|
100
|
+
rendered: this.flags.rendered,
|
|
101
|
+
storage: this.flags.storage,
|
|
119
102
|
};
|
|
120
103
|
}
|
|
121
|
-
async executeCacheMiss(normalizedUrl, currentTime, cacheKey, summaryLevel) {
|
|
122
|
-
const { topic, tags, tier, ttl, rendered } = this.flags;
|
|
123
|
-
const siteModule = detectSite(normalizedUrl);
|
|
124
|
-
const useRendered = rendered || Boolean(siteModule?.defaults?.rendered);
|
|
125
|
-
let fetchResult;
|
|
126
|
-
let extraction;
|
|
127
|
-
let capture = null;
|
|
128
|
-
let siteFetch = null;
|
|
129
|
-
if (siteModule?.fetchPage) {
|
|
130
|
-
// Custom site modules own their fetch/extract strategy; the generic capture path is skipped.
|
|
131
|
-
siteFetch = await siteModule.fetchPage(normalizedUrl);
|
|
132
|
-
({ fetchResult, extraction } = siteFetch);
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
capture = await capturePage(normalizedUrl, { forceRendered: useRendered }, CAPTURE_DEPS);
|
|
136
|
-
fetchResult = capture.fetchResult;
|
|
137
|
-
extraction = capture.extraction;
|
|
138
|
-
}
|
|
139
|
-
const artifact = createArtifactFromFetch(normalizedUrl, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl || null, currentTime, summaryLevel);
|
|
140
|
-
artifact.metadata.topic = topic || null;
|
|
141
|
-
artifact.metadata.tags = tags || [];
|
|
142
|
-
artifact.metadata.site_module_id = siteModule?.id ?? null;
|
|
143
|
-
if (capture) {
|
|
144
|
-
applyCaptureMetadata(artifact, capture);
|
|
145
|
-
}
|
|
146
|
-
else if (siteFetch?.captureMethod) {
|
|
147
|
-
applySiteFetchProvenance(artifact.metadata, siteFetch);
|
|
148
|
-
}
|
|
149
|
-
else if (useRendered) {
|
|
150
|
-
artifact.metadata.capture_method = 'browser_fallback';
|
|
151
|
-
}
|
|
152
|
-
// Auto-tag from the fetched content when the caller supplied none, keeping cached pages
|
|
153
|
-
// searchable by keyword. Explicit --tags always win (handled in applyAutoTags).
|
|
154
|
-
return applyAutoTags(artifact);
|
|
155
|
-
}
|
|
156
|
-
buildResultData(url, normalizedUrl, cacheKey, storageDir, storageMode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal) {
|
|
157
|
-
return {
|
|
158
|
-
schemaVersion: 1,
|
|
159
|
-
command: this.config.bin,
|
|
160
|
-
cache: {
|
|
161
|
-
key: cacheKey,
|
|
162
|
-
status: cacheStatus,
|
|
163
|
-
freshness: freshnessState,
|
|
164
|
-
path: getArtifactPath(storageDir, cacheKey),
|
|
165
|
-
storage: storageMode,
|
|
166
|
-
redirectedToGlobal,
|
|
167
|
-
},
|
|
168
|
-
source: {
|
|
169
|
-
url,
|
|
170
|
-
normalizedUrl,
|
|
171
|
-
captureMethod: artifact.metadata.capture_method,
|
|
172
|
-
extractionStatus: artifact.metadata.extraction_status,
|
|
173
|
-
extractionConfidence: artifact.metadata.extraction_confidence,
|
|
174
|
-
qualityNotes: artifact.metadata.quality_notes,
|
|
175
|
-
fetchedAt: artifact.metadata.fetched_at,
|
|
176
|
-
validatedAt: artifact.metadata.validated_at,
|
|
177
|
-
staleAfter: artifact.metadata.stale_after,
|
|
178
|
-
},
|
|
179
|
-
artifactType: artifact.metadata.artifact_type,
|
|
180
|
-
docsEngine: artifact.metadata.docs_engine,
|
|
181
|
-
docsFramework: artifact.metadata.docs_framework,
|
|
182
|
-
sourceDocUrl: artifact.metadata.source_doc_url,
|
|
183
|
-
searchProvider: artifact.metadata.search_provider,
|
|
184
|
-
format,
|
|
185
|
-
tokenEstimate: format === 'compressed'
|
|
186
|
-
? artifact.metadata.token_estimate.compressed
|
|
187
|
-
: artifact.metadata.token_estimate.detailed,
|
|
188
|
-
content,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
// Validates the duration flags up front, exiting with code 2 on a malformed value.
|
|
192
|
-
validateDurationFlags(ttl, maxAge) {
|
|
193
|
-
for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
|
|
194
|
-
if (msg)
|
|
195
|
-
this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
// Re-emit a runtime fetch failure with actionable next steps. Deep fetch/extract code throws plain
|
|
199
|
-
// Errors that otherwise reach the user as a bare "what broke" line with no "what to do"; this
|
|
200
|
-
// attaches recovery hints (e.g. import an auth-blocked page). Everything reaching here is a runtime
|
|
201
|
-
// failure — validation errors (bad flags/URL) exit before the try — so the contract's runtime code
|
|
202
|
-
// (1) applies. Suggestions render on stderr for humans only; under --json the envelope carries just
|
|
203
|
-
// the message (toErrorJson), so machine output is unchanged.
|
|
204
|
-
//
|
|
205
|
-
// Uses `describeError` (below) rather than `err.message` alone: Node/undici's fetch wraps
|
|
206
|
-
// transport failures (DNS, refused connections, a proxy that won't tunnel to the host) in a
|
|
207
|
-
// generic "fetch failed" TypeError with the real reason nested in `.cause`, so reading only the
|
|
208
|
-
// top message would surface a content-free "fetch failed" for exactly the failures a user most
|
|
209
|
-
// needs a hint for.
|
|
210
|
-
emitFetchError(err, url) {
|
|
211
|
-
const message = describeError(err);
|
|
212
|
-
const guidance = fetchFailureGuidance(message, url);
|
|
213
|
-
this.error(message, {
|
|
214
|
-
exit: 1,
|
|
215
|
-
code: 'FETCH_FAILED',
|
|
216
|
-
suggestions: guidance?.suggestions,
|
|
217
|
-
ref: guidance?.ref,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
// Serves the cache when a fresh/revalidatable entry exists, otherwise fetches fresh. Reads fall
|
|
221
|
-
// back project→global; the resolved artifact is returned with the dir it lives in / landed in.
|
|
222
|
-
async resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel) {
|
|
223
|
-
if (located) {
|
|
224
|
-
// Revalidate where the entry already lives; on dry-run use the throwaway dir so the cache is
|
|
225
|
-
// never mutated. ponytail: revalidation rewrites in place, so a refreshed project entry that
|
|
226
|
-
// gains a secret is not re-routed — only first-time project writes are scanned.
|
|
227
|
-
const revalDir = tmpDir ?? located.dataDir;
|
|
228
|
-
const hit = await this.executeCacheHit(located.artifact, revalDir, currentTime, summaryLevel);
|
|
229
|
-
return { ...hit, storageDir: located.dataDir, redirectedToGlobal: false };
|
|
230
|
-
}
|
|
231
|
-
const artifact = await this.executeCacheMiss(normalizedUrl, currentTime, cacheKey, summaryLevel);
|
|
232
|
-
const { dir, redirectedToGlobal } = this.persistFreshArtifact(roots, tmpDir, cacheKey, artifact);
|
|
233
|
-
// No entry existed at lookup, so there is no prior freshness to report. 'none' (not
|
|
234
|
-
// 'stale_expired') keeps the field honest — nothing expired; the page was simply uncached and
|
|
235
|
-
// has now been fetched fresh. Mirrors the `status` command's miss reporting.
|
|
236
|
-
return {
|
|
237
|
-
cacheStatus: 'miss',
|
|
238
|
-
freshnessState: 'none',
|
|
239
|
-
artifact,
|
|
240
|
-
storageDir: dir,
|
|
241
|
-
redirectedToGlobal,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
// Writes a freshly fetched artifact, honoring dry-run (throwaway dir) and the secret-safety
|
|
245
|
-
// redirect (project→global). Returns the data dir reported to the user and whether a redirect
|
|
246
|
-
// occurred (so the JSON envelope mirrors `import`).
|
|
247
|
-
persistFreshArtifact(roots, tmpDir, cacheKey, artifact) {
|
|
248
|
-
if (tmpDir) {
|
|
249
|
-
writeArtifact(tmpDir, cacheKey, artifact);
|
|
250
|
-
// Report the real would-be location, not the throwaway dir that is about to be deleted.
|
|
251
|
-
return { dir: roots.writeRoot, redirectedToGlobal: false };
|
|
252
|
-
}
|
|
253
|
-
const result = writeArtifactSecurely(roots, cacheKey, artifact);
|
|
254
|
-
if (result.redirected) {
|
|
255
|
-
this.warn(`Detected ${result.secretLabel} in the page content; stored in the global cache instead of the project to avoid committing secrets.`);
|
|
256
|
-
}
|
|
257
|
-
return { dir: result.dataDir, redirectedToGlobal: result.redirected };
|
|
258
|
-
}
|
|
259
|
-
// Long references are split into searchable/inspectable section children whenever the page artifact
|
|
260
|
-
// is freshly written (T-22). Best-effort: never let chunking break the main result.
|
|
261
|
-
persistSectionsIfFresh(targetDir, artifact, currentTime, cacheStatus, summaryLevel) {
|
|
262
|
-
if (cacheStatus !== 'miss' && cacheStatus !== 'refreshed')
|
|
263
|
-
return;
|
|
264
|
-
try {
|
|
265
|
-
persistSectionArtifacts(targetDir, artifact, currentTime, summaryLevel);
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
/* section generation is non-essential; ignore failures */
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
104
|
async run() {
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (!this.jsonEnabled()) {
|
|
287
|
-
ux.action.stop('failed');
|
|
288
|
-
}
|
|
289
|
-
if (err instanceof Errors.CLIError)
|
|
290
|
-
throw err;
|
|
291
|
-
// Stale-serve (exit 5) signals via process.exitCode and never throws, so it bypasses this
|
|
292
|
-
// path; only genuine fetch/extract failures land here and get classified guidance. Use the
|
|
293
|
-
// normalized URL in hints so the copy-paste command is canonical (and never echoes raw,
|
|
294
|
-
// unsanitized user input back to the terminal).
|
|
295
|
-
const failedUrl = urls[results.length] || urls[0] || '';
|
|
296
|
-
this.emitFetchError(err, failedUrl);
|
|
297
|
-
}
|
|
298
|
-
finally {
|
|
299
|
-
if (tmpDir) {
|
|
300
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
async fetchSingleTarget(url, currentTime, tmpDir, summaryLevel, format, showSeparator) {
|
|
305
|
-
const target = this.resolveResearchTargetOrFail(url, {
|
|
306
|
-
flagOverride: this.flags.storage,
|
|
307
|
-
lookup: !this.flags.force,
|
|
105
|
+
const io = this.cliIo();
|
|
106
|
+
const flags = this.serviceFlags();
|
|
107
|
+
validateFetchCommandFlags(io, flags);
|
|
108
|
+
const dryRun = this.effectiveDryRun(this.flags['dry-run']);
|
|
109
|
+
return runFetchCommandService({
|
|
110
|
+
urls: this.parsedArgv,
|
|
111
|
+
flags,
|
|
112
|
+
dryRun,
|
|
113
|
+
io,
|
|
114
|
+
spinner: {
|
|
115
|
+
running: () => Boolean(ux.action.running),
|
|
116
|
+
start: (msg) => ux.action.start(msg),
|
|
117
|
+
stop: (msg) => ux.action.stop(msg),
|
|
118
|
+
},
|
|
308
119
|
});
|
|
309
|
-
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
310
|
-
if (!this.jsonEnabled()) {
|
|
311
|
-
ux.action.start('Fetching ' + normalizedUrl);
|
|
312
|
-
}
|
|
313
|
-
const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel);
|
|
314
|
-
this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus, summaryLevel);
|
|
315
|
-
if (!this.jsonEnabled()) {
|
|
316
|
-
const CACHE_STATUS_LABEL = {
|
|
317
|
-
hit: 'cached',
|
|
318
|
-
miss: 'done',
|
|
319
|
-
refreshed: 'refreshed',
|
|
320
|
-
revalidated: 'revalidated',
|
|
321
|
-
stale: 'served stale',
|
|
322
|
-
};
|
|
323
|
-
ux.action.stop(CACHE_STATUS_LABEL[cacheStatus] ?? cacheStatus);
|
|
324
|
-
}
|
|
325
|
-
const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
|
|
326
|
-
const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
|
|
327
|
-
if (!this.jsonEnabled()) {
|
|
328
|
-
this.log(content);
|
|
329
|
-
if (showSeparator) {
|
|
330
|
-
this.log('\n' + '='.repeat(40) + '\n');
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
return resultData;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
// How many `.cause` levels to walk. Real undici/Node transport errors nest at most 1-2 deep;
|
|
337
|
-
// the cap exists so a self-referential or cyclic `.cause` chain (Error.cause is an arbitrary,
|
|
338
|
-
// unvalidated property — nothing prevents `e.cause = e`, whether from a bug in this codebase or
|
|
339
|
-
// a dependency) can't hang the process, rather than being a limit expected to matter in practice.
|
|
340
|
-
const MAX_CAUSE_DEPTH = 10;
|
|
341
|
-
// Walks an Error's `.cause` chain and joins every message, deepest last. Node/undici's fetch
|
|
342
|
-
// throws a generic `TypeError: fetch failed` for transport-level failures, with the actionable
|
|
343
|
-
// detail (e.g. a proxy's tunnel rejection) nested one or two `.cause` levels down.
|
|
344
|
-
export function describeError(err) {
|
|
345
|
-
if (!(err instanceof Error))
|
|
346
|
-
return String(err);
|
|
347
|
-
const parts = [err.message];
|
|
348
|
-
let cause = err.cause;
|
|
349
|
-
for (let depth = 0; depth < MAX_CAUSE_DEPTH && cause instanceof Error; depth++) {
|
|
350
|
-
parts.push(cause.message);
|
|
351
|
-
cause = cause.cause;
|
|
352
|
-
}
|
|
353
|
-
return parts.join(': ');
|
|
354
|
-
}
|
|
355
|
-
// Maps a runtime fetch failure to recovery steps keyed off its message. Returns undefined for
|
|
356
|
-
// unrecognized failures, which then surface with their original message and no extra hint. The
|
|
357
|
-
// patterns match the thrown text in fetcher.ts/browser.ts; keep them in sync if those messages
|
|
358
|
-
// change. Resolutions mirror the published troubleshooting guide.
|
|
359
|
-
export function fetchFailureGuidance(message, url) {
|
|
360
|
-
const ref = 'https://bonsai.rhino-inquisitor.com/troubleshooting';
|
|
361
|
-
// Name the pipe step explicitly: bare `import --stdin` blocks waiting for input, so a reader (or
|
|
362
|
-
// an agent) running the hint verbatim would hang. Show the content being piped in from a file.
|
|
363
|
-
const importHint = `Open it in a browser, save the page, then import it: cat page.md | bonsai import ${url} --stdin`;
|
|
364
|
-
// 401/403: an auth wall or anti-scraping WAF. Bonsai has no authenticated-fetch path in v1.
|
|
365
|
-
if (/failed with status 40[13]\b/.test(message)) {
|
|
366
|
-
return {
|
|
367
|
-
suggestions: ['The page requires authentication or blocks automated requests.', importHint],
|
|
368
|
-
ref,
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
if (/failed with status 404\b/.test(message)) {
|
|
372
|
-
return { suggestions: ['Check the URL is correct and the page still exists.'] };
|
|
373
|
-
}
|
|
374
|
-
if (/failed with status 5\d\d\b/.test(message)) {
|
|
375
|
-
return {
|
|
376
|
-
suggestions: ['The server returned an error. Retry later or verify the host is healthy.'],
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
// Server returned a non-HTML body (JSON, binary, or no Content-Type at all). Scope the opener to
|
|
380
|
-
// the real failure: --rendered does produce HTML, so "only scrapes HTML" would read as misleading.
|
|
381
|
-
if (/Rejected content type|does not look like HTML/.test(message)) {
|
|
382
|
-
return {
|
|
383
|
-
suggestions: [
|
|
384
|
-
'The server returned a non-HTML response (e.g. JSON or binary).',
|
|
385
|
-
'If the page is rendered by client-side JavaScript, retry with --rendered.',
|
|
386
|
-
importHint,
|
|
387
|
-
],
|
|
388
|
-
ref,
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
if (/DNS resolution failed/.test(message)) {
|
|
392
|
-
return {
|
|
393
|
-
suggestions: ['Check the hostname is spelled correctly and resolves on a public network.'],
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
// Chrome's own network stack hit a proxy-enforced sandbox egress block (browser.ts's
|
|
397
|
-
// describeNavigationFailure already explains the cause and the fix); only a workaround and the
|
|
398
|
-
// docs ref are added here.
|
|
399
|
-
if (message.includes(SANDBOX_EGRESS_ERROR_MARKER)) {
|
|
400
|
-
return { suggestions: [importHint], ref };
|
|
401
|
-
}
|
|
402
|
-
// A configured proxy (HTTPS_PROXY/HTTP_PROXY) refused to tunnel to this host — distinct from
|
|
403
|
-
// the destination's own 401/403 above, which is the site itself rejecting the request. Shared
|
|
404
|
-
// with fetcher.ts's retry-fallback check so the two stay in sync by construction.
|
|
405
|
-
if (PROXY_TUNNEL_REJECTION_PATTERN.test(message)) {
|
|
406
|
-
return {
|
|
407
|
-
suggestions: [
|
|
408
|
-
"The configured proxy (HTTPS_PROXY/HTTP_PROXY) won't reach this host. Check its allowlist, or add the host to NO_PROXY to bypass the proxy for it.",
|
|
409
|
-
importHint,
|
|
410
|
-
],
|
|
411
|
-
ref,
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
// The connection failed before any HTTP response was received — network-down, DNS, TLS, or a
|
|
415
|
-
// proxy issue not specifically matched above. Node/undici's fetch reports all of these as a
|
|
416
|
-
// generic "fetch failed" TypeError with no further suffix; the negative lookahead keeps this
|
|
417
|
-
// from also matching assertOk's "Fetch failed with status NNN ..." (a real HTTP response was
|
|
418
|
-
// received in that case, so it isn't a transport failure at all).
|
|
419
|
-
if (/^fetch failed\b(?!\s+with status)/i.test(message)) {
|
|
420
|
-
return {
|
|
421
|
-
suggestions: [
|
|
422
|
-
'The connection failed before a response was received. Check network connectivity and any HTTPS_PROXY/HTTP_PROXY/NO_PROXY settings.',
|
|
423
|
-
importHint,
|
|
424
|
-
],
|
|
425
|
-
ref,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
// A hostname that only resolves to a private/local IP at request time (literal private addresses
|
|
429
|
-
// are rejected earlier with exit 2). Blocked to prevent SSRF; only public hosts can be fetched.
|
|
430
|
-
if (/blocked local or private target/.test(message)) {
|
|
431
|
-
return {
|
|
432
|
-
suggestions: [
|
|
433
|
-
'The hostname resolves to a private or local address, which is blocked to prevent SSRF. Only public http(s) hosts can be fetched.',
|
|
434
|
-
],
|
|
435
|
-
ref,
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
return undefined;
|
|
439
|
-
}
|
|
440
|
-
// Copies Phase 2 capability provenance from a capture outcome onto the artifact metadata.
|
|
441
|
-
function applyCaptureMetadata(artifact, capture) {
|
|
442
|
-
const meta = artifact.metadata;
|
|
443
|
-
meta.capture_method = capture.captureMethod;
|
|
444
|
-
meta.docs_engine = capture.capabilities.docsEngine ?? null;
|
|
445
|
-
meta.docs_framework = capture.capabilities.framework ?? null;
|
|
446
|
-
meta.source_doc_url = capture.sourceDocUrl;
|
|
447
|
-
meta.search_provider = capture.capabilities.search?.provider ?? null;
|
|
448
|
-
if (capture.extraction.isIndexHub)
|
|
449
|
-
meta.artifact_type = 'index';
|
|
450
|
-
if (capture.sourceDocUrl && !meta.source_urls.includes(capture.sourceDocUrl)) {
|
|
451
|
-
meta.source_urls.push(capture.sourceDocUrl);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
function handleStaleRevalidationResult(command, revalResult) {
|
|
455
|
-
if (revalResult.status !== 'stale')
|
|
456
|
-
return;
|
|
457
|
-
if (revalResult.allowed) {
|
|
458
|
-
command.warn(`Serving stale content within grace period: revalidation failed (${revalResult.error}).`);
|
|
459
|
-
}
|
|
460
|
-
else {
|
|
461
|
-
command.warn(`Serving stale content within grace period (exit 5): revalidation failed (${revalResult.error}).`);
|
|
462
|
-
process.exitCode = 5;
|
|
463
120
|
}
|
|
464
121
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
|
+
/** Import research notes from a file or stdin. */
|
|
3
4
|
export default class ResearchImport extends BaseCommand<typeof ResearchImport> {
|
|
4
5
|
static id: string;
|
|
5
6
|
static summary: string;
|
|
@@ -20,22 +21,16 @@ export default class ResearchImport extends BaseCommand<typeof ResearchImport> {
|
|
|
20
21
|
tier: import("@oclif/core/interfaces").OptionFlag<"stable" | "standard" | "volatile", import("@oclif/core/interfaces").CustomOptions>;
|
|
21
22
|
'source-url': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
22
23
|
ttl: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
24
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
23
25
|
storage: import("@oclif/core/interfaces").OptionFlag<"global" | "project" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
24
26
|
};
|
|
25
27
|
static stdoutIsPrimaryData: boolean;
|
|
26
28
|
protected stdinIsInteractive(): boolean;
|
|
27
29
|
private readStdin;
|
|
28
|
-
private getSourceUrls;
|
|
29
|
-
private validateSourceMode;
|
|
30
30
|
protected fsExistsSync(filePath: string): boolean;
|
|
31
31
|
protected fsStatSync(filePath: string): fs.Stats;
|
|
32
32
|
protected fsReadFileSync(filePath: string): string;
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
private readStdinWithGuard;
|
|
36
|
-
private readAndValidateFile;
|
|
37
|
-
private readAndValidateInput;
|
|
38
|
-
private deriveImportCacheKey;
|
|
39
|
-
private buildImportArtifact;
|
|
33
|
+
private serviceArgs;
|
|
34
|
+
private serviceFlags;
|
|
40
35
|
run(): Promise<unknown>;
|
|
41
36
|
}
|