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,45 +1,45 @@
1
- export function isPlainObject(value: unknown): value is Record<string, unknown> {
2
- return typeof value === "object" && value !== null && !Array.isArray(value)
3
- }
1
+ import { isPlainObject } from "../../core/object";
2
+
3
+ export { isPlainObject };
4
4
 
5
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
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
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]))
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
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]))
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
21
  }
22
22
 
23
23
  /** Reads the value at a dotted path from a parsed object, or undefined when absent. */
24
24
  export function valueAt(value: Record<string, unknown>, path: readonly string[]): unknown {
25
- let node: unknown = value
25
+ let node: unknown = value;
26
26
  for (const key of path) {
27
27
  if (Array.isArray(node)) {
28
- const index = Number(key)
29
- node = Number.isInteger(index) ? node[index] : undefined
28
+ const index = Number(key);
29
+ node = Number.isInteger(index) ? node[index] : undefined;
30
30
  } else if (isPlainObject(node)) {
31
- node = node[key]
31
+ node = node[key];
32
32
  } else {
33
- return undefined
33
+ return undefined;
34
34
  }
35
- if (node === undefined) return undefined
35
+ if (node === undefined) return undefined;
36
36
  }
37
- return node
37
+ return node;
38
38
  }
39
39
 
40
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]
41
+ const parent = valueAt(value, path.slice(0, -1));
42
+ if (isPlainObject(parent)) delete parent[path[path.length - 1] as string];
43
43
  }
44
44
 
45
45
  /**
@@ -47,11 +47,15 @@ export function removeAt(value: Record<string, unknown>, path: readonly string[]
47
47
  * path and dedupes entries already present, so applied records list only what
48
48
  * was actually appended.
49
49
  */
50
- export function appendEntries(existing: unknown, entries: readonly unknown[], path: readonly string[]): { readonly list: readonly unknown[]; readonly appended: readonly unknown[] } {
50
+ export function appendEntries(
51
+ existing: unknown,
52
+ entries: readonly unknown[],
53
+ path: readonly string[],
54
+ ): { readonly list: readonly unknown[]; readonly appended: readonly unknown[] } {
51
55
  if (existing !== undefined && !Array.isArray(existing)) {
52
- throw new Error(`cannot append to ${path.join(".")}: it is a ${typeof existing}, not an array`)
56
+ throw new Error(`cannot append to ${path.join(".")}: it is a ${typeof existing}, not an array`);
53
57
  }
54
- const list = Array.isArray(existing) ? existing : []
55
- const appended = entries.filter((entry) => !list.some((item) => deepEqual(item, entry)))
56
- return { list, appended }
58
+ const list = Array.isArray(existing) ? existing : [];
59
+ const appended = entries.filter((entry) => !list.some((item) => deepEqual(item, entry)));
60
+ return { list, appended };
57
61
  }
@@ -1,51 +1,81 @@
1
- import { codecFor } from "../formats"
2
- import { isPlainObject } from "../formats/value"
3
- import {
4
- FORCED_COST_NOTE,
5
- othersStayRegisteredNote,
6
- replacingExistingEntryNote,
7
- splitCompetitors,
8
- } from "../detect"
9
- import { join } from "../paths"
10
- import { API_KEY_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "../../core/constants"
11
- import { buildRulesBlock } from "../formats/rules"
12
- import { MCP_TOOL_IDS, rulesLines } from "../rulesContent"
13
- import { planHeader } from "./shared"
14
- import type { ConfigOp, FileEdit, HostPaths, InstallRecord, InstallerInput, InstallPlan } from "../types"
1
+ import { spawnSync } from "node:child_process";
2
+ import { API_KEY_ENV } from "../../core/constants";
3
+ import { FORCED_COST_NOTE, othersStayRegisteredNote, replacingExistingEntryNote, splitCompetitors } from "../detect";
4
+ import { codecFor } from "../formats";
5
+ import { buildRulesBlock } from "../formats/rules";
6
+ import { isPlainObject } from "../formats/value";
7
+ import { join } from "../paths";
8
+ import { MCP_TOOL_IDS, RAW_MCP_TOOL_IDS, rulesLines } from "../rulesContent";
9
+ import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord } from "../types";
10
+ import { planHeader } from "./shared";
11
+
12
+ /**
13
+ * The Codex release this installer's facts are verified against — the lock in
14
+ * docs/hosts.jsonc; the drift note fires when the probed CLI differs.
15
+ */
16
+ export const CODEX_TESTED_VERSION = "0.163.0";
17
+
18
+ /**
19
+ * The one subprocess the installer runs: `codex --version`, probed for the
20
+ * version-drift note. A missing binary or unreadable output skips the check with
21
+ * a stderr note and never blocks the plan — honor or report.
22
+ */
23
+ export function probeCodexVersion(err: (line: string) => void): string | undefined {
24
+ // env passed explicitly: runtime PATH edits (tests) must reach the lookup.
25
+ const result = spawnSync("codex", ["--version"], { timeout: 5000, encoding: "utf8", env: process.env });
26
+ const version = result.status === 0 ? parseCodexVersion(result.stdout ?? "") : undefined;
27
+ if (version === undefined) {
28
+ err("note: codex was not found on PATH or printed no version; the version-drift check was skipped");
29
+ }
30
+ return version;
31
+ }
32
+
33
+ /** Parses `codex --version` output (`codex-cli 0.163.0`) into the bare version. */
34
+ export function parseCodexVersion(output: string): string | undefined {
35
+ return /\bcodex(?:-cli)?\s+(\d+\.\d+\.\d+)\b/.exec(output)?.[1];
36
+ }
15
37
 
16
38
  /** Codex config is user-level only; project scope keeps the rules file project-local. */
17
39
  function codexPaths(input: InstallerInput): HostPaths {
18
40
  return {
19
41
  config: join(input.home, ".codex/config.toml"),
20
42
  rules: input.scope === "global" ? join(input.home, ".codex/AGENTS.md") : join(input.cwd, "AGENTS.md"),
21
- }
43
+ };
22
44
  }
