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,19 +1,34 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
- import { codecFor, writeConfig } from "./formats"
3
- import { deepEqual, valueAt } from "./formats/value"
4
- import { revertRecordedFile } from "./revert"
5
- import { readRecord, writeRecord } from "./state"
6
- import type { ConfigOp, FileEdit, InstallPlan, InstallRecord, InstallerInput, RecordedFile } from "./types"
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { codecFor, writeConfig } from "./formats";
4
+ import { deepEqual, valueAt } from "./formats/value";
5
+ import { withOperationLock } from "./operation-lock";
6
+ import { acquireFileOwnership, assertFileOwnership, releaseFileOwnership } from "./ownership";
7
+ import { pruneEmptyDir, revertRecordedFile } from "./revert";
8
+ import { deleteRecord, readRecord, statePathFor, writeRecord } from "./state";
9
+ import type { ConfigOp, FileEdit, InstallerInput, InstallPlan, InstallRecord, RecordedFile } from "./types";
7
10
 
8
11
  export interface ApplyResult {
9
- readonly lines: string[]
10
- readonly ok: boolean
12
+ readonly lines: string[];
13
+ readonly ok: boolean;
14
+ }
15
+
16
+ /** A failed apply tells callers whether retrying is safe or recovery is still needed. */
17
+ export class ApplyError extends Error {
18
+ constructor(
19
+ readonly status: "rolled_back" | "recovery_required",
20
+ message: string,
21
+ cause: unknown,
22
+ ) {
23
+ super(message, { cause });
24
+ this.name = "ApplyError";
25
+ }
11
26
  }
12
27
 
13
28
  interface PreparedEdit {
14
- readonly edit: FileEdit
15
- readonly recorded: RecordedFile
16
- readonly write: () => void
29
+ readonly edit: FileEdit;
30
+ readonly recorded: RecordedFile;
31
+ readonly write: () => void;
17
32
  }
18
33
 
19
34
  /**
@@ -21,37 +36,110 @@ interface PreparedEdit {
21
36
  * rendered (the plan and the record must describe one plan-time snapshot —
22
37
  * ADR-0001), prepares every edit in memory (a malformed existing config fails
23
38
  * before any file is written), writes the files, reverts artifacts orphaned by
24
- * a tier change, records surgical-undo state, and verifies the result.
39
+ * a tier change, verifies the result, then records surgical-undo state. Write
40
+ * and verification failures restore touched files and the previous record.
41
+ * Incomplete recovery is reported explicitly and keeps new ownership claims
42
+ * so another install cannot adopt partially applied configuration.
25
43
  */
