deepseek-web-retrieval 0.2.0 → 0.3.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 +187 -1
- package/README.md +13 -9
- package/dist/cli.js +4228 -178
- package/docs/adr/0001-refuse-drift-between-plan-and-apply.md +29 -0
- package/docs/agents/codex.md +193 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/{opencode-v2.md → agents/opencode-v2.md} +30 -7
- package/docs/{pi.md → agents/pi.md} +35 -7
- package/docs/agents/triage-labels.md +15 -0
- package/docs/design/installer-design.md +184 -0
- package/docs/hosts.jsonc +59 -33
- package/docs/integration.md +45 -52
- package/docs/research/control-matrix.md +257 -0
- package/docs/{mcp-compatibility.md → research/mcp-compatibility.md} +39 -108
- package/package.json +5 -3
- package/src/core/auth.ts +1 -1
- package/src/core/client.ts +42 -249
- package/src/core/constants.ts +5 -0
- package/src/core/descriptions.ts +19 -0
- package/src/core/format.ts +31 -1
- package/src/core/jsonc.ts +16 -0
- package/src/core/options.ts +2 -3
- package/src/core/search.ts +5 -7
- package/src/core/transport.ts +253 -0
- package/src/core/types.ts +13 -11
- package/src/installer/apply.ts +176 -0
- package/src/installer/cli.ts +215 -0
- package/src/installer/detect.ts +85 -0
- package/src/installer/formats/index.ts +58 -0
- package/src/installer/formats/json.ts +151 -0
- package/src/installer/formats/rules.ts +62 -0
- package/src/installer/formats/toml.ts +163 -0
- package/src/installer/formats/value.ts +57 -0
- package/src/installer/hosts/codex.ts +128 -0
- package/src/installer/hosts/index.ts +134 -0
- package/src/installer/hosts/opencode.ts +118 -0
- package/src/installer/hosts/pi.ts +115 -0
- package/src/installer/hosts/shared.ts +31 -0
- package/src/installer/paths.ts +18 -0
- package/src/installer/render.ts +62 -0
- package/src/installer/revert.ts +61 -0
- package/src/installer/rulesContent.ts +32 -0
- package/src/installer/state.ts +63 -0
- package/src/installer/types.ts +126 -0
- package/src/installer/uninstall.ts +33 -0
- package/src/mcp/auth.ts +2 -1
- package/src/mcp/cli.ts +21 -5
- package/src/mcp/config.ts +70 -27
- package/src/mcp/server.ts +81 -65
- package/src/opencode/auth.ts +3 -2
- package/src/opencode/plugin.ts +93 -56
- package/src/mcp/format.ts +0 -31
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { removeBlock } from "./formats/rules"
|
|
3
|
+
import { isPlainObject } from "./formats/value"
|
|
4
|
+
import type { HostName } from "./types"
|
|
5
|
+
|
|
6
|
+
/** Shared plan error: both capabilities off leaves nothing to install. */
|
|
7
|
+
export const BOTH_CAPABILITIES_OFF = "Both capabilities are off; nothing would be installed."
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared Forced-tier cost warning; the README's "Privacy and cost" section carries
|
|
11
|
+
* the detail, so every host planner points at the same anchor.
|
|
12
|
+
*/
|
|
13
|
+
export const FORCED_COST_NOTE =
|
|
14
|
+
"Forced routing is deterministic, and Search and Fetch are billed per successful request — " +
|
|
15
|
+
'see the README\'s "Privacy and cost" section before applying.'
|
|
16
|
+
|
|
17
|
+
/** Shared note when a previous deepseek entry is being replaced. */
|
|
18
|
+
export function replacingExistingEntryNote(path: string): string {
|
|
19
|
+
return `An existing ${path} entry is being replaced; its previous form is recorded for uninstall.`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shared note for third-party servers left registered. */
|
|
23
|
+
export function othersStayRegisteredNote(others: readonly string[]): string {
|
|
24
|
+
return `Other MCP servers stay registered (${others.join(", ")}); routing to DeepSeek is not exclusive while they offer web tools.`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const WEB_TOOL_HINT =
|
|
28
|
+
/(web[_ -]?(search|fetch)|search|fetch|browse|scrape|crawl|tavily|brave|exa|perplexity|firecrawl|jina|serp|duckduckgo|reader)/i
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Heuristic web-capability test for a registered third-party MCP server, matching
|
|
32
|
+
* the server's name plus its command/args/url. A false positive only costs a plan
|
|
33
|
+
* note or an exclusion the user sees before --apply; a miss stays covered by the
|
|
34
|
+
* "not exclusive while they offer web tools" note.
|
|
35
|
+
*/
|
|
36
|
+
export function looksWebCapable(name: string, entry: unknown): boolean {
|
|
37
|
+
if (WEB_TOOL_HINT.test(name)) return true
|
|
38
|
+
if (!isPlainObject(entry)) return false
|
|
39
|
+
const command = typeof entry.command === "string" ? entry.command : ""
|
|
40
|
+
const url = typeof entry.url === "string" ? entry.url : ""
|
|
41
|
+
const args = Array.isArray(entry.args) ? entry.args.filter((arg): arg is string => typeof arg === "string").join(" ") : ""
|
|
42
|
+
return WEB_TOOL_HINT.test(`${command} ${args} ${url}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const STEERING_HINT =
|
|
46
|
+
/(web[_ ]?(search|fetch)|websearch|webfetch|tavily|exa|brave|perplexity|firecrawl|jina|serp|duckduckgo|google|bing)/i
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Detect-phase scan of the rules file the plan would write to: lines steering the
|
|
50
|
+
* model toward another web tool. The installer's own marked block is excluded, and
|
|
51
|
+
* deepseek mentions are ours to make, so both are ignored.
|
|
52
|
+
*/
|
|
53
|
+
export function findCompetitorSteering(host: HostName, file: string): string[] {
|
|
54
|
+
if (!existsSync(file)) return []
|
|
55
|
+
let text: string
|
|
56
|
+
try {
|
|
57
|
+
text = readFileSync(file, "utf8")
|
|
58
|
+
} catch {
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
return removeBlock(host, text)
|
|
62
|
+
.split("\n")
|
|
63
|
+
.map((line) => line.trim())
|
|
64
|
+
.filter((line) => line.length > 0 && STEERING_HINT.test(line) && !line.includes("deepseek"))
|
|
65
|
+
.slice(0, 2)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Shared note for steering the detect phase found; conflicts are surfaced, not resolved. */
|
|
69
|
+
export function competitorSteeringNote(file: string, lines: readonly string[]): string {
|
|
70
|
+
const quoted = lines.map((line) => `"${line}"`).join(", ")
|
|
71
|
+
return `Existing rules in ${file} steer toward other web tools (${quoted}); the plan surfaces this conflict — it does not resolve it.`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Splits other registered servers into web-capable competitors and bystanders. */
|
|
75
|
+
export function splitCompetitors(
|
|
76
|
+
servers: Record<string, unknown>,
|
|
77
|
+
): { readonly competitors: readonly string[]; readonly bystanders: readonly string[] } {
|
|
78
|
+
const competitors: string[] = []
|
|
79
|
+
const bystanders: string[] = []
|
|
80
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
81
|
+
if (name === "deepseek") continue
|
|
82
|
+
;(looksWebCapable(name, entry) ? competitors : bystanders).push(name)
|
|
83
|
+
}
|
|
84
|
+
return { competitors, bystanders }
|
|
85
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import type { ConfigOp, FileEdit, HostName, RecordedFile, RecordedOp } from "../types"
|
|
4
|
+
import { jsonCodec } from "./json"
|
|
5
|
+
import { rulesCodec } from "./rules"
|
|
6
|
+
import { tomlCodec } from "./toml"
|
|
7
|
+
|
|
8
|
+
export type ConfigFormat = FileEdit["format"]
|
|
9
|
+
|
|
10
|
+
/** A config file as the codec read it: parsed, or carrying the parse error. */
|
|
11
|
+
export interface LoadedConfig {
|
|
12
|
+
readonly exists: boolean
|
|
13
|
+
readonly text: string
|
|
14
|
+
readonly value: Record<string, unknown>
|
|
15
|
+
/** True when serializing the parsed value would drop comments (TOML only; JSON edits preserve them). */
|
|
16
|
+
readonly lossy: boolean
|
|
17
|
+
readonly parseError?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What applying one edit produced, beyond the new text: everything undo needs. */
|
|
21
|
+
export interface AppliedText {
|
|
22
|
+
readonly text: string
|
|
23
|
+
readonly recorded: readonly RecordedOp[]
|
|
24
|
+
readonly createdParents: readonly (readonly string[])[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What reverting one recorded file produced: the text plus notes about skipped work. */
|
|
28
|
+
export interface RevertedText {
|
|
29
|
+
readonly text: string
|
|
30
|
+
readonly notes: readonly string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One codec per config format, behind a single registry: load parses, apply edits,
|
|
35
|
+
* verify checks postconditions (per op for json/toml, block presence for rules),
|
|
36
|
+
* revert reverses a recorded file, and isEffectivelyEmpty gates whole-file deletion.
|
|
37
|
+
*/
|
|
38
|
+
export interface FormatCodec {
|
|
39
|
+
readonly format: ConfigFormat
|
|
40
|
+
load(file: string): LoadedConfig
|
|
41
|
+
apply(text: string, edit: FileEdit): AppliedText
|
|
42
|
+
verify(text: string, edit: FileEdit, op?: ConfigOp): boolean
|
|
43
|
+
revert(text: string, recorded: RecordedFile): RevertedText
|
|
44
|
+
isEffectivelyEmpty(text: string): boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Shared write step: every codec consumer writes config files through this. */
|
|
48
|
+
export function writeConfig(file: string, serialized: string): void {
|
|
49
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
50
|
+
writeFileSync(file, serialized)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The one entry point to a format's codec; only the rules codec cares about the host. */
|
|
54
|
+
export function codecFor(format: ConfigFormat, host: HostName): FormatCodec {
|
|
55
|
+
if (format === "json") return jsonCodec
|
|
56
|
+
if (format === "toml") return tomlCodec
|
|
57
|
+
return rulesCodec(host)
|
|
58
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { applyEdits, modify, parse, type JSONPath } 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
|
+
|
|
8
|
+
const JSON_FORMATTING = { insertSpaces: true, tabSize: 2, insertFinalNewline: false } as const
|
|
9
|
+
|
|
10
|
+
/** Reads a JSONC config file; a missing file starts empty, an unparsable one carries the error. */
|
|
11
|
+
function loadJson(file: string): LoadedConfig {
|
|
12
|
+
if (!existsSync(file)) return { exists: false, text: "", value: {}, lossy: false }
|
|
13
|
+
const text = readFileSync(file, "utf8")
|
|
14
|
+
try {
|
|
15
|
+
return { exists: true, text, value: parseJsonc(text) as Record<string, unknown>, lossy: false }
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const parseError = error instanceof Error ? error.message : String(error)
|
|
18
|
+
return { exists: true, text, value: {}, lossy: false, parseError }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Reads the value at a dotted path from JSON text, or undefined when absent. */
|
|
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)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface JsonOpsResult {
|
|
29
|
+
readonly text: string
|
|
30
|
+
readonly recorded: RecordedOp[]
|
|
31
|
+
/** Path prefixes the ops had to create; uninstall removes them when left empty. */
|
|
32
|
+
readonly createdParents: readonly (readonly string[])[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Applies ops to JSONC text with jsonc-parser's comment-preserving edits.
|
|
37
|
+
* Ops are idempotent: `set` writes the value, `append-array` skips deep-equal entries.
|
|
38
|
+
* Each op is recorded with its previous value (or the entries actually appended)
|
|
39
|
+
* for surgical undo.
|
|
40
|
+
*/
|
|
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[])[] = []
|
|
45
|
+
for (const op of ops) {
|
|
46
|
+
const path = op.path as unknown as JSONPath
|
|
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
|
|
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)
|
|
57
|
+
if (existing === undefined) {
|
|
58
|
+
current = edit(current, path, [])
|
|
59
|
+
createdParents.push(op.path)
|
|
60
|
+
}
|
|
61
|
+
let index = list.length
|
|
62
|
+
for (const entry of appended) {
|
|
63
|
+
current = edit(current, [...path, index] as unknown as JSONPath, entry)
|
|
64
|
+
index += 1
|
|
65
|
+
}
|
|
66
|
+
recorded.push({ op: "append-array", path: op.path, applied: appended, previous: undefined })
|
|
67
|
+
}
|
|
68
|
+
return { text: current, recorded, createdParents }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Reverses recorded ops on current JSON text; returns the text plus notes about skipped work. */
|
|
72
|
+
export function revertJsonOps(text: string, ops: readonly RecordedOp[], createdParents: readonly (readonly string[])[]): { text: string; notes: string[] } {
|
|
73
|
+
let current = text
|
|
74
|
+
const notes: string[] = []
|
|
75
|
+
for (const record of [...ops].reverse()) {
|
|
76
|
+
const path = record.path as unknown as JSONPath
|
|
77
|
+
if (record.op === "set") {
|
|
78
|
+
const now = jsonGet(current, record.path)
|
|
79
|
+
if (now === undefined) continue
|
|
80
|
+
if (!deepEqual(now, record.applied)) {
|
|
81
|
+
notes.push(`${record.path.join(".")}: current value differs from what the installer wrote; left untouched`)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
current = record.previous === undefined ? edit(current, path, void 0) : edit(current, path, record.previous)
|
|
85
|
+
} else {
|
|
86
|
+
const entries = Array.isArray(record.applied) ? record.applied : []
|
|
87
|
+
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)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
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
|
|
100
|
+
if (isEmptyObject || isEmptyArray) {
|
|
101
|
+
current = edit(current, parent as unknown as JSONPath, void 0)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { text: current, notes }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function edit(text: string, path: JSONPath, value: unknown): string {
|
|
108
|
+
return applyEdits(text, modify(text, path, value as never, { formattingOptions: JSON_FORMATTING }))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Creates missing path prefixes as objects (or arrays when the next segment is an index). */
|
|
112
|
+
function ensureParents(text: string, path: readonly string[], created: (readonly string[])[]): string {
|
|
113
|
+
let current = text
|
|
114
|
+
for (let depth = 0; depth < path.length - 1; depth += 1) {
|
|
115
|
+
const prefix = path.slice(0, depth + 1)
|
|
116
|
+
const existing = jsonGet(current, prefix)
|
|
117
|
+
if (existing !== undefined && typeof existing !== "object") {
|
|
118
|
+
throw new Error(`cannot descend into ${prefix.join(".")}: it is a ${typeof existing}, not an object`)
|
|
119
|
+
}
|
|
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)
|
|
125
|
+
}
|
|
126
|
+
return current
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
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)))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The JSON codec: comment-preserving edits on JSONC text. */
|
|
136
|
+
export const jsonCodec: FormatCodec = {
|
|
137
|
+
format: "json",
|
|
138
|
+
load: loadJson,
|
|
139
|
+
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 }
|
|
143
|
+
},
|
|
144
|
+
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))
|
|
148
|
+
},
|
|
149
|
+
revert: (text, recorded) => revertJsonOps(text, recorded.ops, recorded.createdParents ?? []),
|
|
150
|
+
isEffectivelyEmpty: (text) => text.replace(/[{}\s]/g, "").length === 0,
|
|
151
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import type { HostName } from "../types"
|
|
3
|
+
import type { AppliedText, FormatCodec } from "./index"
|
|
4
|
+
|
|
5
|
+
/** Marked-block markers are per host, so multiple hosts can share one rules file. */
|
|
6
|
+
function rulesMarkers(host: HostName): { readonly begin: string; readonly end: string } {
|
|
7
|
+
return {
|
|
8
|
+
begin: `<!-- deepseek-web-retrieval:${host}:rules:begin -->`,
|
|
9
|
+
end: `<!-- deepseek-web-retrieval:${host}:rules:end -->`,
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Wraps rule lines in the host's marked block that apply upserts and uninstall remove. */
|
|
14
|
+
export function buildRulesBlock(host: HostName, lines: readonly string[]): string {
|
|
15
|
+
const markers = rulesMarkers(host)
|
|
16
|
+
return [markers.begin, ...lines, markers.end].join("\n")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Replaces the host's existing marked block, or appends one; keeps surrounding text intact. */
|
|
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`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Removes the host's marked block if present; returns the text unchanged otherwise. */
|
|
27
|
+
export function removeBlock(host: HostName, text: string): string {
|
|
28
|
+
const markers = rulesMarkers(host)
|
|
29
|
+
const start = text.indexOf(markers.begin)
|
|
30
|
+
if (start === -1) return text
|
|
31
|
+
const end = text.indexOf(markers.end)
|
|
32
|
+
if (end === -1) return text.slice(0, start)
|
|
33
|
+
let after = text.slice(end + markers.end.length)
|
|
34
|
+
// Drop the blank lines the upsert added around the block.
|
|
35
|
+
after = after.replace(/^\n{1,2}/, "")
|
|
36
|
+
const before = text.slice(0, start)
|
|
37
|
+
return before.endsWith("\n\n") ? `${before.slice(0, -1)}${after}` : before + after
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hasBlock(host: HostName, text: string): boolean {
|
|
41
|
+
const markers = rulesMarkers(host)
|
|
42
|
+
return text.includes(markers.begin) && text.includes(markers.end)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A rules codec is per host: its markers decide which block upsert, verify, and revert touch. */
|
|
46
|
+
export function rulesCodec(host: HostName): FormatCodec {
|
|
47
|
+
return {
|
|
48
|
+
format: "rules",
|
|
49
|
+
load: (file) =>
|
|
50
|
+
existsSync(file)
|
|
51
|
+
? { exists: true, text: readFileSync(file, "utf8"), value: {}, lossy: false }
|
|
52
|
+
: { exists: false, text: "", value: {}, lossy: false },
|
|
53
|
+
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: [] }
|
|
56
|
+
},
|
|
57
|
+
// Rules files are markdown: the block's presence is the only postcondition.
|
|
58
|
+
verify: (text) => hasBlock(host, text),
|
|
59
|
+
revert: (text) => ({ text: removeBlock(host, text), notes: [] }),
|
|
60
|
+
isEffectivelyEmpty: (text) => text.trim().length === 0,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
|
|
7
|
+
/** Reads a TOML config file; a missing file starts empty, an unparsable one carries the error. */
|
|
8
|
+
function loadToml(file: string): LoadedConfig {
|
|
9
|
+
if (!existsSync(file)) return { exists: false, text: "", value: {}, lossy: false }
|
|
10
|
+
const text = readFileSync(file, "utf8")
|
|
11
|
+
try {
|
|
12
|
+
return { exists: true, text, value: parseToml(text) as Record<string, unknown>, lossy: hasTomlComment(text) }
|
|
13
|
+
} catch (error) {
|
|
14
|
+
const parseError = error instanceof Error ? error.message : String(error)
|
|
15
|
+
return { exists: true, text, value: {}, lossy: false, parseError }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* String-aware TOML comment detection: any `#` outside basic ("") or literal ('')
|
|
21
|
+
* strings counts, including trailing comments like `key = "x" # note`.
|
|
22
|
+
*/
|
|
23
|
+
export function hasTomlComment(text: string): boolean {
|
|
24
|
+
let inBasic = false
|
|
25
|
+
let inLiteral = false
|
|
26
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
27
|
+
const ch = text[i] as string
|
|
28
|
+
if (inBasic) {
|
|
29
|
+
if (ch === "\\") i += 1
|
|
30
|
+
else if (ch === '"') inBasic = false
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (inLiteral) {
|
|
34
|
+
if (ch === "'") inLiteral = false
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (ch === '"') inBasic = true
|
|
38
|
+
else if (ch === "'") inLiteral = true
|
|
39
|
+
else if (ch === "#") return true
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function serializeToml(value: Record<string, unknown>): string {
|
|
45
|
+
return `${stringifyTomlValue(value)}\n`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Applies ops to a parsed TOML value, returning the mutated value plus one record
|
|
50
|
+
* per op with the previous value for surgical undo, and the path prefixes created.
|
|
51
|
+
*/
|
|
52
|
+
function applyTomlOps(value: Record<string, unknown>, ops: readonly ConfigOp[]): { value: Record<string, unknown>; recorded: RecordedOp[]; createdParents: (readonly string[])[] } {
|
|
53
|
+
const target: Record<string, unknown> = structuredClone(value)
|
|
54
|
+
const recorded: RecordedOp[] = []
|
|
55
|
+
const createdParents: (readonly string[])[] = []
|
|
56
|
+
for (const op of ops) {
|
|
57
|
+
const parent = navigate(target, op.path, createdParents)
|
|
58
|
+
const key = op.path[op.path.length - 1]
|
|
59
|
+
if (op.op === "set") {
|
|
60
|
+
const previous = Object.prototype.hasOwnProperty.call(parent, key) ? parent[key] : undefined
|
|
61
|
+
parent[key] = structuredClone(op.value)
|
|
62
|
+
recorded.push({ op: "set", path: op.path, applied: structuredClone(op.value), previous })
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
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 })
|
|
70
|
+
}
|
|
71
|
+
return { value: target, recorded, createdParents }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function navigate(root: Record<string, unknown>, path: readonly string[], created: (readonly string[])[]): Record<string, unknown> {
|
|
75
|
+
let node = root
|
|
76
|
+
for (let depth = 0; depth < path.length - 1; depth += 1) {
|
|
77
|
+
const key = path[depth] as string
|
|
78
|
+
const next: unknown = node[key]
|
|
79
|
+
if (next === undefined) {
|
|
80
|
+
node[key] = {}
|
|
81
|
+
created.push(path.slice(0, depth + 1))
|
|
82
|
+
} else if (!isPlainObject(next)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`cannot descend into ${path.slice(0, depth + 1).join(".")}: it is ${Array.isArray(next) ? "an array" : `a ${typeof next}`}, not a table`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
node = node[key] as Record<string, unknown>
|
|
88
|
+
}
|
|
89
|
+
return node
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Surgical undo on a parsed TOML object; user edits made after install win. */
|
|
93
|
+
function revertTomlOps(value: Record<string, unknown>, ops: readonly RecordedOp[]): string[] {
|
|
94
|
+
const notes: string[] = []
|
|
95
|
+
for (const record of [...ops].reverse()) {
|
|
96
|
+
if (record.op === "set") {
|
|
97
|
+
const now = valueAt(value, record.path)
|
|
98
|
+
if (now === undefined) continue
|
|
99
|
+
if (!deepEqual(now, record.applied)) {
|
|
100
|
+
notes.push(`${record.path.join(".")}: current value differs from what the installer wrote; left untouched`)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (record.previous === undefined) {
|
|
104
|
+
removeAt(value, record.path)
|
|
105
|
+
} else {
|
|
106
|
+
setValueAt(value, record.path, record.previous)
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
const entries = Array.isArray(record.applied) ? record.applied : []
|
|
110
|
+
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)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return notes
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Removes path prefixes the installer created when they are left empty, newest first. */
|
|
123
|
+
function pruneCreatedParents(value: Record<string, unknown>, parents: readonly (readonly string[])[]): void {
|
|
124
|
+
for (const parent of [...parents].reverse()) {
|
|
125
|
+
const current = valueAt(value, parent)
|
|
126
|
+
if (isPlainObject(current) && Object.keys(current).length === 0) removeAt(value, parent)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
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
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
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)))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The TOML codec: ops on a parsed value, serialized lossily (comments drop on rewrite). */
|
|
143
|
+
export const tomlCodec: FormatCodec = {
|
|
144
|
+
format: "toml",
|
|
145
|
+
load: loadToml,
|
|
146
|
+
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 }
|
|
150
|
+
},
|
|
151
|
+
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))
|
|
155
|
+
},
|
|
156
|
+
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 }
|
|
161
|
+
},
|
|
162
|
+
isEffectivelyEmpty: (text) => Object.keys(parseToml(text) as Record<string, unknown>).length === 0,
|
|
163
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
6
|
+
if (a === b) return true
|
|
7
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false
|
|
8
|
+
const aArray = Array.isArray(a)
|
|
9
|
+
const bArray = Array.isArray(b)
|
|
10
|
+
if (aArray !== bArray) return false
|
|
11
|
+
if (aArray && bArray) {
|
|
12
|
+
const x = a as unknown[]
|
|
13
|
+
const y = b as unknown[]
|
|
14
|
+
return x.length === y.length && x.every((item, index) => deepEqual(item, y[index]))
|
|
15
|
+
}
|
|
16
|
+
const x = a as Record<string, unknown>
|
|
17
|
+
const y = b as Record<string, unknown>
|
|
18
|
+
const xKeys = Object.keys(x)
|
|
19
|
+
const yKeys = Object.keys(y)
|
|
20
|
+
return xKeys.length === yKeys.length && xKeys.every((key) => deepEqual(x[key], y[key]))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Reads the value at a dotted path from a parsed object, or undefined when absent. */
|
|
24
|
+
export function valueAt(value: Record<string, unknown>, path: readonly string[]): unknown {
|
|
25
|
+
let node: unknown = value
|
|
26
|
+
for (const key of path) {
|
|
27
|
+
if (Array.isArray(node)) {
|
|
28
|
+
const index = Number(key)
|
|
29
|
+
node = Number.isInteger(index) ? node[index] : undefined
|
|
30
|
+
} else if (isPlainObject(node)) {
|
|
31
|
+
node = node[key]
|
|
32
|
+
} else {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
if (node === undefined) return undefined
|
|
36
|
+
}
|
|
37
|
+
return node
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function removeAt(value: Record<string, unknown>, path: readonly string[]): void {
|
|
41
|
+
const parent = valueAt(value, path.slice(0, -1))
|
|
42
|
+
if (isPlainObject(parent)) delete parent[path[path.length - 1] as string]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Shared append-array step for both config formats: rejects a non-array at the
|
|
47
|
+
* path and dedupes entries already present, so applied records list only what
|
|
48
|
+
* was actually appended.
|
|
49
|
+
*/
|
|
50
|
+
export function appendEntries(existing: unknown, entries: readonly unknown[], path: readonly string[]): { readonly list: readonly unknown[]; readonly appended: readonly unknown[] } {
|
|
51
|
+
if (existing !== undefined && !Array.isArray(existing)) {
|
|
52
|
+
throw new Error(`cannot append to ${path.join(".")}: it is a ${typeof existing}, not an array`)
|
|
53
|
+
}
|
|
54
|
+
const list = Array.isArray(existing) ? existing : []
|
|
55
|
+
const appended = entries.filter((entry) => !list.some((item) => deepEqual(item, entry)))
|
|
56
|
+
return { list, appended }
|
|
57
|
+
}
|