23
45
 
24
46
  export function planCodex(input: InstallerInput, _previous: InstallRecord | undefined): InstallPlan {
25
- const paths = codexPaths(input)
26
- const loaded = codecFor("toml", input.host).load(paths.config)
27
- const tiers = { search: input.search, fetch: input.fetch }
28
- const header = planHeader(input, paths, loaded, "TOML")
47
+ const paths = codexPaths(input);
48
+ const loaded = codecFor("toml", input.host).load(paths.config);
49
+ const tiers = { search: input.search, fetch: input.fetch };
50
+ const header = planHeader(input, paths, loaded, "TOML");
29
51
  if (header.abort) {
30
- return { host: "codex", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors }
52
+ return { host: "codex", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors };
31
53
  }
32
- const notes = [...header.notes]
33
- const errors = [...header.errors]
34
- const edits: FileEdit[] = []
54
+ const notes = [...header.notes];
55
+ const errors = [...header.errors];
56
+ const edits: FileEdit[] = [];
35
57
 
36
- const tools = loaded.value.tools
58
+ if (input.hostVersion !== undefined && input.hostVersion !== CODEX_TESTED_VERSION) {
59
+ notes.push(
60
+ `Codex ${input.hostVersion} is installed, but this installer's Codex facts are verified against ` +
61
+ `${CODEX_TESTED_VERSION} (the docs/hosts.jsonc lock). Kill-switch and trap behavior may have drifted ` +
62
+ "between the two — re-verify the tiers if routing misbehaves.",
63
+ );
64
+ }
65
+
66
+ const tools = loaded.value.tools;
37
67
  if (isPlainObject(tools) && "web_search" in tools) {
38
68
  errors.push({
39
69
  message:
40
70
  `${paths.config} sets [tools] web_search, which is a silent no-op at Codex 0.163.0 (only the top-level ` +
41
71
  "web_search key controls the tool). Remove it first so the config cannot mislead.",
42
- })
72
+ });
43
73
  }