26
44
  export function applyPlan(plan: InstallPlan, input: InstallerInput): ApplyResult {
27
45
  if (plan.errors.length > 0) {
28
- throw new Error(`refusing to apply a plan with errors: ${plan.errors.map((error) => error.message).join("; ")}`)
46
+ throw new Error(`refusing to apply a plan with errors: ${plan.errors.map((error) => error.message).join("; ")}`);
29
47
  }
30
- assertNoDrift(plan)
31
- const previous = readRecord(input)
32
- const prepared = plan.edits.map((edit) => prepareEdit(edit, plan.host))
33
- const files: RecordedFile[] = []
34
- const lines: string[] = []
35
- for (const item of prepared) {
36
- item.write()
37
- files.push(mergeRecorded(item.recorded, previous?.files.find((file) => file.file === item.edit.file)))
38
- lines.push(`${item.recorded.created ? "created" : "updated"} ${item.edit.file}`)
48
+ return withOperationLock([statePathFor(input)], () =>
49
+ withOperationLock(
50
+ [...plan.edits, ...(plan.reverts ?? [])].map((file) => file.file),
51
+ () => applyLocked(plan, input),
52
+ ),
53
+ );
54
+ }
55
+
56
+ function applyLocked(plan: InstallPlan, input: InstallerInput): ApplyResult {
57
+ const touched = [...plan.edits, ...(plan.reverts ?? [])];
58
+ assertFileOwnership(input, touched);
59
+ assertNoDrift(plan);
60
+ const previous = readRecord(input);
61
+ if (Object.hasOwn(plan, "previousRecord") && !deepEqual(previous, plan.previousRecord)) {
62
+ throw new Error(
63
+ "The install record changed since the plan was rendered. Re-run init to plan against the current install.",
64
+ );
39
65
  }
40
- if ((plan.reverts ?? []).length > 0) {
41
- lines.push(...applyReverts(plan))
66
+
67
+ const prepared = plan.edits.map((edit) => prepareEdit(edit, plan.host));
68
+ const snapshots = snapshotTouchedFiles(plan);
69
+ const files: RecordedFile[] = [];
70
+ const lines: string[] = [];
71
+ const releaseNewClaims = acquireFileOwnership(input, touched);
72
+ try {
73
+ for (const item of prepared) {
74
+ item.write();
75
+ const desired = item.edit.format === "rules" ? [] : item.edit.ops;
76
+ files.push(
77
+ mergeRecorded(
78
+ item.recorded,
79
+ previous?.files.find((file) => file.file === item.edit.file),
80
+ desired,
81
+ ),
82
+ );
83
+ lines.push(`${item.recorded.created ? "created" : "updated"} ${item.edit.file}`);
84
+ }
85
+ if ((plan.reverts ?? []).length > 0) {
86
+ lines.push(...applyReverts(plan));
87
+ }
88
+ const verification = verifyEdits(plan.edits, plan.host);
89
+ if (!verification.ok) {
90
+ throw new Error(
91
+ `verification failed: ${verification.lines.filter((line) => line.startsWith("FAIL")).join("; ")}`,
92
+ );
93
+ }
94
+ lines.push(...verification.lines);
95
+ const record: InstallRecord = {
96
+ version: 1,
97
+ host: plan.host,
98
+ scope: plan.scope,
99
+ tiers: plan.tiers,
100
+ createdAt: new Date().toISOString(),
101
+ files,
102
+ };
103
+ writeRecord(input, record);
104
+ } catch (error) {
105
+ const rollback = rollbackApply(input, previous, snapshots);
106
+ if (rollback.complete) {
107
+ try {
108
+ releaseNewClaims();
109
+ } catch (releaseError) {
110
+ rollback.complete = false;
111
+ rollback.report.push(`could not release new config ownership: ${errorMessage(releaseError)}`);
112
+ }
113
+ }
114
+ const status = rollback.complete ? "rolled_back" : "recovery_required";
115
+ const outcome = rollback.complete ? "was rolled back" : "recovery is required";
116
+ const guidance = rollback.complete
117
+ ? "Fix the cause and re-run init."
118
+ : "Restore the reported files and ownership metadata before re-running init.";
119
+ throw new ApplyError(
120
+ status,
121
+ `apply failed and ${outcome} (${errorMessage(error)}). Rollback report: ${rollback.report.join("; ")}. ${guidance}`,
122
+ error,
123
+ );
42
124
  }
43
- const record: InstallRecord = {
44
- version: 1,
45
- host: plan.host,
46
- scope: plan.scope,
47
- tiers: plan.tiers,
48
- createdAt: new Date().toISOString(),
49
- files,
125
+ try {
126
+ releaseFileOwnership(
127
+ input,
128
+ (previous?.files ?? []).filter((file) => !files.some((current) => current.file === file.file)),
129
+ );
130
+ } catch (error) {
131
+ throw new ApplyError(
132
+ "recovery_required",
133
+ `apply was written and verified, but old config ownership could not be released (${errorMessage(error)}). ` +
134
+ "The new install record is retained; repair the reported ownership metadata before re-running init.",
135
+ error,
136
+ );
50
137
  }
51
- writeRecord(input, record)
52
- const verification = verifyEdits(plan.edits, plan.host)
53
- lines.push(...verification.lines)
54
- return { lines, ok: verification.ok }
138
+ return { lines, ok: true };
139
+ }
140
+
141
+ function errorMessage(error: unknown): string {
142
+ return error instanceof Error ? error.message : String(error);
55
143
  }
56
144
 
57
145
  /**
@@ -61,19 +149,19 @@ export function applyPlan(plan: InstallPlan, input: InstallerInput): ApplyResult
61
149
  * record's snapshots stay provably equal to what the user approved.
62
150
  */
63
151
  function assertNoDrift(plan: InstallPlan): void {
64
- const drifts: string[] = []
152
+ const drifts: string[] = [];
65
153
  for (const edit of plan.edits) {
66
- if (edit.format === "rules") continue
67
- const loaded = codecFor(edit.format, plan.host).load(edit.file)
154
+ if (edit.format === "rules") continue;
155
+ const loaded = codecFor(edit.format, plan.host).load(edit.file);
68
156
  if (loaded.parseError) {
69
- drifts.push(`${edit.file} no longer parses (${loaded.parseError})`)
70
- continue
157
+ drifts.push(`${edit.file} no longer parses (${loaded.parseError})`);
158
+ continue;
71
159
  }
72
160
  for (const op of edit.ops) {
73
- if (op.op !== "set") continue
74
- const current = valueAt(loaded.value, op.path)
161
+ if (op.op !== "set") continue;
162
+ const current = valueAt(loaded.value, op.path);
75
163
  if (!deepEqual(current, op.previous)) {
76
- drifts.push(`${edit.file}: ${op.path.join(".")} changed since the plan was rendered`)
164
+ drifts.push(`${edit.file}: ${op.path.join(".")} changed since the plan was rendered`);
77
165
  }
78
166
  }
79
167
  }
@@ -81,26 +169,99 @@ function assertNoDrift(plan: InstallPlan): void {
81
169
  throw new Error(
82
170
  `refusing to apply — the config changed since the plan was rendered: ${drifts.join("; ")}. ` +
83
171
  "Re-run init to re-plan against the current files.",
84
- )
172
+ );
173
+ }
174
+ }
175
+
176
+ /** The on-disk state of one file before the apply mutated it. */
177
+ interface FileSnapshot {
178
+ readonly exists: boolean;
179
+ readonly text: string;
180
+ }
181
+
182
+ /** Captures the exact bytes of every file the apply may touch, edits and reverts. */
183
+ function snapshotTouchedFiles(plan: InstallPlan): Map<string, FileSnapshot> {
184
+ const snapshots = new Map<string, FileSnapshot>();
185
+ const touched = [...plan.edits.map((edit) => edit.file), ...(plan.reverts ?? []).map((revert) => revert.file)];
186
+ for (const file of new Set(touched)) {
187
+ snapshots.set(
188
+ file,
189
+ existsSync(file) ? { exists: true, text: readFileSync(file, "utf8") } : { exists: false, text: "" },
190
+ );
85
191
  }
192
+ return snapshots;
193
+ }
194
+
195
+ /**
196
+ * Undoes a partially applied plan: every touched file returns to its pre-apply
197
+ * bytes (a file the apply created is deleted and its directory pruned) and the
198
+ * previous record is restored, so the disk and the install state again
199
+ * describe the world the user approved — the original snapshots a later re-init
200
+ * would inherit are what rollback writes back, not the failed run's values.
201
+ * Restore failures are reported in the thrown error, never thrown over the
202
+ * original cause, which stays the headline.
203
+ */
204
+ function rollbackApply(
205
+ input: InstallerInput,
206
+ previous: InstallRecord | undefined,
207
+ snapshots: ReadonlyMap<string, FileSnapshot>,
208
+ ): { complete: boolean; report: string[] } {
209
+ const report: string[] = [];
210
+ let complete = true;
211
+ for (const [file, snapshot] of [...snapshots.entries()].reverse()) {
212
+ try {
213
+ if (snapshot.exists) {
214
+ if (existsSync(file) && readFileSync(file, "utf8") === snapshot.text) continue;
215
+ writeConfig(file, snapshot.text);
216
+ if (readFileSync(file, "utf8") !== snapshot.text) throw new Error("restored contents differ from the snapshot");
217
+ } else {
218
+ if (!existsSync(file)) continue;
219
+ rmSync(file);
220
+ if (existsSync(file)) throw new Error("created file still exists after removal");
221
+ pruneEmptyDir(dirname(file));
222
+ }
223
+ report.push(`restored ${file}`);
224
+ } catch (restoreError) {
225
+ complete = false;
226
+ report.push(`could not restore ${file}: ${errorMessage(restoreError)}`);
227
+ }
228
+ }
229
+ try {
230
+ if (previous !== undefined) writeRecord(input, previous);
231
+ else deleteRecord(input);
232
+ if (previous === undefined ? existsSync(statePathFor(input)) : !deepEqual(readRecord(input), previous)) {
233
+ throw new Error("restored install record differs from its previous state");
234
+ }
235
+ } catch (restoreError) {
236
+ complete = false;
237
+ report.push(`could not restore the install record: ${errorMessage(restoreError)}`);
238
+ }
239
+ return { complete, report };
86
240
  }
87
241
 
88
242
  function prepareEdit(edit: FileEdit, host: InstallPlan["host"]): PreparedEdit {
89
- const codec = codecFor(edit.format, host)
90
- const loaded = codec.load(edit.file)
243
+ const codec = codecFor(edit.format, host);
244
+ const loaded = codec.load(edit.file);
91
245
  if (loaded.parseError) {
92
- throw new Error(`${edit.file} does not parse: ${loaded.parseError}`)
246
+ throw new Error(`${edit.file} does not parse: ${loaded.parseError}`);
93
247
  }
94
- const applied = codec.apply(loaded.text, edit)
248
+ const applied = codec.apply(loaded.text, edit);
95
249
  const recorded: RecordedFile =
96
250
  edit.format === "rules"
97
251
  ? { file: edit.file, format: "rules", created: !loaded.exists, ops: [], blockApplied: edit.block }
98
- : { file: edit.file, format: edit.format, created: !loaded.exists, ops: applied.recorded, createdParents: applied.createdParents }
252
+ : {
253
+ file: edit.file,
254
+ format: edit.format,
255
+ created: !loaded.exists,
256
+ ...(edit.shared ? { shared: true as const } : {}),
257
+ ops: applied.recorded,
258
+ createdParents: applied.createdParents,
259
+ };
99
260
  return {
100
261
  edit,
101
262
  recorded,
102
263
  write: () => writeConfig(edit.file, applied.text),
103
- }
264
+ };
104
265
  }
105
266
 
106
267
  /**
@@ -110,67 +271,81 @@ function prepareEdit(edit: FileEdit, host: InstallPlan["host"]): PreparedEdit {
110
271
  * installer created them and they are left empty.
111
272
  */
112
273
  function applyReverts(plan: InstallPlan): string[] {
113
- const lines: string[] = []
274
+ const lines: string[] = [];
114
275
  for (const revert of plan.reverts ?? []) {
115
- const whole = !plan.edits.some((edit) => edit.file === revert.file)
116
- lines.push(...revertRecordedFile(revert, plan.host, { label: " (previous init)", whole }))
276
+ const whole = !plan.edits.some((edit) => edit.file === revert.file);
277
+ lines.push(...revertRecordedFile(revert, plan.host, { label: " (previous init)", whole }));
117
278
  }
118
- return lines
279
+ return lines;
119
280
  }
120
281
 
121
282
  /**
122
283
  * Merges a re-apply with the record it supersedes: a set op whose previous value is
123
284
  * what an earlier run applied inherits that run's snapshot, appended arrays
124
- * accumulate, and the created flag keeps the first install's truth.
285
+ * accumulate only the entries the new plan still wants (entries it dropped were
286
+ * reverted as orphans during apply, so claiming them would orphan the record),
287
+ * and the created flag keeps the first install's truth.
125
288
  */
126
- function mergeRecorded(current: RecordedFile, before: RecordedFile | undefined): RecordedFile {
127
- if (before === undefined) return current
289
+ function mergeRecorded(
290
+ current: RecordedFile,
291
+ before: RecordedFile | undefined,
292
+ desired: readonly ConfigOp[],
293
+ ): RecordedFile {
294
+ if (before === undefined) return current;
128
295
  const ops = current.ops.map((op) => {
129
- const old = before.ops.find((candidate) => candidate.op === op.op && candidate.path.join("\u0000") === op.path.join("\u0000"))
130
- if (old === undefined) return op
296
+ const old = before.ops.find(
297
+ (candidate) => candidate.op === op.op && candidate.path.join("\u0000") === op.path.join("\u0000"),
298
+ );
299
+ if (old === undefined) return op;
131
300
  if (op.op === "set" && old.op === "set" && deepEqual(op.previous, old.applied)) {
132
- return { ...op, previous: old.previous }
301
+ return { ...op, previous: old.previous };
133
302
  }
134
303
  if (op.op === "append-array" && old.op === "append-array") {
135
- return { ...op, applied: [...(old.applied as unknown[]), ...(op.applied as unknown[])] }
304
+ const wanted = desired.find(
305
+ (candidate) => candidate.op === "append-array" && candidate.path.join("\u0000") === op.path.join("\u0000"),
306
+ );
307
+ const kept = (Array.isArray(old.applied) ? old.applied : []).filter((entry) =>
308
+ (wanted?.entries ?? []).some((candidate) => deepEqual(candidate, entry)),
309
+ );
310
+ return { ...op, applied: [...kept, ...(Array.isArray(op.applied) ? op.applied : [])] };
136
311
  }
137
- return op
138
- })
312
+ return op;
313
+ });
139
314
  return {
140
315
  ...current,
141
316
  created: before.created,
142
317
  ops,
143
318
  createdParents: [...(before.createdParents ?? []), ...(current.createdParents ?? [])],
144
- }
319
+ };
145
320
  }
