deepseek-web-retrieval 0.3.0 → 0.5.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/CHANGELOG.md +96 -230
- package/README.md +53 -25
- package/dist/cli.js +1358 -617
- package/docs/agents/codex.md +32 -11
- package/docs/agents/opencode-v2.md +5 -5
- package/docs/agents/pi.md +92 -90
- package/docs/design/installer-design.md +72 -9
- package/docs/hosts.jsonc +2 -2
- package/docs/integration.md +14 -9
- package/docs/research/control-matrix.md +48 -13
- package/docs/research/mcp-compatibility.md +9 -4
- package/docs/research/search-api-defaults.md +309 -0
- package/examples/pi.settings.jsonc +17 -0
- package/index.ts +2 -2
- package/package.json +35 -4
- package/src/core/auth.ts +4 -6
- package/src/core/client.ts +65 -53
- package/src/core/constants.ts +30 -24
- package/src/core/descriptions.ts +30 -6
- package/src/core/domains.ts +56 -67
- package/src/core/env.ts +112 -0
- package/src/core/format.ts +53 -50
- package/src/core/index.ts +9 -9
- package/src/core/jsonc.ts +6 -6
- package/src/core/object.ts +4 -0
- package/src/core/options.ts +21 -26
- package/src/core/search.ts +45 -44
- package/src/core/transport.ts +109 -113
- package/src/core/types.ts +47 -47
- package/src/core/url.ts +20 -20
- package/src/installer/apply.ts +256 -81
- package/src/installer/cli.ts +135 -104
- package/src/installer/detect.ts +46 -31
- package/src/installer/formats/index.ts +26 -28
- package/src/installer/formats/json.ts +81 -77
- package/src/installer/formats/rules.ts +55 -28
- package/src/installer/formats/toml.ts +88 -81
- package/src/installer/formats/value.ts +34 -30
- package/src/installer/hosts/codex.ts +100 -54
- package/src/installer/hosts/index.ts +126 -41
- package/src/installer/hosts/opencode.ts +70 -51
- package/src/installer/hosts/pi.ts +269 -75
- package/src/installer/hosts/shared.ts +19 -14
- package/src/installer/operation-lock.ts +54 -0
- package/src/installer/ownership.ts +129 -0
- package/src/installer/paths.ts +7 -5
- package/src/installer/persistence.ts +66 -0
- package/src/installer/render.ts +29 -29
- package/src/installer/revert.ts +33 -29
- package/src/installer/rulesContent.ts +24 -13
- package/src/installer/state.ts +116 -31
- package/src/installer/types.ts +74 -56
- package/src/installer/uninstall.ts +40 -17
- package/src/mcp/auth.ts +7 -8
- package/src/mcp/cli.ts +38 -20
- package/src/mcp/client.ts +35 -0
- package/src/mcp/server.ts +68 -97
- package/src/opencode/auth.ts +24 -26
- package/src/opencode/plugin.ts +99 -87
- package/src/pi/extension.ts +164 -0
- package/src/pi/settings.ts +49 -0
- package/src/mcp/config.ts +0 -124
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import { applyEdits, modify, parse
|
|
3
|
-
import { parseJsonc } from "../../core/jsonc"
|
|
4
|
-
import type { ConfigOp, RecordedOp } from "../types"
|
|
5
|
-
import type { AppliedText, FormatCodec, LoadedConfig } from "./index"
|
|
6
|
-
import { appendEntries, deepEqual, isPlainObject, valueAt } from "./value"
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { applyEdits, type JSONPath, modify, parse } from "jsonc-parser/lib/esm/main.js";
|
|
3
|
+
import { parseJsonc } from "../../core/jsonc";
|
|
4
|
+
import type { ConfigOp, RecordedOp } from "../types";
|
|
5
|
+
import type { AppliedText, FormatCodec, LoadedConfig } from "./index";
|
|
6
|
+
import { appendEntries, deepEqual, isPlainObject, valueAt } from "./value";
|
|
7
7
|
|
|
8
|
-
const JSON_FORMATTING = { insertSpaces: true, tabSize: 2, insertFinalNewline: false } as const
|
|
8
|
+
const JSON_FORMATTING = { insertSpaces: true, tabSize: 2, insertFinalNewline: false } as const;
|
|
9
9
|
|
|
10
10
|
/** Reads a JSONC config file; a missing file starts empty, an unparsable one carries the error. */
|
|
11
11
|
function loadJson(file: string): LoadedConfig {
|
|
12
|
-
if (!existsSync(file)) return { exists: false, text: "", value: {}, lossy: false }
|
|
13
|
-
const text = readFileSync(file, "utf8")
|
|
12
|
+
if (!existsSync(file)) return { exists: false, text: "", value: {}, lossy: false };
|
|
13
|
+
const text = readFileSync(file, "utf8");
|
|
14
14
|
try {
|
|
15
|
-
return { exists: true, text, value: parseJsonc(text) as Record<string, unknown>, lossy: false }
|
|
15
|
+
return { exists: true, text, value: parseJsonc(text) as Record<string, unknown>, lossy: false };
|
|
16
16
|
} catch (error) {
|
|
17
|
-
const parseError = error instanceof Error ? error.message : String(error)
|
|
18
|
-
return { exists: true, text, value: {}, lossy: false, parseError }
|
|
17
|
+
const parseError = error instanceof Error ? error.message : String(error);
|
|
18
|
+
return { exists: true, text, value: {}, lossy: false, parseError };
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/** Reads the value at a dotted path from JSON text, or undefined when absent. */
|
|
23
23
|
function jsonGet(text: string, path: readonly string[]): unknown {
|
|
24
|
-
const root = parse(text, undefined, { allowTrailingComma: true }) as Record<string, unknown
|
|
25
|
-
return valueAt(root, path)
|
|
24
|
+
const root = parse(text, undefined, { allowTrailingComma: true }) as Record<string, unknown>;
|
|
25
|
+
return valueAt(root, path);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface JsonOpsResult {
|
|
29
|
-
readonly text: string
|
|
30
|
-
readonly recorded: RecordedOp[]
|
|
29
|
+
readonly text: string;
|
|
30
|
+
readonly recorded: RecordedOp[];
|
|
31
31
|
/** Path prefixes the ops had to create; uninstall removes them when left empty. */
|
|
32
|
-
readonly createdParents: readonly (readonly string[])[]
|
|
32
|
+
readonly createdParents: readonly (readonly string[])[];
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -39,97 +39,101 @@ export interface JsonOpsResult {
|
|
|
39
39
|
* for surgical undo.
|
|
40
40
|
*/
|
|
41
41
|
export function applyJsonOps(text: string, ops: readonly ConfigOp[]): JsonOpsResult {
|
|
42
|
-
let current = text.trim().length > 0 ? text : "{}\n"
|
|
43
|
-
const recorded: RecordedOp[] = []
|
|
44
|
-
const createdParents: (readonly string[])[] = []
|
|
42
|
+
let current = text.trim().length > 0 ? text : "{}\n";
|
|
43
|
+
const recorded: RecordedOp[] = [];
|
|
44
|
+
const createdParents: (readonly string[])[] = [];
|
|
45
45
|
for (const op of ops) {
|
|
46
|
-
const path = op.path as unknown as JSONPath
|
|
46
|
+
const path = op.path as unknown as JSONPath;
|
|
47
47
|
if (op.op === "set") {
|
|
48
|
-
current = ensureParents(current, op.path, createdParents)
|
|
49
|
-
const previous = jsonGet(current, op.path)
|
|
50
|
-
current = edit(current, path, op.value)
|
|
51
|
-
recorded.push({ op: "set", path: op.path, applied: structuredClone(op.value), previous })
|
|
52
|
-
continue
|
|
48
|
+
current = ensureParents(current, op.path, createdParents);
|
|
49
|
+
const previous = jsonGet(current, op.path);
|
|
50
|
+
current = edit(current, path, op.value);
|
|
51
|
+
recorded.push({ op: "set", path: op.path, applied: structuredClone(op.value), previous });
|
|
52
|
+
continue;
|
|
53
53
|
}
|
|
54
|
-
current = ensureParents(current, op.path, createdParents)
|
|
55
|
-
const existing = jsonGet(current, op.path)
|
|
56
|
-
const { list, appended } = appendEntries(existing, op.entries ?? [], op.path)
|
|
54
|
+
current = ensureParents(current, op.path, createdParents);
|
|
55
|
+
const existing = jsonGet(current, op.path);
|
|
56
|
+
const { list, appended } = appendEntries(existing, op.entries ?? [], op.path);
|
|
57
57
|
if (existing === undefined) {
|
|
58
|
-
current = edit(current, path, [])
|
|
59
|
-
createdParents.push(op.path)
|
|
58
|
+
current = edit(current, path, []);
|
|
59
|
+
createdParents.push(op.path);
|
|
60
60
|
}
|
|
61
|
-
let index = list.length
|
|
61
|
+
let index = list.length;
|
|
62
62
|
for (const entry of appended) {
|
|
63
|
-
current = edit(current, [...path, index] as unknown as JSONPath, entry)
|
|
64
|
-
index += 1
|
|
63
|
+
current = edit(current, [...path, index] as unknown as JSONPath, entry);
|
|
64
|
+
index += 1;
|
|
65
65
|
}
|
|
66
|
-
recorded.push({ op: "append-array", path: op.path, applied: appended, previous: undefined })
|
|
66
|
+
recorded.push({ op: "append-array", path: op.path, applied: appended, previous: undefined });
|
|
67
67
|
}
|
|
68
|
-
return { text: current, recorded, createdParents }
|
|
68
|
+
return { text: current, recorded, createdParents };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** Reverses recorded ops on current JSON text; returns the text plus notes about skipped work. */
|
|
72
|
-
export function revertJsonOps(
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
export function revertJsonOps(
|
|
73
|
+
text: string,
|
|
74
|
+
ops: readonly RecordedOp[],
|
|
75
|
+
createdParents: readonly (readonly string[])[],
|
|
76
|
+
): { text: string; notes: string[] } {
|
|
77
|
+
let current = text;
|
|
78
|
+
const notes: string[] = [];
|
|
75
79
|
for (const record of [...ops].reverse()) {
|
|
76
|
-
const path = record.path as unknown as JSONPath
|
|
80
|
+
const path = record.path as unknown as JSONPath;
|
|
77
81
|
if (record.op === "set") {
|
|
78
|
-
const now = jsonGet(current, record.path)
|
|
79
|
-
if (now === undefined) continue
|
|
82
|
+
const now = jsonGet(current, record.path);
|
|
83
|
+
if (now === undefined) continue;
|
|
80
84
|
if (!deepEqual(now, record.applied)) {
|
|
81
|
-
notes.push(`${record.path.join(".")}: current value differs from what the installer wrote; left untouched`)
|
|
82
|
-
continue
|
|
85
|
+
notes.push(`${record.path.join(".")}: current value differs from what the installer wrote; left untouched`);
|
|
86
|
+
continue;
|
|
83
87
|
}
|
|
84
|
-
current = record.previous === undefined ? edit(current, path, void 0) : edit(current, path, record.previous)
|
|
88
|
+
current = record.previous === undefined ? edit(current, path, void 0) : edit(current, path, record.previous);
|
|
85
89
|
} else {
|
|
86
|
-
const entries = Array.isArray(record.applied) ? record.applied : []
|
|
90
|
+
const entries = Array.isArray(record.applied) ? record.applied : [];
|
|
87
91
|
for (const entry of [...entries].reverse()) {
|
|
88
|
-
const list = jsonGet(current, record.path)
|
|
89
|
-
if (!Array.isArray(list)) continue
|
|
90
|
-
const index = list.findIndex((item) => deepEqual(item, entry))
|
|
91
|
-
if (index === -1) continue
|
|
92
|
-
current = edit(current, [...path, index] as unknown as JSONPath, void 0)
|
|
92
|
+
const list = jsonGet(current, record.path);
|
|
93
|
+
if (!Array.isArray(list)) continue;
|
|
94
|
+
const index = list.findIndex((item) => deepEqual(item, entry));
|
|
95
|
+
if (index === -1) continue;
|
|
96
|
+
current = edit(current, [...path, index] as unknown as JSONPath, void 0);
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
for (const parent of [...createdParents].reverse()) {
|
|
97
|
-
const value = jsonGet(current, parent)
|
|
98
|
-
const isEmptyObject = isPlainObject(value) && Object.keys(value).length === 0
|
|
99
|
-
const isEmptyArray = Array.isArray(value) && value.length === 0
|
|
101
|
+
const value = jsonGet(current, parent);
|
|
102
|
+
const isEmptyObject = isPlainObject(value) && Object.keys(value).length === 0;
|
|
103
|
+
const isEmptyArray = Array.isArray(value) && value.length === 0;
|
|
100
104
|
if (isEmptyObject || isEmptyArray) {
|
|
101
|
-
current = edit(current, parent as unknown as JSONPath, void 0)
|
|
105
|
+
current = edit(current, parent as unknown as JSONPath, void 0);
|
|
102
106
|
}
|
|
103
107
|
}
|
|
104
|
-
return { text: current, notes }
|
|
108
|
+
return { text: current, notes };
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
function edit(text: string, path: JSONPath, value: unknown): string {
|
|
108
|
-
return applyEdits(text, modify(text, path, value as never, { formattingOptions: JSON_FORMATTING }))
|
|
112
|
+
return applyEdits(text, modify(text, path, value as never, { formattingOptions: JSON_FORMATTING }));
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
/** Creates missing path prefixes as objects (or arrays when the next segment is an index). */
|
|
112
116
|
function ensureParents(text: string, path: readonly string[], created: (readonly string[])[]): string {
|
|
113
|
-
let current = text
|
|
117
|
+
let current = text;
|
|
114
118
|
for (let depth = 0; depth < path.length - 1; depth += 1) {
|
|
115
|
-
const prefix = path.slice(0, depth + 1)
|
|
116
|
-
const existing = jsonGet(current, prefix)
|
|
119
|
+
const prefix = path.slice(0, depth + 1);
|
|
120
|
+
const existing = jsonGet(current, prefix);
|
|
117
121
|
if (existing !== undefined && typeof existing !== "object") {
|
|
118
|
-
throw new Error(`cannot descend into ${prefix.join(".")}: it is a ${typeof existing}, not an object`)
|
|
122
|
+
throw new Error(`cannot descend into ${prefix.join(".")}: it is a ${typeof existing}, not an object`);
|
|
119
123
|
}
|
|
120
|
-
if (existing !== undefined) continue
|
|
121
|
-
const nextSegmentIsIndex = Number.isInteger(Number(path[depth + 1]))
|
|
122
|
-
const placeholder = nextSegmentIsIndex ? [] : {}
|
|
123
|
-
current = edit(current, prefix as unknown as JSONPath, placeholder)
|
|
124
|
-
created.push(prefix)
|
|
124
|
+
if (existing !== undefined) continue;
|
|
125
|
+
const nextSegmentIsIndex = Number.isInteger(Number(path[depth + 1]));
|
|
126
|
+
const placeholder = nextSegmentIsIndex ? [] : {};
|
|
127
|
+
current = edit(current, prefix as unknown as JSONPath, placeholder);
|
|
128
|
+
created.push(prefix);
|
|
125
129
|
}
|
|
126
|
-
return current
|
|
130
|
+
return current;
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
function jsonOpHolds(text: string, op: ConfigOp): boolean {
|
|
130
|
-
if (op.op === "set") return deepEqual(jsonGet(text, op.path), op.value)
|
|
131
|
-
const list = jsonGet(text, op.path)
|
|
132
|
-
return Array.isArray(list) && (op.entries ?? []).every((entry) => list.some((item) => deepEqual(item, entry)))
|
|
134
|
+
if (op.op === "set") return deepEqual(jsonGet(text, op.path), op.value);
|
|
135
|
+
const list = jsonGet(text, op.path);
|
|
136
|
+
return Array.isArray(list) && (op.entries ?? []).every((entry) => list.some((item) => deepEqual(item, entry)));
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
/** The JSON codec: comment-preserving edits on JSONC text. */
|
|
@@ -137,15 +141,15 @@ export const jsonCodec: FormatCodec = {
|
|
|
137
141
|
format: "json",
|
|
138
142
|
load: loadJson,
|
|
139
143
|
apply: (text, edit): AppliedText => {
|
|
140
|
-
if (edit.format !== "json") throw new Error(`json codec cannot apply a ${edit.format} edit`)
|
|
141
|
-
const result = applyJsonOps(text, edit.ops)
|
|
142
|
-
return { text: result.text, recorded: result.recorded, createdParents: result.createdParents }
|
|
144
|
+
if (edit.format !== "json") throw new Error(`json codec cannot apply a ${edit.format} edit`);
|
|
145
|
+
const result = applyJsonOps(text, edit.ops);
|
|
146
|
+
return { text: result.text, recorded: result.recorded, createdParents: result.createdParents };
|
|
143
147
|
},
|
|
144
148
|
verify: (text, edit, op) => {
|
|
145
|
-
if (edit.format !== "json") throw new Error(`json codec cannot verify a ${edit.format} edit`)
|
|
146
|
-
const ops = op === undefined ? edit.ops : [op]
|
|
147
|
-
return ops.every((candidate) => jsonOpHolds(text, candidate))
|
|
149
|
+
if (edit.format !== "json") throw new Error(`json codec cannot verify a ${edit.format} edit`);
|
|
150
|
+
const ops = op === undefined ? edit.ops : [op];
|
|
151
|
+
return ops.every((candidate) => jsonOpHolds(text, candidate));
|
|
148
152
|
},
|
|
149
153
|
revert: (text, recorded) => revertJsonOps(text, recorded.ops, recorded.createdParents ?? []),
|
|
150
154
|
isEffectivelyEmpty: (text) => text.replace(/[{}\s]/g, "").length === 0,
|
|
151
|
-
}
|
|
155
|
+
};
|
|
@@ -1,62 +1,89 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import type { HostName } from "../types"
|
|
3
|
-
import type { AppliedText, FormatCodec } from "./index"
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import type { HostName } from "../types";
|
|
3
|
+
import type { AppliedText, FormatCodec } from "./index";
|
|
4
4
|
|
|
5
5
|
/** Marked-block markers are per host, so multiple hosts can share one rules file. */
|
|
6
6
|
function rulesMarkers(host: HostName): { readonly begin: string; readonly end: string } {
|
|
7
7
|
return {
|
|
8
8
|
begin: `<!-- deepseek-web-retrieval:${host}:rules:begin -->`,
|
|
9
9
|
end: `<!-- deepseek-web-retrieval:${host}:rules:end -->`,
|
|
10
|
-
}
|
|
10
|
+
};
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/** Wraps rule lines in the host's marked block that apply upserts and uninstall remove. */
|
|
14
14
|
export function buildRulesBlock(host: HostName, lines: readonly string[]): string {
|
|
15
|
-
const markers = rulesMarkers(host)
|
|
16
|
-
return [markers.begin, ...lines, markers.end].join("\n")
|
|
15
|
+
const markers = rulesMarkers(host);
|
|
16
|
+
return [markers.begin, ...lines, markers.end].join("\n");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** Replaces the host's existing marked block, or appends one; keeps surrounding text intact. */
|
|
20
20
|
export function upsertBlock(host: HostName, text: string, block: string): string {
|
|
21
|
-
const without = removeBlock(host, text)
|
|
22
|
-
const trimmed = without.trimEnd()
|
|
23
|
-
return trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n
|
|
21
|
+
const without = removeBlock(host, text);
|
|
22
|
+
const trimmed = without.trimEnd();
|
|
23
|
+
return trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Removes the host's marked block if present; returns the text unchanged otherwise. */
|
|
27
27
|
export function removeBlock(host: HostName, text: string): string {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
const range = blockRange(host, text);
|
|
29
|
+
if (range === undefined) return text;
|
|
30
|
+
// The upsert joins non-empty neighbors with exactly one blank line; removal
|
|
31
|
+
// mirrors it, so the surrounding spacing stays stable across round-trips
|
|
32
|
+
// instead of collapsing the separator the block used to occupy.
|
|
33
|
+
const before = text.slice(0, range.start).trimEnd();
|
|
34
|
+
const after = text
|
|
35
|
+
.slice(range.end)
|
|
36
|
+
.replace(/^\n{1,2}/, "")
|
|
37
|
+
.trimStart();
|
|
38
|
+
if (before && after) return `${before}\n\n${after}`;
|
|
39
|
+
if (before) return `${before}\n`;
|
|
40
|
+
return after;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export function hasBlock(host: HostName, text: string): boolean {
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
return blockRange(host, text) !== undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Only one complete, ordered marker pair establishes which text we may remove. */
|
|
48
|
+
function blockRange(host: HostName, text: string): { start: number; end: number } | undefined {
|
|
49
|
+
const markers = rulesMarkers(host);
|
|
50
|
+
const start = text.indexOf(markers.begin);
|
|
51
|
+
const end = text.indexOf(markers.end);
|
|
52
|
+
if (start === -1 && end === -1) return undefined;
|
|
53
|
+
if (
|
|
54
|
+
start === -1 ||
|
|
55
|
+
end < start ||
|
|
56
|
+
text.indexOf(markers.begin, start + markers.begin.length) !== -1 ||
|
|
57
|
+
text.indexOf(markers.end, end + markers.end.length) !== -1
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`malformed rules block for ${host}: expected one begin marker followed by one end marker; repair the markers before retrying`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return { start, end: end + markers.end.length };
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
/** A rules codec is per host: its markers decide which block upsert, verify, and revert touch. */
|
|
46
67
|
export function rulesCodec(host: HostName): FormatCodec {
|
|
47
68
|
return {
|
|
48
69
|
format: "rules",
|
|
49
|
-
load: (file) =>
|
|
50
|
-
existsSync(file)
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
load: (file) => {
|
|
71
|
+
const exists = existsSync(file);
|
|
72
|
+
const text = exists ? readFileSync(file, "utf8") : "";
|
|
73
|
+
try {
|
|
74
|
+
blockRange(host, text);
|
|
75
|
+
return { exists, text, value: {}, lossy: false };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return { exists, text, value: {}, lossy: false, parseError: String(error) };
|
|
78
|
+
}
|
|
79
|
+
},
|
|
53
80
|
apply: (text, edit): AppliedText => {
|
|
54
|
-
if (edit.format !== "rules") throw new Error(`rules codec cannot apply a ${edit.format} edit`)
|
|
55
|
-
return { text: upsertBlock(host, text, edit.block), recorded: [], createdParents: [] }
|
|
81
|
+
if (edit.format !== "rules") throw new Error(`rules codec cannot apply a ${edit.format} edit`);
|
|
82
|
+
return { text: upsertBlock(host, text, edit.block), recorded: [], createdParents: [] };
|
|
56
83
|
},
|
|
57
84
|
// Rules files are markdown: the block's presence is the only postcondition.
|
|
58
85
|
verify: (text) => hasBlock(host, text),
|
|
59
86
|
revert: (text) => ({ text: removeBlock(host, text), notes: [] }),
|
|
60
87
|
isEffectivelyEmpty: (text) => text.trim().length === 0,
|
|
61
|
-
}
|
|
88
|
+
};
|
|
62
89
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import { parse as parseToml, stringify as stringifyTomlValue } from "smol-toml"
|
|
3
|
-
import type { ConfigOp, RecordedOp } from "../types"
|
|
4
|
-
import type { AppliedText, FormatCodec, LoadedConfig } from "./index"
|
|
5
|
-
import { appendEntries, deepEqual, isPlainObject, removeAt, valueAt } from "./value"
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { parse as parseToml, stringify as stringifyTomlValue } from "smol-toml";
|
|
3
|
+
import type { ConfigOp, RecordedOp } from "../types";
|
|
4
|
+
import type { AppliedText, FormatCodec, LoadedConfig } from "./index";
|
|
5
|
+
import { appendEntries, deepEqual, isPlainObject, removeAt, valueAt } from "./value";
|
|
6
6
|
|
|
7
7
|
/** Reads a TOML config file; a missing file starts empty, an unparsable one carries the error. */
|
|
8
8
|
function loadToml(file: string): LoadedConfig {
|
|
9
|
-
if (!existsSync(file)) return { exists: false, text: "", value: {}, lossy: false }
|
|
10
|
-
const text = readFileSync(file, "utf8")
|
|
9
|
+
if (!existsSync(file)) return { exists: false, text: "", value: {}, lossy: false };
|
|
10
|
+
const text = readFileSync(file, "utf8");
|
|
11
11
|
try {
|
|
12
|
-
return { exists: true, text, value: parseToml(text) as Record<string, unknown>, lossy: hasTomlComment(text) }
|
|
12
|
+
return { exists: true, text, value: parseToml(text) as Record<string, unknown>, lossy: hasTomlComment(text) };
|
|
13
13
|
} catch (error) {
|
|
14
|
-
const parseError = error instanceof Error ? error.message : String(error)
|
|
15
|
-
return { exists: true, text, value: {}, lossy: false, parseError }
|
|
14
|
+
const parseError = error instanceof Error ? error.message : String(error);
|
|
15
|
+
return { exists: true, text, value: {}, lossy: false, parseError };
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -21,122 +21,129 @@ function loadToml(file: string): LoadedConfig {
|
|
|
21
21
|
* strings counts, including trailing comments like `key = "x" # note`.
|
|
22
22
|
*/
|
|
23
23
|
export function hasTomlComment(text: string): boolean {
|
|
24
|
-
let inBasic = false
|
|
25
|
-
let inLiteral = false
|
|
24
|
+
let inBasic = false;
|
|
25
|
+
let inLiteral = false;
|
|
26
26
|
for (let i = 0; i < text.length; i += 1) {
|
|
27
|
-
const ch = text[i] as string
|
|
27
|
+
const ch = text[i] as string;
|
|
28
28
|
if (inBasic) {
|
|
29
|
-
if (ch === "\\") i += 1
|
|
30
|
-
else if (ch === '"') inBasic = false
|
|
31
|
-
continue
|
|
29
|
+
if (ch === "\\") i += 1;
|
|
30
|
+
else if (ch === '"') inBasic = false;
|
|
31
|
+
continue;
|
|
32
32
|
}
|
|
33
33
|
if (inLiteral) {
|
|
34
|
-
if (ch === "'") inLiteral = false
|
|
35
|
-
continue
|
|
34
|
+
if (ch === "'") inLiteral = false;
|
|
35
|
+
continue;
|
|
36
36
|
}
|
|
37
|
-
if (ch === '"') inBasic = true
|
|
38
|
-
else if (ch === "'") inLiteral = true
|
|
39
|
-
else if (ch === "#") return true
|
|
37
|
+
if (ch === '"') inBasic = true;
|
|
38
|
+
else if (ch === "'") inLiteral = true;
|
|
39
|
+
else if (ch === "#") return true;
|
|
40
40
|
}
|
|
41
|
-
return false
|
|
41
|
+
return false;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function serializeToml(value: Record<string, unknown>): string {
|
|
45
|
-
return `${stringifyTomlValue(value)}\n
|
|
45
|
+
return `${stringifyTomlValue(value)}\n`;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Applies ops to a parsed TOML value, returning the mutated value plus one record
|
|
50
50
|
* per op with the previous value for surgical undo, and the path prefixes created.
|
|
51
51
|
*/
|
|
52
|
-
function applyTomlOps(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
function applyTomlOps(
|
|
53
|
+
value: Record<string, unknown>,
|
|
54
|
+
ops: readonly ConfigOp[],
|
|
55
|
+
): { value: Record<string, unknown>; recorded: RecordedOp[]; createdParents: (readonly string[])[] } {
|
|
56
|
+
const target: Record<string, unknown> = structuredClone(value);
|
|
57
|
+
const recorded: RecordedOp[] = [];
|
|
58
|
+
const createdParents: (readonly string[])[] = [];
|
|
56
59
|
for (const op of ops) {
|
|
57
|
-
const parent = navigate(target, op.path, createdParents)
|
|
58
|
-
const key = op.path[op.path.length - 1]
|
|
60
|
+
const parent = navigate(target, op.path, createdParents);
|
|
61
|
+
const key = op.path[op.path.length - 1];
|
|
59
62
|
if (op.op === "set") {
|
|
60
|
-
const previous = Object.
|
|
61
|
-
parent[key] = structuredClone(op.value)
|
|
62
|
-
recorded.push({ op: "set", path: op.path, applied: structuredClone(op.value), previous })
|
|
63
|
-
continue
|
|
63
|
+
const previous = Object.hasOwn(parent, key) ? parent[key] : undefined;
|
|
64
|
+
parent[key] = structuredClone(op.value);
|
|
65
|
+
recorded.push({ op: "set", path: op.path, applied: structuredClone(op.value), previous });
|
|
66
|
+
continue;
|
|
64
67
|
}
|
|
65
|
-
const existing = parent[key]
|
|
66
|
-
if (existing === undefined) createdParents.push(op.path)
|
|
67
|
-
const { list, appended } = appendEntries(existing, op.entries ?? [], op.path)
|
|
68
|
-
parent[key] = [...list, ...appended.map((entry) => structuredClone(entry))]
|
|
69
|
-
recorded.push({ op: "append-array", path: op.path, applied: appended, previous: undefined })
|
|
68
|
+
const existing = parent[key];
|
|
69
|
+
if (existing === undefined) createdParents.push(op.path);
|
|
70
|
+
const { list, appended } = appendEntries(existing, op.entries ?? [], op.path);
|
|
71
|
+
parent[key] = [...list, ...appended.map((entry) => structuredClone(entry))];
|
|
72
|
+
recorded.push({ op: "append-array", path: op.path, applied: appended, previous: undefined });
|
|
70
73
|
}
|
|
71
|
-
return { value: target, recorded, createdParents }
|
|
74
|
+
return { value: target, recorded, createdParents };
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
function navigate(
|
|
75
|
-
|
|
77
|
+
function navigate(
|
|
78
|
+
root: Record<string, unknown>,
|
|
79
|
+
path: readonly string[],
|
|
80
|
+
created: (readonly string[])[],
|
|
81
|
+
): Record<string, unknown> {
|
|
82
|
+
let node = root;
|
|
76
83
|
for (let depth = 0; depth < path.length - 1; depth += 1) {
|
|
77
|
-
const key = path[depth] as string
|
|
78
|
-
const next: unknown = node[key]
|
|
84
|
+
const key = path[depth] as string;
|
|
85
|
+
const next: unknown = node[key];
|
|
79
86
|
if (next === undefined) {
|
|
80
|
-
node[key] = {}
|
|
81
|
-
created.push(path.slice(0, depth + 1))
|
|
87
|
+
node[key] = {};
|
|
88
|
+
created.push(path.slice(0, depth + 1));
|
|
82
89
|
} else if (!isPlainObject(next)) {
|
|
83
90
|
throw new Error(
|
|
84
91
|
`cannot descend into ${path.slice(0, depth + 1).join(".")}: it is ${Array.isArray(next) ? "an array" : `a ${typeof next}`}, not a table`,
|
|
85
|
-
)
|
|
92
|
+
);
|
|
86
93
|
}
|
|
87
|
-
node = node[key] as Record<string, unknown
|
|
94
|
+
node = node[key] as Record<string, unknown>;
|
|
88
95
|
}
|
|
89
|
-
return node
|
|
96
|
+
return node;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
/** Surgical undo on a parsed TOML object; user edits made after install win. */
|
|
93
100
|
function revertTomlOps(value: Record<string, unknown>, ops: readonly RecordedOp[]): string[] {
|
|
94
|
-
const notes: string[] = []
|
|
101
|
+
const notes: string[] = [];
|
|
95
102
|
for (const record of [...ops].reverse()) {
|
|
96
103
|
if (record.op === "set") {
|
|
97
|
-
const now = valueAt(value, record.path)
|
|
98
|
-
if (now === undefined) continue
|
|
104
|
+
const now = valueAt(value, record.path);
|
|
105
|
+
if (now === undefined) continue;
|
|
99
106
|
if (!deepEqual(now, record.applied)) {
|
|
100
|
-
notes.push(`${record.path.join(".")}: current value differs from what the installer wrote; left untouched`)
|
|
101
|
-
continue
|
|
107
|
+
notes.push(`${record.path.join(".")}: current value differs from what the installer wrote; left untouched`);
|
|
108
|
+
continue;
|
|
102
109
|
}
|
|
103
110
|
if (record.previous === undefined) {
|
|
104
|
-
removeAt(value, record.path)
|
|
111
|
+
removeAt(value, record.path);
|
|
105
112
|
} else {
|
|
106
|
-
setValueAt(value, record.path, record.previous)
|
|
113
|
+
setValueAt(value, record.path, record.previous);
|
|
107
114
|
}
|
|
108
115
|
} else {
|
|
109
|
-
const entries = Array.isArray(record.applied) ? record.applied : []
|
|
116
|
+
const entries = Array.isArray(record.applied) ? record.applied : [];
|
|
110
117
|
for (const entry of entries) {
|
|
111
|
-
const list = valueAt(value, record.path)
|
|
112
|
-
if (!Array.isArray(list)) continue
|
|
113
|
-
const index = list.findIndex((item) => deepEqual(item, entry))
|
|
114
|
-
if (index === -1) continue
|
|
115
|
-
list.splice(index, 1)
|
|
118
|
+
const list = valueAt(value, record.path);
|
|
119
|
+
if (!Array.isArray(list)) continue;
|
|
120
|
+
const index = list.findIndex((item) => deepEqual(item, entry));
|
|
121
|
+
if (index === -1) continue;
|
|
122
|
+
list.splice(index, 1);
|
|
116
123
|
}
|
|
117
124
|
}
|
|
118
125
|
}
|
|
119
|
-
return notes
|
|
126
|
+
return notes;
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
/** Removes path prefixes the installer created when they are left empty, newest first. */
|
|
123
130
|
function pruneCreatedParents(value: Record<string, unknown>, parents: readonly (readonly string[])[]): void {
|
|
124
131
|
for (const parent of [...parents].reverse()) {
|
|
125
|
-
const current = valueAt(value, parent)
|
|
126
|
-
if (isPlainObject(current) && Object.keys(current).length === 0) removeAt(value, parent)
|
|
132
|
+
const current = valueAt(value, parent);
|
|
133
|
+
if (isPlainObject(current) && Object.keys(current).length === 0) removeAt(value, parent);
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
function setValueAt(value: Record<string, unknown>, path: readonly string[], replacement: unknown): void {
|
|
131
|
-
const parent = valueAt(value, path.slice(0, -1))
|
|
132
|
-
if (isPlainObject(parent)) parent[path[path.length - 1] as string] = replacement
|
|
138
|
+
const parent = valueAt(value, path.slice(0, -1));
|
|
139
|
+
if (isPlainObject(parent)) parent[path[path.length - 1] as string] = replacement;
|
|
133
140
|
}
|
|
134
141
|
|
|
135
142
|
function tomlOpHolds(text: string, op: ConfigOp): boolean {
|
|
136
|
-
const value = parseToml(text) as Record<string, unknown
|
|
137
|
-
const current = valueAt(value, op.path)
|
|
138
|
-
if (op.op === "set") return deepEqual(current, op.value)
|
|
139
|
-
return Array.isArray(current) && (op.entries ?? []).every((entry) => current.some((item) => deepEqual(item, entry)))
|
|
143
|
+
const value = parseToml(text) as Record<string, unknown>;
|
|
144
|
+
const current = valueAt(value, op.path);
|
|
145
|
+
if (op.op === "set") return deepEqual(current, op.value);
|
|
146
|
+
return Array.isArray(current) && (op.entries ?? []).every((entry) => current.some((item) => deepEqual(item, entry)));
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
/** The TOML codec: ops on a parsed value, serialized lossily (comments drop on rewrite). */
|
|
@@ -144,20 +151,20 @@ export const tomlCodec: FormatCodec = {
|
|
|
144
151
|
format: "toml",
|
|
145
152
|
load: loadToml,
|
|
146
153
|
apply: (text, edit): AppliedText => {
|
|
147
|
-
if (edit.format !== "toml") throw new Error(`toml codec cannot apply a ${edit.format} edit`)
|
|
148
|
-
const result = applyTomlOps(parseToml(text) as Record<string, unknown>, edit.ops)
|
|
149
|
-
return { text: serializeToml(result.value), recorded: result.recorded, createdParents: result.createdParents }
|
|
154
|
+
if (edit.format !== "toml") throw new Error(`toml codec cannot apply a ${edit.format} edit`);
|
|
155
|
+
const result = applyTomlOps(parseToml(text) as Record<string, unknown>, edit.ops);
|
|
156
|
+
return { text: serializeToml(result.value), recorded: result.recorded, createdParents: result.createdParents };
|
|
150
157
|
},
|
|
151
158
|
verify: (text, edit, op) => {
|
|
152
|
-
if (edit.format !== "toml") throw new Error(`toml codec cannot verify a ${edit.format} edit`)
|
|
153
|
-
const ops = op === undefined ? edit.ops : [op]
|
|
154
|
-
return ops.every((candidate) => tomlOpHolds(text, candidate))
|
|
159
|
+
if (edit.format !== "toml") throw new Error(`toml codec cannot verify a ${edit.format} edit`);
|
|
160
|
+
const ops = op === undefined ? edit.ops : [op];
|
|
161
|
+
return ops.every((candidate) => tomlOpHolds(text, candidate));
|
|
155
162
|
},
|
|
156
163
|
revert: (text, recorded) => {
|
|
157
|
-
const value = parseToml(text) as Record<string, unknown
|
|
158
|
-
const notes = revertTomlOps(value, recorded.ops)
|
|
159
|
-
pruneCreatedParents(value, recorded.createdParents ?? [])
|
|
160
|
-
return { text: serializeToml(value), notes }
|
|
164
|
+
const value = parseToml(text) as Record<string, unknown>;
|
|
165
|
+
const notes = revertTomlOps(value, recorded.ops);
|
|
166
|
+
pruneCreatedParents(value, recorded.createdParents ?? []);
|
|
167
|
+
return { text: serializeToml(value), notes };
|
|
161
168
|
},
|
|
162
169
|
isEffectivelyEmpty: (text) => Object.keys(parseToml(text) as Record<string, unknown>).length === 0,
|
|
163
|
-
}
|
|
170
|
+
};
|