44
74
 
45
- const servers = loaded.value.mcp_servers
75
+ const servers = loaded.value.mcp_servers;
46
76
  if (isPlainObject(servers)) {
47
- if ("deepseek" in servers) notes.push(replacingExistingEntryNote("mcp_servers.deepseek"))
48
- const { competitors, bystanders } = splitCompetitors(servers)
77
+ if ("deepseek" in servers) notes.push(replacingExistingEntryNote("mcp_servers.deepseek"));
78
+ const { competitors, bystanders } = splitCompetitors(servers);
49
79
  if ((input.search === "forced" || input.fetch === "forced") && competitors.length > 0) {
50
80
  // Codex has no per-server kill switch (only per-tool enabled_tools/disabled_tools
51
81
  // inside a server), so a web-capable competitor cannot be excluded from the plan —
@@ -55,60 +85,76 @@ export function planCodex(input: InstallerInput, _previous: InstallRecord | unde
55
85
  `Forced cannot deliver exclusive routing: [mcp_servers.${competitors.join("], [mcp_servers.")}] look(s) web-capable, ` +
56
86
  "and Codex offers no per-server kill switch. Remove the server(s), disable their tools by hand via their " +
57
87
  "disabled_tools key, or choose a lower tier.",
58
- })
88
+ });
59
89
  }
60
- const stayRegistered = input.search === "forced" || input.fetch === "forced" ? bystanders : [...competitors, ...bystanders]
61
- if (stayRegistered.length > 0) notes.push(othersStayRegisteredNote(stayRegistered))
90
+ const stayRegistered =
91
+ input.search === "forced" || input.fetch === "forced" ? bystanders : [...competitors, ...bystanders];
92
+ if (stayRegistered.length > 0) notes.push(othersStayRegisteredNote(stayRegistered));
62
93
  }
63
94
 
64
- const env: Record<string, string> = {}
65
- if (input.search === "off") env[ENABLE_SEARCH_ENV] = "false"
66
- if (input.fetch === "off") env[ENABLE_FETCH_ENV] = "false"
95
+ const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined;
96
+ // The entry's disabled_tools key carries the user's own entries (ours filtered out)
97
+ // plus the off tier's tool names, so re-inits and uninstall stay whole-entry ops —
98
+ // an append inside a set op's subtree would break both verify and the drift check.
99
+ const rawToolIds: readonly string[] = Object.values(RAW_MCP_TOOL_IDS);
100
+ const keptDisabled =
101
+ isPlainObject(existingEntry) && Array.isArray(existingEntry.disabled_tools)
102
+ ? existingEntry.disabled_tools.filter(
103
+ (name): name is string => typeof name === "string" && !rawToolIds.includes(name),
104
+ )
105
+ : [];
106
+ const offDisabled = [
107
+ ...(input.search === "off" ? [RAW_MCP_TOOL_IDS.search] : []),
108
+ ...(input.fetch === "off" ? [RAW_MCP_TOOL_IDS.fetch] : []),
109
+ ];
67
110
  const entry = {
68
111
  command: "npx",
69
112
  args: ["-y", input.packageRef],
70
- ...(Object.keys(env).length > 0 ? { env } : {}),
113
+ ...(keptDisabled.length > 0 || offDisabled.length > 0 ? { disabled_tools: [...keptDisabled, ...offDisabled] } : {}),
71
114
  env_vars: [API_KEY_ENV],
72
- }
115
+ };
73
116
 
