@taurgis/bonsai 3.0.3 → 3.2.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/README.md +15 -1
- package/dist/base-command.d.ts +17 -0
- package/dist/base-command.js +32 -2
- package/dist/commands/config/set.js +1 -1
- package/dist/commands/config/unset.js +1 -1
- package/dist/commands/fetch.js +9 -2
- package/dist/commands/import.js +7 -3
- package/dist/commands/prune.js +13 -2
- package/dist/lib/config/index.d.ts +1 -1
- package/dist/lib/config/index.js +1 -1
- package/dist/lib/config/resolve.d.ts +21 -0
- package/dist/lib/config/resolve.js +55 -6
- package/dist/lib/research/browser.js +15 -4
- package/dist/lib/research/docs/markdown-source.d.ts +7 -0
- package/dist/lib/research/docs/markdown-source.js +46 -20
- package/dist/lib/research/regression.js +4 -1
- package/dist/lib/research/revalidate.js +8 -3
- package/dist/lib/research/secure-write.d.ts +7 -1
- package/dist/lib/research/secure-write.js +9 -7
- package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
- package/dist/sites/salesforce-developer/fetch-page.js +10 -1
- package/dist/sites/salesforce-developer/route-markdown.d.ts +23 -0
- package/dist/sites/salesforce-developer/route-markdown.js +142 -0
- package/dist/sites/salesforce-doc-fetch.js +10 -1
- package/dist/sites/types.d.ts +13 -0
- package/dist/sites/types.js +14 -1
- package/oclif.manifest.json +100 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,6 +112,7 @@ Common flags:
|
|
|
112
112
|
* `--dry-run`: Scrape and validate without writing to cache.
|
|
113
113
|
* `--allow-stale`: Serve stale cache if the remote site is offline.
|
|
114
114
|
* `--rendered`: Use browser-rendered extraction for SPA pages.
|
|
115
|
+
* `--read-only` (alias `--plan`): Block all filesystem writes/deletes across every command; network fetches still run. See [Read-only / plan mode](#read-only--plan-mode) below.
|
|
115
116
|
* `--json`: Return structured machine-readable output.
|
|
116
117
|
|
|
117
118
|
### `import [url]`
|
|
@@ -148,7 +149,7 @@ npx @taurgis/bonsai list [flags]
|
|
|
148
149
|
|
|
149
150
|
### `prune`
|
|
150
151
|
|
|
151
|
-
Delete old or inactive cache entries. Requires a filter (`--older-than`, `--inactive`, `--artifact-type`, or `--url`) and exactly one of `--dry-run` or `--yes` (passing both is a usage error).
|
|
152
|
+
Delete old or inactive cache entries. Requires a filter (`--older-than`, `--inactive`, `--artifact-type`, or `--url`) and exactly one of `--dry-run` or `--yes` (passing both is a usage error). In [read-only mode](#read-only--plan-mode), `--yes` is rejected and the safety check is implicitly satisfied by a preview.
|
|
152
153
|
|
|
153
154
|
```bash
|
|
154
155
|
npx @taurgis/bonsai prune --older-than 90d --dry-run
|
|
@@ -170,6 +171,18 @@ npx @taurgis/bonsai config set summary balanced
|
|
|
170
171
|
|
|
171
172
|
Both keys can also be overridden per-invocation via the `BONSAI_STORAGE` and `BONSAI_SUMMARY` environment variables. Project config is stored in `.bonsai.json`; project cache files are stored under `.bonsai/research/`.
|
|
172
173
|
|
|
174
|
+
### Read-only / plan mode
|
|
175
|
+
|
|
176
|
+
Every command accepts a global `--read-only` flag (alias `--plan`) that blocks all filesystem writes and deletes — cache persistence, config file writes, `prune` deletions — while still allowing network fetches to run. This is for agent harnesses (Claude Code and others) that enter a read-only "plan mode": set it once and every `bonsai` invocation for the rest of that session automatically skips writes, without needing the flag on every call.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
BONSAI_READ_ONLY=1 npx @taurgis/bonsai https://example.com/docs
|
|
180
|
+
npx @taurgis/bonsai import https://example.com/docs --file notes.md --read-only
|
|
181
|
+
npx @taurgis/bonsai prune --older-than 90d --plan
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
It can also be set via the `BONSAI_READ_ONLY` or `BONSAI_PLAN_MODE` environment variables (both are equivalent). Composition is OR, not override: read-only mode is active if *either* env var or the flag is set, and there is no way to force writes back on once one of them is — this is a safety gate, not a preference. Commands that already expose `--dry-run` (`fetch`, `prune`, `config set`/`unset`) treat it the same as an explicit `--dry-run`; `import` (which has no `--dry-run` of its own) previews the write and reports `dryRun: true` instead of persisting.
|
|
185
|
+
|
|
173
186
|
---
|
|
174
187
|
|
|
175
188
|
## Shell Autocomplete
|
|
@@ -273,6 +286,7 @@ When run with `--json`, commands return a stable envelope:
|
|
|
273
286
|
| --- | --- |
|
|
274
287
|
| `BONSAI_STORAGE` | Override the default storage location per-invocation (`global` or `project`). |
|
|
275
288
|
| `BONSAI_SUMMARY` | Override the summary compression level per-invocation (`conservative`, `balanced`, or `aggressive`). |
|
|
289
|
+
| `BONSAI_READ_ONLY` / `BONSAI_PLAN_MODE` | Set to `1`/`true`/`yes` to enable [read-only/plan mode](#read-only--plan-mode) for every command in the session, equivalent to passing `--read-only` (`--plan`) each time. Either variable is sufficient; there is no way to override it back off from a single invocation. |
|
|
276
290
|
| `NO_COLOR` | Set to any non-empty value to disable all ANSI color output. An empty `NO_COLOR` is treated as unset. |
|
|
277
291
|
| `FORCE_COLOR` | Set to any value other than an empty string, `0`, or `false` to force ANSI color on, even in non-TTY environments. |
|
|
278
292
|
| `TERM=dumb` | Disables ANSI color output when the terminal cannot render escape sequences. |
|
package/dist/base-command.d.ts
CHANGED
|
@@ -8,6 +8,15 @@ import { type ResolveResearchTargetOptions, type ResolvedResearchTarget } from '
|
|
|
8
8
|
*/
|
|
9
9
|
export declare abstract class BaseCommand<T extends typeof Command> extends Command {
|
|
10
10
|
static enableJsonFlag: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Flags shared by every command. Passed as `baseFlags` to `this.parse()` in `init()` below, which
|
|
13
|
+
* is oclif's own mechanism for merging them into `this.ctor.flags` at both parse time and
|
|
14
|
+
* `--help`/manifest generation — no per-command `static flags` changes are needed to expose these.
|
|
15
|
+
* `--plan` mirrors agent-harness "plan mode" terminology; `--read-only` is the canonical name.
|
|
16
|
+
*/
|
|
17
|
+
static baseFlags: {
|
|
18
|
+
'read-only': Interfaces.BooleanFlag<boolean>;
|
|
19
|
+
};
|
|
11
20
|
flags: Interfaces.InferredFlags<T['flags']>;
|
|
12
21
|
args: Interfaces.InferredArgs<T['args']>;
|
|
13
22
|
parsedArgv: string[];
|
|
@@ -19,6 +28,14 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
|
|
|
19
28
|
* JSON envelope, so machine output stays clean.
|
|
20
29
|
*/
|
|
21
30
|
warn(input: string | Error): string | Error;
|
|
31
|
+
/** Whether read-only/plan mode is active for this invocation (flag OR either env var). */
|
|
32
|
+
protected get readOnly(): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Combines a command's own explicit dry-run intent with global read-only mode. Warns once (on
|
|
35
|
+
* stderr, so it never pollutes `--json`) when read-only mode is what's actually suppressing the
|
|
36
|
+
* write, so a skipped write is never a silent surprise to the caller.
|
|
37
|
+
*/
|
|
38
|
+
protected effectiveDryRun(explicitDryRun: boolean): boolean;
|
|
22
39
|
/**
|
|
23
40
|
* The exit code an error maps to. oclif `CLIError`s carry it in `err.oclif.exit`; other errors may
|
|
24
41
|
* expose `err.exitCode`. Defined once so the process exit code (`catch`) and the JSON envelope
|
package/dist/base-command.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Command, Errors, toConfiguredId } from '@oclif/core';
|
|
2
|
-
import { invalidEnvOverrideWarnings } from './lib/config/index.js';
|
|
1
|
+
import { Command, Errors, Flags, toConfiguredId } from '@oclif/core';
|
|
2
|
+
import { invalidEnvOverrideWarnings, resolveReadOnly } from './lib/config/index.js';
|
|
3
3
|
import { buildEnvelope, formatErrorForJson, normalizeCliErrorMessage, stableErrorCodeFrom, } from './lib/envelope.js';
|
|
4
4
|
import { resolveResearchTarget, } from './lib/research/resolve-target.js';
|
|
5
5
|
import { looksLikeSchemelessUrl } from './lib/research/url.js';
|
|
@@ -11,6 +11,20 @@ import { looksLikeSchemelessUrl } from './lib/research/url.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export class BaseCommand extends Command {
|
|
13
13
|
static enableJsonFlag = true;
|
|
14
|
+
/**
|
|
15
|
+
* Flags shared by every command. Passed as `baseFlags` to `this.parse()` in `init()` below, which
|
|
16
|
+
* is oclif's own mechanism for merging them into `this.ctor.flags` at both parse time and
|
|
17
|
+
* `--help`/manifest generation — no per-command `static flags` changes are needed to expose these.
|
|
18
|
+
* `--plan` mirrors agent-harness "plan mode" terminology; `--read-only` is the canonical name.
|
|
19
|
+
*/
|
|
20
|
+
static baseFlags = {
|
|
21
|
+
'read-only': Flags.boolean({
|
|
22
|
+
aliases: ['plan'],
|
|
23
|
+
default: false,
|
|
24
|
+
description: 'Block all filesystem writes/deletes (cache and config); network fetches still run. ' +
|
|
25
|
+
'Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.',
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
14
28
|
flags;
|
|
15
29
|
args;
|
|
16
30
|
parsedArgv;
|
|
@@ -18,6 +32,7 @@ export class BaseCommand extends Command {
|
|
|
18
32
|
await super.init();
|
|
19
33
|
const { args, flags, argv } = await this.parse({
|
|
20
34
|
flags: this.ctor.flags,
|
|
35
|
+
baseFlags: this.ctor.baseFlags,
|
|
21
36
|
enableJsonFlag: this.ctor.enableJsonFlag,
|
|
22
37
|
args: this.ctor.args,
|
|
23
38
|
strict: this.ctor.strict,
|
|
@@ -41,6 +56,21 @@ export class BaseCommand extends Command {
|
|
|
41
56
|
Errors.warn(input);
|
|
42
57
|
return input;
|
|
43
58
|
}
|
|
59
|
+
/** Whether read-only/plan mode is active for this invocation (flag OR either env var). */
|
|
60
|
+
get readOnly() {
|
|
61
|
+
return resolveReadOnly({ flag: Boolean(this.flags?.['read-only']), env: process.env });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Combines a command's own explicit dry-run intent with global read-only mode. Warns once (on
|
|
65
|
+
* stderr, so it never pollutes `--json`) when read-only mode is what's actually suppressing the
|
|
66
|
+
* write, so a skipped write is never a silent surprise to the caller.
|
|
67
|
+
*/
|
|
68
|
+
effectiveDryRun(explicitDryRun) {
|
|
69
|
+
if (this.readOnly && !explicitDryRun) {
|
|
70
|
+
this.warn('Read-only mode active (--read-only/--plan or BONSAI_READ_ONLY/BONSAI_PLAN_MODE): skipping filesystem writes.');
|
|
71
|
+
}
|
|
72
|
+
return explicitDryRun || this.readOnly;
|
|
73
|
+
}
|
|
44
74
|
/**
|
|
45
75
|
* The exit code an error maps to. oclif `CLIError`s carry it in `err.oclif.exit`; other errors may
|
|
46
76
|
* expose `err.exitCode`. Defined once so the process exit code (`catch`) and the JSON envelope
|
|
@@ -51,7 +51,7 @@ export default class ConfigSet extends ConfigCommand {
|
|
|
51
51
|
}
|
|
52
52
|
const scope = this.writeScope(this.flags.local);
|
|
53
53
|
const formatted = meta.format(parsed);
|
|
54
|
-
if (this.flags['dry-run']) {
|
|
54
|
+
if (this.effectiveDryRun(this.flags['dry-run'])) {
|
|
55
55
|
if (!this.jsonEnabled())
|
|
56
56
|
this.log(`[dry-run] Would set ${keyArg} = ${formatted} (${scope})`);
|
|
57
57
|
return { key: keyArg, value: parsed, scope, dryRun: true };
|
|
@@ -33,7 +33,7 @@ export default class ConfigUnset extends ConfigCommand {
|
|
|
33
33
|
const key = this.args.key;
|
|
34
34
|
this.validateConfigKeyAndScope(key, this.flags.global, this.flags.local);
|
|
35
35
|
const scope = this.writeScope(this.flags.local);
|
|
36
|
-
if (this.flags['dry-run']) {
|
|
36
|
+
if (this.effectiveDryRun(this.flags['dry-run'])) {
|
|
37
37
|
if (!this.jsonEnabled())
|
|
38
38
|
this.log(`[dry-run] Would unset ${key} (${scope})`);
|
|
39
39
|
return { key, scope, dryRun: true };
|
package/dist/commands/fetch.js
CHANGED
|
@@ -15,6 +15,7 @@ import { capturePage } from '../lib/research/capture.js';
|
|
|
15
15
|
import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
|
|
16
16
|
import { applyAutoTags } from '../lib/research/keywords.js';
|
|
17
17
|
import { detectSite } from '../sites/index.js';
|
|
18
|
+
import { applySiteFetchProvenance } from '../sites/types.js';
|
|
18
19
|
const CAPTURE_DEPS = {
|
|
19
20
|
fetchStatic: (url) => fetchStaticHtml(url),
|
|
20
21
|
fetchRendered: (url) => fetchRenderedHtml(url),
|
|
@@ -124,9 +125,11 @@ export default class FetchCommand extends BaseCommand {
|
|
|
124
125
|
let fetchResult;
|
|
125
126
|
let extraction;
|
|
126
127
|
let capture = null;
|
|
128
|
+
let siteFetch = null;
|
|
127
129
|
if (siteModule?.fetchPage) {
|
|
128
130
|
// Custom site modules own their fetch/extract strategy; the generic capture path is skipped.
|
|
129
|
-
|
|
131
|
+
siteFetch = await siteModule.fetchPage(normalizedUrl);
|
|
132
|
+
({ fetchResult, extraction } = siteFetch);
|
|
130
133
|
}
|
|
131
134
|
else {
|
|
132
135
|
capture = await capturePage(normalizedUrl, { forceRendered: useRendered }, CAPTURE_DEPS);
|
|
@@ -140,6 +143,9 @@ export default class FetchCommand extends BaseCommand {
|
|
|
140
143
|
if (capture) {
|
|
141
144
|
applyCaptureMetadata(artifact, capture);
|
|
142
145
|
}
|
|
146
|
+
else if (siteFetch?.captureMethod) {
|
|
147
|
+
applySiteFetchProvenance(artifact.metadata, siteFetch);
|
|
148
|
+
}
|
|
143
149
|
else if (useRendered) {
|
|
144
150
|
artifact.metadata.capture_method = 'browser_fallback';
|
|
145
151
|
}
|
|
@@ -264,8 +270,9 @@ export default class FetchCommand extends BaseCommand {
|
|
|
264
270
|
}
|
|
265
271
|
async run() {
|
|
266
272
|
const urls = this.parsedArgv;
|
|
267
|
-
const { format, ttl, 'max-age': maxAge
|
|
273
|
+
const { format, ttl, 'max-age': maxAge } = this.flags;
|
|
268
274
|
this.validateDurationFlags(ttl, maxAge);
|
|
275
|
+
const dryRun = this.effectiveDryRun(this.flags['dry-run']);
|
|
269
276
|
const summaryLevel = loadSummaryLevel(this.config.configDir, process.cwd());
|
|
270
277
|
const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
|
|
271
278
|
const currentTime = new Date();
|
package/dist/commands/import.js
CHANGED
|
@@ -343,13 +343,16 @@ export default class ResearchImport extends BaseCommand {
|
|
|
343
343
|
dataDir: this.config.dataDir,
|
|
344
344
|
flagOverride: this.flags.storage,
|
|
345
345
|
});
|
|
346
|
-
const
|
|
346
|
+
const dryRun = this.effectiveDryRun(false);
|
|
347
|
+
const writeResult = writeArtifactSecurely(roots, cacheKey, artifact, { dryRun });
|
|
347
348
|
const storagePath = getArtifactPath(writeResult.dataDir, cacheKey);
|
|
348
349
|
if (writeResult.redirected) {
|
|
349
350
|
this.warn(`Detected ${writeResult.secretLabel} in the imported content; stored in the global cache instead of the project to avoid committing secrets.`);
|
|
350
351
|
}
|
|
351
352
|
if (!this.jsonEnabled()) {
|
|
352
|
-
this.log(
|
|
353
|
+
this.log(dryRun
|
|
354
|
+
? `[dry-run] Would import research artifact.`
|
|
355
|
+
: `Successfully imported research artifact.`);
|
|
353
356
|
this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
|
|
354
357
|
this.log(`${'Storage Path:'.padEnd(25)} ${storagePath}`);
|
|
355
358
|
}
|
|
@@ -357,9 +360,10 @@ export default class ResearchImport extends BaseCommand {
|
|
|
357
360
|
return {
|
|
358
361
|
schemaVersion: 1,
|
|
359
362
|
command: 'import',
|
|
363
|
+
dryRun,
|
|
360
364
|
cache: {
|
|
361
365
|
key: cacheKey,
|
|
362
|
-
status: 'imported',
|
|
366
|
+
status: dryRun ? 'would_import' : 'imported',
|
|
363
367
|
freshness: 'fresh',
|
|
364
368
|
path: storagePath,
|
|
365
369
|
storage: roots.mode,
|
package/dist/commands/prune.js
CHANGED
|
@@ -68,10 +68,21 @@ export default class ResearchPrune extends BaseCommand {
|
|
|
68
68
|
],
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
|
+
// Checked before the generic --dry-run/--yes conflict below so a combination like
|
|
72
|
+
// `--dry-run --yes --read-only` reports the more specific READ_ONLY_MODE code, not
|
|
73
|
+
// CONFLICTING_FLAGS — read-only mode is the more fundamental constraint being violated.
|
|
74
|
+
if (this.readOnly && this.flags.yes) {
|
|
75
|
+
this.error('--yes cannot be used while read-only mode is active (--read-only/--plan or BONSAI_READ_ONLY/BONSAI_PLAN_MODE): mutations are disabled.', {
|
|
76
|
+
exit: 2,
|
|
77
|
+
code: 'READ_ONLY_MODE',
|
|
78
|
+
suggestions: [`Preview instead: ${this.config.bin} prune --dry-run`],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
71
81
|
if (this.flags['dry-run'] && this.flags.yes) {
|
|
72
82
|
this.error('--dry-run and --yes are mutually exclusive: --dry-run previews without deleting, --yes confirms deletion. Choose one.', { exit: 2, code: 'CONFLICTING_FLAGS' });
|
|
73
83
|
}
|
|
74
|
-
|
|
84
|
+
// Read-only mode implicitly previews, so the usual "pick one" safety check is redundant here.
|
|
85
|
+
if (!this.readOnly && !this.flags['dry-run'] && !this.flags.yes) {
|
|
75
86
|
const olderThanPart = this.flags['older-than']
|
|
76
87
|
? ` --older-than ${this.flags['older-than']}`
|
|
77
88
|
: '';
|
|
@@ -155,7 +166,7 @@ export default class ResearchPrune extends BaseCommand {
|
|
|
155
166
|
});
|
|
156
167
|
const currentTime = new Date();
|
|
157
168
|
const filesToPrune = this.findPruneCandidates(roots.readRoots, currentTime);
|
|
158
|
-
const dryRun = this.flags['dry-run'];
|
|
169
|
+
const dryRun = this.effectiveDryRun(this.flags['dry-run']);
|
|
159
170
|
const count = filesToPrune.length;
|
|
160
171
|
// Track deletions actually performed so the JSON envelope and human output agree even when an
|
|
161
172
|
// unlink fails (e.g. a permission error). Reporting candidate count as `prunedCount` would
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, resolveReadOnly, parseEnvStorage, parseEnvSummary, parseEnvBoolean, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, READ_ONLY_ENV_VAR, PLAN_MODE_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, type ResolveReadOnlyInput, } from './resolve.js';
|
|
4
4
|
import type { ConfigValues, ResolvedConfig, StorageMode, SummaryLevel } from './schema.js';
|
|
5
5
|
/**
|
|
6
6
|
* Read both config files once and resolve the effective storage mode.
|
package/dist/lib/config/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, resolveReadOnly, parseEnvStorage, parseEnvSummary, parseEnvBoolean, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, READ_ONLY_ENV_VAR, PLAN_MODE_ENV_VAR, } from './resolve.js';
|
|
4
4
|
import { readUserConfig, readProjectConfig } from './io.js';
|
|
5
5
|
import { resolveStorageMode, resolveSummaryLevel } from './resolve.js';
|
|
6
6
|
/**
|
|
@@ -2,6 +2,15 @@ import type { ConfigValues, StorageMode, SummaryLevel } from './schema.js';
|
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export declare const STORAGE_ENV_VAR = "BONSAI_STORAGE";
|
|
4
4
|
export declare const SUMMARY_ENV_VAR = "BONSAI_SUMMARY";
|
|
5
|
+
/** Two names for the same safety switch: `PLAN_MODE` mirrors agent-harness "plan mode" terminology. */
|
|
6
|
+
export declare const READ_ONLY_ENV_VAR = "BONSAI_READ_ONLY";
|
|
7
|
+
export declare const PLAN_MODE_ENV_VAR = "BONSAI_PLAN_MODE";
|
|
8
|
+
/**
|
|
9
|
+
* Parses a boolean-ish env value. Empty/unset is `undefined` (not present); an unrecognized
|
|
10
|
+
* non-empty value is also `undefined` (invalid — see {@link invalidEnvOverrideWarnings}), so a
|
|
11
|
+
* typo never silently reads as `true`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseEnvBoolean(raw: string | undefined): boolean | undefined;
|
|
5
14
|
/**
|
|
6
15
|
* Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
|
|
7
16
|
* dropped during resolution (falling back to config/default), which would otherwise silently mask
|
|
@@ -33,3 +42,15 @@ export interface ResolveSummaryInput {
|
|
|
33
42
|
* flag > env > project file > user file > built-in default.
|
|
34
43
|
*/
|
|
35
44
|
export declare function resolveSummaryLevel(input: ResolveSummaryInput): SummaryLevel;
|
|
45
|
+
export interface ResolveReadOnlyInput {
|
|
46
|
+
/** True when `--read-only`/`--plan` was passed on this invocation. */
|
|
47
|
+
flag: boolean;
|
|
48
|
+
env: Record<string, string | undefined>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve whether read-only (plan) mode is active. Unlike storage/summary, this is a safety gate
|
|
52
|
+
* rather than a preference: composition is OR, not override-by-precedence — if the flag or either
|
|
53
|
+
* env var says read-only, the run is read-only. There is no way to force writes back on when an
|
|
54
|
+
* ambient `BONSAI_READ_ONLY`/`BONSAI_PLAN_MODE` is set, by design.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveReadOnly(input: ResolveReadOnlyInput): boolean;
|
|
@@ -2,6 +2,26 @@ import { BUILT_IN_DEFAULTS, STORAGE_MODES, SUMMARY_LEVELS } from './schema.js';
|
|
|
2
2
|
/** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
|
|
3
3
|
export const STORAGE_ENV_VAR = 'BONSAI_STORAGE';
|
|
4
4
|
export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
|
|
5
|
+
/** Two names for the same safety switch: `PLAN_MODE` mirrors agent-harness "plan mode" terminology. */
|
|
6
|
+
export const READ_ONLY_ENV_VAR = 'BONSAI_READ_ONLY';
|
|
7
|
+
export const PLAN_MODE_ENV_VAR = 'BONSAI_PLAN_MODE';
|
|
8
|
+
const BOOLEAN_TRUE_VALUES = ['1', 'true', 'yes'];
|
|
9
|
+
const BOOLEAN_FALSE_VALUES = ['0', 'false', 'no'];
|
|
10
|
+
/**
|
|
11
|
+
* Parses a boolean-ish env value. Empty/unset is `undefined` (not present); an unrecognized
|
|
12
|
+
* non-empty value is also `undefined` (invalid — see {@link invalidEnvOverrideWarnings}), so a
|
|
13
|
+
* typo never silently reads as `true`.
|
|
14
|
+
*/
|
|
15
|
+
export function parseEnvBoolean(raw) {
|
|
16
|
+
const v = raw?.trim().toLowerCase();
|
|
17
|
+
if (!v)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (BOOLEAN_TRUE_VALUES.includes(v))
|
|
20
|
+
return true;
|
|
21
|
+
if (BOOLEAN_FALSE_VALUES.includes(v))
|
|
22
|
+
return false;
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
5
25
|
/**
|
|
6
26
|
* Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
|
|
7
27
|
* dropped during resolution (falling back to config/default), which would otherwise silently mask
|
|
@@ -9,18 +29,36 @@ export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
|
|
|
9
29
|
* is visible without breaking the run. Returns one message per offending variable, or empty.
|
|
10
30
|
*/
|
|
11
31
|
export function invalidEnvOverrideWarnings(env) {
|
|
32
|
+
// Each check names the var, how to tell a value is invalid, and the hint to show when it is.
|
|
12
33
|
const checks = [
|
|
13
|
-
[
|
|
14
|
-
|
|
34
|
+
[
|
|
35
|
+
STORAGE_ENV_VAR,
|
|
36
|
+
(raw) => STORAGE_MODES.includes(raw),
|
|
37
|
+
`not one of ${STORAGE_MODES.join(', ')}. Using the configured value or default instead.`,
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
SUMMARY_ENV_VAR,
|
|
41
|
+
(raw) => SUMMARY_LEVELS.includes(raw),
|
|
42
|
+
`not one of ${SUMMARY_LEVELS.join(', ')}. Using the configured value or default instead.`,
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
READ_ONLY_ENV_VAR,
|
|
46
|
+
(raw) => parseEnvBoolean(raw) !== undefined,
|
|
47
|
+
'expected 1/true/yes or 0/false/no. Read-only mode was not toggled by this value.',
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
PLAN_MODE_ENV_VAR,
|
|
51
|
+
(raw) => parseEnvBoolean(raw) !== undefined,
|
|
52
|
+
'expected 1/true/yes or 0/false/no. Read-only mode was not toggled by this value.',
|
|
53
|
+
],
|
|
15
54
|
];
|
|
16
55
|
const warnings = [];
|
|
17
|
-
for (const [name,
|
|
56
|
+
for (const [name, isValid, hint] of checks) {
|
|
18
57
|
// After trim, an empty/whitespace-only value is falsy and treated as "unset" (no warning),
|
|
19
58
|
// matching parseEnv*; matching is case-sensitive, so a wrong-case value is invalid and warns.
|
|
20
59
|
const raw = env[name]?.trim();
|
|
21
|
-
if (raw && !
|
|
22
|
-
warnings.push(`Ignoring ${name}="${raw}":
|
|
23
|
-
`Using the configured value or default instead.`);
|
|
60
|
+
if (raw && !isValid(raw)) {
|
|
61
|
+
warnings.push(`Ignoring ${name}="${raw}": ${hint}`);
|
|
24
62
|
}
|
|
25
63
|
}
|
|
26
64
|
return warnings;
|
|
@@ -59,3 +97,14 @@ export function resolveSummaryLevel(input) {
|
|
|
59
97
|
input.userConfig.summary ??
|
|
60
98
|
BUILT_IN_DEFAULTS.summary);
|
|
61
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve whether read-only (plan) mode is active. Unlike storage/summary, this is a safety gate
|
|
102
|
+
* rather than a preference: composition is OR, not override-by-precedence — if the flag or either
|
|
103
|
+
* env var says read-only, the run is read-only. There is no way to force writes back on when an
|
|
104
|
+
* ambient `BONSAI_READ_ONLY`/`BONSAI_PLAN_MODE` is set, by design.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveReadOnly(input) {
|
|
107
|
+
return Boolean(input.flag ||
|
|
108
|
+
parseEnvBoolean(input.env[READ_ONLY_ENV_VAR]) ||
|
|
109
|
+
parseEnvBoolean(input.env[PLAN_MODE_ENV_VAR]));
|
|
110
|
+
}
|
|
@@ -127,6 +127,9 @@ export function buildChromeArgs() {
|
|
|
127
127
|
...getChromeTlsCompatibilityArgs(),
|
|
128
128
|
];
|
|
129
129
|
}
|
|
130
|
+
// CI runners occasionally starve headless Chrome's startup (disk/CPU contention from concurrent
|
|
131
|
+
// jobs), so this is generous enough to absorb that rather than a "typical" local startup time.
|
|
132
|
+
const CHROME_STARTUP_TIMEOUT_MS = 20_000;
|
|
130
133
|
async function spawnChrome(chromePath) {
|
|
131
134
|
const chromeProcess = spawn(chromePath, buildChromeArgs());
|
|
132
135
|
try {
|
|
@@ -135,7 +138,7 @@ async function spawnChrome(chromePath) {
|
|
|
135
138
|
const timeoutId = setTimeout(() => {
|
|
136
139
|
chromeProcess.kill('SIGKILL');
|
|
137
140
|
reject(new Error('Timed out waiting for Chrome to start.'));
|
|
138
|
-
},
|
|
141
|
+
}, CHROME_STARTUP_TIMEOUT_MS);
|
|
139
142
|
const onData = (data) => {
|
|
140
143
|
output += data.toString();
|
|
141
144
|
const match = output.match(/ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\/[a-zA-Z0-9-]+/);
|
|
@@ -363,9 +366,17 @@ export function describeNavigationFailure(errorText, url) {
|
|
|
363
366
|
}
|
|
364
367
|
// Chrome can emit these on first navigation in headless CI when the cert verifier reloads mid-run.
|
|
365
368
|
const TRANSIENT_NAV_ERROR_CODES = ['ERR_CERT_VERIFIER_CHANGED'];
|
|
366
|
-
|
|
369
|
+
// Resource contention in CI (concurrent jobs competing for CPU/disk) can make Chrome itself fail
|
|
370
|
+
// to come up in time, or die before its devtools websocket URL is printed. Neither reflects a
|
|
371
|
+
// problem with the target page, so it's worth a fresh attempt rather than failing the whole fetch.
|
|
372
|
+
const TRANSIENT_CHROME_STARTUP_MESSAGES = [
|
|
373
|
+
'Timed out waiting for Chrome to start.',
|
|
374
|
+
'Chrome exited prematurely',
|
|
375
|
+
];
|
|
376
|
+
function isTransientBrowserError(err) {
|
|
367
377
|
const message = err instanceof Error ? err.message : String(err);
|
|
368
|
-
return TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code))
|
|
378
|
+
return (TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code)) ||
|
|
379
|
+
TRANSIENT_CHROME_STARTUP_MESSAGES.some((text) => message.includes(text)));
|
|
369
380
|
}
|
|
370
381
|
async function fetchRenderedHtmlOnce(url, options = {}) {
|
|
371
382
|
const timeout = options.timeoutMs ?? 15_000;
|
|
@@ -432,7 +443,7 @@ export async function fetchRenderedHtml(url, options = {}) {
|
|
|
432
443
|
return await fetchRenderedHtmlOnce(url, options);
|
|
433
444
|
}
|
|
434
445
|
catch (err) {
|
|
435
|
-
if (attempt === maxAttempts || !
|
|
446
|
+
if (attempt === maxAttempts || !isTransientBrowserError(err))
|
|
436
447
|
throw err;
|
|
437
448
|
}
|
|
438
449
|
}
|
|
@@ -9,6 +9,13 @@ export declare function parseFrontmatter(md: string): ParsedFrontmatter;
|
|
|
9
9
|
* handler attributes, and javascript: URLs. Leaves ordinary Markdown and benign inline HTML intact.
|
|
10
10
|
*/
|
|
11
11
|
export declare function sanitizeSourceMarkdown(md: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Applies a cleanup transform to the parts of a document OUTSIDE fenced code blocks, passing the
|
|
14
|
+
* fences through verbatim. Every text-level cleanup in this pipeline must go through this guard:
|
|
15
|
+
* fenced code is quoted material, and "cleaning" it destroys the samples readers came for
|
|
16
|
+
* (a `<script>` line in an example, a bash `# comment`, a literal `javascript:` URL).
|
|
17
|
+
*/
|
|
18
|
+
export declare function outsideFences(md: string, transform: (segment: string) => string): string;
|
|
12
19
|
/**
|
|
13
20
|
* Builds an ExtractionResult from public Markdown/MDX source. Frontmatter is parsed for the title
|
|
14
21
|
* and the body is sanitized. The detailed Markdown is the source itself, preserving code fences,
|
|
@@ -98,28 +98,31 @@ function replaceMacro(_match, inner) {
|
|
|
98
98
|
function stripMdnHiddenSamples(md) {
|
|
99
99
|
return md.replace(/<pre\b[^>]*\bhidden\b[^>]*>[\s\S]*?<\/pre>/gi, '');
|
|
100
100
|
}
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Applies a cleanup transform to the parts of a document OUTSIDE fenced code blocks, passing the
|
|
103
|
+
* fences through verbatim. Every text-level cleanup in this pipeline must go through this guard:
|
|
104
|
+
* fenced code is quoted material, and "cleaning" it destroys the samples readers came for
|
|
105
|
+
* (a `<script>` line in an example, a bash `# comment`, a literal `javascript:` URL).
|
|
106
|
+
*/
|
|
107
|
+
export function outsideFences(md, transform) {
|
|
103
108
|
return md
|
|
104
109
|
.split(/(```[\s\S]*?```)/)
|
|
105
|
-
.map((segment, i) => (i % 2 === 1 ? segment : segment
|
|
110
|
+
.map((segment, i) => (i % 2 === 1 ? segment : transform(segment)))
|
|
106
111
|
.join('');
|
|
107
112
|
}
|
|
113
|
+
function cleanKumaScript(md) {
|
|
114
|
+
return outsideFences(md, (segment) => segment.replace(/\{\{(.+?)\}\}/g, replaceMacro));
|
|
115
|
+
}
|
|
108
116
|
// MDN (and occasionally other docs) author tables as raw HTML inside their Markdown source, which
|
|
109
117
|
// otherwise leaks verbatim into the artifact and wastes agent tokens. Turndown already emits clean
|
|
110
118
|
// GFM tables, so convert any literal <table> region to Markdown. Code fences are skipped so an HTML
|
|
111
119
|
// table shown as a *code example* (```html …<table>…) is preserved, not rewritten.
|
|
112
120
|
function convertHtmlTables(md) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// the inner </table>, leaving the outer wrapper's closing tags as inert residue (no attrs,
|
|
119
|
-
// so it can't trip the leak gate). Doc sources use flat tables; upgrade to a DOM walk over
|
|
120
|
-
// the whole segment via htmlToMarkdown if a nested-table source ever appears.
|
|
121
|
-
segment.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => `\n\n${htmlToMarkdown(table).trim()}\n\n`))
|
|
122
|
-
.join('');
|
|
121
|
+
// ponytail: non-greedy per-table match. A <table> nested in a cell would stop the match at
|
|
122
|
+
// the inner </table>, leaving the outer wrapper's closing tags as inert residue (no attrs,
|
|
123
|
+
// so it can't trip the leak gate). Doc sources use flat tables; upgrade to a DOM walk over
|
|
124
|
+
// the whole segment via htmlToMarkdown if a nested-table source ever appears.
|
|
125
|
+
return outsideFences(md, (segment) => segment.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => `\n\n${htmlToMarkdown(table).trim()}\n\n`));
|
|
123
126
|
}
|
|
124
127
|
const HEADING = /^(#{1,6})\s/;
|
|
125
128
|
function headingLevel(line) {
|
|
@@ -140,17 +143,37 @@ function dropEmptySections(md) {
|
|
|
140
143
|
} while (current !== prev);
|
|
141
144
|
return current;
|
|
142
145
|
}
|
|
146
|
+
const FENCE_DELIMITER = /^\s*```/;
|
|
147
|
+
// Marks each line that belongs to fenced code, delimiters included. Fenced lines are content,
|
|
148
|
+
// never headings: a bash `# comment` in an example must not be classified — and deleted — as an
|
|
149
|
+
// empty section.
|
|
150
|
+
function fencedLineMask(lines) {
|
|
151
|
+
let fenced = false;
|
|
152
|
+
return lines.map((line) => {
|
|
153
|
+
if (!FENCE_DELIMITER.test(line))
|
|
154
|
+
return fenced;
|
|
155
|
+
fenced = !fenced;
|
|
156
|
+
return true;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Index of the first non-blank line after `start`, or lines.length. */
|
|
160
|
+
function nextContentLine(lines, start) {
|
|
161
|
+
let next = start + 1;
|
|
162
|
+
while (next < lines.length && lines[next].trim() === '')
|
|
163
|
+
next++;
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
143
166
|
function dropEmptySectionsOnce(md) {
|
|
144
167
|
const lines = md.split('\n');
|
|
168
|
+
const inFence = fencedLineMask(lines);
|
|
145
169
|
const keep = lines.map(() => true);
|
|
146
170
|
for (let i = 0; i < lines.length; i++) {
|
|
147
|
-
const level = headingLevel(lines[i]);
|
|
171
|
+
const level = inFence[i] ? 0 : headingLevel(lines[i]);
|
|
148
172
|
if (!level)
|
|
149
173
|
continue;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const nextLevel = headingLevel(lines[next]);
|
|
174
|
+
const next = nextContentLine(lines, i);
|
|
175
|
+
// A fenced line after the heading means the section has content (nextLevel 0 → not empty).
|
|
176
|
+
const nextLevel = next < lines.length && !inFence[next] ? headingLevel(lines[next]) : 0;
|
|
154
177
|
const sectionIsEmpty = next >= lines.length || (nextLevel > 0 && nextLevel <= level);
|
|
155
178
|
if (sectionIsEmpty)
|
|
156
179
|
for (let k = i; k < next; k++)
|
|
@@ -183,8 +206,11 @@ export function extractFromSource(md, sourceUrl) {
|
|
|
183
206
|
const deMacroed = /mdn\/content/.test(sourceUrl)
|
|
184
207
|
? cleanKumaScript(stripMdnHiddenSamples(body))
|
|
185
208
|
: body;
|
|
186
|
-
|
|
187
|
-
|
|
209
|
+
// Sanitization and link/section cleanup are fence-guarded: code samples legitimately contain
|
|
210
|
+
// <script>/<button>/on*=/javascript: and bare [](…) lines. sanitizePromptInjection deliberately
|
|
211
|
+
// is NOT guarded — injection text is just as live when the agent reads it inside a code block.
|
|
212
|
+
const tablesAsMarkdown = convertHtmlTables(outsideFences(deMacroed, sanitizeSourceMarkdown));
|
|
213
|
+
const cleaned = sanitizePromptInjection(dropEmptySections(outsideFences(tablesAsMarkdown, dropEmptyLinks))).trim();
|
|
188
214
|
const notes = [`captured from public Markdown/MDX source: ${sourceUrl}`];
|
|
189
215
|
return {
|
|
190
216
|
title: titleFrom(frontmatter, cleaned),
|
|
@@ -93,7 +93,10 @@ export function contentMetrics(markdown) {
|
|
|
93
93
|
return {
|
|
94
94
|
chars: markdown.length,
|
|
95
95
|
codeBlocks: Math.floor((markdown.match(/```/g) || []).length / 2),
|
|
96
|
-
|
|
96
|
+
// Separator rows come in every authored width and alignment (| --- |, | ------ |, | :---: |).
|
|
97
|
+
// Converted HTML always emits exactly ---, but route/source Markdown keeps the author's
|
|
98
|
+
// padding, so a width-sensitive count would misreport source captures as losing tables.
|
|
99
|
+
tables: (markdown.match(/^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/gm) || []).length,
|
|
97
100
|
images: (markdown.match(/!\[[^\]]*\]\(/g) || []).length,
|
|
98
101
|
orderedSteps: (markdown.match(/^\d+\.\s+/gm) || []).length,
|
|
99
102
|
bullets: (markdown.match(/^[-*]\s+/gm) || []).length,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { getSiteModuleById } from '../../sites/index.js';
|
|
3
|
+
import { applySiteFetchProvenance } from '../../sites/types.js';
|
|
3
4
|
import { fetchStaticHtml } from './fetcher.js';
|
|
4
5
|
import { extractHtmlContent } from './extract.js';
|
|
5
6
|
import { writeArtifact } from './storage.js';
|
|
@@ -111,9 +112,13 @@ function preserveUserMetadata(source, target, rendered) {
|
|
|
111
112
|
// Builds a refreshed artifact from a fresh fetch, carries over user metadata, persists it, and
|
|
112
113
|
// returns the "refreshed" result. Shared by both refresh paths (conditional-request fallthrough
|
|
113
114
|
// and site-module re-fetch), which differ only in how fetchResult/extraction are obtained.
|
|
114
|
-
function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options) {
|
|
115
|
+
function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options, siteFetch) {
|
|
115
116
|
const refreshed = createArtifactFromFetch(meta.source_url, meta.normalized_url, meta.cache_key, fetchResult, extraction, meta.tier, options.ttlOverride || meta.ttl, currentTime, options.summaryLevel);
|
|
116
117
|
preserveUserMetadata(meta, refreshed, options.rendered);
|
|
118
|
+
// A site-module refresh reports how it actually captured this time; that overrides the
|
|
119
|
+
// carried-over provenance, so a withdrawn .md twin can't leave a stale source_doc_url behind.
|
|
120
|
+
if (siteFetch)
|
|
121
|
+
applySiteFetchProvenance(refreshed.metadata, siteFetch);
|
|
117
122
|
// Back-fill keyword tags when the carried-over set is empty (e.g. an artifact first cached before
|
|
118
123
|
// auto-tagging, or one originally stored without tags), so refreshing keeps it searchable.
|
|
119
124
|
applyAutoTags(refreshed);
|
|
@@ -162,8 +167,8 @@ export async function revalidateCache(dataDir, existing, currentTime, options) {
|
|
|
162
167
|
if (siteModule?.fetchPage) {
|
|
163
168
|
// Site modules use custom fetch strategies that don't speak HTTP conditional requests
|
|
164
169
|
// (no ETag/If-Modified-Since), so a full re-fetch is the only correct revalidation here.
|
|
165
|
-
const
|
|
166
|
-
return persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options);
|
|
170
|
+
const siteFetch = await siteModule.fetchPage(meta.source_url);
|
|
171
|
+
return persistRefreshedArtifact(dataDir, meta, siteFetch.fetchResult, siteFetch.extraction, currentTime, options, siteFetch);
|
|
167
172
|
}
|
|
168
173
|
const fetchResult = options.rendered
|
|
169
174
|
? await fetchRenderedHtml(meta.source_url)
|
|
@@ -12,5 +12,11 @@ export interface SecureWriteResult {
|
|
|
12
12
|
* Write an artifact to the configured write root, except when it contains a secret and the
|
|
13
13
|
* target is a (potentially committed) project cache — those are redirected to the global cache.
|
|
14
14
|
* Returns where it landed so the caller can warn the user and report the real path.
|
|
15
|
+
*
|
|
16
|
+
* `dryRun` still runs the secret scan and reports the real would-be destination, but skips the
|
|
17
|
+
* actual `writeArtifact` persist — so a read-only preview never gives a falsely reassuring answer
|
|
18
|
+
* about where secret-bearing content would land.
|
|
15
19
|
*/
|
|
16
|
-
export declare function writeArtifactSecurely(roots: StoreRoots, key: string, artifact: ResearchArtifact
|
|
20
|
+
export declare function writeArtifactSecurely(roots: StoreRoots, key: string, artifact: ResearchArtifact, options?: {
|
|
21
|
+
dryRun?: boolean;
|
|
22
|
+
}): SecureWriteResult;
|
|
@@ -4,14 +4,16 @@ import { scanArtifactForSecret } from './secret-scan.js';
|
|
|
4
4
|
* Write an artifact to the configured write root, except when it contains a secret and the
|
|
5
5
|
* target is a (potentially committed) project cache — those are redirected to the global cache.
|
|
6
6
|
* Returns where it landed so the caller can warn the user and report the real path.
|
|
7
|
+
*
|
|
8
|
+
* `dryRun` still runs the secret scan and reports the real would-be destination, but skips the
|
|
9
|
+
* actual `writeArtifact` persist — so a read-only preview never gives a falsely reassuring answer
|
|
10
|
+
* about where secret-bearing content would land.
|
|
7
11
|
*/
|
|
8
|
-
export function writeArtifactSecurely(roots, key, artifact) {
|
|
12
|
+
export function writeArtifactSecurely(roots, key, artifact, options = {}) {
|
|
9
13
|
const isProjectWrite = roots.writeRoot !== roots.globalRoot;
|
|
10
14
|
const secretLabel = isProjectWrite ? scanArtifactForSecret(artifact) : null;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
writeArtifact(roots.writeRoot, key, artifact);
|
|
16
|
-
return { dataDir: roots.writeRoot, redirected: false, secretLabel: null };
|
|
15
|
+
const dataDir = secretLabel ? roots.globalRoot : roots.writeRoot;
|
|
16
|
+
if (!options.dryRun)
|
|
17
|
+
writeArtifact(dataDir, key, artifact);
|
|
18
|
+
return { dataDir, redirected: Boolean(secretLabel), secretLabel };
|
|
17
19
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
import type { SiteFetchResult } from '../types.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Fetches a developer.salesforce.com doc page. Supported articles publish a Markdown twin at the
|
|
4
|
+
* derived `.md` route ("View as Markdown"), which is preferred: one static request instead of a
|
|
5
|
+
* browser render, and it returns the article's source. Pages without a validated twin (API
|
|
6
|
+
* reference, atlas.* books, landing pages) fall back to the shared LWR shadow-DOM fetcher.
|
|
7
|
+
*/
|
|
3
8
|
export declare function fetchDeveloperPage(url: string): Promise<SiteFetchResult>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchSalesforceDoc } from '../salesforce-doc-fetch.js';
|
|
2
|
+
import { fetchDeveloperRouteMarkdown } from './route-markdown.js';
|
|
2
3
|
// Containers a developer doc renders into. doc-content-layout / doc-amf-reference are the
|
|
3
4
|
// web-component hosts for guide pages and API-reference (AMF) pages respectively.
|
|
4
5
|
const CONTENT_SELECTORS = [
|
|
@@ -11,8 +12,16 @@ const CONTENT_SELECTORS = [
|
|
|
11
12
|
'article',
|
|
12
13
|
'main',
|
|
13
14
|
];
|
|
14
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Fetches a developer.salesforce.com doc page. Supported articles publish a Markdown twin at the
|
|
17
|
+
* derived `.md` route ("View as Markdown"), which is preferred: one static request instead of a
|
|
18
|
+
* browser render, and it returns the article's source. Pages without a validated twin (API
|
|
19
|
+
* reference, atlas.* books, landing pages) fall back to the shared LWR shadow-DOM fetcher.
|
|
20
|
+
*/
|
|
15
21
|
export async function fetchDeveloperPage(url) {
|
|
22
|
+
const fromMarkdownRoute = await fetchDeveloperRouteMarkdown(url);
|
|
23
|
+
if (fromMarkdownRoute)
|
|
24
|
+
return fromMarkdownRoute;
|
|
16
25
|
return fetchSalesforceDoc(url, {
|
|
17
26
|
allowedHost: 'developer.salesforce.com',
|
|
18
27
|
contentSelectors: CONTENT_SELECTORS,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type FetchResult } from '../../lib/research/fetcher.js';
|
|
2
|
+
import type { SiteFetchResult } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Derives the candidate `.md` route for a developer.salesforce.com doc URL, or null when the URL
|
|
5
|
+
* can't serve one (wrong host, not under /docs/, or an atlas.* book — verified to answer `.md`
|
|
6
|
+
* with an HTML shell, never Markdown). Query/hash are view state and are dropped.
|
|
7
|
+
*/
|
|
8
|
+
export declare function deriveMarkdownUrl(url: string): string | null;
|
|
9
|
+
/** Removes unresolvable `::include{…}` directive lines, reporting how many were dropped. */
|
|
10
|
+
export declare function stripIncludeDirectives(md: string): {
|
|
11
|
+
body: string;
|
|
12
|
+
dropped: number;
|
|
13
|
+
};
|
|
14
|
+
export type RouteMarkdownFetcher = (url: string, options?: {
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}) => Promise<FetchResult>;
|
|
18
|
+
/**
|
|
19
|
+
* Probes the derived `.md` route for a developer doc URL. Returns a complete SiteFetchResult
|
|
20
|
+
* (with route_markdown provenance) when the twin validates, or null so the caller falls back to
|
|
21
|
+
* the browser capture. Never throws: any network/validation failure is just "no twin".
|
|
22
|
+
*/
|
|
23
|
+
export declare function fetchDeveloperRouteMarkdown(url: string, fetcher?: RouteMarkdownFetcher): Promise<SiteFetchResult | null>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { fetchText } from '../../lib/research/fetcher.js';
|
|
2
|
+
import { validateTextArtifact } from '../../lib/research/docs/validate.js';
|
|
3
|
+
import { extractFromSource } from '../../lib/research/docs/markdown-source.js';
|
|
4
|
+
// developer.salesforce.com now publishes a Markdown twin for supported doc articles (the
|
|
5
|
+
// "View as Markdown" link): /docs/<cloud>/<book>/guide/<article>[.html] also serves
|
|
6
|
+
// <article>.md with Content-Type text/markdown. The URL mapping is deterministic, so we derive
|
|
7
|
+
// the .md URL and probe it BEFORE the browser path — one static request replaces a ~45s
|
|
8
|
+
// shadow-DOM render and returns the article's actual source (code fences, admonitions intact).
|
|
9
|
+
//
|
|
10
|
+
// Coverage is rolling out per doc set, so the probe result is only a candidate (T-19/T-24):
|
|
11
|
+
// unsupported articles 404, and atlas.* books answer the .md route with an HTTP 200 HTML SPA
|
|
12
|
+
// shell — which makes the strict content-type + body validation below load-bearing, not
|
|
13
|
+
// defensive garnish.
|
|
14
|
+
const DEVELOPER_HOST = 'developer.salesforce.com';
|
|
15
|
+
const MARKDOWN_CONTENT_TYPE = 'text/markdown';
|
|
16
|
+
// Salesforce's WAF answers 403 unless the User-Agent LEADS with a recognized HTTP-tool token
|
|
17
|
+
// (curl/wget/python-requests pass; bare product names and browser UAs on a non-browser TLS stack
|
|
18
|
+
// are blocked). Lead with the compatibility token — the same convention as browsers' Mozilla/5.0
|
|
19
|
+
// prefix — and carry Bonsai's honest identity after it.
|
|
20
|
+
const PROBE_HEADERS = {
|
|
21
|
+
'user-agent': 'curl/8.7.1 bonsai-research-cache (+https://bonsai.rhino-inquisitor.com)',
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Derives the candidate `.md` route for a developer.salesforce.com doc URL, or null when the URL
|
|
25
|
+
* can't serve one (wrong host, not under /docs/, or an atlas.* book — verified to answer `.md`
|
|
26
|
+
* with an HTML shell, never Markdown). Query/hash are view state and are dropped.
|
|
27
|
+
*/
|
|
28
|
+
export function deriveMarkdownUrl(url) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(url);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (parsed.hostname.toLowerCase() !== DEVELOPER_HOST)
|
|
37
|
+
return null;
|
|
38
|
+
let path = parsed.pathname.replace(/\/+$/, '');
|
|
39
|
+
if (!path.startsWith('/docs/') || path.startsWith('/docs/atlas.'))
|
|
40
|
+
return null;
|
|
41
|
+
if (path.endsWith('.md'))
|
|
42
|
+
return `https://${DEVELOPER_HOST}${path}`;
|
|
43
|
+
path = path.replace(/\.html?$/i, '');
|
|
44
|
+
return `https://${DEVELOPER_HOST}${path}.md`;
|
|
45
|
+
}
|
|
46
|
+
// `::include{src="../../shared/….md"}` directives reference build-time snippet files that are
|
|
47
|
+
// not published on the .md route (they 404), so they'd survive as inert noise in the artifact.
|
|
48
|
+
const INCLUDE_DIRECTIVE = /^\s*::include\{[^}]*\}\s*$/;
|
|
49
|
+
/** Removes unresolvable `::include{…}` directive lines, reporting how many were dropped. */
|
|
50
|
+
export function stripIncludeDirectives(md) {
|
|
51
|
+
let dropped = 0;
|
|
52
|
+
const body = md
|
|
53
|
+
.split(/(```[\s\S]*?```)/)
|
|
54
|
+
.map((segment, i) => {
|
|
55
|
+
// Fenced code is untouched: an ::include shown as a syntax example is content.
|
|
56
|
+
if (i % 2 === 1)
|
|
57
|
+
return segment;
|
|
58
|
+
return segment
|
|
59
|
+
.split('\n')
|
|
60
|
+
.filter((line) => {
|
|
61
|
+
if (!INCLUDE_DIRECTIVE.test(line))
|
|
62
|
+
return true;
|
|
63
|
+
dropped++;
|
|
64
|
+
return false;
|
|
65
|
+
})
|
|
66
|
+
.join('\n');
|
|
67
|
+
})
|
|
68
|
+
.join('');
|
|
69
|
+
return { body, dropped };
|
|
70
|
+
}
|
|
71
|
+
// A probe response counts as the Markdown twin only when the server labels it text/markdown AND
|
|
72
|
+
// the redirect chain ended on https at the developer host AND the body validates as non-HTML,
|
|
73
|
+
// non-error text. Atlas shells fail the content-type check; non-2xx statuses already threw in
|
|
74
|
+
// the fetcher (the probe sends no conditional headers, so a 304 can't occur either).
|
|
75
|
+
function isMarkdownResponse(res) {
|
|
76
|
+
if (!res.content)
|
|
77
|
+
return false;
|
|
78
|
+
const mediaType = (res.contentType?.split(';')[0] ?? '').trim().toLowerCase();
|
|
79
|
+
if (mediaType !== MARKDOWN_CONTENT_TYPE)
|
|
80
|
+
return false;
|
|
81
|
+
let finalUrl;
|
|
82
|
+
try {
|
|
83
|
+
finalUrl = new URL(res.finalUrl);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
// Scheme matters, not just host: a redirect hop downgrading to plain http would let an on-path
|
|
89
|
+
// response be cached as the trusted twin.
|
|
90
|
+
if (finalUrl.protocol !== 'https:' || finalUrl.hostname.toLowerCase() !== DEVELOPER_HOST) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return validateTextArtifact(res.content).ok;
|
|
94
|
+
}
|
|
95
|
+
// A real twin is a small static file that answers fast; anything slower should mean "no twin,
|
|
96
|
+
// fall back now" instead of stacking the fetcher's default 10s on top of the browser render.
|
|
97
|
+
const PROBE_TIMEOUT_MS = 4_000;
|
|
98
|
+
// The browser path refuses to cache captures under this floor (MIN_CONTAINER_CHARS); a twin that
|
|
99
|
+
// thin is a rollout stub, and falling back gives the rendered page a chance to do better.
|
|
100
|
+
const MIN_TWIN_CHARS = 100;
|
|
101
|
+
/**
|
|
102
|
+
* Probes the derived `.md` route for a developer doc URL. Returns a complete SiteFetchResult
|
|
103
|
+
* (with route_markdown provenance) when the twin validates, or null so the caller falls back to
|
|
104
|
+
* the browser capture. Never throws: any network/validation failure is just "no twin".
|
|
105
|
+
*/
|
|
106
|
+
export async function fetchDeveloperRouteMarkdown(url, fetcher = fetchText) {
|
|
107
|
+
const candidate = deriveMarkdownUrl(url);
|
|
108
|
+
if (!candidate)
|
|
109
|
+
return null;
|
|
110
|
+
let res;
|
|
111
|
+
try {
|
|
112
|
+
res = await fetcher(candidate, { headers: PROBE_HEADERS, timeoutMs: PROBE_TIMEOUT_MS });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (!isMarkdownResponse(res))
|
|
118
|
+
return null;
|
|
119
|
+
const { body, dropped } = stripIncludeDirectives(res.content);
|
|
120
|
+
const extraction = extractFromSource(body, res.finalUrl);
|
|
121
|
+
if (extraction.detailedMarkdown.length < MIN_TWIN_CHARS)
|
|
122
|
+
return null;
|
|
123
|
+
if (dropped > 0) {
|
|
124
|
+
extraction.qualityNotes = [
|
|
125
|
+
...(extraction.qualityNotes ?? []),
|
|
126
|
+
`${dropped} ::include directive(s) removed (shared snippets are not published on the .md route)`,
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
fetchResult: {
|
|
131
|
+
contentType: res.contentType,
|
|
132
|
+
etag: res.etag,
|
|
133
|
+
lastModified: res.lastModified,
|
|
134
|
+
finalUrl: res.finalUrl,
|
|
135
|
+
responseSize: res.responseSize,
|
|
136
|
+
content: res.content,
|
|
137
|
+
},
|
|
138
|
+
extraction,
|
|
139
|
+
captureMethod: 'route_markdown',
|
|
140
|
+
sourceDocUrl: res.finalUrl,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -64,13 +64,19 @@ function confidenceFor(length) {
|
|
|
64
64
|
// page. Help wraps it in a removable component (see fetch-page.ts), but Developer renders it
|
|
65
65
|
// as bare divs, so we also strip its lines from the converted Markdown for both sites.
|
|
66
66
|
const BOILERPLATE_LINE = /(did this article solve your issue|let us know so we can improve|share your feedback)/i;
|
|
67
|
+
// Toolbar of the "View as Markdown" feature on Developer articles. When the .md route probe
|
|
68
|
+
// succeeds the browser never runs, but rollout/network asymmetries can still land such a page
|
|
69
|
+
// here, and the toolbar labels serialize as bare paragraph lines.
|
|
70
|
+
const MARKDOWN_TOOLBAR_LINE = /^(copy as markdown|view as markdown|copy url to markdown)$/i;
|
|
67
71
|
// A link with empty anchor text — e.g. an icon-only "home" logo link. It carries no readable
|
|
68
72
|
// content and renders as a bare `[](url)`. (Images `` are intentionally not matched.)
|
|
69
73
|
const EMPTY_LINK_LINE = /^\[\]\([^)]*\)$/;
|
|
70
74
|
export function stripBoilerplate(markdown) {
|
|
71
75
|
return markdown
|
|
72
76
|
.split('\n')
|
|
73
|
-
.filter((line) => !BOILERPLATE_LINE.test(line) &&
|
|
77
|
+
.filter((line) => !BOILERPLATE_LINE.test(line) &&
|
|
78
|
+
!MARKDOWN_TOOLBAR_LINE.test(line.trim()) &&
|
|
79
|
+
!EMPTY_LINK_LINE.test(line.trim()))
|
|
74
80
|
.join('\n')
|
|
75
81
|
.replace(/\n{3,}/g, '\n\n')
|
|
76
82
|
.trim();
|
|
@@ -336,6 +342,9 @@ function buildSalesforceFetchResult(url, html, title, detailedMarkdown, qualityN
|
|
|
336
342
|
confidence: confidenceFor(detailedMarkdown.length),
|
|
337
343
|
qualityNotes,
|
|
338
344
|
},
|
|
345
|
+
// Rendered capture is this fetcher's only strategy; reporting it lets a refresh overwrite
|
|
346
|
+
// route_markdown provenance when an article's .md twin has since vanished.
|
|
347
|
+
captureMethod: 'browser_fallback',
|
|
339
348
|
};
|
|
340
349
|
}
|
|
341
350
|
/**
|
package/dist/sites/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtractionResult } from '../lib/research/extract.js';
|
|
2
|
+
import type { CaptureMethod } from '../lib/research/schema.js';
|
|
2
3
|
export interface SiteFetchResult {
|
|
3
4
|
fetchResult: {
|
|
4
5
|
contentType: string | null;
|
|
@@ -9,7 +10,19 @@ export interface SiteFetchResult {
|
|
|
9
10
|
content: string;
|
|
10
11
|
};
|
|
11
12
|
extraction: ExtractionResult;
|
|
13
|
+
captureMethod?: CaptureMethod;
|
|
14
|
+
sourceDocUrl?: string;
|
|
12
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Stamps a site module's reported provenance onto artifact metadata. Shared by the cache-miss
|
|
18
|
+
* fetch and revalidation so the two write paths can't drift. A result without captureMethod (a
|
|
19
|
+
* module predating provenance reporting) leaves the metadata untouched.
|
|
20
|
+
*/
|
|
21
|
+
export declare function applySiteFetchProvenance(metadata: {
|
|
22
|
+
capture_method: CaptureMethod | null;
|
|
23
|
+
source_doc_url: string | null;
|
|
24
|
+
source_urls: string[];
|
|
25
|
+
}, siteFetch: SiteFetchResult): void;
|
|
13
26
|
export interface SiteModule {
|
|
14
27
|
id: string;
|
|
15
28
|
name: string;
|
package/dist/sites/types.js
CHANGED
|
@@ -1 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Stamps a site module's reported provenance onto artifact metadata. Shared by the cache-miss
|
|
3
|
+
* fetch and revalidation so the two write paths can't drift. A result without captureMethod (a
|
|
4
|
+
* module predating provenance reporting) leaves the metadata untouched.
|
|
5
|
+
*/
|
|
6
|
+
export function applySiteFetchProvenance(metadata, siteFetch) {
|
|
7
|
+
if (!siteFetch.captureMethod)
|
|
8
|
+
return;
|
|
9
|
+
metadata.capture_method = siteFetch.captureMethod;
|
|
10
|
+
metadata.source_doc_url = siteFetch.sourceDocUrl ?? null;
|
|
11
|
+
if (siteFetch.sourceDocUrl && !metadata.source_urls.includes(siteFetch.sourceDocUrl)) {
|
|
12
|
+
metadata.source_urls.push(siteFetch.sourceDocUrl);
|
|
13
|
+
}
|
|
14
|
+
}
|
package/oclif.manifest.json
CHANGED
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
"allowNo": false,
|
|
29
29
|
"type": "boolean"
|
|
30
30
|
},
|
|
31
|
+
"read-only": {
|
|
32
|
+
"aliases": [
|
|
33
|
+
"plan"
|
|
34
|
+
],
|
|
35
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
36
|
+
"name": "read-only",
|
|
37
|
+
"allowNo": false,
|
|
38
|
+
"type": "boolean"
|
|
39
|
+
},
|
|
31
40
|
"topic": {
|
|
32
41
|
"char": "t",
|
|
33
42
|
"description": "the main category/topic of the research for metadata tagging",
|
|
@@ -169,6 +178,15 @@
|
|
|
169
178
|
"allowNo": false,
|
|
170
179
|
"type": "boolean"
|
|
171
180
|
},
|
|
181
|
+
"read-only": {
|
|
182
|
+
"aliases": [
|
|
183
|
+
"plan"
|
|
184
|
+
],
|
|
185
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
186
|
+
"name": "read-only",
|
|
187
|
+
"allowNo": false,
|
|
188
|
+
"type": "boolean"
|
|
189
|
+
},
|
|
172
190
|
"stdin": {
|
|
173
191
|
"description": "read Markdown from stdin",
|
|
174
192
|
"name": "stdin",
|
|
@@ -288,6 +306,15 @@
|
|
|
288
306
|
"name": "json",
|
|
289
307
|
"allowNo": false,
|
|
290
308
|
"type": "boolean"
|
|
309
|
+
},
|
|
310
|
+
"read-only": {
|
|
311
|
+
"aliases": [
|
|
312
|
+
"plan"
|
|
313
|
+
],
|
|
314
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
315
|
+
"name": "read-only",
|
|
316
|
+
"allowNo": false,
|
|
317
|
+
"type": "boolean"
|
|
291
318
|
}
|
|
292
319
|
},
|
|
293
320
|
"hasDynamicHelp": false,
|
|
@@ -331,6 +358,15 @@
|
|
|
331
358
|
"allowNo": false,
|
|
332
359
|
"type": "boolean"
|
|
333
360
|
},
|
|
361
|
+
"read-only": {
|
|
362
|
+
"aliases": [
|
|
363
|
+
"plan"
|
|
364
|
+
],
|
|
365
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
366
|
+
"name": "read-only",
|
|
367
|
+
"allowNo": false,
|
|
368
|
+
"type": "boolean"
|
|
369
|
+
},
|
|
334
370
|
"topic": {
|
|
335
371
|
"char": "t",
|
|
336
372
|
"description": "filter by exact topic (case-insensitive)",
|
|
@@ -438,6 +474,15 @@
|
|
|
438
474
|
"allowNo": false,
|
|
439
475
|
"type": "boolean"
|
|
440
476
|
},
|
|
477
|
+
"read-only": {
|
|
478
|
+
"aliases": [
|
|
479
|
+
"plan"
|
|
480
|
+
],
|
|
481
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
482
|
+
"name": "read-only",
|
|
483
|
+
"allowNo": false,
|
|
484
|
+
"type": "boolean"
|
|
485
|
+
},
|
|
441
486
|
"older-than": {
|
|
442
487
|
"description": "prune entries older than duration (e.g. \"30d\", \"90d\")",
|
|
443
488
|
"name": "older-than",
|
|
@@ -525,6 +570,15 @@
|
|
|
525
570
|
"allowNo": false,
|
|
526
571
|
"type": "boolean"
|
|
527
572
|
},
|
|
573
|
+
"read-only": {
|
|
574
|
+
"aliases": [
|
|
575
|
+
"plan"
|
|
576
|
+
],
|
|
577
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
578
|
+
"name": "read-only",
|
|
579
|
+
"allowNo": false,
|
|
580
|
+
"type": "boolean"
|
|
581
|
+
},
|
|
528
582
|
"tier": {
|
|
529
583
|
"description": "freshness tier policy to evaluate against",
|
|
530
584
|
"name": "tier",
|
|
@@ -586,6 +640,15 @@
|
|
|
586
640
|
"name": "json",
|
|
587
641
|
"allowNo": false,
|
|
588
642
|
"type": "boolean"
|
|
643
|
+
},
|
|
644
|
+
"read-only": {
|
|
645
|
+
"aliases": [
|
|
646
|
+
"plan"
|
|
647
|
+
],
|
|
648
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
649
|
+
"name": "read-only",
|
|
650
|
+
"allowNo": false,
|
|
651
|
+
"type": "boolean"
|
|
589
652
|
}
|
|
590
653
|
},
|
|
591
654
|
"hasDynamicHelp": false,
|
|
@@ -627,6 +690,15 @@
|
|
|
627
690
|
"allowNo": false,
|
|
628
691
|
"type": "boolean"
|
|
629
692
|
},
|
|
693
|
+
"read-only": {
|
|
694
|
+
"aliases": [
|
|
695
|
+
"plan"
|
|
696
|
+
],
|
|
697
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
698
|
+
"name": "read-only",
|
|
699
|
+
"allowNo": false,
|
|
700
|
+
"type": "boolean"
|
|
701
|
+
},
|
|
630
702
|
"global": {
|
|
631
703
|
"char": "g",
|
|
632
704
|
"description": "Read from user-level config only.",
|
|
@@ -679,6 +751,15 @@
|
|
|
679
751
|
"allowNo": false,
|
|
680
752
|
"type": "boolean"
|
|
681
753
|
},
|
|
754
|
+
"read-only": {
|
|
755
|
+
"aliases": [
|
|
756
|
+
"plan"
|
|
757
|
+
],
|
|
758
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
759
|
+
"name": "read-only",
|
|
760
|
+
"allowNo": false,
|
|
761
|
+
"type": "boolean"
|
|
762
|
+
},
|
|
682
763
|
"global": {
|
|
683
764
|
"char": "g",
|
|
684
765
|
"description": "Show user-level config only.",
|
|
@@ -742,6 +823,15 @@
|
|
|
742
823
|
"allowNo": false,
|
|
743
824
|
"type": "boolean"
|
|
744
825
|
},
|
|
826
|
+
"read-only": {
|
|
827
|
+
"aliases": [
|
|
828
|
+
"plan"
|
|
829
|
+
],
|
|
830
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
831
|
+
"name": "read-only",
|
|
832
|
+
"allowNo": false,
|
|
833
|
+
"type": "boolean"
|
|
834
|
+
},
|
|
745
835
|
"global": {
|
|
746
836
|
"char": "g",
|
|
747
837
|
"description": "Write to user-level config (default).",
|
|
@@ -806,6 +896,15 @@
|
|
|
806
896
|
"allowNo": false,
|
|
807
897
|
"type": "boolean"
|
|
808
898
|
},
|
|
899
|
+
"read-only": {
|
|
900
|
+
"aliases": [
|
|
901
|
+
"plan"
|
|
902
|
+
],
|
|
903
|
+
"description": "Block all filesystem writes/deletes (cache and config); network fetches still run. Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.",
|
|
904
|
+
"name": "read-only",
|
|
905
|
+
"allowNo": false,
|
|
906
|
+
"type": "boolean"
|
|
907
|
+
},
|
|
809
908
|
"global": {
|
|
810
909
|
"char": "g",
|
|
811
910
|
"description": "Remove from user-level config (default).",
|
|
@@ -843,5 +942,5 @@
|
|
|
843
942
|
"stdoutIsPrimaryData": true
|
|
844
943
|
}
|
|
845
944
|
},
|
|
846
|
-
"version": "3.0
|
|
945
|
+
"version": "3.2.0"
|
|
847
946
|
}
|