@taurgis/bonsai 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/config/set.d.ts +0 -9
- package/dist/commands/config/set.js +1 -1
- package/dist/lib/atomic-write.d.ts +8 -0
- package/dist/lib/atomic-write.js +25 -0
- package/dist/lib/config/io.js +3 -16
- package/dist/lib/research/docs/validate.d.ts +0 -2
- package/dist/lib/research/docs/validate.js +0 -22
- package/dist/lib/research/freshness.js +15 -16
- package/dist/lib/research/storage.js +19 -22
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -20,12 +20,3 @@ export default class ConfigSet extends ConfigCommand<typeof ConfigSet> {
|
|
|
20
20
|
init(): Promise<void>;
|
|
21
21
|
execute(): Promise<unknown>;
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
* Accept the `key=value` convenience form alongside the canonical `key value`.
|
|
25
|
-
* Splits only when no separate value positional was supplied, and on the FIRST `=`
|
|
26
|
-
* so a value may itself contain `=`.
|
|
27
|
-
*/
|
|
28
|
-
export declare function splitInlineKeyValue(rawKey: string | undefined, rawValue: string | undefined): {
|
|
29
|
-
keyArg: string | undefined;
|
|
30
|
-
valueArg: string | undefined;
|
|
31
|
-
};
|
|
@@ -83,7 +83,7 @@ export default class ConfigSet extends ConfigCommand {
|
|
|
83
83
|
* Splits only when no separate value positional was supplied, and on the FIRST `=`
|
|
84
84
|
* so a value may itself contain `=`.
|
|
85
85
|
*/
|
|
86
|
-
|
|
86
|
+
function splitInlineKeyValue(rawKey, rawValue) {
|
|
87
87
|
if (rawValue === undefined && rawKey !== undefined) {
|
|
88
88
|
const eq = rawKey.indexOf('=');
|
|
89
89
|
if (eq !== -1) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes `content` to `dest` atomically: write a sibling temp file, then rename it into place.
|
|
3
|
+
* `rename(2)` is atomic on the same filesystem, so a reader never sees a half-written file. The temp
|
|
4
|
+
* lives in `dest`'s directory so the rename never crosses a mount boundary (which would fail EXDEV),
|
|
5
|
+
* and the unique suffix keeps two rapid writers to the same `dest` from clobbering each other's temp.
|
|
6
|
+
* The temp file is cleaned up if the rename throws.
|
|
7
|
+
*/
|
|
8
|
+
export declare function atomicWriteFile(dest: string, content: string): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs';
|
|
2
|
+
/**
|
|
3
|
+
* Writes `content` to `dest` atomically: write a sibling temp file, then rename it into place.
|
|
4
|
+
* `rename(2)` is atomic on the same filesystem, so a reader never sees a half-written file. The temp
|
|
5
|
+
* lives in `dest`'s directory so the rename never crosses a mount boundary (which would fail EXDEV),
|
|
6
|
+
* and the unique suffix keeps two rapid writers to the same `dest` from clobbering each other's temp.
|
|
7
|
+
* The temp file is cleaned up if the rename throws.
|
|
8
|
+
*/
|
|
9
|
+
export function atomicWriteFile(dest, content) {
|
|
10
|
+
const tmp = `${dest}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 7)}.tmp`;
|
|
11
|
+
try {
|
|
12
|
+
writeFileSync(tmp, content, 'utf-8');
|
|
13
|
+
renameSync(tmp, dest);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
// A failed temp write or rename must not leave the half-written temp behind.
|
|
17
|
+
if (existsSync(tmp)) {
|
|
18
|
+
try {
|
|
19
|
+
unlinkSync(tmp);
|
|
20
|
+
}
|
|
21
|
+
catch { }
|
|
22
|
+
}
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/lib/config/io.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { atomicWriteFile } from '../atomic-write.js';
|
|
3
4
|
import { KEY_META, ALL_KEYS } from './schema.js';
|
|
4
5
|
/** Global, user-level config lives in the OCLIF config dir; project config sits at the repo root. */
|
|
5
6
|
export const USER_CONFIG_FILENAME = 'config.json';
|
|
@@ -43,21 +44,7 @@ export function readProjectConfig(cwd) {
|
|
|
43
44
|
return readConfigFile(join(cwd, PROJECT_CONFIG_FILENAME));
|
|
44
45
|
}
|
|
45
46
|
function writeAtomically(filePath, data) {
|
|
46
|
-
|
|
47
|
-
// share — and clobber — the same temp before its rename. Mirrors writeArtifact in storage.ts.
|
|
48
|
-
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 7)}.tmp`;
|
|
49
|
-
const content = JSON.stringify(data, null, 2) + '\n';
|
|
50
|
-
try {
|
|
51
|
-
writeFileSync(tempPath, content, 'utf-8');
|
|
52
|
-
renameSync(tempPath, filePath);
|
|
53
|
-
}
|
|
54
|
-
catch (error) {
|
|
55
|
-
try {
|
|
56
|
-
unlinkSync(tempPath);
|
|
57
|
-
}
|
|
58
|
-
catch { }
|
|
59
|
-
throw error;
|
|
60
|
-
}
|
|
47
|
+
atomicWriteFile(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
61
48
|
}
|
|
62
49
|
function omitUndefined(obj) {
|
|
63
50
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
@@ -11,5 +11,3 @@ export declare function isSameDocsOrigin(candidateUrl: string, baseUrl: string):
|
|
|
11
11
|
* not a soft error page. HTML returned for a `.md`/`.txt` probe is the most common false positive.
|
|
12
12
|
*/
|
|
13
13
|
export declare function validateTextArtifact(body: string): ArtifactValidation;
|
|
14
|
-
/** Validates a JSON search-index body parses and is a non-empty object/array. */
|
|
15
|
-
export declare function validateJsonArtifact(body: string): ArtifactValidation;
|
|
@@ -39,25 +39,3 @@ export function validateTextArtifact(body) {
|
|
|
39
39
|
return { ok: false, reason: 'body looks like an error page' };
|
|
40
40
|
return { ok: true };
|
|
41
41
|
}
|
|
42
|
-
/** Validates a JSON search-index body parses and is a non-empty object/array. */
|
|
43
|
-
export function validateJsonArtifact(body) {
|
|
44
|
-
const trimmed = body.trim();
|
|
45
|
-
if (trimmed.length < MIN_BODY_CHARS)
|
|
46
|
-
return { ok: false, reason: 'empty body' };
|
|
47
|
-
if (looksLikeHtml(trimmed))
|
|
48
|
-
return { ok: false, reason: 'body is HTML, not JSON' };
|
|
49
|
-
let parsed;
|
|
50
|
-
try {
|
|
51
|
-
parsed = JSON.parse(trimmed);
|
|
52
|
-
}
|
|
53
|
-
catch (err) {
|
|
54
|
-
return { ok: false, reason: `invalid JSON: ${err.message}` };
|
|
55
|
-
}
|
|
56
|
-
if (parsed === null || typeof parsed !== 'object') {
|
|
57
|
-
return { ok: false, reason: 'JSON is not an object or array' };
|
|
58
|
-
}
|
|
59
|
-
if (Array.isArray(parsed) ? parsed.length === 0 : Object.keys(parsed).length === 0) {
|
|
60
|
-
return { ok: false, reason: 'JSON is empty' };
|
|
61
|
-
}
|
|
62
|
-
return { ok: true };
|
|
63
|
-
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const HOUR_MS = 3600 * 1000;
|
|
2
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
1
3
|
/**
|
|
2
4
|
* Parses simple TTL duration strings (e.g. '24h', '7d', '30d') to milliseconds.
|
|
3
5
|
*/
|
|
@@ -8,19 +10,17 @@ export function parseTtlToMs(ttl) {
|
|
|
8
10
|
}
|
|
9
11
|
const val = parseInt(match[1] || '', 10);
|
|
10
12
|
const unit = match[2] || '';
|
|
11
|
-
const hour = 3600 * 1000;
|
|
12
|
-
const day = 24 * hour;
|
|
13
13
|
switch (unit) {
|
|
14
14
|
case 'h':
|
|
15
|
-
return val *
|
|
15
|
+
return val * HOUR_MS;
|
|
16
16
|
case 'd':
|
|
17
|
-
return val *
|
|
17
|
+
return val * DAY_MS;
|
|
18
18
|
case 'w':
|
|
19
|
-
return val * 7 *
|
|
19
|
+
return val * 7 * DAY_MS;
|
|
20
20
|
case 'm':
|
|
21
|
-
return val * 30 *
|
|
21
|
+
return val * 30 * DAY_MS;
|
|
22
22
|
case 'y':
|
|
23
|
-
return val * 365 *
|
|
23
|
+
return val * 365 * DAY_MS;
|
|
24
24
|
default:
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
@@ -29,22 +29,21 @@ export function parseTtlToMs(ttl) {
|
|
|
29
29
|
* Derives the active freshness policy based on the tier and optional TTL override.
|
|
30
30
|
*/
|
|
31
31
|
export function getPolicy(tier, ttlOverride) {
|
|
32
|
-
|
|
33
|
-
let
|
|
34
|
-
let graceWindowMs = 14 * day;
|
|
32
|
+
let freshWindowMs = 30 * DAY_MS;
|
|
33
|
+
let graceWindowMs = 14 * DAY_MS;
|
|
35
34
|
if (tier === 'stable') {
|
|
36
|
-
freshWindowMs = 180 *
|
|
37
|
-
graceWindowMs = 60 *
|
|
35
|
+
freshWindowMs = 180 * DAY_MS;
|
|
36
|
+
graceWindowMs = 60 * DAY_MS;
|
|
38
37
|
}
|
|
39
38
|
else if (tier === 'volatile') {
|
|
40
|
-
freshWindowMs = 7 *
|
|
41
|
-
graceWindowMs = 5 *
|
|
39
|
+
freshWindowMs = 7 * DAY_MS;
|
|
40
|
+
graceWindowMs = 5 * DAY_MS;
|
|
42
41
|
}
|
|
43
42
|
const activeTtl = ttlOverride;
|
|
44
43
|
if (activeTtl) {
|
|
45
44
|
freshWindowMs = parseTtlToMs(activeTtl);
|
|
46
|
-
const defaultFresh = tier === 'stable' ? 180 *
|
|
47
|
-
const defaultGrace = tier === 'stable' ? 60 *
|
|
45
|
+
const defaultFresh = tier === 'stable' ? 180 * DAY_MS : tier === 'volatile' ? 7 * DAY_MS : 30 * DAY_MS;
|
|
46
|
+
const defaultGrace = tier === 'stable' ? 60 * DAY_MS : tier === 'volatile' ? 5 * DAY_MS : 14 * DAY_MS;
|
|
48
47
|
graceWindowMs = Math.floor(freshWindowMs * (defaultGrace / defaultFresh));
|
|
49
48
|
}
|
|
50
49
|
return { freshWindowMs, graceWindowMs };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync,
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, renameSync } from 'node:fs';
|
|
3
|
+
import { atomicWriteFile } from '../atomic-write.js';
|
|
3
4
|
import { parseArtifact, serializeArtifact } from './artifact.js';
|
|
4
5
|
function isResearchFile(file) {
|
|
5
6
|
return (file.endsWith('.md') &&
|
|
@@ -114,30 +115,26 @@ export function writeArtifact(dataDir, key, artifact) {
|
|
|
114
115
|
if (!existsSync(dir)) {
|
|
115
116
|
mkdirSync(dir, { recursive: true });
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
catch (err) {
|
|
128
|
-
console.warn(`Warning: failed to archive superseded artifact: ${err.message}`);
|
|
129
|
-
}
|
|
118
|
+
// Capture the current artifact before overwriting it, then commit the new content atomically.
|
|
119
|
+
// Archiving is best-effort and happens only after the write succeeds: a read/archive failure
|
|
120
|
+
// never aborts the write, and a failed write never leaves a stray `.superseded` copy.
|
|
121
|
+
let existingContent = null;
|
|
122
|
+
if (existsSync(path)) {
|
|
123
|
+
try {
|
|
124
|
+
existingContent = readFileSync(path, 'utf-8');
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
console.warn(`Warning: failed to read artifact for archiving: ${err.message}`);
|
|
130
128
|
}
|
|
131
|
-
renameSync(tmpPath, path);
|
|
132
129
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
130
|
+
atomicWriteFile(path, serializeArtifact(artifact));
|
|
131
|
+
if (existingContent !== null) {
|
|
132
|
+
try {
|
|
133
|
+
writeFileSync(join(dir, `${key}.superseded.${Date.now()}.md`), existingContent, 'utf-8');
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
console.warn(`Warning: failed to archive superseded artifact: ${err.message}`);
|
|
139
137
|
}
|
|
140
|
-
throw err;
|
|
141
138
|
}
|
|
142
139
|
}
|
|
143
140
|
/**
|
package/oclif.manifest.json
CHANGED