74
- const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined
75
- const ops: ConfigOp[] = [
76
- { op: "set", path: ["mcp_servers", "deepseek"], value: entry, previous: existingEntry },
77
- ]
117
+ const ops: ConfigOp[] = [{ op: "set", path: ["mcp_servers", "deepseek"], value: entry, previous: existingEntry }];
118
+ if (offDisabled.length > 0) {
119
+ notes.push(
120
+ `Off capabilities use Codex's own disabled_tools key (${offDisabled.join(", ")}): the tools are removed from ` +
121
+ "the model's context while the server keeps running and stays re-enableable by deleting the entries.",
122
+ );
123
+ }
78
124
  if (input.search === "forced") {
79
- const previous = typeof loaded.value.web_search === "string" ? loaded.value.web_search : undefined
80
- ops.push({ op: "set", path: ["web_search"], value: "disabled", previous })
125
+ const previous = typeof loaded.value.web_search === "string" ? loaded.value.web_search : undefined;
126
+ ops.push({ op: "set", path: ["web_search"], value: "disabled", previous });
81
127
  notes.push(
82
128
  previous === undefined
83
- ? "web_search is being set to \"disabled\" (no previous value existed; the default is \"cached\")."
129
+ ? 'web_search is being set to "disabled" (no previous value existed; the default is "cached").'
84
130
  : `web_search is being set to "disabled"; the previous value "${previous}" is snapshotted for uninstall.`,
85
- )
131
+ );
86
132
  }
87
- edits.push({ file: paths.config, format: "toml", ops })
133
+ edits.push({ file: paths.config, format: "toml", shared: true, ops });
88
134
 
89
135
  notes.push(
90
136
  `Codex stdio children do not inherit the parent environment, so the entry forwards ${API_KEY_ENV} explicitly ` +
91
137
  "via env_vars; the key must be set in the shell that launches Codex.",
92
- )
138
+ );
93
139
  if (input.scope === "project") {
94
140
  notes.push(
95
141
  "Codex config lives in ~/.codex/config.toml only (user level), so the server registration and kill switch are " +
96
142
  "written there even for a project-scope install; the rules file stays project-local.",
97
- )
143
+ );
98
144
  }
99
145
  if (loaded.lossy) {
100
- notes.push(`${paths.config} contains comments; the TOML rewrite will drop them.`)
146
+ notes.push(`${paths.config} contains comments; the TOML rewrite will drop them.`);
101
147
  }
102
148
  if (input.search === "forced" || input.fetch === "forced") {
103
149
  notes.push(
104
- 'Forced search is exclusion: the built-in web_search tool is disabled ("disabled" mode). ' + FORCED_COST_NOTE,
105
- )
150
+ `Forced search is exclusion: the built-in web_search tool is disabled ("disabled" mode). ${FORCED_COST_NOTE}`,
151
+ );
106
152
  }
107
153
  if (input.search === "optional" || input.fetch === "optional") {
108
154
  notes.push(
109
155
  'Note: Codex ships a built-in web_search tool (default "cached" mode, an OpenAI index) that stays available ' +
110
156
  "alongside the DeepSeek tools under the optional tier.",
111
- )
157
+ );
112
158
  }
113
159
 
114
160
  if (input.search === "preferred" || input.fetch === "preferred") {
@@ -116,13 +162,13 @@ export function planCodex(input: InstallerInput, _previous: InstallRecord | unde
116
162
  file: paths.rules,
117
163
  format: "rules",
118
164
  block: buildRulesBlock(input.host, rulesLines(MCP_TOOL_IDS, input.search, input.fetch)),
119
- })
165
+ });
120
166
  notes.push(
121
167
  input.scope === "global"
122
168
  ? `Rules block upserted in ${paths.rules}; Codex reads ~/.codex/AGENTS.md plus every AGENTS.md from the project root to the cwd.`
123
169
  : `Rules block upserted in ${paths.rules}; Codex reads every AGENTS.md from the project root to the cwd.`,
124
- )
170
+ );
125
171
  }
126
172
 
127
- return { host: "codex", scope: input.scope, tiers, edits, notes, errors }
173
+ return { host: "codex", scope: input.scope, tiers, edits, notes, errors };
128
174
  }
