@taurgis/bonsai 3.1.0 → 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 +2 -1
- 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/secure-write.d.ts +7 -1
- package/dist/lib/research/secure-write.js +9 -7
- 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
|
@@ -270,8 +270,9 @@ export default class FetchCommand extends BaseCommand {
|
|
|
270
270
|
}
|
|
271
271
|
async run() {
|
|
272
272
|
const urls = this.parsedArgv;
|
|
273
|
-
const { format, ttl, 'max-age': maxAge
|
|
273
|
+
const { format, ttl, 'max-age': maxAge } = this.flags;
|
|
274
274
|
this.validateDurationFlags(ttl, maxAge);
|
|
275
|
+
const dryRun = this.effectiveDryRun(this.flags['dry-run']);
|
|
275
276
|
const summaryLevel = loadSummaryLevel(this.config.configDir, process.cwd());
|
|
276
277
|
const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
|
|
277
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
|
+
}
|
|
@@ -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
|
}
|
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.
|
|
945
|
+
"version": "3.2.0"
|
|
847
946
|
}
|