146
321
 
147
322
  function verifyEdits(edits: readonly FileEdit[], host: InstallPlan["host"]): { lines: string[]; ok: boolean } {
148
- const lines: string[] = []
149
- let ok = true
323
+ const lines: string[] = [];
324
+ let ok = true;
150
325
  for (const edit of edits) {
151
326
  if (!existsSync(edit.file)) {
152
- lines.push(`FAIL ${edit.file} does not exist after apply`)
153
- ok = false
154
- continue
327
+ lines.push(`FAIL ${edit.file} does not exist after apply`);
328
+ ok = false;
329
+ continue;
155
330
  }
156
- const codec = codecFor(edit.format, host)
157
- const text = readFileSync(edit.file, "utf8")
331
+ const codec = codecFor(edit.format, host);
332
+ const text = readFileSync(edit.file, "utf8");
158
333
  if (edit.format === "rules") {
159
- const good = codec.verify(text, edit)
160
- lines.push(`${good ? "pass" : "FAIL"} ${edit.file}: rules block ${good ? "present" : "missing"}`)
161
- ok = ok && good
162
- continue
334
+ const good = codec.verify(text, edit);
335
+ lines.push(`${good ? "pass" : "FAIL"} ${edit.file}: rules block ${good ? "present" : "missing"}`);
336
+ ok = ok && good;
337
+ continue;
163
338
  }
164
339
  for (const op of edit.ops) {
165
- const good = codec.verify(text, edit, op)
166
- lines.push(`${good ? "pass" : "FAIL"} ${edit.file}: ${describeOp(op)} ${good ? "holds" : "missing"}`)
167
- ok = ok && good
340
+ const good = codec.verify(text, edit, op);
341
+ lines.push(`${good ? "pass" : "FAIL"} ${edit.file}: ${describeOp(op)} ${good ? "holds" : "missing"}`);
342
+ ok = ok && good;
168
343
  }
169
344
  }
170
- return { lines, ok }
345
+ return { lines, ok };
171
346
  }
172
347
 
173
348
  function describeOp(op: ConfigOp): string {
174
- if (op.op === "set") return `set ${op.path.join(".")}`
175
- return `append ${op.path.join(".")}`
349
+ if (op.op === "set") return `set ${op.path.join(".")}`;
350
+ return `append ${op.path.join(".")}`;
176
351
  }