@@ -1,15 +1,17 @@
1
- import { planCodex } from "./codex"
2
- import { planOpenCode } from "./opencode"
3
- import { planPi } from "./pi"
4
- import { API_KEY_ENV } from "../../core/constants"
5
- import { claimedByOtherScope, readRecord } from "../state"
6
- import type { HostName, InstallRecord, InstallerInput, InstallPlan, RecordedFile } from "../types"
1
+ import { API_KEY_ENV } from "../../core/constants";
2
+ import { deepEqual, isPlainObject } from "../formats/value";
3
+ import { assertFileOwnership, fileOwnershipNotes } from "../ownership";
4
+ import { claimedByOtherScope, InstallRecordError, readRecord } from "../state";
5
+ import type { HostName, InstallerInput, InstallPlan, InstallRecord, RecordedFile } from "../types";
6
+ import { planCodex, probeCodexVersion } from "./codex";
7
+ import { planOpenCode } from "./opencode";
8
+ import { planPi } from "./pi";
7
9
 
8
10
  /**
9
11
  * A host planner sees the previous install record for its scope, so it never reads
10
12
  * install state itself; planForHost is the one place a record read happens per plan.
11
13
  */
12
- export type HostPlanner = (input: InstallerInput, previous: InstallRecord | undefined) => InstallPlan
14
+ export type HostPlanner = (input: InstallerInput, previous: InstallRecord | undefined) => InstallPlan;
13
15
 
14
16
  /**
15
17
  * The host registry. Key order feeds the CLI's usage string; a new host is one
@@ -21,12 +23,36 @@ export const HOST_PLANNERS: Readonly<Record<HostName, HostPlanner>> = {
21
23
  opencode: planOpenCode,
22
24
  pi: planPi,
23
25
  codex: planCodex,
24
- }
26
+ };
27
+
28
+ /**
29
+ * Per-host version probes the CLI edge runs before planning, so "which host
30
+ * needs a probe" is a host-module fact, not a CLI branch. A missing entry means
31
+ * the host's plan never reads hostVersion.
32
+ */
33
+ export const HOST_VERSION_PROBES: Partial<Record<HostName, (err: (line: string) => void) => string | undefined>> = {
34
+ codex: probeCodexVersion,
35
+ };
25
36
 
26
37
  export function planForHost(host: HostName, input: InstallerInput): InstallPlan {
27
- const previous = readRecord(input)
28
- const planned = withCrossScopeGuard(HOST_PLANNERS[host](input, previous), input)
29
- return withSecretGuard(withOrphanReverts(planned, input, previous))
38
+ try {
39
+ const previous = readRecord(input);
40
+ const planned = withCrossScopeGuard(HOST_PLANNERS[host](input, previous), input);
41
+ return {
42
+ ...withSecretGuard(withOrphanReverts(withLegacySharedConfigGuard(planned, previous), previous)),
43
+ previousRecord: previous,
44
+ };
45
+ } catch (error) {
46
+ if (!(error instanceof InstallRecordError)) throw error;
47
+ return {
48
+ host,
49
+ scope: input.scope,
50
+ tiers: { search: input.search, fetch: input.fetch },
51
+ edits: [],
52
+ notes: [],
53
+ errors: [{ message: error.message }],
54
+ };
55
+ }
30
56
  }
31
57
 
32
58
  /**
@@ -36,10 +62,18 @@ export function planForHost(host: HostName, input: InstallerInput): InstallPlan
36
62
  * instead. Hosts whose scope pairs map to disjoint files (Pi, OpenCode) pass through.
37
63
  */
