deepseek-web-retrieval 0.4.0 → 0.5.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +99 -244
  2. package/README.md +16 -11
  3. package/dist/cli.js +1366 -615
  4. package/docs/agents/codex.md +28 -7
  5. package/docs/agents/opencode-v2.md +1 -1
  6. package/docs/agents/pi.md +88 -86
  7. package/docs/design/installer-design.md +72 -9
  8. package/docs/hosts.jsonc +2 -2
  9. package/docs/integration.md +14 -9
  10. package/docs/research/control-matrix.md +48 -13
  11. package/docs/research/mcp-compatibility.md +9 -4
  12. package/examples/pi.settings.jsonc +17 -0
  13. package/index.ts +2 -2
  14. package/package.json +35 -4
  15. package/src/core/auth.ts +4 -6
  16. package/src/core/client.ts +65 -53
  17. package/src/core/constants.ts +24 -24
  18. package/src/core/descriptions.ts +30 -6
  19. package/src/core/domains.ts +56 -67
  20. package/src/core/env.ts +112 -0
  21. package/src/core/format.ts +53 -50
  22. package/src/core/index.ts +9 -9
  23. package/src/core/jsonc.ts +6 -6
  24. package/src/core/object.ts +4 -0
  25. package/src/core/options.ts +20 -25
  26. package/src/core/search.ts +45 -44
  27. package/src/core/transport.ts +109 -113
  28. package/src/core/types.ts +47 -47
  29. package/src/core/url.ts +20 -20
  30. package/src/installer/apply.ts +256 -81
  31. package/src/installer/cli.ts +135 -104
  32. package/src/installer/detect.ts +46 -31
  33. package/src/installer/formats/index.ts +26 -28
  34. package/src/installer/formats/json.ts +81 -77
  35. package/src/installer/formats/rules.ts +55 -28
  36. package/src/installer/formats/toml.ts +88 -81
  37. package/src/installer/formats/value.ts +34 -30
  38. package/src/installer/hosts/codex.ts +100 -54
  39. package/src/installer/hosts/index.ts +126 -41
  40. package/src/installer/hosts/opencode.ts +70 -51
  41. package/src/installer/hosts/pi.ts +269 -75
  42. package/src/installer/hosts/shared.ts +19 -14
  43. package/src/installer/operation-lock.ts +54 -0
  44. package/src/installer/ownership.ts +129 -0
  45. package/src/installer/paths.ts +7 -5
  46. package/src/installer/persistence.ts +66 -0
  47. package/src/installer/render.ts +29 -29
  48. package/src/installer/revert.ts +33 -29
  49. package/src/installer/rulesContent.ts +24 -13
  50. package/src/installer/state.ts +116 -31
  51. package/src/installer/types.ts +74 -56
  52. package/src/installer/uninstall.ts +40 -17
  53. package/src/mcp/auth.ts +7 -8
  54. package/src/mcp/cli.ts +38 -20
  55. package/src/mcp/client.ts +35 -0
  56. package/src/mcp/server.ts +68 -97
  57. package/src/opencode/auth.ts +24 -26
  58. package/src/opencode/plugin.ts +99 -87
  59. package/src/pi/extension.ts +164 -0
  60. package/src/pi/settings.ts +49 -0
  61. package/src/mcp/config.ts +0 -112
@@ -1,35 +1,35 @@
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"
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(text: string, ops: readonly RecordedOp[], createdParents: readonly (readonly string[])[]): { text: string; notes: string[] } {
73
- let current = text
74
- const notes: string[] = []
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 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
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
- const markers = rulesMarkers(host)
42
- return text.includes(markers.begin) && text.includes(markers.end)
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
- ? { exists: true, text: readFileSync(file, "utf8"), value: {}, lossy: false }
52
- : { exists: false, text: "", value: {}, lossy: false },
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(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[])[] = []
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.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
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(root: Record<string, unknown>, path: readonly string[], created: (readonly string[])[]): Record<string, unknown> {
75
- let node = root
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
+ };