38
64
  function withCrossScopeGuard(plan: InstallPlan, input: InstallerInput): InstallPlan {
39
- if (plan.errors.length > 0) return plan
40
- const other = input.scope === "global" ? "project" : "global"
41
- const overlap = claimedByOtherScope(input, plan.edits.map((edit) => edit.file))
42
- if (overlap.length === 0) return plan
65
+ if (plan.errors.length > 0) return plan;
66
+ try {
67
+ assertFileOwnership(input, plan.edits);
68
+ } catch (error) {
69
+ return { ...plan, edits: [], reverts: undefined, errors: [{ message: String(error) }] };
70
+ }
71
+ const other = input.scope === "global" ? "project" : "global";
72
+ const overlap = claimedByOtherScope(
73
+ input,
74
+ plan.edits.map((edit) => edit.file),
75
+ );
76
+ if (overlap.length === 0) return { ...plan, notes: [...plan.notes, ...fileOwnershipNotes(input, plan.edits)] };
43
77
  return {
44
78
  ...plan,
45
79
  edits: [],
@@ -52,7 +86,46 @@ function withCrossScopeGuard(plan: InstallPlan, input: InstallerInput): InstallP
52
86
  `or re-run with --scope ${other} to update it in place — two scopes must not edit one file.`,
53
87
  },
54
88
  ],
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Old installers had no shared ownership sidecar. A generated registration
94
+ * without this scope's record may belong to an unknown project; adopting it
95
+ * would snapshot installed values and recreate the cross-project undo bug.
96
+ */
97
+ function withLegacySharedConfigGuard(plan: InstallPlan, previous: InstallRecord | undefined): InstallPlan {
98
+ if (plan.errors.length > 0 || previous !== undefined) return plan;
99
+ for (const edit of plan.edits) {
100
+ if (edit.format === "rules" || !edit.shared) continue;
101
+ for (const op of edit.ops) {
102
+ const old = op.previous;
103
+ if (
104
+ op.op !== "set" ||
105
+ !isPlainObject(old) ||
106
+ old.command !== "npx" ||
107
+ !Array.isArray(old.args) ||
108
+ old.args[0] !== "-y" ||
109
+ !Array.isArray(old.env_vars) ||
110
+ !old.env_vars.includes(API_KEY_ENV)
111
+ )
112
+ continue;
113
+ return {
114
+ ...plan,
115
+ edits: [],
116
+ reverts: undefined,
117
+ errors: [
118
+ {
119
+ message:
120
+ `${edit.file} contains an installer-style registration without an install record for this location. ` +
121
+ "It may belong to an older install in another project. Re-run init or uninstall from that original project and scope first; " +
122
+ "if the registration was created manually, remove it before retrying. No existing registration was adopted.",
123
+ },
124
+ ],
125
+ };
126
+ }
55
127
  }
128
+ return plan;
56
129
  }
57
130
 
58
131
  /**
@@ -64,10 +137,10 @@ function withCrossScopeGuard(plan: InstallPlan, input: InstallerInput): InstallP
64
137
  */
65
138
  function withSecretGuard(plan: InstallPlan): InstallPlan {
66
139
  for (const edit of plan.edits) {
67
- if (edit.format === "rules") continue // rules edits carry no values to snapshot
140
+ if (edit.format === "rules") continue; // rules edits carry no values to snapshot
68
141
  for (const op of edit.ops) {
69
- if (op.op !== "set" || op.previous === undefined) continue
70
- const offender = findSecretLike(op.previous, op.path.join("."))
142
+ if (op.op !== "set" || op.previous === undefined) continue;
143
+ const offender = findSecretLike(op.previous, op.path.join("."));
71
144
  if (offender !== undefined) {
72
145
  return {
73
146
  ...plan,
@@ -81,29 +154,29 @@ function withSecretGuard(plan: InstallPlan): InstallPlan {
81
154
  `environment indirection ("\${${API_KEY_ENV}}" expansion or env_vars) and re-run.`,
82
155
  },
83
156
  ],
84
- }
157
+ };
85
158
  }
86
159
  }
87
160
  }
88
- return plan
161
+ return plan;
89
162
  }
90
163
 
91
164
  function findSecretLike(value: unknown, at: string): string | undefined {
92
- if (typeof value !== "object" || value === null) return undefined
165
+ if (typeof value !== "object" || value === null) return undefined;
93
166
  for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
94
- const path = `${at}.${key}`
167
+ const path = `${at}.${key}`;
95
168
  if (
96
169
  /(api[_-]?key|token|secret|password|credential)/i.test(key) &&
97
170
  typeof child === "string" &&
98
171
  child.length > 0 &&
99
172
  !/^\$\{[^}]+\}$/.test(child)
100
173
  ) {
101
- return path
174
+ return path;
102
175
  }
103
- const nested = findSecretLike(child, path)
104
- if (nested !== undefined) return nested
176
+ const nested = findSecretLike(child, path);
177
+ if (nested !== undefined) return nested;
105
178
  }
106
- return undefined
179
+ return undefined;
107
180
  }
108
181
 
109
182
  /**
@@ -111,24 +184,36 @@ function findSecretLike(value: unknown, at: string): string | undefined {
111
184
  * that the new plan no longer wants must be reverted explicitly — otherwise it is
112
185
  * silently orphaned (and uninstall could never restore it). Whole files missing
113
186
  * from the new plan revert entirely; files shared with the new plan revert only
114
- * the ops whose paths the new plan does not touch.
187
+ * the ops whose paths the new plan does not touch — and for a same-path
188
+ * append-array, only the entries the new plan no longer wants (a changed
189
+ * --package ref, retired tier options) revert, so a replaced entry never
190
+ * lingers beside its replacement.
115
191
  */
116
- function withOrphanReverts(plan: InstallPlan, input: InstallerInput, previous: InstallRecord | undefined): InstallPlan {
117
- if (plan.errors.length > 0) return plan
118
- if (previous === undefined) return plan
119
- const reverts: RecordedFile[] = []
192
+ function withOrphanReverts(plan: InstallPlan, previous: InstallRecord | undefined): InstallPlan {
193
+ if (plan.errors.length > 0) return plan;
194
+ if (previous === undefined) return plan;
195
+ const reverts: RecordedFile[] = [];
120
196
  for (const file of previous.files) {
121
- const edit = plan.edits.find((candidate) => candidate.file === file.file)
197
+ const edit = plan.edits.find((candidate) => candidate.file === file.file);
122
198
  if (edit === undefined) {
123
- reverts.push(file)
124
- continue
199
+ reverts.push(file);
200
+ continue;
125
201
  }
126
- if (edit.format === "rules" || file.format === "rules") continue // the new plan's block supersedes ours
127
- const orphanOps = file.ops.filter(
128
- (op) => !edit.ops.some((candidate) => candidate.op === op.op && candidate.path.join("\u0000") === op.path.join("\u0000")),
129
- )
130
- if (orphanOps.length > 0) reverts.push({ ...file, ops: orphanOps })
202
+ if (edit.format === "rules" || file.format === "rules") continue; // the new plan's block supersedes ours
203
+ const orphanOps = file.ops.flatMap((op) => {
204
+ const claimed = edit.ops.find(
205
+ (candidate) => candidate.op === op.op && candidate.path.join("\u0000") === op.path.join("\u0000"),
206
+ );
207
+ if (op.op !== "append-array" || claimed === undefined || claimed.op !== "append-array") {
208
+ return claimed === undefined ? [op] : [];
209
+ }
210
+ const dropped = (Array.isArray(op.applied) ? op.applied : []).filter(
211
+ (entry) => !(claimed.entries ?? []).some((wanted) => deepEqual(wanted, entry)),
212
+ );
213
+ return dropped.length > 0 ? [{ ...op, applied: dropped }] : [];
214
+ });
215
+ if (orphanOps.length > 0) reverts.push({ ...file, ops: orphanOps });
131
216
  }
132
- if (reverts.length === 0) return plan
133
- return { ...plan, reverts }
217
+ if (reverts.length === 0) return plan;
218
+ return { ...plan, reverts };
134
219
  }